1#!/usr/bin/env node
2/**
3 * Unpacks a CLI archive into a scratch directory and runs the executable the
4 * way an installer would: no repo, no node_modules, no Node on PATH. Catches
5 * the failures that only show inside the single-executable, such as an
6 * external package reached through `import` or a native addon the hardened
7 * runtime refuses to load.
8 */
9import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Logger from "effect/Logger";
17import * as Path from "effect/Path";
18import * as Schedule from "effect/Schedule";
19import * as Schema from "effect/Schema";
20import * as Stream from "effect/Stream";
21import { Command, Flag } from "effect/cli";
22import { ChildProcess, ChildProcessSpawner } from "effect/process";
23
24import * as NetService from "@t3tools/shared/Net";
25import * as HostProcess from "@t3tools/shared/HostProcess";
26import { windowsSystemTar } from "./build-cli-archive.ts";
27import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
28
29export class CliArchiveSmokeError extends Schema.TaggedError<CliArchiveSmokeError>()(
30 "CliArchiveSmokeError",
31 { step: Schema.String, detail: Schema.String },
32) {
33 override get message(): string {
34 return `CLI archive smoke test failed while ${this.step}: ${this.detail}`;
35 }
36}
37
38const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
39 stream.pipe(
40 Stream.decodeText(),
41 Stream.runFold(
42 () => "",
43 (acc, chunk) => acc + chunk,
44 ),
45 );
46
47const runExecutable = Effect.fn("runExecutable")(function* (
48 executable: string,
49 args: ReadonlyArray<string>,
50 cwd: string,
51) {
52 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
53 const child = yield* spawner.spawn(
54 ChildProcess.make(executable, args, {
55 cwd,
56 // Empty PATH: the archive must not reach a system node, and the
57 // launcher context must not leak in from a developer shell.
58 env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd },
59 extendEnv: false,
60 }),
61 );
62 const [stdout, stderr, exitCode] = yield* Effect.all(
63 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
64 { concurrency: "unbounded" },
65 );
66 return { stdout, stderr, exitCode };
67});
68
69const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: {
70 readonly archive: string;
71 readonly expectVersion: string;
72}) {
73 const fs = yield* FileSystem.FileSystem;
74 const path = yield* Path.Path;
75 const platform = yield* HostProcess.Platform;
76 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
77 const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" });
78 // Windows can keep t3.exe locked (EBUSY) for a moment after the server
79 // exits. A leftover scratch directory on a CI runner is harmless, so
80 // cleanup retries briefly and never fails a smoke test that passed.
81 yield* Effect.addFinalizer(() =>
82 fs.remove(scratch, { recursive: true }).pipe(
83 Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }),
84 Effect.catch((error) =>
85 Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`),
86 ),
87 ),
88 );
89
90 // On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU
91 // tar; use the bsdtar Windows ships, which reads both formats.
92 const tar = platform === "win32" ? windowsSystemTar() : "tar";
93 const extract = yield* spawner
94 .spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch]))
95 .pipe(Effect.flatMap((child) => child.exitCode));
96 if (Number(extract) !== 0) {
97 return yield* new CliArchiveSmokeError({
98 step: "extracting the archive",
99 detail: `tar exited with ${String(extract)}`,
100 });
101 }
102 const [root] = yield* fs.readDirectory(scratch);
103 if (root === undefined) {
104 return yield* new CliArchiveSmokeError({
105 step: "extracting the archive",
106 detail: "the archive was empty",
107 });
108 }
109 const contentDir = path.join(scratch, root);
110 const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3");
111 for (const required of [executable, path.join(contentDir, "client/index.html")]) {
112 if (!(yield* fs.exists(required))) {
113 return yield* new CliArchiveSmokeError({
114 step: "checking the archive layout",
115 detail: `missing ${path.relative(contentDir, required)}`,
116 });
117 }
118 }
119
120 const version = yield* runExecutable(executable, ["--version"], contentDir);
121 if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) {
122 return yield* new CliArchiveSmokeError({
123 step: "running --version",
124 detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`,
125 });
126 }
127
128 // Starting the server is what actually opens sqlite, loads the terminal
129 // and search stacks (node-pty, fff, msgpackr-extract), and serves the
130 // client, so probe a real `serve` in a scratch home rather than a
131 // command that only reads package metadata.
132 const net = yield* NetService.NetService;
133 const port = yield* net.findAvailablePort(47700);
134 const home = path.join(scratch, "home");
135 const server = yield* spawner.spawn(
136 ChildProcess.make(
137 executable,
138 ["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"],
139 {
140 cwd: contentDir,
141 env: {
142 PATH: "",
143 HOME: home,
144 USERPROFILE: home,
145 TMPDIR: scratch,
146 TEMP: scratch,
147 T3CODE_HOME: home,
148 },
149 extendEnv: false,
150 },
151 ),
152 );
153 const output = yield* Effect.forkScoped(
154 Effect.all([collect(server.stdout), collect(server.stderr)]),
155 );
156 const httpClient = yield* HttpClient.HttpClient;
157 // A request that connects while the server is still initializing can hang,
158 // so each probe gets its own deadline, like the SSH readiness probe.
159 const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe(
160 Effect.map((response) => response.status === 200),
161 Effect.timeout(Duration.seconds(2)),
162 Effect.orElseSucceed(() => false),
163 );
164 const pollUntilReady = Effect.gen(function* () {
165 while (!(yield* probe)) {
166 yield* Effect.sleep(Duration.millis(250));
167 }
168 return true;
169 });
170 const ready = yield* pollUntilReady.pipe(
171 Effect.timeout(Duration.seconds(30)),
172 Effect.orElseSucceed(() => false),
173 );
174 yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore);
175 yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore);
176 const [stdout, stderr] = yield* Fiber.join(output).pipe(
177 Effect.timeout(Duration.seconds(5)),
178 Effect.orElseSucceed(() => ["", ""] as const),
179 );
180 if (!ready) {
181 return yield* new CliArchiveSmokeError({
182 step: "serving from the extracted archive",
183 detail: `no 200 from / within 30s\n${stdout}${stderr}`,
184 });
185 }
186 yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`);
187});
188
189const command = Command.make(
190 "smoke-cli-archive",
191 {
192 archive: Flag.String("archive"),
193 expectVersion: Flag.String("expect-version"),
194 },
195 (input) => smokeCliArchive(input).pipe(Effect.scoped),
196).pipe(Command.withDescription("Extract a CLI archive and run its executable."));
197
198if (import.meta.main) {
199 Command.run(command, { version: "0.0.0" }).pipe(
200 Effect.provide(
201 Layer.mergeAll(
202 Logger.layer([Logger.consolePretty()]),
203 NodeServices.layer,
204 NetService.layer,
205 FetchHttpClient.layer,
206 ),
207 ),
208 NodeRuntime.runMain,
209 );
210}