From f1b497a5552abf4616a7477b730b78a1274f3db4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 16 Sep 2026 14:54:29 -0700 Subject: [PATCH] feat(cli): show installer and update download progress (#12044) --- apps/server/src/cli/update.ts | 32 ++++-- apps/server/src/cli/updateProgress.test.ts | 101 ++++++++++++++++ apps/server/src/cli/updateProgress.ts | 72 ++++++++++++ apps/server/src/cloud/pinnedRuntime.test.ts | 121 ++++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 45 +++++++- scripts/install.ps1 | 120 +++++++++++++++++-- scripts/install.sh | 103 +++++++++++++++-- scripts/install.test.ts | 115 +++++++++++++++++++ 8 files changed, 683 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/cli/updateProgress.test.ts create mode 100644 apps/server/src/cli/updateProgress.ts create mode 100644 scripts/install.test.ts diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index 950910c403f17a8802bedaf04e9c988c30507588..31ecebca378c7a497163b13272e0bffcfe827478 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -42,6 +42,7 @@ import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/ser import * as ProcessRunner from "../processRunner.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { createUpdateProgress } from "./updateProgress.ts"; import { bootServiceLayer } from "./service.ts"; export class CliUpdateError extends Schema.TaggedError()("CliUpdateError", { @@ -357,7 +358,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { reason: `'${input.requestedVersion}' is not an exact t3 version.`, }); } - const targetVersion = input.requestedVersion ?? (yield* resolveNewestVersion(channel)); + const progress = createUpdateProgress(); + progress.status("Checking for updates..."); + const targetVersion = yield* ( + input.requestedVersion === undefined + ? resolveNewestVersion(channel) + : Effect.succeed(input.requestedVersion) + ).pipe(Effect.ensuring(Effect.sync(progress.finish))); const targetChannel = cliReleaseChannelOf(targetVersion); // Preview is a maintainers' dogfooding train: it is cut by hand from @@ -446,14 +453,17 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { Effect.orElseSucceed(() => false), ); - yield* Console.log( + progress.heading( executableCurrent && restartPending ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` : executableCurrent ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` : alreadyOnDisk - ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` - : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, + ? "Switching T3 Code" + : "Updating T3 Code", + executableCurrent + ? "" + : `${currentVersion} → ${targetVersion}${targetChannel === "stable" ? "" : ` (${targetChannel})`}`, ); let restartService = false; if (serviceInstalled && !serviceCurrent) { @@ -477,6 +487,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { } const runtime = yield* ensurePinnedRuntimeInstalled({ + onProgress: progress.report, baseDir: input.baseDir, version: targetVersion, fs, @@ -510,6 +521,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ), ), }).pipe( + Effect.ensuring(Effect.sync(progress.finish)), Effect.catchIf( (error): error is PinnedRuntimeInstallError => error._tag === "PinnedRuntimeInstallError" && @@ -539,6 +551,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { // version; only the restart itself waits for the user's answer. let serviceUpdated = false; if (serviceInstalled && !serviceCurrent) { + yield* Console.log( + restartService + ? "Restarting the background service..." + : "Updating the background service...", + ); yield* BootService.BootService.pipe( Effect.flatMap((target) => target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), @@ -560,12 +577,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { serviceUpdated = restartService; } - yield* Console.log(""); - yield* Console.log(`t3 ${targetVersion} is installed at ${runtime.entryPath}`); + progress.success(`Installed T3 Code ${targetVersion}`); if (Option.isSome(repointed)) { - yield* Console.log(` ${repointed.value} now runs ${targetVersion}`); + yield* Console.log(" Run t3 to get started.\n"); } else { - yield* Console.log(` Run it as ${runtime.entryPath}, or point your \`t3\` launcher at it.`); + yield* Console.log(` Run ${runtime.entryPath}\n`); } if (serviceUpdated) { yield* Console.log(` Background service restarted on ${targetVersion}`); diff --git a/apps/server/src/cli/updateProgress.test.ts b/apps/server/src/cli/updateProgress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07790af41b987de84c62344ff59821cdd4263168 --- /dev/null +++ b/apps/server/src/cli/updateProgress.test.ts @@ -0,0 +1,101 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { createUpdateProgress } from "./updateProgress.ts"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +function terminal(isTTY = true, columns = 80) { + vi.stubEnv("TERM", "xterm"); + vi.stubEnv("NO_COLOR", "1"); + let text = ""; + const progress = createUpdateProgress({ + isTTY, + columns, + write(chunk: string | Uint8Array) { + text += chunk; + return true; + }, + }); + return { ...progress, text: () => text }; +} + +it("keeps redirected output readable without per-chunk updates or escapes", () => { + const output = terminal(false); + for (let received = 0; received <= 100; received++) { + output.report({ stage: "download", received, total: 100 }); + } + output.report({ stage: "verify" }); + output.report({ stage: "extract" }); + output.report({ stage: "validate" }); + output.finish(); + expect(output.text()).toBe( + " Downloading...\n Verifying the download...\n Extracting T3 Code...\n Checking the new executable...\n", + ); +}); + +it("throttles redraws, shows the final byte count, and closes the line once", () => { + const now = vi.spyOn(performance, "now").mockReturnValue(0); + const output = terminal(); + output.report({ stage: "download", received: 0, total: 1024 ** 2 }); + const initial = output.text(); + for (let received = 1; received < 100; received++) { + output.report({ stage: "download", received, total: 1024 ** 2 }); + } + expect(output.text()).toBe(initial); + now.mockReturnValue(100); + output.report({ stage: "download", received: 524288, total: 1024 ** 2 }); + output.report({ stage: "download", received: 1024 ** 2, total: 1024 ** 2 }); + output.finish(); + output.finish(); + expect(output.text().match(/100%/g)).toHaveLength(1); + expect(output.text()).toContain("50% 0.5 / 1.0 MB"); + expect(output.text()).toMatch(/100% 1.0 \/ 1.0 MB\n$/); +}); + +it("shows bytes for an unknown size and leaves a clean line on interruption", () => { + const output = terminal(); + output.report({ stage: "download", received: 524288, total: undefined }); + output.finish(); + expect(output.text()).toMatch(/0.5 MB\n$/); + expect(output.text()).not.toContain("%"); +}); + +it.each([1, 2, 7, 8, 9, 30])("fits a %i-column terminal without wrapping", (columns) => { + const output = terminal(true, columns); + output.report({ stage: "download", received: 50, total: 100 }); + for (const line of output.text().split("\r\x1b[2K")) { + expect(line.length).toBeLessThan(columns); + } +}); + +it("draws the final size of a fast chunked download even inside the throttle window", () => { + vi.spyOn(performance, "now").mockReturnValue(0); + const output = terminal(); + output.report({ stage: "download", received: 0, total: undefined }); + output.report({ stage: "download", received: 524288, total: undefined }); + output.report({ stage: "download", received: 524288, total: 524288 }); + output.report({ stage: "verify" }); + expect(output.text()).toContain("100% 0.5 / 0.5 MB\n\r\x1b[2K Verifying"); +}); + +it("uses color only in capable terminals that have not requested NO_COLOR", () => { + const output = terminal(); + // Create a new renderer after changing the color preference. + vi.stubEnv("NO_COLOR", undefined); + const chunks: string[] = []; + const progress = createUpdateProgress({ + isTTY: true, + columns: 80, + write(chunk) { + chunks.push(String(chunk)); + return true; + }, + }); + progress.report({ stage: "download", received: 50, total: 100 }); + expect(chunks.join("")).toContain("\x1b[94m"); + output.report({ stage: "download", received: 50, total: 100 }); + expect(output.text()).not.toContain("\x1b[94m"); +}); diff --git a/apps/server/src/cli/updateProgress.ts b/apps/server/src/cli/updateProgress.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7f209b4e4c46614f934eded12d3ee9f6e91c420 --- /dev/null +++ b/apps/server/src/cli/updateProgress.ts @@ -0,0 +1,72 @@ +import type { PinnedRuntimeProgress } from "../cloud/pinnedRuntime.ts"; + +/** A single status line below the download bar; redirected output remains plain. */ +export function createUpdateProgress( + output: Pick = process.stderr, +) { + const interactive = output.isTTY && process.env.TERM !== "dumb"; + const color = interactive && !process.env.NO_COLOR; + const style = (code: number, text: string) => (color ? `\x1b[${code}m${text}\x1b[0m` : text); + let stage: PinnedRuntimeProgress["stage"] | undefined; + let lastDraw = -Infinity; + let lineOpen = false; + let downloadLine = false; + const finish = () => { + if (lineOpen) output.write(downloadLine ? "\n" : "\r\x1b[2K"); + lineOpen = false; + }; + const status = (message: string) => { + finish(); + let line = ` ${message}`; + if (interactive) line = line.slice(0, Math.max(0, (output.columns || 80) - 1)); + output.write(interactive ? `\r\x1b[2K${style(2, line)}` : `${line}\n`); + lineOpen = Boolean(interactive); + downloadLine = false; + }; + return { + finish, + status, + heading(message: string, detail = "") { + finish(); + output.write(` ${style(2, message)}${detail ? ` ${style(1, detail)}` : ""}\n\n`); + }, + success(message: string) { + finish(); + output.write(` ${style(32, message)}\n\n`); + }, + report(progress: PinnedRuntimeProgress) { + if (progress.stage !== stage) { + stage = progress.stage; + const labels = { + download: "Downloading...", + verify: "Verifying the download...", + extract: "Extracting T3 Code...", + validate: "Checking the new executable...", + cached: "Using the downloaded release...", + }; + status(labels[stage]); + } + if (progress.stage !== "download" || !interactive) return; + const now = performance.now(); + if (now - lastDraw < 100 && progress.received !== progress.total) return; + lastDraw = now; + const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1); + const { received, total } = progress; + const columns = output.columns || 80; + let line = + columns >= 32 + ? ` ${style(2, "Downloading")} ${mb(received)} MB` + : ` ${mb(received)} MB`.slice(0, Math.max(0, columns - 1)); + if (total !== undefined && total > 0 && columns >= 9) { + const percent = Math.min(100, Math.floor((received / total) * 100)); + const width = Math.max(1, Math.min(32, columns - 10)); + const filled = Math.floor((percent * width) / 100); + line = ` ${style(94, "■".repeat(filled))}${style(2, "·".repeat(width - filled))} ${String(percent).padStart(3)}%`; + if (columns >= 68) line += ` ${style(2, `${mb(received)} / ${mb(total)} MB`)}`; + } + output.write(`\r\x1b[2K${line}`); + lineOpen = true; + downloadLine = true; + }, + }; +} diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index e4b16b8f7190b3f08ae0bdb8ef7e0e790a425710..27092f20a3e7993455df5a5439c66e6e6dba3415 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -14,6 +14,7 @@ import { pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, + type PinnedRuntimeProgress, } from "./pinnedRuntime.ts"; // Every install fetches the release archive, checks it against SHA256SUMS, @@ -97,6 +98,126 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }), ); + it.effect.each([true, false])( + "reports bytes before completion, then verifies and extracts (known size: %s)", + (knownSize) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-" }); + const firstChunk = yield* Deferred.make(); + let archiveController: ReadableStreamDefaultController | undefined; + const checksums = yield* validChecksums; + const progress: PinnedRuntimeProgress[] = []; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/SHA256SUMS") + ? new Response(checksums) + : new Response( + new ReadableStream({ + start(controller) { + archiveController = controller; + controller.enqueue(archiveBytes.slice(0, 4)); + }, + }), + { headers: knownSize ? { "content-length": String(archiveBytes.length) } : {} }, + ), + ), + ), + ); + const install = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: client, + runner: extractingRunner(fs, path), + validate: () => Effect.void, + onProgress: (event) => { + progress.push(event); + if (event.stage === "download" && event.received === 4) { + Deferred.doneUnsafe(firstChunk, Effect.void); + } + }, + }).pipe(Effect.forkScoped); + yield* Deferred.await(firstChunk); + assert.deepEqual(progress.at(-1), { + stage: "download", + received: 4, + total: knownSize ? archiveBytes.length : undefined, + }); + assert.isFalse(progress.some((event) => event.stage === "extract")); + assert.isDefined(archiveController); + archiveController!.enqueue(archiveBytes.slice(4)); + archiveController!.close(); + const installed = yield* Fiber.join(install); + assert.deepEqual(progress.slice(-4), [ + { stage: "download", received: archiveBytes.length, total: archiveBytes.length }, + { stage: "verify" }, + { stage: "extract" }, + { stage: "validate" }, + ]); + assert.equal(yield* fs.readFileString(installed.sentinelPath), `${version}\n`); + }), + ); + + it.effect("cleans up an interrupted download without reporting verification or extraction", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-failed-" }); + const checksums = yield* validChecksums; + const progress: PinnedRuntimeProgress[] = []; + let cancelled = false; + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/SHA256SUMS") + ? new Response(checksums) + : new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(archiveBytes.slice(0, 4)); + }, + cancel() { + cancelled = true; + }, + }), + ), + ), + ), + ); + const firstChunk = yield* Deferred.make(); + const install = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: client, + runner: extractingRunner(fs, path), + validate: () => Effect.die("must not validate an interrupted archive"), + onProgress: (event) => { + progress.push(event); + if (event.stage === "download" && event.received === 4) + Deferred.doneUnsafe(firstChunk, Effect.void); + }, + }).pipe(Effect.forkScoped); + yield* Deferred.await(firstChunk); + yield* Fiber.interrupt(install); + assert.deepEqual(progress.at(-1), { stage: "download", received: 4, total: undefined }); + assert.isTrue(progress.every((event) => event.stage === "download")); + assert.isTrue(cancelled); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); + }), + ); + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 680d80e46cd18e169f3736ee69527620aa8f9d5e..892ed4bcaba156b1983e8118f6ebd9be2a5bf326 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; @@ -98,6 +99,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError void; } const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( httpClient: HttpClient.HttpClient, url: string, step: string, + onProgress?: (progress: PinnedRuntimeProgress) => void, ) { // The install lock is held for the whole transaction, so a stalled download // must fail rather than block every other caller. return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.arrayBuffer), - Effect.map((buffer) => new Uint8Array(buffer)), + Effect.flatMap( + Effect.fn(function* (response) { + if (onProgress === undefined) return new Uint8Array(yield* response.arrayBuffer); + const length = Number(response.headers["content-length"]); + const total = Number.isFinite(length) && length > 0 ? length : undefined; + let received = 0; + onProgress({ stage: "download", received, total }); + const chunks = yield* response.stream.pipe( + Stream.tap((chunk) => + Effect.sync(() => { + received += chunk.byteLength; + onProgress({ stage: "download", received, total }); + }), + ), + Stream.runCollect, + ); + // A completed chunked response finally gives us its total size. + if (total === undefined && received > 0) { + onProgress({ stage: "download", received, total: received }); + } + const bytes = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + }), + ), Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), Effect.timeoutOrElse({ duration: PINNED_RUNTIME_INSTALL_TIMEOUT, @@ -161,6 +196,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); const fileName = cliArchiveFileName(input.version, platformKey); + input.onProgress?.({ stage: "download", received: 0, total: undefined }); const checksums = parseChecksums( new TextDecoder().decode( yield* fetchReleaseAsset( @@ -180,7 +216,9 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun httpClient, `${baseUrl}/${fileName}`, "downloading the t3 release archive", + input.onProgress, ); + input.onProgress?.({ stage: "verify" }); const digest = yield* Effect.tryPromise({ try: () => crypto.subtle.digest("SHA-256", archive), catch: (cause) => @@ -200,6 +238,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), ), ); + input.onProgress?.({ stage: "extract" }); const extractStep = "extracting the t3 release archive"; // The archive wraps everything in one directory named after its stem; // strip it so the executable lands at /t3. @@ -242,6 +281,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( const alreadyPinned = entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; if (alreadyPinned) { + input.onProgress?.({ stage: "cached" }); yield* input.validate(paths); return paths; } @@ -290,6 +330,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { yield* installFromArchive(input, stagingDir); + input.onProgress?.({ stage: "validate" }); yield* input.validate(stagingPaths); yield* fs .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a779854717ad6659d7b910c22d95a3f9a3132144..d0d7140d2f4f2079d72cc66477077da3d897eb17 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -26,6 +26,97 @@ function Fail([string] $message) { exit 1 } +$terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb" +$interactive = $terminal -and $Host.UI.SupportsVirtualTerminal +if ($interactive) { + # Legacy code pages may support ANSI escapes but not the logo or bar glyphs. + $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7) + $encoding = [Console]::Error.Encoding + $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs +} +$esc = [char]27 +$reset = $bold = $muted = $accent = $green = "" +if ($interactive -and -not $env:NO_COLOR) { + $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m" + $accent = "$esc[94m"; $green = "$esc[32m" +} +function Step([string] $message) { + if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") } + else { [Console]::Error.WriteLine(" $message") } +} +function Draw-Download([long] $bytes, [long] $total) { + if (-not $interactive) { return } + $columns = [Console]::WindowWidth + if ($columns -le 0) { $columns = 80 } + if ($total -gt 0 -and $columns -ge 9) { + $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total)) + $width = [Math]::Min(32, $columns - 8) + $filled = [int][Math]::Floor($percent * $width / 100) + $bar = ([string][char]0x25A0) * $filled + $rest = ([string][char]0x00B7) * ($width - $filled) + $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB)) + if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" } + [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset") + } else { + $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) } + [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1))) + } +} +function Fetch([string] $uri, [string] $destination, [switch] $progress) { + if (-not $progress -or -not $interactive) { + $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" } + Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing + return + } + # Read the response once, displaying actual bytes received at most ten times a second. + Add-Type -AssemblyName System.Net.Http + $client = New-Object System.Net.Http.HttpClient + $response = $source = $file = $null + try { + $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $file = [IO.File]::Create($destination) + $buffer = New-Object byte[] 65536 + $bytes = 0L + $clock = [Diagnostics.Stopwatch]::StartNew() + Draw-Download 0 $response.Content.Headers.ContentLength + while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { + $file.Write($buffer, 0, $count) + $bytes += $count + if ($clock.ElapsedMilliseconds -ge 100) { + Draw-Download $bytes $response.Content.Headers.ContentLength + $clock.Restart() + } + } + Draw-Download $bytes $bytes + [Console]::Error.WriteLine() + } finally { + if ($file) { $file.Dispose() } + if ($source) { $source.Dispose() } + if ($response) { $response.Dispose() } + $client.Dispose() + } +} +if ($interactive) { + # Block characters are constructed so this file also loads correctly in PowerShell 5.1. + $mark = @( + "########## ######## ", + " ### _##^ ", + " ### ####_ ", + " ### _ ###", + " ### #######^ " + ) + [Console]::Error.WriteLine() + for ($i = 0; $i -lt $mark.Length; $i++) { + $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584) + $label = if ($i -eq 1) { " ${bold}T3 Code$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" } + [Console]::Error.WriteLine(" $bold$row$reset$label") + } + [Console]::Error.WriteLine() +} +Step "Finding your release..." + # PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell # runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts # may lack. @@ -67,15 +158,17 @@ $targetDir = Join-Path $versionsDir $version $marker = Join-Path $targetDir ".install-complete" if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { - Write-Host "t3 $version is already installed at $targetDir" + Step "Version $version is already downloaded." } else { New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) New-Item -ItemType Directory -Path $staging | Out-Null try { - Write-Host "Downloading $archive..." + if ($interactive) { [Console]::Error.Write("`r$esc[2K") } + [Console]::Error.WriteLine(" ${muted}Installing$reset T3 Code $bold$version$reset`n") + Step "Downloading..." try { - Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS") } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 404) { @@ -83,7 +176,9 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { } throw } - Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing + Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress + + Step "Verifying the download..." $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } @@ -91,7 +186,13 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } - Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force + Step "Extracting T3 Code..." + # The archive module reads the global preference, not the caller's local scope. + $savedProgress = $global:ProgressPreference + try { + $global:ProgressPreference = "SilentlyContinue" + Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force + } finally { $global:ProgressPreference = $savedProgress } # The archive wraps everything in one directory named after its stem. Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force @@ -108,13 +209,16 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { } } +Step "Setting up the t3 command..." New-Item -ItemType Directory -Force -Path $binDir | Out-Null $shim = Join-Path $binDir "t3.cmd" # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt # non-ASCII characters in the user's home path. [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) -Write-Host "Installed t3 $version" -Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" +if ($interactive) { [Console]::Error.Write("`r$esc[2K") } +[Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n") if (($env:PATH -split ";") -notcontains $binDir) { - Write-Host "Add $binDir to your PATH to run t3." + Write-Host " Add $binDir to your PATH, then run ${bold}t3$reset.`n" +} else { + Write-Host " Run ${bold}t3$reset to get started.`n" } diff --git a/scripts/install.sh b/scripts/install.sh index 22e4b11867a121de9c061e2ead106fb32bcee4c2..421d8d021aefffc76896989f0917c05223e796e4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -23,10 +23,33 @@ t3_home="${T3CODE_HOME:-$HOME/.t3}" bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" fail() { - printf 't3 install: %s\n' "$1" >&2 + printf '\nt3 install: %s\n' "$1" >&2 exit 1 } +# ANSI stays on stderr, so `curl ... | sh` still gets progress. +interactive=false +if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi +reset= bold= muted= accent= green= +if "$interactive" && [ -z "${NO_COLOR:-}" ]; then + reset="$(printf '\033[0m')"; bold="$(printf '\033[1m')" + muted="$(printf '\033[2m')"; accent="$(printf '\033[94m')"; green="$(printf '\033[32m')" +fi +step() { + if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2 + else printf ' %s\n' "$1" >&2; fi +} +if "$interactive"; then + printf '\n%s' "$bold" >&2 + printf ' %s\n' '██████████ ████████ ' >&2 + printf ' %s\n' ' ███ ▄██▀ T3 Code' >&2 + printf ' %s%s %sCLI installer%s\n' ' ███ ████▄ ' "$reset" "$muted" "$reset$bold" >&2 + printf ' %s\n' ' ███ ▄ ███' >&2 + printf ' %s\n' ' ███ ███████▀ ' >&2 + printf '%s\n' "$reset" >&2 +fi +step "Finding your release..." + # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. fetch() { if command -v curl >/dev/null 2>&1; then @@ -45,6 +68,61 @@ fetch() { fi } +mb() { + tenths=$((($1 * 10 + 524288) / 1048576)) + printf '%s.%s' "$((tenths / 10))" "$((tenths % 10))" +} +# Poll the file written by the downloader; no progress-output parsing or extra request. +download() { + if ! "$interactive"; then fetch "$1" "$2"; return; fi + if command -v curl >/dev/null 2>&1; then + curl -fsSL -D "$2.headers" "$1" -o "$2" 2>"$2.errors" & + else + wget -q --server-response "$1" -O "$2" 2>"$2.headers" & + fi + download_pid=$! + previous=-1 + cr="$(printf '\r')" + while kill -0 "$download_pid" 2>/dev/null; do + bytes=0; total=0 + if [ -f "$2" ]; then bytes="$(wc -c < "$2")"; fi + if [ -f "$2.headers" ]; then + while read -r key value; do + case "$key" in + HTTP/*) total=0 ;; + [Cc]ontent-[Ll]ength:) total="${value%"$cr"}" ;; + esac + done < "$2.headers" + fi + case "$total" in ''|*[!0-9]*) total=0 ;; esac + if [ "$bytes" -ne "$previous" ]; then + if [ "$total" -gt 0 ]; then + percent=$((bytes * 100 / total)); [ "$percent" -le 100 ] || percent=100 + filled=$((percent * 32 / 100)); bar=; rest=; n=0 + while [ "$n" -lt 32 ]; do + if [ "$n" -lt "$filled" ]; then bar="${bar}■"; else rest="${rest}·"; fi + n=$((n + 1)) + done + printf '\r\033[2K %s%s%s%s%s %3d%% %s%s / %s MB%s' "$accent" "$bar" "$reset$muted" "$rest" "$reset" "$percent" "$muted" "$(mb "$bytes")" "$(mb "$total")" "$reset" >&2 + else + printf '\r\033[2K %sDownloading%s %s MB' "$muted" "$reset" "$(mb "$bytes")" >&2 + fi + previous="$bytes" + fi + sleep 0.1 + done + result=0; wait "$download_pid" || result=$? + download_pid= + if [ "$result" -ne 0 ]; then + printf '\n' >&2 + if [ -f "$2.errors" ]; then cat "$2.errors" >&2; else cat "$2.headers" >&2; fi + return "$result" + fi + size="$(mb "$(wc -c < "$2")")" + printf '\r\033[2K %s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■%s 100%% %s%s / %s MB%s\n' "$accent" "$reset" "$muted" "$size" "$size" "$reset" >&2 + rm -f "$2.headers" "$2.errors" +} + case "$(uname -s)" in Darwin) platform="darwin" ;; Linux) platform="linux" ;; @@ -100,13 +178,18 @@ versions_dir="${t3_home}/runtime/versions" target_dir="${versions_dir}/${version}" if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then - printf 't3 %s is already installed at %s\n' "$version" "$target_dir" + step "Version ${version} is already downloaded." else mkdir -p "$versions_dir" staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" - trap 'rm -rf "$staging"' EXIT + download_pid= + trap '[ -z "$download_pid" ] || { kill "$download_pid" 2>/dev/null || true; wait "$download_pid" 2>/dev/null || true; }; rm -rf "$staging"' EXIT + trap 'printf "\n" >&2; exit 130' INT + trap 'printf "\n" >&2; exit 143' TERM - printf 'Downloading %s...\n' "$archive" + if "$interactive"; then printf '\r\033[2K' >&2; fi + printf ' %sInstalling%s T3 Code %s%s%s\n\n' "$muted" "$reset" "$bold" "$version" "$reset" >&2 + step "Downloading..." fetch_status=0 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? if [ "$fetch_status" -eq 44 ]; then @@ -114,13 +197,15 @@ else elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi - fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" + download "${base_url}/v${version}/${archive}" "${staging}/${archive}" + step "Verifying the download..." expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" actual="$(checksum "${staging}/${archive}")" [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" + step "Extracting T3 Code..." tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" @@ -131,10 +216,12 @@ else trap - EXIT fi +step "Setting up the t3 command..." mkdir -p "$bin_dir" ln -sfn "${target_dir}/t3" "${bin_dir}/t3" -printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" +if "$interactive"; then printf '\r\033[2K' >&2; fi +printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2 case ":${PATH}:" in - *":${bin_dir}:"*) ;; - *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; + *":${bin_dir}:"*) printf ' Run %st3%s to get started.\n\n' "$bold" "$reset" ;; + *) printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset" ;; esac diff --git a/scripts/install.test.ts b/scripts/install.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ded5cb5116673da2e8aa3c525c34fddb1391bc9 --- /dev/null +++ b/scripts/install.test.ts @@ -0,0 +1,115 @@ +// @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer through a PTY and a gated HTTP fixture. +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +// util-linux's script gives the real installer a terminal without a browser or extra packages. +describe.skipIf(HostProcessPlatform.defaultValue() !== "linux")("installer terminal", () => { + it.each([false, true])( + "preserves download and install behavior (HTTP failure: %s)", + async (fail) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-progress-")); + const version = "1.2.3"; + const stem = `t3-${version}-linux-${HostProcessArchitecture.defaultValue()}`; + const archiveName = `${stem}.tar.gz`; + let resumeDownload: (() => void) | undefined; + let sawPartialProgress = false; + let output = ""; + await NodeFSP.mkdir(NodePath.join(root, stem)); + await NodeFSP.writeFile(NodePath.join(root, stem, "t3"), "#!/bin/sh\necho 't3 v1.2.3'\n", { + mode: 0o755, + }); + await NodeFSP.writeFile( + NodePath.join(root, stem, "payload"), + NodeCrypto.randomBytes(64 * 1024), + ); + NodeChildProcess.execFileSync("tar", [ + "-czf", + NodePath.join(root, archiveName), + "-C", + root, + stem, + ]); + const archive = await NodeFSP.readFile(NodePath.join(root, archiveName)); + const checksum = NodeCrypto.createHash("sha256").update(archive).digest("hex"); + const server = NodeHttp.createServer((request, response) => { + if (request.url?.endsWith("/SHA256SUMS")) { + response.end(`${checksum} ${archiveName}\n`); + } else if (fail) { + response.writeHead(500).end(); + } else { + response.writeHead(200, { "Content-Length": archive.length }); + resumeDownload = () => response.end(archive.subarray(Math.floor(archive.length / 2))); + response.write(archive.subarray(0, Math.floor(archive.length / 2))); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + const installer = NodePath.resolve(import.meta.dirname, "install.sh").replaceAll( + "'", + "'\\''", + ); + const child = NodeChildProcess.spawn("script", ["-qec", `sh '${installer}'`, "/dev/null"], { + env: { + ...process.env, + TERM: "xterm", + NO_COLOR: "1", + T3CODE_VERSION: version, + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), + T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + const collect = (chunk: Buffer) => { + output += chunk.toString(); + if (!sawPartialProgress && /\b[1-9]\d?%/.test(output)) { + sawPartialProgress = true; + resumeDownload?.(); + } + }; + child.stdout.on("data", collect); + child.stderr.on("data", collect); + try { + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); + }); + const versions = NodePath.join(root, "home/runtime/versions"); + if (fail) { + expect(code).not.toBe(0); + expect(output).toContain("500"); + expect(output).not.toContain("100%"); + expect(output).not.toContain("Installed T3 Code"); + expect(await NodeFSP.readdir(versions)).toEqual([]); + } else { + expect(code).toBe(0); + expect(sawPartialProgress).toBe(true); + expect(output).toContain("100%"); + expect(output).toContain("0.1 / 0.1 MB"); + expect(output).toContain("Installed T3 Code 1.2.3"); + expect( + await NodeFSP.readFile(NodePath.join(versions, version, ".install-complete"), "utf8"), + ).toBe("1.2.3\n"); + expect( + NodeChildProcess.execFileSync(NodePath.join(root, "bin/t3"), ["--version"], { + encoding: "utf8", + }).trim(), + ).toBe("t3 v1.2.3"); + expect(await NodeFSP.readdir(versions)).toEqual([version]); + } + } finally { + if (child.exitCode === null) child.kill(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }, + ); +}); -- 2.54.0