| 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 | */ |
| 9 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 11 | import * as Duration from "effect/Duration"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Fiber from "effect/Fiber"; |
| 14 | import * as FileSystem from "effect/FileSystem"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Logger from "effect/Logger"; |
| 17 | import * as Path from "effect/Path"; |
| 18 | import * as Schedule from "effect/Schedule"; |
| 19 | import * as Schema from "effect/Schema"; |
| 20 | import * as Stream from "effect/Stream"; |
| 21 | import { Command, Flag } from "effect/cli"; |
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 23 | |
| 24 | import * as NetService from "@t3tools/shared/Net"; |
| 25 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 26 | import { windowsSystemTar } from "./build-cli-archive.ts"; |
| 27 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; |
| 28 | |
| 29 | export 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 | |
| 38 | const 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 | |
| 47 | const 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 | |
| 69 | const 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 | |
| 189 | const 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 | |
| 198 | if (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 | } |