| author | |
| committer | |
| log | f1b497a5552abf4616a7477b730b78a1274f3db4 |
| tree | 1ecb8c7f1b02e99da47832b44b08784e637aec7d |
| parent | 52ad70ec43b5dcfcdc72ac79c0964da8e4d163db |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 683 insertions(+), 26 deletions(-)
apps/server/src/cli/update.ts+24-8| ... | @@ -42,6 +42,7 @@ import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/ser | ... | @@ -42,6 +42,7 @@ import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/ser |
| 42 | import * as ProcessRunner from "../processRunner.ts"; | 42 | import * as ProcessRunner from "../processRunner.ts"; |
| 43 | import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; | 43 | import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; |
| 44 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | 44 | import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; |
| 45 | import { createUpdateProgress } from "./updateProgress.ts"; | ||
| 45 | import { bootServiceLayer } from "./service.ts"; | 46 | import { bootServiceLayer } from "./service.ts"; |
| 46 | 47 | ||
| 47 | export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", { | 48 | export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", { |
| ... | @@ -357,7 +358,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -357,7 +358,13 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 357 | reason: `'${input.requestedVersion}' is not an exact t3 version.`, | 358 | reason: `'${input.requestedVersion}' is not an exact t3 version.`, |
| 358 | }); | 359 | }); |
| 359 | } | 360 | } |
| 360 | const targetVersion = input.requestedVersion ?? (yield* resolveNewestVersion(channel)); | 361 | const progress = createUpdateProgress(); |
| 362 | progress.status("Checking for updates..."); | ||
| 363 | const targetVersion = yield* ( | ||
| 364 | input.requestedVersion === undefined | ||
| 365 | ? resolveNewestVersion(channel) | ||
| 366 | : Effect.succeed(input.requestedVersion) | ||
| 367 | ).pipe(Effect.ensuring(Effect.sync(progress.finish))); | ||
| 361 | const targetChannel = cliReleaseChannelOf(targetVersion); | 368 | const targetChannel = cliReleaseChannelOf(targetVersion); |
| 362 | 369 | ||
| 363 | // Preview is a maintainers' dogfooding train: it is cut by hand from | 370 | // 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: { | ... | @@ -446,14 +453,17 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 446 | Effect.orElseSucceed(() => false), | 453 | Effect.orElseSucceed(() => false), |
| 447 | ); | 454 | ); |
| 448 | 455 | ||
| 449 | yield* Console.log( | 456 | progress.heading( |
| 450 | executableCurrent && restartPending | 457 | executableCurrent && restartPending |
| 451 | ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` | 458 | ? `The background service is still running the version before ${targetVersion} (${targetChannel}).` |
| 452 | : executableCurrent | 459 | : executableCurrent |
| 453 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` | 460 | ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).` |
| 454 | : alreadyOnDisk | 461 | : alreadyOnDisk |
| 455 | ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).` | 462 | ? "Switching T3 Code" |
| 456 | : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`, | 463 | : "Updating T3 Code", |
| 464 | executableCurrent | ||
| 465 | ? "" | ||
| 466 | : `${currentVersion} → ${targetVersion}${targetChannel === "stable" ? "" : ` (${targetChannel})`}`, | ||
| 457 | ); | 467 | ); |
| 458 | let restartService = false; | 468 | let restartService = false; |
| 459 | if (serviceInstalled && !serviceCurrent) { | 469 | if (serviceInstalled && !serviceCurrent) { |
| ... | @@ -477,6 +487,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -477,6 +487,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 477 | } | 487 | } |
| 478 | 488 | ||
| 479 | const runtime = yield* ensurePinnedRuntimeInstalled({ | 489 | const runtime = yield* ensurePinnedRuntimeInstalled({ |
| 490 | onProgress: progress.report, | ||
| 480 | baseDir: input.baseDir, | 491 | baseDir: input.baseDir, |
| 481 | version: targetVersion, | 492 | version: targetVersion, |
| 482 | fs, | 493 | fs, |
| ... | @@ -510,6 +521,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -510,6 +521,7 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 510 | ), | 521 | ), |
| 511 | ), | 522 | ), |
| 512 | }).pipe( | 523 | }).pipe( |
| 524 | Effect.ensuring(Effect.sync(progress.finish)), | ||
| 513 | Effect.catchIf( | 525 | Effect.catchIf( |
| 514 | (error): error is PinnedRuntimeInstallError => | 526 | (error): error is PinnedRuntimeInstallError => |
| 515 | error._tag === "PinnedRuntimeInstallError" && | 527 | error._tag === "PinnedRuntimeInstallError" && |
| ... | @@ -539,6 +551,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -539,6 +551,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 539 | // version; only the restart itself waits for the user's answer. | 551 | // version; only the restart itself waits for the user's answer. |
| 540 | let serviceUpdated = false; | 552 | let serviceUpdated = false; |
| 541 | if (serviceInstalled && !serviceCurrent) { | 553 | if (serviceInstalled && !serviceCurrent) { |
| 554 | yield* Console.log( | ||
| 555 | restartService | ||
| 556 | ? "Restarting the background service..." | ||
| 557 | : "Updating the background service...", | ||
| 558 | ); | ||
| 542 | yield* BootService.BootService.pipe( | 559 | yield* BootService.BootService.pipe( |
| 543 | Effect.flatMap((target) => | 560 | Effect.flatMap((target) => |
| 544 | target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), | 561 | target.install({ allowDowngrade: input.allowDowngrade, start: restartService }), |
| ... | @@ -560,12 +577,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -560,12 +577,11 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 560 | serviceUpdated = restartService; | 577 | serviceUpdated = restartService; |
| 561 | } | 578 | } |
| 562 | 579 | ||
| 563 | yield* Console.log(""); | 580 | progress.success(`Installed T3 Code ${targetVersion}`); |
| 564 | yield* Console.log(`t3 ${targetVersion} is installed at ${runtime.entryPath}`); | ||
| 565 | if (Option.isSome(repointed)) { | 581 | if (Option.isSome(repointed)) { |
| 566 | yield* Console.log(` ${repointed.value} now runs ${targetVersion}`); | 582 | yield* Console.log(" Run t3 to get started.\n"); |
| 567 | } else { | 583 | } else { |
| 568 | yield* Console.log(` Run it as ${runtime.entryPath}, or point your \`t3\` launcher at it.`); | 584 | yield* Console.log(` Run ${runtime.entryPath}\n`); |
| 569 | } | 585 | } |
| 570 | if (serviceUpdated) { | 586 | if (serviceUpdated) { |
| 571 | yield* Console.log(` Background service restarted on ${targetVersion}`); | 587 | yield* Console.log(` Background service restarted on ${targetVersion}`); |
apps/server/src/cli/updateProgress.test.ts created+101| ... | @@ -0,0 +1,101 @@ | ||
| 1 | import { afterEach, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { createUpdateProgress } from "./updateProgress.ts"; | ||
| 4 | |||
| 5 | afterEach(() => { | ||
| 6 | vi.unstubAllEnvs(); | ||
| 7 | vi.restoreAllMocks(); | ||
| 8 | }); | ||
| 9 | |||
| 10 | function terminal(isTTY = true, columns = 80) { | ||
| 11 | vi.stubEnv("TERM", "xterm"); | ||
| 12 | vi.stubEnv("NO_COLOR", "1"); | ||
| 13 | let text = ""; | ||
| 14 | const progress = createUpdateProgress({ | ||
| 15 | isTTY, | ||
| 16 | columns, | ||
| 17 | write(chunk: string | Uint8Array) { | ||
| 18 | text += chunk; | ||
| 19 | return true; | ||
| 20 | }, | ||
| 21 | }); | ||
| 22 | return { ...progress, text: () => text }; | ||
| 23 | } | ||
| 24 | |||
| 25 | it("keeps redirected output readable without per-chunk updates or escapes", () => { | ||
| 26 | const output = terminal(false); | ||
| 27 | for (let received = 0; received <= 100; received++) { | ||
| 28 | output.report({ stage: "download", received, total: 100 }); | ||
| 29 | } | ||
| 30 | output.report({ stage: "verify" }); | ||
| 31 | output.report({ stage: "extract" }); | ||
| 32 | output.report({ stage: "validate" }); | ||
| 33 | output.finish(); | ||
| 34 | expect(output.text()).toBe( | ||
| 35 | " Downloading...\n Verifying the download...\n Extracting T3 Code...\n Checking the new executable...\n", | ||
| 36 | ); | ||
| 37 | }); | ||
| 38 | |||
| 39 | it("throttles redraws, shows the final byte count, and closes the line once", () => { | ||
| 40 | const now = vi.spyOn(performance, "now").mockReturnValue(0); | ||
| 41 | const output = terminal(); | ||
| 42 | output.report({ stage: "download", received: 0, total: 1024 ** 2 }); | ||
| 43 | const initial = output.text(); | ||
| 44 | for (let received = 1; received < 100; received++) { | ||
| 45 | output.report({ stage: "download", received, total: 1024 ** 2 }); | ||
| 46 | } | ||
| 47 | expect(output.text()).toBe(initial); | ||
| 48 | now.mockReturnValue(100); | ||
| 49 | output.report({ stage: "download", received: 524288, total: 1024 ** 2 }); | ||
| 50 | output.report({ stage: "download", received: 1024 ** 2, total: 1024 ** 2 }); | ||
| 51 | output.finish(); | ||
| 52 | output.finish(); | ||
| 53 | expect(output.text().match(/100%/g)).toHaveLength(1); | ||
| 54 | expect(output.text()).toContain("50% 0.5 / 1.0 MB"); | ||
| 55 | expect(output.text()).toMatch(/100% 1.0 \/ 1.0 MB\n$/); | ||
| 56 | }); | ||
| 57 | |||
| 58 | it("shows bytes for an unknown size and leaves a clean line on interruption", () => { | ||
| 59 | const output = terminal(); | ||
| 60 | output.report({ stage: "download", received: 524288, total: undefined }); | ||
| 61 | output.finish(); | ||
| 62 | expect(output.text()).toMatch(/0.5 MB\n$/); | ||
| 63 | expect(output.text()).not.toContain("%"); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it.each([1, 2, 7, 8, 9, 30])("fits a %i-column terminal without wrapping", (columns) => { | ||
| 67 | const output = terminal(true, columns); | ||
| 68 | output.report({ stage: "download", received: 50, total: 100 }); | ||
| 69 | for (const line of output.text().split("\r\x1b[2K")) { | ||
| 70 | expect(line.length).toBeLessThan(columns); | ||
| 71 | } | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("draws the final size of a fast chunked download even inside the throttle window", () => { | ||
| 75 | vi.spyOn(performance, "now").mockReturnValue(0); | ||
| 76 | const output = terminal(); | ||
| 77 | output.report({ stage: "download", received: 0, total: undefined }); | ||
| 78 | output.report({ stage: "download", received: 524288, total: undefined }); | ||
| 79 | output.report({ stage: "download", received: 524288, total: 524288 }); | ||
| 80 | output.report({ stage: "verify" }); | ||
| 81 | expect(output.text()).toContain("100% 0.5 / 0.5 MB\n\r\x1b[2K Verifying"); | ||
| 82 | }); | ||
| 83 | |||
| 84 | it("uses color only in capable terminals that have not requested NO_COLOR", () => { | ||
| 85 | const output = terminal(); | ||
| 86 | // Create a new renderer after changing the color preference. | ||
| 87 | vi.stubEnv("NO_COLOR", undefined); | ||
| 88 | const chunks: string[] = []; | ||
| 89 | const progress = createUpdateProgress({ | ||
| 90 | isTTY: true, | ||
| 91 | columns: 80, | ||
| 92 | write(chunk) { | ||
| 93 | chunks.push(String(chunk)); | ||
| 94 | return true; | ||
| 95 | }, | ||
| 96 | }); | ||
| 97 | progress.report({ stage: "download", received: 50, total: 100 }); | ||
| 98 | expect(chunks.join("")).toContain("\x1b[94m"); | ||
| 99 | output.report({ stage: "download", received: 50, total: 100 }); | ||
| 100 | expect(output.text()).not.toContain("\x1b[94m"); | ||
| 101 | }); | ||
apps/server/src/cli/updateProgress.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import type { PinnedRuntimeProgress } from "../cloud/pinnedRuntime.ts"; | ||
| 2 | |||
| 3 | /** A single status line below the download bar; redirected output remains plain. */ | ||
| 4 | export function createUpdateProgress( | ||
| 5 | output: Pick<NodeJS.WriteStream, "write" | "isTTY" | "columns"> = process.stderr, | ||
| 6 | ) { | ||
| 7 | const interactive = output.isTTY && process.env.TERM !== "dumb"; | ||
| 8 | const color = interactive && !process.env.NO_COLOR; | ||
| 9 | const style = (code: number, text: string) => (color ? `\x1b[${code}m${text}\x1b[0m` : text); | ||
| 10 | let stage: PinnedRuntimeProgress["stage"] | undefined; | ||
| 11 | let lastDraw = -Infinity; | ||
| 12 | let lineOpen = false; | ||
| 13 | let downloadLine = false; | ||
| 14 | const finish = () => { | ||
| 15 | if (lineOpen) output.write(downloadLine ? "\n" : "\r\x1b[2K"); | ||
| 16 | lineOpen = false; | ||
| 17 | }; | ||
| 18 | const status = (message: string) => { | ||
| 19 | finish(); | ||
| 20 | let line = ` ${message}`; | ||
| 21 | if (interactive) line = line.slice(0, Math.max(0, (output.columns || 80) - 1)); | ||
| 22 | output.write(interactive ? `\r\x1b[2K${style(2, line)}` : `${line}\n`); | ||
| 23 | lineOpen = Boolean(interactive); | ||
| 24 | downloadLine = false; | ||
| 25 | }; | ||
| 26 | return { | ||
| 27 | finish, | ||
| 28 | status, | ||
| 29 | heading(message: string, detail = "") { | ||
| 30 | finish(); | ||
| 31 | output.write(` ${style(2, message)}${detail ? ` ${style(1, detail)}` : ""}\n\n`); | ||
| 32 | }, | ||
| 33 | success(message: string) { | ||
| 34 | finish(); | ||
| 35 | output.write(` ${style(32, message)}\n\n`); | ||
| 36 | }, | ||
| 37 | report(progress: PinnedRuntimeProgress) { | ||
| 38 | if (progress.stage !== stage) { | ||
| 39 | stage = progress.stage; | ||
| 40 | const labels = { | ||
| 41 | download: "Downloading...", | ||
| 42 | verify: "Verifying the download...", | ||
| 43 | extract: "Extracting T3 Code...", | ||
| 44 | validate: "Checking the new executable...", | ||
| 45 | cached: "Using the downloaded release...", | ||
| 46 | }; | ||
| 47 | status(labels[stage]); | ||
| 48 | } | ||
| 49 | if (progress.stage !== "download" || !interactive) return; | ||
| 50 | const now = performance.now(); | ||
| 51 | if (now - lastDraw < 100 && progress.received !== progress.total) return; | ||
| 52 | lastDraw = now; | ||
| 53 | const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1); | ||
| 54 | const { received, total } = progress; | ||
| 55 | const columns = output.columns || 80; | ||
| 56 | let line = | ||
| 57 | columns >= 32 | ||
| 58 | ? ` ${style(2, "Downloading")} ${mb(received)} MB` | ||
| 59 | : ` ${mb(received)} MB`.slice(0, Math.max(0, columns - 1)); | ||
| 60 | if (total !== undefined && total > 0 && columns >= 9) { | ||
| 61 | const percent = Math.min(100, Math.floor((received / total) * 100)); | ||
| 62 | const width = Math.max(1, Math.min(32, columns - 10)); | ||
| 63 | const filled = Math.floor((percent * width) / 100); | ||
| 64 | line = ` ${style(94, "■".repeat(filled))}${style(2, "·".repeat(width - filled))} ${String(percent).padStart(3)}%`; | ||
| 65 | if (columns >= 68) line += ` ${style(2, `${mb(received)} / ${mb(total)} MB`)}`; | ||
| 66 | } | ||
| 67 | output.write(`\r\x1b[2K${line}`); | ||
| 68 | lineOpen = true; | ||
| 69 | downloadLine = true; | ||
| 70 | }, | ||
| 71 | }; | ||
| 72 | } | ||
apps/server/src/cloud/pinnedRuntime.test.ts+121| ... | @@ -14,6 +14,7 @@ import { | ... | @@ -14,6 +14,7 @@ import { |
| 14 | pinnedRuntimeCommand, | 14 | pinnedRuntimeCommand, |
| 15 | pinnedRuntimePaths, | 15 | pinnedRuntimePaths, |
| 16 | PinnedRuntimeInstallError, | 16 | PinnedRuntimeInstallError, |
| 17 | type PinnedRuntimeProgress, | ||
| 17 | } from "./pinnedRuntime.ts"; | 18 | } from "./pinnedRuntime.ts"; |
| 18 | 19 | ||
| 19 | // Every install fetches the release archive, checks it against SHA256SUMS, | 20 | // Every install fetches the release archive, checks it against SHA256SUMS, |
| ... | @@ -97,6 +98,126 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -97,6 +98,126 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 97 | }), | 98 | }), |
| 98 | ); | 99 | ); |
| 99 | 100 | ||
| 101 | it.effect.each([true, false])( | ||
| 102 | "reports bytes before completion, then verifies and extracts (known size: %s)", | ||
| 103 | (knownSize) => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | const fs = yield* FileSystem.FileSystem; | ||
| 106 | const path = yield* Path.Path; | ||
| 107 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-" }); | ||
| 108 | const firstChunk = yield* Deferred.make<void>(); | ||
| 109 | let archiveController: ReadableStreamDefaultController<Uint8Array> | undefined; | ||
| 110 | const checksums = yield* validChecksums; | ||
| 111 | const progress: PinnedRuntimeProgress[] = []; | ||
| 112 | const client = HttpClient.make((request) => | ||
| 113 | Effect.succeed( | ||
| 114 | HttpClientResponse.fromWeb( | ||
| 115 | request, | ||
| 116 | request.url.endsWith("/SHA256SUMS") | ||
| 117 | ? new Response(checksums) | ||
| 118 | : new Response( | ||
| 119 | new ReadableStream({ | ||
| 120 | start(controller) { | ||
| 121 | archiveController = controller; | ||
| 122 | controller.enqueue(archiveBytes.slice(0, 4)); | ||
| 123 | }, | ||
| 124 | }), | ||
| 125 | { headers: knownSize ? { "content-length": String(archiveBytes.length) } : {} }, | ||
| 126 | ), | ||
| 127 | ), | ||
| 128 | ), | ||
| 129 | ); | ||
| 130 | const install = yield* ensurePinnedRuntimeInstalled({ | ||
| 131 | baseDir, | ||
| 132 | version, | ||
| 133 | fs, | ||
| 134 | path, | ||
| 135 | platform: "linux", | ||
| 136 | arch: "x64", | ||
| 137 | httpClient: client, | ||
| 138 | runner: extractingRunner(fs, path), | ||
| 139 | validate: () => Effect.void, | ||
| 140 | onProgress: (event) => { | ||
| 141 | progress.push(event); | ||
| 142 | if (event.stage === "download" && event.received === 4) { | ||
| 143 | Deferred.doneUnsafe(firstChunk, Effect.void); | ||
| 144 | } | ||
| 145 | }, | ||
| 146 | }).pipe(Effect.forkScoped); | ||
| 147 | yield* Deferred.await(firstChunk); | ||
| 148 | assert.deepEqual(progress.at(-1), { | ||
| 149 | stage: "download", | ||
| 150 | received: 4, | ||
| 151 | total: knownSize ? archiveBytes.length : undefined, | ||
| 152 | }); | ||
| 153 | assert.isFalse(progress.some((event) => event.stage === "extract")); | ||
| 154 | assert.isDefined(archiveController); | ||
| 155 | archiveController!.enqueue(archiveBytes.slice(4)); | ||
| 156 | archiveController!.close(); | ||
| 157 | const installed = yield* Fiber.join(install); | ||
| 158 | assert.deepEqual(progress.slice(-4), [ | ||
| 159 | { stage: "download", received: archiveBytes.length, total: archiveBytes.length }, | ||
| 160 | { stage: "verify" }, | ||
| 161 | { stage: "extract" }, | ||
| 162 | { stage: "validate" }, | ||
| 163 | ]); | ||
| 164 | assert.equal(yield* fs.readFileString(installed.sentinelPath), `${version}\n`); | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 168 | it.effect("cleans up an interrupted download without reporting verification or extraction", () => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const fs = yield* FileSystem.FileSystem; | ||
| 171 | const path = yield* Path.Path; | ||
| 172 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-progress-failed-" }); | ||
| 173 | const checksums = yield* validChecksums; | ||
| 174 | const progress: PinnedRuntimeProgress[] = []; | ||
| 175 | let cancelled = false; | ||
| 176 | const client = HttpClient.make((request) => | ||
| 177 | Effect.succeed( | ||
| 178 | HttpClientResponse.fromWeb( | ||
| 179 | request, | ||
| 180 | request.url.endsWith("/SHA256SUMS") | ||
| 181 | ? new Response(checksums) | ||
| 182 | : new Response( | ||
| 183 | new ReadableStream({ | ||
| 184 | start(controller) { | ||
| 185 | controller.enqueue(archiveBytes.slice(0, 4)); | ||
| 186 | }, | ||
| 187 | cancel() { | ||
| 188 | cancelled = true; | ||
| 189 | }, | ||
| 190 | }), | ||
| 191 | ), | ||
| 192 | ), | ||
| 193 | ), | ||
| 194 | ); | ||
| 195 | const firstChunk = yield* Deferred.make<void>(); | ||
| 196 | const install = yield* ensurePinnedRuntimeInstalled({ | ||
| 197 | baseDir, | ||
| 198 | version, | ||
| 199 | fs, | ||
| 200 | path, | ||
| 201 | platform: "linux", | ||
| 202 | arch: "x64", | ||
| 203 | httpClient: client, | ||
| 204 | runner: extractingRunner(fs, path), | ||
| 205 | validate: () => Effect.die("must not validate an interrupted archive"), | ||
| 206 | onProgress: (event) => { | ||
| 207 | progress.push(event); | ||
| 208 | if (event.stage === "download" && event.received === 4) | ||
| 209 | Deferred.doneUnsafe(firstChunk, Effect.void); | ||
| 210 | }, | ||
| 211 | }).pipe(Effect.forkScoped); | ||
| 212 | yield* Deferred.await(firstChunk); | ||
| 213 | yield* Fiber.interrupt(install); | ||
| 214 | assert.deepEqual(progress.at(-1), { stage: "download", received: 4, total: undefined }); | ||
| 215 | assert.isTrue(progress.every((event) => event.stage === "download")); | ||
| 216 | assert.isTrue(cancelled); | ||
| 217 | assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); | ||
| 218 | }), | ||
| 219 | ); | ||
| 220 | |||
| 100 | it.effect("refuses an archive whose checksum does not match the release", () => | 221 | it.effect("refuses an archive whose checksum does not match the release", () => |
| 101 | Effect.gen(function* () { | 222 | Effect.gen(function* () { |
| 102 | const fs = yield* FileSystem.FileSystem; | 223 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/cloud/pinnedRuntime.ts+43-2| ... | @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -5,6 +5,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; | 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Stream from "effect/Stream"; | ||
| 8 | import * as Semaphore from "effect/Semaphore"; | 9 | import * as Semaphore from "effect/Semaphore"; |
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 10 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 10 | 11 | ||
| ... | @@ -98,6 +99,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne | ... | @@ -98,6 +99,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne |
| 98 | } | 99 | } |
| 99 | } | 100 | } |
| 100 | 101 | ||
| 102 | export type PinnedRuntimeProgress = | ||
| 103 | | { readonly stage: "download"; readonly received: number; readonly total: number | undefined } | ||
| 104 | | { readonly stage: "verify" | "extract" | "validate" | "cached" }; | ||
| 105 | |||
| 101 | /** | 106 | /** |
| 102 | * Installs the t3 release archive for `version` into the pinned runtime | 107 | * Installs the t3 release archive for `version` into the pinned runtime |
| 103 | * directory unless a complete install is already there, and returns its | 108 | * directory unless a complete install is already there, and returns its |
| ... | @@ -106,6 +111,7 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne | ... | @@ -106,6 +111,7 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne |
| 106 | * executable before the last native package and a killed install leaves a | 111 | * executable before the last native package and a killed install leaves a |
| 107 | * plausible-looking but broken tree behind. | 112 | * plausible-looking but broken tree behind. |
| 108 | */ | 113 | */ |
| 114 | |||
| 109 | interface PinnedRuntimeInstallInput { | 115 | interface PinnedRuntimeInstallInput { |
| 110 | readonly baseDir: string; | 116 | readonly baseDir: string; |
| 111 | readonly version: string; | 117 | readonly version: string; |
| ... | @@ -119,19 +125,48 @@ interface PinnedRuntimeInstallInput { | ... | @@ -119,19 +125,48 @@ interface PinnedRuntimeInstallInput { |
| 119 | readonly arch: string; | 125 | readonly arch: string; |
| 120 | readonly httpClient: HttpClient.HttpClient; | 126 | readonly httpClient: HttpClient.HttpClient; |
| 121 | readonly releaseBaseUrl?: string | undefined; | 127 | readonly releaseBaseUrl?: string | undefined; |
| 128 | readonly onProgress?: (progress: PinnedRuntimeProgress) => void; | ||
| 122 | } | 129 | } |
| 123 | 130 | ||
| 124 | const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( | 131 | const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( |
| 125 | httpClient: HttpClient.HttpClient, | 132 | httpClient: HttpClient.HttpClient, |
| 126 | url: string, | 133 | url: string, |
| 127 | step: string, | 134 | step: string, |
| 135 | onProgress?: (progress: PinnedRuntimeProgress) => void, | ||
| 128 | ) { | 136 | ) { |
| 129 | // The install lock is held for the whole transaction, so a stalled download | 137 | // The install lock is held for the whole transaction, so a stalled download |
| 130 | // must fail rather than block every other caller. | 138 | // must fail rather than block every other caller. |
| 131 | return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( | 139 | return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( |
| 132 | Effect.flatMap(HttpClientResponse.filterStatusOk), | 140 | Effect.flatMap(HttpClientResponse.filterStatusOk), |
| 133 | Effect.flatMap((response) => response.arrayBuffer), | 141 | Effect.flatMap( |
| 134 | Effect.map((buffer) => new Uint8Array(buffer)), | 142 | Effect.fn(function* (response) { |
| 143 | if (onProgress === undefined) return new Uint8Array(yield* response.arrayBuffer); | ||
| 144 | const length = Number(response.headers["content-length"]); | ||
| 145 | const total = Number.isFinite(length) && length > 0 ? length : undefined; | ||
| 146 | let received = 0; | ||
| 147 | onProgress({ stage: "download", received, total }); | ||
| 148 | const chunks = yield* response.stream.pipe( | ||
| 149 | Stream.tap((chunk) => | ||
| 150 | Effect.sync(() => { | ||
| 151 | received += chunk.byteLength; | ||
| 152 | onProgress({ stage: "download", received, total }); | ||
| 153 | }), | ||
| 154 | ), | ||
| 155 | Stream.runCollect, | ||
| 156 | ); | ||
| 157 | // A completed chunked response finally gives us its total size. | ||
| 158 | if (total === undefined && received > 0) { | ||
| 159 | onProgress({ stage: "download", received, total: received }); | ||
| 160 | } | ||
| 161 | const bytes = new Uint8Array(received); | ||
| 162 | let offset = 0; | ||
| 163 | for (const chunk of chunks) { | ||
| 164 | bytes.set(chunk, offset); | ||
| 165 | offset += chunk.byteLength; | ||
| 166 | } | ||
| 167 | return bytes; | ||
| 168 | }), | ||
| 169 | ), | ||
| 135 | Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), | 170 | Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), |
| 136 | Effect.timeoutOrElse({ | 171 | Effect.timeoutOrElse({ |
| 137 | duration: PINNED_RUNTIME_INSTALL_TIMEOUT, | 172 | duration: PINNED_RUNTIME_INSTALL_TIMEOUT, |
| ... | @@ -161,6 +196,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun | ... | @@ -161,6 +196,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun |
| 161 | const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); | 196 | const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); |
| 162 | const fileName = cliArchiveFileName(input.version, platformKey); | 197 | const fileName = cliArchiveFileName(input.version, platformKey); |
| 163 | 198 | ||
| 199 | input.onProgress?.({ stage: "download", received: 0, total: undefined }); | ||
| 164 | const checksums = parseChecksums( | 200 | const checksums = parseChecksums( |
| 165 | new TextDecoder().decode( | 201 | new TextDecoder().decode( |
| 166 | yield* fetchReleaseAsset( | 202 | yield* fetchReleaseAsset( |
| ... | @@ -180,7 +216,9 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun | ... | @@ -180,7 +216,9 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun |
| 180 | httpClient, | 216 | httpClient, |
| 181 | `${baseUrl}/${fileName}`, | 217 | `${baseUrl}/${fileName}`, |
| 182 | "downloading the t3 release archive", | 218 | "downloading the t3 release archive", |
| 219 | input.onProgress, | ||
| 183 | ); | 220 | ); |
| 221 | input.onProgress?.({ stage: "verify" }); | ||
| 184 | const digest = yield* Effect.tryPromise({ | 222 | const digest = yield* Effect.tryPromise({ |
| 185 | try: () => crypto.subtle.digest("SHA-256", archive), | 223 | try: () => crypto.subtle.digest("SHA-256", archive), |
| 186 | catch: (cause) => | 224 | catch: (cause) => |
| ... | @@ -200,6 +238,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun | ... | @@ -200,6 +238,7 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun |
| 200 | (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), | 238 | (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), |
| 201 | ), | 239 | ), |
| 202 | ); | 240 | ); |
| 241 | input.onProgress?.({ stage: "extract" }); | ||
| 203 | const extractStep = "extracting the t3 release archive"; | 242 | const extractStep = "extracting the t3 release archive"; |
| 204 | // The archive wraps everything in one directory named after its stem; | 243 | // The archive wraps everything in one directory named after its stem; |
| 205 | // strip it so the executable lands at <versionDir>/t3. | 244 | // strip it so the executable lands at <versionDir>/t3. |
| ... | @@ -242,6 +281,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( | ... | @@ -242,6 +281,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 242 | const alreadyPinned = | 281 | const alreadyPinned = |
| 243 | entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; | 282 | entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.version; |
| 244 | if (alreadyPinned) { | 283 | if (alreadyPinned) { |
| 284 | input.onProgress?.({ stage: "cached" }); | ||
| 245 | yield* input.validate(paths); | 285 | yield* input.validate(paths); |
| 246 | return paths; | 286 | return paths; |
| 247 | } | 287 | } |
| ... | @@ -290,6 +330,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( | ... | @@ -290,6 +330,7 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 290 | return yield* Effect.gen(function* () { | 330 | return yield* Effect.gen(function* () { |
| 291 | yield* installFromArchive(input, stagingDir); | 331 | yield* installFromArchive(input, stagingDir); |
| 292 | 332 | ||
| 333 | input.onProgress?.({ stage: "validate" }); | ||
| 293 | yield* input.validate(stagingPaths); | 334 | yield* input.validate(stagingPaths); |
| 294 | yield* fs | 335 | yield* fs |
| 295 | .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) | 336 | .writeFileString(stagingPaths.sentinelPath, `${input.version}\n`) |
scripts/install.ps1+112-8| ... | @@ -26,6 +26,97 @@ function Fail([string] $message) { | ... | @@ -26,6 +26,97 @@ function Fail([string] $message) { |
| 26 | exit 1 | 26 | exit 1 |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | $terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb" | ||
| 30 | $interactive = $terminal -and $Host.UI.SupportsVirtualTerminal | ||
| 31 | if ($interactive) { | ||
| 32 | # Legacy code pages may support ANSI escapes but not the logo or bar glyphs. | ||
| 33 | $glyphs = -join [char[]](0x2588, 0x2580, 0x2584, 0x25A0, 0x00B7) | ||
| 34 | $encoding = [Console]::Error.Encoding | ||
| 35 | $interactive = $encoding.GetString($encoding.GetBytes($glyphs)) -eq $glyphs | ||
| 36 | } | ||
| 37 | $esc = [char]27 | ||
| 38 | $reset = $bold = $muted = $accent = $green = "" | ||
| 39 | if ($interactive -and -not $env:NO_COLOR) { | ||
| 40 | $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m" | ||
| 41 | $accent = "$esc[94m"; $green = "$esc[32m" | ||
| 42 | } | ||
| 43 | function Step([string] $message) { | ||
| 44 | if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") } | ||
| 45 | else { [Console]::Error.WriteLine(" $message") } | ||
| 46 | } | ||
| 47 | function Draw-Download([long] $bytes, [long] $total) { | ||
| 48 | if (-not $interactive) { return } | ||
| 49 | $columns = [Console]::WindowWidth | ||
| 50 | if ($columns -le 0) { $columns = 80 } | ||
| 51 | if ($total -gt 0 -and $columns -ge 9) { | ||
| 52 | $percent = [Math]::Min(100, [Math]::Floor($bytes * 100.0 / $total)) | ||
| 53 | $width = [Math]::Min(32, $columns - 8) | ||
| 54 | $filled = [int][Math]::Floor($percent * $width / 100) | ||
| 55 | $bar = ([string][char]0x25A0) * $filled | ||
| 56 | $rest = ([string][char]0x00B7) * ($width - $filled) | ||
| 57 | $sizes = (" {0:F1} / {1:F1} MB" -f ($bytes / 1MB), ($total / 1MB)) | ||
| 58 | if ($width + 7 + $sizes.Length -ge $columns) { $sizes = "" } | ||
| 59 | [Console]::Error.Write(("`r$esc[2K $accent$bar$reset$muted$rest$reset {0,3}%" -f $percent) + "$muted$sizes$reset") | ||
| 60 | } else { | ||
| 61 | $line = if ($columns -ge 32) { " Downloading {0:F1} MB" -f ($bytes / 1MB) } else { " {0:F1} MB" -f ($bytes / 1MB) } | ||
| 62 | [Console]::Error.Write("`r$esc[2K" + $line.Substring(0, [Math]::Min($line.Length, $columns - 1))) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | function Fetch([string] $uri, [string] $destination, [switch] $progress) { | ||
| 66 | if (-not $progress -or -not $interactive) { | ||
| 67 | $ProgressPreference = if ($progress -and $terminal) { "Continue" } else { "SilentlyContinue" } | ||
| 68 | Invoke-WebRequest -Uri $uri -OutFile $destination -UseBasicParsing | ||
| 69 | return | ||
| 70 | } | ||
| 71 | # Read the response once, displaying actual bytes received at most ten times a second. | ||
| 72 | Add-Type -AssemblyName System.Net.Http | ||
| 73 | $client = New-Object System.Net.Http.HttpClient | ||
| 74 | $response = $source = $file = $null | ||
| 75 | try { | ||
| 76 | $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() | ||
| 77 | $response.EnsureSuccessStatusCode() | Out-Null | ||
| 78 | $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() | ||
| 79 | $file = [IO.File]::Create($destination) | ||
| 80 | $buffer = New-Object byte[] 65536 | ||
| 81 | $bytes = 0L | ||
| 82 | $clock = [Diagnostics.Stopwatch]::StartNew() | ||
| 83 | Draw-Download 0 $response.Content.Headers.ContentLength | ||
| 84 | while (($count = $source.Read($buffer, 0, $buffer.Length)) -gt 0) { | ||
| 85 | $file.Write($buffer, 0, $count) | ||
| 86 | $bytes += $count | ||
| 87 | if ($clock.ElapsedMilliseconds -ge 100) { | ||
| 88 | Draw-Download $bytes $response.Content.Headers.ContentLength | ||
| 89 | $clock.Restart() | ||
| 90 | } | ||
| 91 | } | ||
| 92 | Draw-Download $bytes $bytes | ||
| 93 | [Console]::Error.WriteLine() | ||
| 94 | } finally { | ||
| 95 | if ($file) { $file.Dispose() } | ||
| 96 | if ($source) { $source.Dispose() } | ||
| 97 | if ($response) { $response.Dispose() } | ||
| 98 | $client.Dispose() | ||
| 99 | } | ||
| 100 | } | ||
| 101 | if ($interactive) { | ||
| 102 | # Block characters are constructed so this file also loads correctly in PowerShell 5.1. | ||
| 103 | $mark = @( | ||
| 104 | "########## ######## ", | ||
| 105 | " ### _##^ ", | ||
| 106 | " ### ####_ ", | ||
| 107 | " ### _ ###", | ||
| 108 | " ### #######^ " | ||
| 109 | ) | ||
| 110 | [Console]::Error.WriteLine() | ||
| 111 | for ($i = 0; $i -lt $mark.Length; $i++) { | ||
| 112 | $row = $mark[$i].Replace('#', [char]0x2588).Replace('^', [char]0x2580).Replace('_', [char]0x2584) | ||
| 113 | $label = if ($i -eq 1) { " ${bold}T3 Code$reset" } elseif ($i -eq 2) { " ${muted}CLI installer$reset" } else { "" } | ||
| 114 | [Console]::Error.WriteLine(" $bold$row$reset$label") | ||
| 115 | } | ||
| 116 | [Console]::Error.WriteLine() | ||
| 117 | } | ||
| 118 | Step "Finding your release..." | ||
| 119 | |||
| 29 | # PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell | 120 | # PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell |
| 30 | # runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts | 121 | # runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts |
| 31 | # may lack. | 122 | # may lack. |
| ... | @@ -67,15 +158,17 @@ $targetDir = Join-Path $versionsDir $version | ... | @@ -67,15 +158,17 @@ $targetDir = Join-Path $versionsDir $version |
| 67 | $marker = Join-Path $targetDir ".install-complete" | 158 | $marker = Join-Path $targetDir ".install-complete" |
| 68 | 159 | ||
| 69 | if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { | 160 | if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { |
| 70 | Write-Host "t3 $version is already installed at $targetDir" | 161 | Step "Version $version is already downloaded." |
| 71 | } else { | 162 | } else { |
| 72 | New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null | 163 | New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null |
| 73 | $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) | 164 | $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) |
| 74 | New-Item -ItemType Directory -Path $staging | Out-Null | 165 | New-Item -ItemType Directory -Path $staging | Out-Null |
| 75 | try { | 166 | try { |
| 76 | Write-Host "Downloading $archive..." | 167 | if ($interactive) { [Console]::Error.Write("`r$esc[2K") } |
| 168 | [Console]::Error.WriteLine(" ${muted}Installing$reset T3 Code $bold$version$reset`n") | ||
| 169 | Step "Downloading..." | ||
| 77 | try { | 170 | try { |
| 78 | Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing | 171 | Fetch "$baseUrl/v$version/SHA256SUMS" (Join-Path $staging "SHA256SUMS") |
| 79 | } catch { | 172 | } catch { |
| 80 | $status = $_.Exception.Response.StatusCode.value__ | 173 | $status = $_.Exception.Response.StatusCode.value__ |
| 81 | if ($status -eq 404) { | 174 | if ($status -eq 404) { |
| ... | @@ -83,7 +176,9 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { | ... | @@ -83,7 +176,9 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { |
| 83 | } | 176 | } |
| 84 | throw | 177 | throw |
| 85 | } | 178 | } |
| 86 | Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing | 179 | Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress |
| 180 | |||
| 181 | Step "Verifying the download..." | ||
| 87 | 182 | ||
| 88 | $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) | 183 | $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) |
| 89 | if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } | 184 | 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)) { | ... | @@ -91,7 +186,13 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { |
| 91 | $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() | 186 | $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() |
| 92 | if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } | 187 | if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } |
| 93 | 188 | ||
| 94 | Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force | 189 | Step "Extracting T3 Code..." |
| 190 | # The archive module reads the global preference, not the caller's local scope. | ||
| 191 | $savedProgress = $global:ProgressPreference | ||
| 192 | try { | ||
| 193 | $global:ProgressPreference = "SilentlyContinue" | ||
| 194 | Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force | ||
| 195 | } finally { $global:ProgressPreference = $savedProgress } | ||
| 95 | # The archive wraps everything in one directory named after its stem. | 196 | # The archive wraps everything in one directory named after its stem. |
| 96 | Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging | 197 | Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging |
| 97 | Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force | 198 | 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)) { | ... | @@ -108,13 +209,16 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { |
| 108 | } | 209 | } |
| 109 | } | 210 | } |
| 110 | 211 | ||
| 212 | Step "Setting up the t3 command..." | ||
| 111 | New-Item -ItemType Directory -Force -Path $binDir | Out-Null | 213 | New-Item -ItemType Directory -Force -Path $binDir | Out-Null |
| 112 | $shim = Join-Path $binDir "t3.cmd" | 214 | $shim = Join-Path $binDir "t3.cmd" |
| 113 | # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt | 215 | # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt |
| 114 | # non-ASCII characters in the user's home path. | 216 | # non-ASCII characters in the user's home path. |
| 115 | [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) | 217 | [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) |
| 116 | Write-Host "Installed t3 $version" | 218 | if ($interactive) { [Console]::Error.Write("`r$esc[2K") } |
| 117 | Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" | 219 | [Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n") |
| 118 | if (($env:PATH -split ";") -notcontains $binDir) { | 220 | if (($env:PATH -split ";") -notcontains $binDir) { |
| 119 | Write-Host "Add $binDir to your PATH to run t3." | 221 | Write-Host " Add $binDir to your PATH, then run ${bold}t3$reset.`n" |
| 222 | } else { | ||
| 223 | Write-Host " Run ${bold}t3$reset to get started.`n" | ||
| 120 | } | 224 | } |
scripts/install.sh+95-8| ... | @@ -23,10 +23,33 @@ t3_home="${T3CODE_HOME:-$HOME/.t3}" | ... | @@ -23,10 +23,33 @@ t3_home="${T3CODE_HOME:-$HOME/.t3}" |
| 23 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" | 23 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" |
| 24 | 24 | ||
| 25 | fail() { | 25 | fail() { |
| 26 | printf 't3 install: %s\n' "$1" >&2 | 26 | printf '\nt3 install: %s\n' "$1" >&2 |
| 27 | exit 1 | 27 | exit 1 |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | # ANSI stays on stderr, so `curl ... | sh` still gets progress. | ||
| 31 | interactive=false | ||
| 32 | if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi | ||
| 33 | reset= bold= muted= accent= green= | ||
| 34 | if "$interactive" && [ -z "${NO_COLOR:-}" ]; then | ||
| 35 | reset="$(printf '\033[0m')"; bold="$(printf '\033[1m')" | ||
| 36 | muted="$(printf '\033[2m')"; accent="$(printf '\033[94m')"; green="$(printf '\033[32m')" | ||
| 37 | fi | ||
| 38 | step() { | ||
| 39 | if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2 | ||
| 40 | else printf ' %s\n' "$1" >&2; fi | ||
| 41 | } | ||
| 42 | if "$interactive"; then | ||
| 43 | printf '\n%s' "$bold" >&2 | ||
| 44 | printf ' %s\n' '██████████ ████████ ' >&2 | ||
| 45 | printf ' %s\n' ' ███ ▄██▀ T3 Code' >&2 | ||
| 46 | printf ' %s%s %sCLI installer%s\n' ' ███ ████▄ ' "$reset" "$muted" "$reset$bold" >&2 | ||
| 47 | printf ' %s\n' ' ███ ▄ ███' >&2 | ||
| 48 | printf ' %s\n' ' ███ ███████▀ ' >&2 | ||
| 49 | printf '%s\n' "$reset" >&2 | ||
| 50 | fi | ||
| 51 | step "Finding your release..." | ||
| 52 | |||
| 30 | # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. | 53 | # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. |
| 31 | fetch() { | 54 | fetch() { |
| 32 | if command -v curl >/dev/null 2>&1; then | 55 | if command -v curl >/dev/null 2>&1; then |
| ... | @@ -45,6 +68,61 @@ fetch() { | ... | @@ -45,6 +68,61 @@ fetch() { |
| 45 | fi | 68 | fi |
| 46 | } | 69 | } |
| 47 | 70 | ||
| 71 | mb() { | ||
| 72 | tenths=$((($1 * 10 + 524288) / 1048576)) | ||
| 73 | printf '%s.%s' "$((tenths / 10))" "$((tenths % 10))" | ||
| 74 | } | ||
| 75 | # Poll the file written by the downloader; no progress-output parsing or extra request. | ||
| 76 | download() { | ||
| 77 | if ! "$interactive"; then fetch "$1" "$2"; return; fi | ||
| 78 | if command -v curl >/dev/null 2>&1; then | ||
| 79 | curl -fsSL -D "$2.headers" "$1" -o "$2" 2>"$2.errors" & | ||
| 80 | else | ||
| 81 | wget -q --server-response "$1" -O "$2" 2>"$2.headers" & | ||
| 82 | fi | ||
| 83 | download_pid=$! | ||
| 84 | previous=-1 | ||
| 85 | cr="$(printf '\r')" | ||
| 86 | while kill -0 "$download_pid" 2>/dev/null; do | ||
| 87 | bytes=0; total=0 | ||
| 88 | if [ -f "$2" ]; then bytes="$(wc -c < "$2")"; fi | ||
| 89 | if [ -f "$2.headers" ]; then | ||
| 90 | while read -r key value; do | ||
| 91 | case "$key" in | ||
| 92 | HTTP/*) total=0 ;; | ||
| 93 | [Cc]ontent-[Ll]ength:) total="${value%"$cr"}" ;; | ||
| 94 | esac | ||
| 95 | done < "$2.headers" | ||
| 96 | fi | ||
| 97 | case "$total" in ''|*[!0-9]*) total=0 ;; esac | ||
| 98 | if [ "$bytes" -ne "$previous" ]; then | ||
| 99 | if [ "$total" -gt 0 ]; then | ||
| 100 | percent=$((bytes * 100 / total)); [ "$percent" -le 100 ] || percent=100 | ||
| 101 | filled=$((percent * 32 / 100)); bar=; rest=; n=0 | ||
| 102 | while [ "$n" -lt 32 ]; do | ||
| 103 | if [ "$n" -lt "$filled" ]; then bar="${bar}■"; else rest="${rest}·"; fi | ||
| 104 | n=$((n + 1)) | ||
| 105 | done | ||
| 106 | 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 | ||
| 107 | else | ||
| 108 | printf '\r\033[2K %sDownloading%s %s MB' "$muted" "$reset" "$(mb "$bytes")" >&2 | ||
| 109 | fi | ||
| 110 | previous="$bytes" | ||
| 111 | fi | ||
| 112 | sleep 0.1 | ||
| 113 | done | ||
| 114 | result=0; wait "$download_pid" || result=$? | ||
| 115 | download_pid= | ||
| 116 | if [ "$result" -ne 0 ]; then | ||
| 117 | printf '\n' >&2 | ||
| 118 | if [ -f "$2.errors" ]; then cat "$2.errors" >&2; else cat "$2.headers" >&2; fi | ||
| 119 | return "$result" | ||
| 120 | fi | ||
| 121 | size="$(mb "$(wc -c < "$2")")" | ||
| 122 | printf '\r\033[2K %s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■%s 100%% %s%s / %s MB%s\n' "$accent" "$reset" "$muted" "$size" "$size" "$reset" >&2 | ||
| 123 | rm -f "$2.headers" "$2.errors" | ||
| 124 | } | ||
| 125 | |||
| 48 | case "$(uname -s)" in | 126 | case "$(uname -s)" in |
| 49 | Darwin) platform="darwin" ;; | 127 | Darwin) platform="darwin" ;; |
| 50 | Linux) platform="linux" ;; | 128 | Linux) platform="linux" ;; |
| ... | @@ -100,13 +178,18 @@ versions_dir="${t3_home}/runtime/versions" | ... | @@ -100,13 +178,18 @@ versions_dir="${t3_home}/runtime/versions" |
| 100 | target_dir="${versions_dir}/${version}" | 178 | target_dir="${versions_dir}/${version}" |
| 101 | 179 | ||
| 102 | if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then | 180 | if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then |
| 103 | printf 't3 %s is already installed at %s\n' "$version" "$target_dir" | 181 | step "Version ${version} is already downloaded." |
| 104 | else | 182 | else |
| 105 | mkdir -p "$versions_dir" | 183 | mkdir -p "$versions_dir" |
| 106 | staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" | 184 | staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" |
| 107 | trap 'rm -rf "$staging"' EXIT | 185 | download_pid= |
| 186 | trap '[ -z "$download_pid" ] || { kill "$download_pid" 2>/dev/null || true; wait "$download_pid" 2>/dev/null || true; }; rm -rf "$staging"' EXIT | ||
| 187 | trap 'printf "\n" >&2; exit 130' INT | ||
| 188 | trap 'printf "\n" >&2; exit 143' TERM | ||
| 108 | 189 | ||
| 109 | printf 'Downloading %s...\n' "$archive" | 190 | if "$interactive"; then printf '\r\033[2K' >&2; fi |
| 191 | printf ' %sInstalling%s T3 Code %s%s%s\n\n' "$muted" "$reset" "$bold" "$version" "$reset" >&2 | ||
| 192 | step "Downloading..." | ||
| 110 | fetch_status=0 | 193 | fetch_status=0 |
| 111 | fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? | 194 | fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? |
| 112 | if [ "$fetch_status" -eq 44 ]; then | 195 | if [ "$fetch_status" -eq 44 ]; then |
| ... | @@ -114,13 +197,15 @@ else | ... | @@ -114,13 +197,15 @@ else |
| 114 | elif [ "$fetch_status" -ne 0 ]; then | 197 | elif [ "$fetch_status" -ne 0 ]; then |
| 115 | fail "could not download the release checksums" | 198 | fail "could not download the release checksums" |
| 116 | fi | 199 | fi |
| 117 | fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" | 200 | download "${base_url}/v${version}/${archive}" "${staging}/${archive}" |
| 118 | 201 | ||
| 202 | step "Verifying the download..." | ||
| 119 | expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" | 203 | expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" |
| 120 | [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" | 204 | [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" |
| 121 | actual="$(checksum "${staging}/${archive}")" | 205 | actual="$(checksum "${staging}/${archive}")" |
| 122 | [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" | 206 | [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" |
| 123 | 207 | ||
| 208 | step "Extracting T3 Code..." | ||
| 124 | tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 | 209 | tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 |
| 125 | rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" | 210 | rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" |
| 126 | "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" | 211 | "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" |
| ... | @@ -131,10 +216,12 @@ else | ... | @@ -131,10 +216,12 @@ else |
| 131 | trap - EXIT | 216 | trap - EXIT |
| 132 | fi | 217 | fi |
| 133 | 218 | ||
| 219 | step "Setting up the t3 command..." | ||
| 134 | mkdir -p "$bin_dir" | 220 | mkdir -p "$bin_dir" |
| 135 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" | 221 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" |
| 136 | printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" | 222 | if "$interactive"; then printf '\r\033[2K' >&2; fi |
| 223 | printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2 | ||
| 137 | case ":${PATH}:" in | 224 | case ":${PATH}:" in |
| 138 | *":${bin_dir}:"*) ;; | 225 | *":${bin_dir}:"*) printf ' Run %st3%s to get started.\n\n' "$bold" "$reset" ;; |
| 139 | *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; | 226 | *) printf ' Add %s to your PATH, then run %st3%s.\n\n' "$bin_dir" "$bold" "$reset" ;; |
| 140 | esac | 227 | esac |
scripts/install.test.ts created+115| ... | @@ -0,0 +1,115 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer through a PTY and a gated HTTP fixture. | ||
| 2 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 3 | import * as NodeChildProcess from "node:child_process"; | ||
| 4 | import * as NodeCrypto from "node:crypto"; | ||
| 5 | import * as NodeFSP from "node:fs/promises"; | ||
| 6 | import * as NodeHttp from "node:http"; | ||
| 7 | import * as NodeOS from "node:os"; | ||
| 8 | import * as NodePath from "node:path"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | ||
| 10 | |||
| 11 | // util-linux's script gives the real installer a terminal without a browser or extra packages. | ||
| 12 | describe.skipIf(HostProcessPlatform.defaultValue() !== "linux")("installer terminal", () => { | ||
| 13 | it.each([false, true])( | ||
| 14 | "preserves download and install behavior (HTTP failure: %s)", | ||
| 15 | async (fail) => { | ||
| 16 | const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-progress-")); | ||
| 17 | const version = "1.2.3"; | ||
| 18 | const stem = `t3-${version}-linux-${HostProcessArchitecture.defaultValue()}`; | ||
| 19 | const archiveName = `${stem}.tar.gz`; | ||
| 20 | let resumeDownload: (() => void) | undefined; | ||
| 21 | let sawPartialProgress = false; | ||
| 22 | let output = ""; | ||
| 23 | await NodeFSP.mkdir(NodePath.join(root, stem)); | ||
| 24 | await NodeFSP.writeFile(NodePath.join(root, stem, "t3"), "#!/bin/sh\necho 't3 v1.2.3'\n", { | ||
| 25 | mode: 0o755, | ||
| 26 | }); | ||
| 27 | await NodeFSP.writeFile( | ||
| 28 | NodePath.join(root, stem, "payload"), | ||
| 29 | NodeCrypto.randomBytes(64 * 1024), | ||
| 30 | ); | ||
| 31 | NodeChildProcess.execFileSync("tar", [ | ||
| 32 | "-czf", | ||
| 33 | NodePath.join(root, archiveName), | ||
| 34 | "-C", | ||
| 35 | root, | ||
| 36 | stem, | ||
| 37 | ]); | ||
| 38 | const archive = await NodeFSP.readFile(NodePath.join(root, archiveName)); | ||
| 39 | const checksum = NodeCrypto.createHash("sha256").update(archive).digest("hex"); | ||
| 40 | const server = NodeHttp.createServer((request, response) => { | ||
| 41 | if (request.url?.endsWith("/SHA256SUMS")) { | ||
| 42 | response.end(`${checksum} ${archiveName}\n`); | ||
| 43 | } else if (fail) { | ||
| 44 | response.writeHead(500).end(); | ||
| 45 | } else { | ||
| 46 | response.writeHead(200, { "Content-Length": archive.length }); | ||
| 47 | resumeDownload = () => response.end(archive.subarray(Math.floor(archive.length / 2))); | ||
| 48 | response.write(archive.subarray(0, Math.floor(archive.length / 2))); | ||
| 49 | } | ||
| 50 | }); | ||
| 51 | await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); | ||
| 52 | const address = server.address(); | ||
| 53 | if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); | ||
| 54 | const installer = NodePath.resolve(import.meta.dirname, "install.sh").replaceAll( | ||
| 55 | "'", | ||
| 56 | "'\\''", | ||
| 57 | ); | ||
| 58 | const child = NodeChildProcess.spawn("script", ["-qec", `sh '${installer}'`, "/dev/null"], { | ||
| 59 | env: { | ||
| 60 | ...process.env, | ||
| 61 | TERM: "xterm", | ||
| 62 | NO_COLOR: "1", | ||
| 63 | T3CODE_VERSION: version, | ||
| 64 | T3CODE_HOME: NodePath.join(root, "home"), | ||
| 65 | T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), | ||
| 66 | T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`, | ||
| 67 | }, | ||
| 68 | stdio: ["ignore", "pipe", "pipe"], | ||
| 69 | }); | ||
| 70 | const collect = (chunk: Buffer) => { | ||
| 71 | output += chunk.toString(); | ||
| 72 | if (!sawPartialProgress && /\b[1-9]\d?%/.test(output)) { | ||
| 73 | sawPartialProgress = true; | ||
| 74 | resumeDownload?.(); | ||
| 75 | } | ||
| 76 | }; | ||
| 77 | child.stdout.on("data", collect); | ||
| 78 | child.stderr.on("data", collect); | ||
| 79 | try { | ||
| 80 | const code = await new Promise<number | null>((resolve, reject) => { | ||
| 81 | child.on("error", reject); | ||
| 82 | child.on("close", resolve); | ||
| 83 | }); | ||
| 84 | const versions = NodePath.join(root, "home/runtime/versions"); | ||
| 85 | if (fail) { | ||
| 86 | expect(code).not.toBe(0); | ||
| 87 | expect(output).toContain("500"); | ||
| 88 | expect(output).not.toContain("100%"); | ||
| 89 | expect(output).not.toContain("Installed T3 Code"); | ||
| 90 | expect(await NodeFSP.readdir(versions)).toEqual([]); | ||
| 91 | } else { | ||
| 92 | expect(code).toBe(0); | ||
| 93 | expect(sawPartialProgress).toBe(true); | ||
| 94 | expect(output).toContain("100%"); | ||
| 95 | expect(output).toContain("0.1 / 0.1 MB"); | ||
| 96 | expect(output).toContain("Installed T3 Code 1.2.3"); | ||
| 97 | expect( | ||
| 98 | await NodeFSP.readFile(NodePath.join(versions, version, ".install-complete"), "utf8"), | ||
| 99 | ).toBe("1.2.3\n"); | ||
| 100 | expect( | ||
| 101 | NodeChildProcess.execFileSync(NodePath.join(root, "bin/t3"), ["--version"], { | ||
| 102 | encoding: "utf8", | ||
| 103 | }).trim(), | ||
| 104 | ).toBe("t3 v1.2.3"); | ||
| 105 | expect(await NodeFSP.readdir(versions)).toEqual([version]); | ||
| 106 | } | ||
| 107 | } finally { | ||
| 108 | if (child.exitCode === null) child.kill(); | ||
| 109 | server.closeAllConnections(); | ||
| 110 | await new Promise<void>((resolve) => server.close(() => resolve())); | ||
| 111 | await NodeFSP.rm(root, { recursive: true, force: true }); | ||
| 112 | } | ||
| 113 | }, | ||
| 114 | ); | ||
| 115 | }); | ||