authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-16 14:54:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 14:54:29-07:00
logf1b497a5552abf4616a7477b730b78a1274f3db4
tree1ecb8c7f1b02e99da47832b44b08784e637aec7d
parent52ad70ec43b5dcfcdc72ac79c0964da8e4d163db
signature Signed by PGP key B5690EEEBB952194

feat(cli): show installer and update download progress (#12044)


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
42import * as ProcessRunner from "../processRunner.ts";42import * as ProcessRunner from "../processRunner.ts";
43import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";43import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
44import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";44import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
45import { createUpdateProgress } from "./updateProgress.ts";
45import { bootServiceLayer } from "./service.ts";46import { bootServiceLayer } from "./service.ts";
4647
47export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", {48export 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);
362369
363 // Preview is a maintainers' dogfooding train: it is cut by hand from370 // 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 );
448455
449 yield* Console.log(456 progress.heading(
450 executableCurrent && restartPending457 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 : executableCurrent459 : executableCurrent
453 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`460 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`
454 : alreadyOnDisk461 : 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 }
478488
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 }
562579
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 @@
1import { afterEach, expect, it, vi } from "vite-plus/test";
2
3import { createUpdateProgress } from "./updateProgress.ts";
4
5afterEach(() => {
6 vi.unstubAllEnvs();
7 vi.restoreAllMocks();
8});
9
10function 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
25it("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
39it("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
58it("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
66it.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
74it("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
84it("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 @@
1import type { PinnedRuntimeProgress } from "../cloud/pinnedRuntime.ts";
2
3/** A single status line below the download bar; redirected output remains plain. */
4export 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";
1819
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 );
99100
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";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Stream from "effect/Stream";
8import * as Semaphore from "effect/Semaphore";9import * as Semaphore from "effect/Semaphore";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
1011
...@@ -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}
100101
102export 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 runtime107 * Installs the t3 release archive for `version` into the pinned runtime
103 * directory unless a complete install is already there, and returns its108 * 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 a111 * 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
109interface PinnedRuntimeInstallInput {115interface 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}
123130
124const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (131const 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 download137 // 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);
163198
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);
292332
333 input.onProgress?.({ stage: "validate" });
293 yield* input.validate(stagingPaths);334 yield* input.validate(stagingPaths);
294 yield* fs335 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 126 exit 1
27}27}
2828
29$terminal = -not [Console]::IsErrorRedirected -and $env:TERM -ne "dumb"
30$interactive = $terminal -and $Host.UI.SupportsVirtualTerminal
31if ($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 = ""
39if ($interactive -and -not $env:NO_COLOR) {
40 $reset = "$esc[0m"; $bold = "$esc[1m"; $muted = "$esc[2m"
41 $accent = "$esc[94m"; $green = "$esc[32m"
42}
43function Step([string] $message) {
44 if ($interactive) { [Console]::Error.Write("`r$esc[2K $muted$message$reset") }
45 else { [Console]::Error.WriteLine(" $message") }
46}
47function 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}
65function 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}
101if ($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}
118Step "Finding your release..."
119
29# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell120# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell
30# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts121# 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"
68159
69if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {160if ((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-Null163 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-Null165 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") -UseBasicParsing171 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 throw177 throw
85 }178 }
86 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing179 Fetch "$baseUrl/v$version/$archive" (Join-Path $staging $archive) -progress
180
181 Step "Verifying the download..."
87182
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" }
93188
94 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force189 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 $staging197 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 -Force198 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}
110211
212Step "Setting up the t3 command..."
111New-Item -ItemType Directory -Force -Path $binDir | Out-Null213New-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 corrupt215# 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))
116Write-Host "Installed t3 $version"218if ($interactive) { [Console]::Error.Write("`r$esc[2K") }
117Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')"219[Console]::Error.WriteLine(" ${green}Installed T3 Code $version$reset`n")
118if (($env:PATH -split ";") -notcontains $binDir) {220if (($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}"
23bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"23bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
2424
25fail() {25fail() {
26 printf 't3 install: %s\n' "$1" >&226 printf '\nt3 install: %s\n' "$1" >&2
27 exit 127 exit 1
28}28}
2929
30# ANSI stays on stderr, so `curl ... | sh` still gets progress.
31interactive=false
32if [ -t 2 ] && [ "${TERM:-}" != dumb ]; then interactive=true; fi
33reset= bold= muted= accent= green=
34if "$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')"
37fi
38step() {
39 if "$interactive"; then printf '\r\033[2K %s%s%s' "$muted" "$1" "$reset" >&2
40 else printf ' %s\n' "$1" >&2; fi
41}
42if "$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
50fi
51step "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.
31fetch() {54fetch() {
32 if command -v curl >/dev/null 2>&1; then55 if command -v curl >/dev/null 2>&1; then
...@@ -45,6 +68,61 @@ fetch() {...@@ -45,6 +68,61 @@ fetch() {
45 fi68 fi
46}69}
4770
71mb() {
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.
76download() {
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
48case "$(uname -s)" in126case "$(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"
100target_dir="${versions_dir}/${version}"178target_dir="${versions_dir}/${version}"
101179
102if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then180if [ -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."
104else182else
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"' EXIT185 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
108189
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=0193 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 ]; then195 if [ "$fetch_status" -eq 44 ]; then
...@@ -114,13 +197,15 @@ else...@@ -114,13 +197,15 @@ else
114 elif [ "$fetch_status" -ne 0 ]; then197 elif [ "$fetch_status" -ne 0 ]; then
115 fail "could not download the release checksums"198 fail "could not download the release checksums"
116 fi199 fi
117 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"200 download "${base_url}/v${version}/${archive}" "${staging}/${archive}"
118201
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}"
123207
208 step "Extracting T3 Code..."
124 tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1209 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 - EXIT216 trap - EXIT
132fi217fi
133218
219step "Setting up the t3 command..."
134mkdir -p "$bin_dir"220mkdir -p "$bin_dir"
135ln -sfn "${target_dir}/t3" "${bin_dir}/t3"221ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
136printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3"222if "$interactive"; then printf '\r\033[2K' >&2; fi
223printf ' %sInstalled T3 Code %s%s\n\n' "$green" "$version" "$reset" >&2
137case ":${PATH}:" in224case ":${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" ;;
140esac227esac
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.
2import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeCrypto from "node:crypto";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeHttp from "node:http";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import { 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.
12describe.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});