mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-09 03:41:17 +02:00
210 lines
7.7 KiB
JavaScript
210 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Unpacks a CLI archive into a scratch directory and runs the executable the
|
|
* way an installer would: no repo, no node_modules, no Node on PATH. Catches
|
|
* the failures that only show inside the single-executable, such as an
|
|
* external package reached through `import` or a native addon the hardened
|
|
* runtime refuses to load.
|
|
*/
|
|
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
import * as Duration from "effect/Duration";
|
|
import * as Effect from "effect/Effect";
|
|
import * as Fiber from "effect/Fiber";
|
|
import * as FileSystem from "effect/FileSystem";
|
|
import * as Layer from "effect/Layer";
|
|
import * as Logger from "effect/Logger";
|
|
import * as Path from "effect/Path";
|
|
import * as Schedule from "effect/Schedule";
|
|
import * as Schema from "effect/Schema";
|
|
import * as Stream from "effect/Stream";
|
|
import { Command, Flag } from "effect/cli";
|
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
|
|
import * as NetService from "@t3tools/shared/Net";
|
|
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
|
|
import { windowsSystemTar } from "./build-cli-archive.ts";
|
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
|
|
export class CliArchiveSmokeError extends Schema.TaggedError<CliArchiveSmokeError>()(
|
|
"CliArchiveSmokeError",
|
|
{ step: Schema.String, detail: Schema.String },
|
|
) {
|
|
override get message(): string {
|
|
return `CLI archive smoke test failed while ${this.step}: ${this.detail}`;
|
|
}
|
|
}
|
|
|
|
const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
|
|
stream.pipe(
|
|
Stream.decodeText(),
|
|
Stream.runFold(
|
|
() => "",
|
|
(acc, chunk) => acc + chunk,
|
|
),
|
|
);
|
|
|
|
const runExecutable = Effect.fn("runExecutable")(function* (
|
|
executable: string,
|
|
args: ReadonlyArray<string>,
|
|
cwd: string,
|
|
) {
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
const child = yield* spawner.spawn(
|
|
ChildProcess.make(executable, args, {
|
|
cwd,
|
|
// Empty PATH: the archive must not reach a system node, and the
|
|
// launcher context must not leak in from a developer shell.
|
|
env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd },
|
|
extendEnv: false,
|
|
}),
|
|
);
|
|
const [stdout, stderr, exitCode] = yield* Effect.all(
|
|
[collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
|
|
{ concurrency: "unbounded" },
|
|
);
|
|
return { stdout, stderr, exitCode };
|
|
});
|
|
|
|
const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: {
|
|
readonly archive: string;
|
|
readonly expectVersion: string;
|
|
}) {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const path = yield* Path.Path;
|
|
const platform = yield* HostProcessPlatform;
|
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" });
|
|
// Windows can keep t3.exe locked (EBUSY) for a moment after the server
|
|
// exits. A leftover scratch directory on a CI runner is harmless, so
|
|
// cleanup retries briefly and never fails a smoke test that passed.
|
|
yield* Effect.addFinalizer(() =>
|
|
fs.remove(scratch, { recursive: true }).pipe(
|
|
Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }),
|
|
Effect.catch((error) =>
|
|
Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`),
|
|
),
|
|
),
|
|
);
|
|
|
|
// On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU
|
|
// tar; use the bsdtar Windows ships, which reads both formats.
|
|
const tar = platform === "win32" ? windowsSystemTar() : "tar";
|
|
const extract = yield* spawner
|
|
.spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch]))
|
|
.pipe(Effect.flatMap((child) => child.exitCode));
|
|
if (Number(extract) !== 0) {
|
|
return yield* new CliArchiveSmokeError({
|
|
step: "extracting the archive",
|
|
detail: `tar exited with ${String(extract)}`,
|
|
});
|
|
}
|
|
const [root] = yield* fs.readDirectory(scratch);
|
|
if (root === undefined) {
|
|
return yield* new CliArchiveSmokeError({
|
|
step: "extracting the archive",
|
|
detail: "the archive was empty",
|
|
});
|
|
}
|
|
const contentDir = path.join(scratch, root);
|
|
const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3");
|
|
for (const required of [executable, path.join(contentDir, "client/index.html")]) {
|
|
if (!(yield* fs.exists(required))) {
|
|
return yield* new CliArchiveSmokeError({
|
|
step: "checking the archive layout",
|
|
detail: `missing ${path.relative(contentDir, required)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const version = yield* runExecutable(executable, ["--version"], contentDir);
|
|
if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) {
|
|
return yield* new CliArchiveSmokeError({
|
|
step: "running --version",
|
|
detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`,
|
|
});
|
|
}
|
|
|
|
// Starting the server is what actually opens sqlite, loads the terminal
|
|
// and search stacks (node-pty, fff, msgpackr-extract), and serves the
|
|
// client, so probe a real `serve` in a scratch home rather than a
|
|
// command that only reads package metadata.
|
|
const net = yield* NetService.NetService;
|
|
const port = yield* net.findAvailablePort(47700);
|
|
const home = path.join(scratch, "home");
|
|
const server = yield* spawner.spawn(
|
|
ChildProcess.make(
|
|
executable,
|
|
["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"],
|
|
{
|
|
cwd: contentDir,
|
|
env: {
|
|
PATH: "",
|
|
HOME: home,
|
|
USERPROFILE: home,
|
|
TMPDIR: scratch,
|
|
TEMP: scratch,
|
|
T3CODE_HOME: home,
|
|
},
|
|
extendEnv: false,
|
|
},
|
|
),
|
|
);
|
|
const output = yield* Effect.forkScoped(
|
|
Effect.all([collect(server.stdout), collect(server.stderr)]),
|
|
);
|
|
const httpClient = yield* HttpClient.HttpClient;
|
|
// A request that connects while the server is still initializing can hang,
|
|
// so each probe gets its own deadline, like the SSH readiness probe.
|
|
const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe(
|
|
Effect.map((response) => response.status === 200),
|
|
Effect.timeout(Duration.seconds(2)),
|
|
Effect.orElseSucceed(() => false),
|
|
);
|
|
const pollUntilReady = Effect.gen(function* () {
|
|
while (!(yield* probe)) {
|
|
yield* Effect.sleep(Duration.millis(250));
|
|
}
|
|
return true;
|
|
});
|
|
const ready = yield* pollUntilReady.pipe(
|
|
Effect.timeout(Duration.seconds(30)),
|
|
Effect.orElseSucceed(() => false),
|
|
);
|
|
yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore);
|
|
yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore);
|
|
const [stdout, stderr] = yield* Fiber.join(output).pipe(
|
|
Effect.timeout(Duration.seconds(5)),
|
|
Effect.orElseSucceed(() => ["", ""] as const),
|
|
);
|
|
if (!ready) {
|
|
return yield* new CliArchiveSmokeError({
|
|
step: "serving from the extracted archive",
|
|
detail: `no 200 from / within 30s\n${stdout}${stderr}`,
|
|
});
|
|
}
|
|
yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`);
|
|
});
|
|
|
|
const command = Command.make(
|
|
"smoke-cli-archive",
|
|
{
|
|
archive: Flag.String("archive"),
|
|
expectVersion: Flag.String("expect-version"),
|
|
},
|
|
(input) => smokeCliArchive(input).pipe(Effect.scoped),
|
|
).pipe(Command.withDescription("Extract a CLI archive and run its executable."));
|
|
|
|
if (import.meta.main) {
|
|
Command.run(command, { version: "0.0.0" }).pipe(
|
|
Effect.provide(
|
|
Layer.mergeAll(
|
|
Logger.layer([Logger.consolePretty()]),
|
|
NodeServices.layer,
|
|
NetService.layer,
|
|
FetchHttpClient.layer,
|
|
),
|
|
),
|
|
NodeRuntime.runMain,
|
|
);
|
|
}
|