From af6c138a0de297f94ddef3133e2f47d017b46e94 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:22 -0700 Subject: [PATCH] feat(server): manage runtimes as release archives only, never from npm (#11510) Co-authored-by: Claude Fable 5 --- apps/desktop/src/main.ts | 25 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 12 +- apps/marketing/.gitignore | 2 + apps/marketing/package.json | 5 +- .../scripts/stage-install-scripts.mjs | 15 + apps/server/package.json | 2 +- apps/server/scripts/cli.ts | 6 +- apps/server/src/cli/service.test.ts | 3 +- apps/server/src/cli/serviceLauncher.ts | 7 +- apps/server/src/cli/update.ts | 17 +- apps/server/src/cloud/bootService.test.ts | 127 +++---- apps/server/src/cloud/bootService.ts | 50 +-- apps/server/src/cloud/pinnedRuntime.test.ts | 272 ++++---------- apps/server/src/cloud/pinnedRuntime.ts | 133 ++----- apps/server/src/cloud/selfUpdate.test.ts | 43 ++- apps/server/src/cloud/selfUpdate.ts | 13 +- apps/server/src/cloud/serviceProtocol.ts | 1 - apps/server/src/service-launcher.ts | 20 -- apps/server/src/serviceLauncher.test.ts | 65 ++-- apps/server/src/serviceLauncher.ts | 40 +-- docs/operations/release.md | 28 +- docs/user/background-service.md | 7 +- knip.jsonc | 1 - packages/shared/src/cliRelease.test.ts | 7 - packages/shared/src/cliRelease.ts | 5 - packages/ssh/src/command.test.ts | 36 -- packages/ssh/src/command.ts | 20 +- packages/ssh/src/runnerProcess.test.ts | 339 +++++------------- packages/ssh/src/tunnel.test.ts | 214 +++++------ packages/ssh/src/tunnel.ts | 278 +++++++------- .../marketing/public => scripts}/install.ps1 | 2 +- {apps/marketing/public => scripts}/install.sh | 2 +- 32 files changed, 630 insertions(+), 1167 deletions(-) create mode 100644 apps/marketing/.gitignore create mode 100644 apps/marketing/scripts/stage-install-scripts.mjs delete mode 100644 apps/server/src/service-launcher.ts rename {apps/marketing/public => scripts}/install.ps1 (97%) rename {apps/marketing/public => scripts}/install.sh (96%) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b273fe7822a5c7271c2a766a60dbc6a189d0c9e..0d626a51955dd9e63038fc77ff0769a989d4463f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,8 +17,6 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; -import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( }), ); +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } - // Preview builds ship as self-contained archives, so the remote runs the - // same version this app is on without Node or npm. - if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { - return { archiveVersion: environment.appVersion }; - } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, - updateChannel: settings.updateChannel, - isDevelopment: environment.isDevelopment, - }), - nodeEngineRange: serverPackageJson.engines.node, - }; + return { archiveVersion: environment.appVersion }; }; const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settings.get.pipe( - Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), - ), + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3b135eb2508f01bca53fc0246b14763a30993034..b0094f6867bc00c1599bb3c5ae55b0bef3e1e45b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined + SshTunnel.SshEnvironmentManager.layer( + options.resolveCliRunner === undefined ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), + : { resolveCliRunner: options.resolveCliRunner }, + ), ), ); diff --git a/apps/marketing/.gitignore b/apps/marketing/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..254b88f1a73ffd739e47bbeaac5754ce93f5af68 --- /dev/null +++ b/apps/marketing/.gitignore @@ -0,0 +1,2 @@ +/public/install.sh +/public/install.ps1 diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 80a36fc54af4688d2af40680e3e987d54a7f927c..511552d74fbd5b9ec702dc169a37a11b834ef763 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "stage:install-scripts": "node scripts/stage-install-scripts.mjs", + "dev": "node scripts/stage-install-scripts.mjs && astro dev", + "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/scripts/stage-install-scripts.mjs b/apps/marketing/scripts/stage-install-scripts.mjs new file mode 100644 index 0000000000000000000000000000000000000000..80f458e8acaae73c612da0f5b1bb45b897256934 --- /dev/null +++ b/apps/marketing/scripts/stage-install-scripts.mjs @@ -0,0 +1,15 @@ +// The CLI install scripts live in scripts/ at the repo root with the rest of +// the release tooling; the site serves them at /install.sh and /install.ps1. +// Copy them into public/ before every Astro build and dev server so the two +// never drift. The copies are gitignored. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); +const publicDir = NodePath.join(marketingDir, "public"); +NodeFS.mkdirSync(publicDir, { recursive: true }); +for (const name of ["install.sh", "install.ps1"]) { + NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); +} diff --git a/apps/server/package.json b/apps/server/package.json index 1892f7f3d6d7e972ee82346a1d5f8b3d6296569a..f3ee6cda3aef0a139ebfb484cdfddb420a32649b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:bundle": "vp pack", "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 09866c316eea82bcb3645869eaa6882f6d28a6c7..06c22738853e3bd019cc1cd7cb56ef957ba6eac0 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -284,11 +284,7 @@ const publishCmd = Command.make( const packageJsonPath = path.join(serverDir, "package.json"); // Assert build assets exist - for (const relPath of [ - "dist/bin.mjs", - "dist/service-launcher.mjs", - "dist/client/index.html", - ]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index b682c4ce44ce526af8e98e510e535c539662817e..50bdb1f5fbd3024441e77024eddde438971893b1 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { Effect.sync(() => { installOptions.push(options); return { - program: ["/test/node", "/test/service-launcher.mjs"], - launcherPath: "/test/service-launcher.mjs", + program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"], baseDir: "/test/t3", unitPath: serviceStatus.unitPath, logPath: serviceStatus.logPath, diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts index 696fc55c45f011db10a54cc893fdc852e9bdbea3..280177fc28079bf444188068d95b6e19867d7748 100644 --- a/apps/server/src/cli/serviceLauncher.ts +++ b/apps/server/src/cli/serviceLauncher.ts @@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli"; import { main as runServiceLauncher } from "../serviceLauncher.ts"; /** - * Hosts the service launcher inside the CLI executable. Archive-distributed - * runtimes have no Node on the machine to run `service-launcher.mjs`, so the - * service manager runs `t3 __service-launcher` and the launcher spawns the - * server from the same executable. + * Hosts the service launcher inside the CLI executable. The service manager + * runs `t3 __service-launcher` and the launcher spawns the server from the + * same executable, so the machine needs no Node to run either. * * The launcher owns SIGTERM handling and the process lifetime: it must finish * stopping its child before the process exits, so it runs detached from the diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index dfcebf07e4eed0bbfec7bd584f41bf8b86774381..62a66b985022fbd039910b612e5763e081f60dba 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,7 +1,6 @@ import { HostProcessArchitecture, HostProcessEnvironment, - HostProcessExecutablePath, HostProcessInvokedAs, HostProcessIsExecutable, HostProcessPlatform, @@ -12,7 +11,6 @@ import { CLI_RELEASE_CHANNELS, cliReleaseIndexPageUrl, cliReleaseChannelOf, - isArchiveDistributedVersion, newestCliReleaseVersion, type CliReleaseChannel, } from "@t3tools/shared/cliRelease"; @@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun Effect.map((result) => (result.code === 0 ? result.stdout : "")), Effect.orElseSucceed(() => ""), ); - return /__service-launcher|service-launcher\.mjs/.test(command); + return /__service-launcher/.test(command); } return false; }); @@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const runner = yield* ProcessRunner.ProcessRunner; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; - const execPath = yield* HostProcessExecutablePath; const environment = yield* HostProcessEnvironment; const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; @@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ? serviceVersion : currentVersion; - // Only archive-distributed versions install without Node and npm on the - // machine. Until nightly and stable ship archives, updating onto them from - // here would reintroduce the dependency this command exists to remove. - if (!isArchiveDistributedVersion(targetVersion)) { - return yield* new CliUpdateError({ - reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`, - }); - } if (executableCurrent && serviceCurrent) { yield* Console.log( serviceVersion !== undefined @@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { validate: (paths) => runner .run({ - command: pinnedRuntimeCommand(paths, execPath).command, - args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"], + command: pinnedRuntimeCommand(paths).command, + args: [...pinnedRuntimeCommand(paths).args, "--version"], timeout: Duration.seconds(30), }) .pipe( diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 52a2fdbb9abe9cbc3a463d5a865d7f360a89ad86..54ef1ffdd5f5b8d330cca2209aa8ef2b618365ee 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { - HostProcessArguments, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -23,24 +23,25 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; -it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); +const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; +const linuxPlan = { + program: [linuxRuntime, "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", +}; - expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); +it("runs the pinned runtime's own executable as the systemd launcher", () => { + const unit = BootService.renderBootServiceUnit(linuxPlan); + + expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); expect(unit).toContain("KillMode=mixed"); - expect(unit).not.toContain("versions/1.2.3"); + expect(unit).not.toContain("node"); }); it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ - program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`], - launcherPath: `${baseDir}/runtime/service-launcher.mjs`, + program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], baseDir, logPath: `${baseDir}/userdata/logs/boot-service.log`, unitPath: "/home/theo/.config/systemd/user/t3code.service", @@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); -it("runs archive-distributed runtimes as their own executable", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); - - expect(unit).toContain( - "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", - ); - expect(unit).not.toContain("node"); -}); - it("survives the kernel OOM-killing a greedy agent child", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); + const unit = BootService.renderBootServiceUnit(linuxPlan); expect(unit).toContain("OOMPolicy=continue"); }); +const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; const macPlan = { - program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", + program: [macRuntime, "__service-launcher"], baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", @@ -104,12 +84,13 @@ const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; -it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { +it("runs the pinned runtime's own executable as the launch agent", () => { const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - expect(plist).toContain("/opt/homebrew/bin/node"); - expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); - expect(plist).not.toContain("versions/1.2.3"); + expect(plist).toContain( + ` \n ${macRuntime}\n __service-launcher\n `, + ); + expect(plist).not.toContain("node"); }); it("preserves the installer's provider search path in the launch agent", () => { @@ -150,23 +131,18 @@ it("escapes XML in host paths", () => { const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", - usePinnedLauncher = false, installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); const baseDir = path.join(home, ".t3"); - const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); - yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + // A complete pinned runtime is already present, so install only validates + // it and never downloads a release archive. + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, "export {};\n"); - yield* fs.writeFileString( - path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), - "export const source = 'pinned runtime';\n", - ); + yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( } return { stdout: - input.args[1] === "--version" + input.args[0] === "--version" ? "t3 v1.2.3\n" : input.command === "loginctl" && input.args[0] === "show-user" ? `${control.linger}\n` @@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( baseDir, logsDir: path.join(baseDir, "userdata", "logs"), cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, + host: { execPath: "/usr/bin/t3" }, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("no release download expected")), + ), ConfigProvider.layer( ConfigProvider.fromEnv({ env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, @@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(error.message).toContain("last login session ends"); expect(yield* fs.exists(before.unitPath)).toBe(false); expect(yield* fs.exists(statePath)).toBe(false); - expect( - commands.some((command) => command.startsWith("npm ") || command.includes("--version")), - ).toBe(false); + expect(commands.some((command) => command.includes("--version"))).toBe(false); expect( commands.some( (command) => command.includes("daemon-reload") || command.includes("restart"), @@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); + const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + `ExecStart=${runtime.entryPath} __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(false); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( @@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath, commands, control } = yield* makeHarness(platform); const plan = yield* service.install(); - const launcher = yield* fs.readFileString(plan.launcherPath); const unit = yield* fs.readFileString(plan.unitPath); control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; commands.length = 0; @@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { targetVersion: "1.2.3", }); expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); - expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect( commands.filter( @@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("copies the launcher from the prepared pinned runtime", () => - Effect.gen(function* () { - const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install(); - - expect(yield* fs.readFileString(plan.launcherPath)).toBe( - "export const source = 'pinned runtime';\n", - ); - }), - ); - it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); @@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); + const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); const path = yield* Path.Path; const plan = yield* service.install(); @@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` ${runtime.entryPath}\n __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. @@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, ""); + const { service, fs } = yield* makeHarness("darwin", ""); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs } = yield* makeHarness( "darwin", - false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); const plan = yield* service.install(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 94046b2e07c1068b3dfcd17cd31cba9f339b7071..cb712879bbddbe94b34b54ea3406b34a74a47346 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -26,7 +26,6 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { - SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, @@ -86,7 +85,6 @@ export interface BootServicePlan { * subcommand so the machine never needs Node. */ readonly program: ReadonlyArray; - readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; @@ -533,7 +531,6 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { @@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; - // Archive-distributed versions download from GitHub Releases; npm versions - // never touch HTTP, so callers without a client still work. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); - const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); - const launcherSourcePath = - host.launcherSourcePath ?? - path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { @@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // The executable hosts the launcher as a hidden subcommand of itself, so + // the unit runs the pinned runtime directly. const plan: BootServicePlan = { - program: - runtimePaths.layout === "archive" - ? [runtimePaths.entryPath, "__service-launcher"] - : [host.execPath, launcherPath], - launcherPath, + program: [runtimePaths.entryPath, "__service-launcher"], baseDir: input.baseDir, logPath, unitPath, @@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, host.execPath).command, - args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], + command: pinnedRuntimeCommand(runtime).command, + args: [...pinnedRuntimeCommand(runtime).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - // Archive runtimes host the launcher in the executable itself; there is - // no standalone script to copy. - const launcherSource = - runtimePaths.layout === "archive" - ? undefined - : yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); @@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (launcherSource !== undefined) { - yield* writeDurably(launcherPath, launcherSource); - } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = - yield* Effect.all([ - fs.readFileString(unitPath), - runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), - fs.exists(runtimePaths.entryPath), - fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), - fs.readFileString(statePath).pipe(Effect.option), - ]); + const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ + fs.readFileString(unitPath), + fs.exists(runtimePaths.entryPath), + fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + fs.readFileString(statePath).pipe(Effect.option), + ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) @@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && - launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && runtimeSentinel.value.trim() === input.cliVersion && diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index b542907b4d01f92831aaf81712b67062df600ab4..e4b16b8f7190b3f08ae0bdb8ef7e0e790a425710 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -17,16 +16,38 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; -const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => +// Every install fetches the release archive, checks it against SHA256SUMS, +// and unpacks it with tar. The fake client serves both files; the fake runner +// stands in for tar and drops the executable where extraction would. +const version = "1.2.3"; +const archiveName = `t3-${version}-linux-x64.tar.gz`; +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); +const validChecksums = archiveHex(archiveBytes).pipe( + Effect.map((hex) => `${hex} ${archiveName}\n`), +); +const releaseHttpClient = (checksums: string, requests: string[] = []) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); +const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - const prefixIndex = input.args.indexOf("--prefix"); - const stagingDir = input.args[prefixIndex + 1]; - if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); - const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("installs through pnpm when its Node runtime has no npm executable", () => + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); - const commands: Array = []; - const install = successfulRunner(fs, path); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; const paths = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input); - return input.command === "npm" - ? Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "npm", - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }), - }), - ) - : install.run(input); - }, - }), + httpClient: releaseHttpClient(yield* validChecksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), validate: (staging) => fs.exists(staging.entryPath).pipe( Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), Effect.orDie, ), }); - assert.deepEqual( - commands.map((command) => command.command), - ["npm", "pnpm"], - ); - assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${version}/SHA256SUMS`, + `https://releases.example/download/v${version}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); }), ); - it.effect("does not try a different installer for npm permission failures", () => + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); const commands: string[] = []; - yield* ensurePinnedRuntimeInstalled({ + const error = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input.command); - return Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - }), - }), - ); - }, - }), - validate: () => Effect.die("must not validate a failed install"), + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), }).pipe(Effect.flip); - assert.deepEqual(commands, ["npm"]); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); }), ); @@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: (staging) => Effect.gen(function* () { validatedDirectory = staging.versionDir; @@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.notEqual(validatedDirectory, finalPaths.versionDir); assert.deepEqual(installed, finalPaths); assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`); }), ); @@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), }).pipe(Effect.flip); @@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.void, }); @@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); - yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`); let validations = 0; + const requests: string[] = []; yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path), validate: (paths) => Effect.gen(function* () { validations += 1; @@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }).pipe(Effect.flip); assert.equal(validations, 1); + assert.deepEqual(requests, []); assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); }), ); @@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }); const install = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); @@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); - - // Archive-distributed versions never touch npm: the release archive is - // fetched, checked against SHA256SUMS, and unpacked with tar. - const archiveVersion = "1.3.0-preview.20260911.7"; - const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`; - const archiveBytes = new TextEncoder().encode("not really a tarball"); - const archiveHex = (bytes: Uint8Array) => - Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( - Effect.map((digest) => - Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const releaseHttpClient = (checksums: string, requests: string[]) => - HttpClient.make((request) => { - requests.push(request.url); - const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); - }); - const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) => - ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.gen(function* () { - commands.push(input.command); - const targetIndex = input.args.indexOf("-C"); - const stagingDir = input.args[targetIndex + 1]; - if (input.command !== "tar" || stagingDir === undefined) { - return yield* Effect.die(`unexpected command ${input.command}`); - } - yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); - return { - stdout: "", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - - it.effect("installs archive-distributed versions from the verified release archive", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); - const requests: string[] = []; - const commands: string[] = []; - const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`; - const paths = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(checksums, requests), - releaseBaseUrl: "https://releases.example/download", - runner: extractingRunner(fs, path, commands), - validate: (staging) => - fs.exists(staging.entryPath).pipe( - Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), - Effect.orDie, - ), - }); - assert.equal(paths.layout, "archive"); - assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: paths.entryPath, - args: [], - }); - assert.deepEqual(requests, [ - `https://releases.example/download/v${archiveVersion}/SHA256SUMS`, - `https://releases.example/download/v${archiveVersion}/${archiveName}`, - ]); - assert.deepEqual(commands, ["tar"]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`); - assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); - }), - ); - - it.effect("refuses an archive whose checksum does not match the release", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); - const commands: string[] = []; - const error = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []), - runner: extractingRunner(fs, path, commands), - validate: () => Effect.die("must not validate an unverified archive"), - }).pipe(Effect.flip); - assert.instanceOf(error, PinnedRuntimeInstallError); - assert.equal(error.step, "verifying the t3 release archive checksum"); - assert.deepEqual(commands, []); - assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); - }), - ); - - it("runs npm layouts through the host Node", () => { - const paths = pinnedRuntimePaths( - { join: (...parts: string[]) => parts.join("/") } as Path.Path, - "/home/theo/.t3", - "1.2.3", - "linux", - ); - assert.equal(paths.layout, "npm"); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: "/usr/bin/node", - args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"], - }); - }); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index c4e02e41ddf7f02c027d8e258b8402aca7d3d085..686d5cec9d2a36593c92cddd153aa3936e03dcca 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -15,26 +14,21 @@ import { cliArchivePlatformKey, cliArchiveTarCommand, cliReleaseDownloadBaseUrl, - isArchiveDistributedVersion, parseChecksums, } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` installed into - * /runtime/versions/. The boot service points its unit or - * launch agent here, and server self-update installs the target version here before - * switching over, never `npx t3`, whose cache is ephemeral and whose - * registry fetch at boot would make startup depend on the network. - * - * Two layouts exist. npm-distributed versions are `npm install`ed and run as - * ` node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the - * self-contained release archive unpacked in place and run as `./t3`, which - * needs neither Node nor npm on the machine. The layout is decided by the - * version string alone so every consumer agrees without probing the disk. + * A pinned runtime is an exact t3 release archive unpacked into + * /runtime/versions/: the self-contained executable, the + * web client, and the native packages beside it. The boot service points its + * unit or launch agent at the executable, and server self-update installs the + * target version here before switching over. The runtime never depends on a + * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` + * package for people who prefer `npx t3` or `npm install -g t3`, and even a + * CLI installed that way pins an archive when it sets up the service. */ - const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; @@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); -export type PinnedRuntimeLayout = "npm" | "archive"; - export interface PinnedRuntimePaths { - readonly layout: PinnedRuntimeLayout; readonly versionDir: string; - /** - * `bin.mjs` for npm layouts, the executable itself for archives. Existence - * of this file is what marks a runtime as present. - */ + /** The executable. Its existence is what marks a runtime as present. */ readonly entryPath: string; readonly sentinelPath: string; } -/** The exact command that runs a pinned runtime, given the Node hosting the caller. */ -export function pinnedRuntimeCommand( - paths: PinnedRuntimePaths, - nodePath: string, -): { readonly command: string; readonly args: ReadonlyArray } { - return paths.layout === "archive" - ? { command: paths.entryPath, args: [] } - : { command: nodePath, args: [paths.entryPath] }; +/** The exact command that runs a pinned runtime. */ +export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { + readonly command: string; + readonly args: ReadonlyArray; +} { + return { command: paths.entryPath, args: [] }; } export function pinnedRuntimePaths( @@ -72,20 +58,10 @@ export function pinnedRuntimePaths( platform: NodeJS.Platform, ): PinnedRuntimePaths { const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); - const sentinelPath = path.join(versionDir, ".install-complete"); - if (isArchiveDistributedVersion(version)) { - return { - layout: "archive", - versionDir, - entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), - sentinelPath, - }; - } return { - layout: "npm", versionDir, - entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), - sentinelPath, + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), + sentinelPath: path.join(versionDir, ".install-complete"), }; } @@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete - * install is already there, and returns its paths. The sentinel is written - * only after the install step exits 0; checking the entry file alone is not - * enough. npm extracts files before running native builds (node-pty), and tar - * writes the executable before the last native package, so a killed install - * leaves a plausible-looking but broken tree behind. + * Installs the t3 release archive for `version` into the pinned runtime + * directory unless a complete install is already there, and returns its + * paths. The sentinel is written only after extraction and validation + * succeed; checking the entry file alone is not enough, since tar writes the + * executable before the last native package and a killed install leaves a + * plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput { ) => Effect.Effect; readonly platform: NodeJS.Platform; readonly arch: string; - /** Archive-distributed versions download from here; npm versions never need it. */ - readonly httpClient?: HttpClient.HttpClient | undefined; + readonly httpClient: HttpClient.HttpClient; readonly releaseBaseUrl?: string | undefined; } -const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* ( - input: PinnedRuntimeInstallInput, - stagingDir: string, -) { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* input.runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - input.runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); -}); - const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( httpClient: HttpClient.HttpClient, url: string, @@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun }); } const httpClient = input.httpClient; - if (httpClient === undefined) { - return yield* new PinnedRuntimeInstallError({ - step: "downloading the t3 release archive (no HTTP client available)", - }); - } const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); const fileName = cliArchiveFileName(input.version, platformKey); @@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ); const stagingPaths: PinnedRuntimePaths = { - layout: paths.layout, versionDir: stagingDir, entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - if (paths.layout === "archive") { - yield* installFromArchive(input, stagingDir); - } else { - yield* installFromNpm(input, stagingDir); - } + yield* installFromArchive(input, stagingDir); yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index e6d8010f19d734f10aa5ce2683f5d0a502aee909..8ca0baa64a323fc7ca031bc955223e2bed13d2a3 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,13 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; @@ -25,6 +26,28 @@ interface HarnessOptions { readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } +// The staged runtime is a release archive: the fake client serves SHA256SUMS +// and the tarball, and the fake runner stands in for tar before it answers +// the staged preflight. +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const releaseHttpClient = (order: string[]) => + HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/SHA256SUMS")) { + const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return HttpClientResponse.fromWeb( + request, + new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`), + ); + } + order.push("download"); + return HttpClientResponse.fromWeb(request, new Response(archiveBytes)); + }), + ); + const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( options: HarnessOptions = {}, ) { @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - if (input.command === "npm") { - order.push("install"); - const prefix = input.args[input.args.indexOf("--prefix") + 1]; - if (prefix === undefined) return yield* Effect.die("missing npm prefix"); - const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + if (input.command === "tar") { + order.push("extract"); + const stagingDir = input.args[input.args.indexOf("-C") + 1]; + if (stagingDir === undefined) return yield* Effect.die("missing tar target"); + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( run: () => Effect.die("unexpected desktop app update run"), }, ), - Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), + Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order }; @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { method: "boot-service", updateId: "launcher-id", }); - expect(order).toEqual(["install", "preflight", "accept"]); + expect(order).toEqual(["download", "extract", "preflight", "accept"]); }), ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 734c8ca732fd42a9a0233204b8acc69867f70d86..a388f8a0b4d73199496af8e6ef2b27600b4eb337 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,11 +6,7 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const execPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; // Archive-distributed targets download from GitHub Releases. The client is // optional so callers without one (tests, npm-only hosts) still construct. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, execPath).command, + command: pinnedRuntimeCommand(runtime).command, args: [ - ...pinnedRuntimeCommand(runtime, execPath).args, + ...pinnedRuntimeCommand(runtime).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index bb61866a93ffce0332ab71291b17664ac70a4196..89610ca918a3da9ea3e5042105f146a7796066ab 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; -export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; export const SERVICE_STATE_FILE = "service-state.json"; /** Written by the launcher just before an explicit stop kills its child, so the child can tell "the service is going away" from "the launcher is about diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts deleted file mode 100644 index b761998339235b5d267f30a0ef97baffcda1d6ea..0000000000000000000000000000000000000000 --- a/apps/server/src/service-launcher.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Standalone launcher entry for npm-distributed runtimes: `node -// service-launcher.mjs`. Archive runtimes reach the same code through the -// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run -// anything on import. -import { isEntrypoint } from "./entrypoint.ts"; -import { main } from "./serviceLauncher.ts"; - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 45c472af1fc48628048d63447cc28a188feda3f6..db1a6a8bee34b2bc6d532fe45e494a5c47203f85 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -75,6 +75,27 @@ it("rejects contradictory service state", () => { ); }); +// A pinned runtime is an executable at /t3. The tests stand one up +// as a Node shebang script so the launcher spawns it the way it spawns the +// real single-executable, IPC channel included. +const writeFakeRuntime = ( + fs: FileSystem.FileSystem, + path: Path.Path, + versionDir: string, + childSource: string, +) => + Effect.gen(function* () { + const entryPath = path.join(versionDir, "t3"); + yield* fs.makeDirectory(versionDir, { recursive: true }); + yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`); + yield* fs.chmod(entryPath, 0o755); + yield* fs.writeFileString( + path.join(versionDir, ".install-complete"), + `${path.basename(versionDir)}\n`, + ); + return entryPath; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { @@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); - const versionDir = path.join(root, "runtime", "versions", "1.0.0"); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); yield* Effect.promise(() => writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -148,11 +170,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -198,11 +221,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -257,11 +281,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index fc3d8674e68e59bec56140f036be6fcc804eff58..a1e16a62716327324322ee4321077ef91f6191b2 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,13 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// This file is shipped as a standalone bundle and copied to a stable path by -// `t3 service update`. Keep runtime imports limited to Node built-ins. +// The launcher supervises the server child for the boot service and must keep +// working across server versions, so it stays on Node built-ins with no Effect +// runtime: it is the one part of the executable that cannot depend on the +// rest of it being loadable. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeSea from "node:sea"; import type { PendingServiceUpdate, @@ -41,37 +42,24 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } -// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked -// release archives whose executable runs on its own, npm versions are a -// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node +// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose +// executable runs on its own. Kept inline so this file stays on Node // built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); - const archive = /-preview\.\d{8}\.\d+$/.test(version); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: archive - ? NodePath.join(versionDir, executableName) - : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: NodePath.join(versionDir, executableName), sentinelPath: NodePath.join(versionDir, ".install-complete"), - executable: archive, }; }; -const runtimeSpawnArguments = (paths: ReturnType) => - paths.executable - ? { command: paths.entryPath, args: ["serve"] } - : { command: process.execPath, args: [paths.entryPath, "serve"] }; - -// An npm-layout runtime needs a Node interpreter. When the launcher itself is -// the single-executable, process.execPath is `t3`, which cannot run a -// bin.mjs, so the two layouts cannot be mixed within one service install. -const launcherIsExecutable = NodeSea.isSea(); - -const canLaunchRuntime = (paths: ReturnType) => - paths.executable || !launcherIsExecutable; +const runtimeSpawnArguments = (paths: ReturnType) => ({ + command: paths.entryPath, + args: ["serve"], +}); /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; @@ -505,12 +493,6 @@ export class Launcher { await reject("The requested database path is not absolute."); return; } - if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) { - await reject( - "This service runs from a self-contained t3 executable and cannot switch to an npm-installed version. Reinstall the service with the target version instead.", - ); - return; - } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; diff --git a/docs/operations/release.md b/docs/operations/release.md index 2641bccf3547f4f4757f39e59b1df465f98c4f57..54e3f5e55eaec700396805a08f7314e0bae0c351 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. - - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. + - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. @@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native dependency closure in `resources/server.asar`. Native modules and helper executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads -the archive in place through Electron. Packaged Windows builds also ship a -Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies -and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside -the selected distro, then reuses it for later launches of the same update. The -Windows-side `wsl-server-tree/` extraction remains a fallback and is -removed after the distro-local runtime passes preflight. +the archive in place through Electron. Packaged Windows builds also ship +`resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive +(`t3--linux-x64.tar.gz`) built by the `build_linux_cli` job and handed +to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs +the exact bytes a Linux user downloads. WSL verifies and extracts that archive +into `~/.t3/wsl-runtime/sha256-` inside the selected distro, +then reuses it for later launches of the same update. Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only native libraries and helper executables. Avoid enabling whole-package smart @@ -272,11 +273,12 @@ break: - On same-architecture Windows builds, the packaged primary cannot load the fff native library from inside `server.asar` through its `.unpacked` sibling. - The isolated, extracted sidecar cannot load the server entry with plain Node. -- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256 - sidecar, the sidecar digest does not match the emitted archive, or required - Linux runtime members are absent. -- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY, - pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings. +- A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256 + sidecar, or the sidecar digest does not match the emitted archive. +- The emitted WSL archive is not a Linux CLI release archive: it must unpack to + a single `t3--linux-` directory holding `t3`, `client/`, and + `node_modules/` with the Linux node-pty binary, and must not carry a loose + server bundle (`bin.mjs`). - The external Windows resource monitor is absent. - The unpacked Windows application contains more than 80 files. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 06a32e533029cf7812f3d382db63b03d1be9147b..86b022cf1245e554835a0362177a9e6caa24ac66 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. It places `t3` in `~/.local/bin` and reuses the same download when you later run `t3 service install`. It follows the stable train by default; set `T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact -version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that -were only published to npm are refused with the `npm install` to run instead. +version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. `preview` is a third train that maintainers cut from unreleased branches to exercise the release pipeline. Those builds can be broken, receive no fixes, @@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the command tells you it is still on the old version so you can restart it yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or -`--allow-downgrade` to move backwards. Versions published only to npm cannot -be installed this way; the command says so and names the `npm install` to run -instead. +`--allow-downgrade` to move backwards. ## Platform support diff --git a/knip.jsonc b/knip.jsonc index e225298fabc0a464f8c3957107781ef794384930..d203e6405585998cc917301eb969ea274202be03 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -20,7 +20,6 @@ // Vite+ pack entries and the launcher used by installed background services. "entry": [ "src/bin.ts!", - "src/service-launcher.ts!", "src/claude-history-worker.ts!", "scripts/cli.ts", "src/provider/testFixtures/*.mjs", diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index e988dabc799b5a05b638177476c49b5f9ff28796..c5109ebee3a06af19b7bf1196bfb1e9e208f4224 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -7,7 +7,6 @@ import { cliReleaseDownloadBaseUrl, cliReleaseChannelOf, cliReleaseIndexPageUrl, - isArchiveDistributedVersion, newestCliReleaseVersion, parseChecksums, } from "./cliRelease.ts"; @@ -56,12 +55,6 @@ describe("cliRelease", () => { expect(checksums.size).toBe(2); }); - it("treats only preview builds as archive-distributed", () => { - expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); - expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); - expect(isArchiveDistributedVersion("1.2.3")).toBe(false); - }); - it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { expect(cliArchiveTarCommand("linux", {})).toBe("tar"); expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 4bdc4749b6194edbc9002733eb8a328a569aa82e..614b0d48bc0384f8a9f53bbb1cb1bcf72b8e400e 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap { return checksums; } -/** Whether a version was published from a release train that ships archives. */ -export function isArchiveDistributedVersion(version: string): boolean { - return /-preview\.\d{8}\.\d+$/.test(version); -} - export type CliReleaseChannel = "stable" | "nightly" | "preview"; export const CLI_RELEASE_CHANNELS: ReadonlyArray = [ "stable", diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e5b621f87aa8cdf426493ed1e6e66d1a3f4810f0..02e86b699a35b4b6d17f6baace44f8098515e817 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -14,7 +14,6 @@ import { baseSshArgs, getLastNonEmptyOutputLine, parseSshResolveOutput, - resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; import { SshCommandError } from "./errors.ts"; @@ -99,41 +98,6 @@ describe("ssh command", () => { }), ); - it.effect("resolves the remote t3 package spec from the desktop release channel", () => - Effect.sync(() => { - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17", - updateChannel: "latest", - }), - "t3@0.0.17", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17-nightly.20260415.44", - updateChannel: "nightly", - }), - "t3@0.0.17-nightly.20260415.44", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "nightly", - isDevelopment: true, - }), - "t3@nightly", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "latest", - isDevelopment: true, - }), - "t3@nightly", - ); - }), - ); - it.effect("reads the last non-empty ssh output line", () => Effect.sync(() => { assert.equal( diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 7a94670370b2fe6ae5cbffb9fea8ee973cb22569..fc388114351d3ba694565ddf11a9ba449f32867b 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; +import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; -const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; @@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi ), ); }); - -export function resolveRemoteT3CliPackageSpec(input: { - readonly appVersion: string; - readonly updateChannel: DesktopUpdateChannel; - readonly isDevelopment?: boolean; -}): string { - const appVersion = input.appVersion.trim(); - if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { - return `t3@${appVersion}`; - } - - if (input.isDevelopment) { - return "t3@nightly"; - } - - return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; -} diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 82061c0d8c84d9900c6eccf499ebbea1ba3bb9a6..f26bbc39415e4d5b9d6994776ea2e01780c6abfa 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( "remote runner process ownership", () => { - it.live.each(["npx", "npm"] as const)( - "keeps the server PID and graceful shutdown through the %s fallback", - (packageManager) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "package-manager-calls.jsonl"); - const packageSpec = "t3@0.0.35"; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node + it.live("keeps the server PID and graceful shutdown through the node-script runner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node import * as net from "node:net"; const server = net.createServer((socket) => { socket.end(); @@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { }) + "\\n"); }); `, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -const childProcess = require("node:child_process"); -const args = process.argv.slice(2); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); -if (args.includes("--package")) { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} else { - const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); - child.once("exit", (code) => { process.exitCode = code ?? 1; }); -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); + ); + yield* fs.chmod(cliPath, 0o700); - const runServer = (port = 0) => - Effect.gen(function* () { - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { - cwd: fixture, - env: { - PATH: bin, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - T3_TEST_PORT: String(port), - }, - detached: false, - stdin: Stream.make( - new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), - ), - }), - ); - const ready = yield* Deferred.make(); - const stdout: string[] = []; - const output = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.runForEach((line) => - Effect.gen(function* () { - stdout.push(line); - if (stdout.length === 1) { - yield* Deferred.succeed(ready, decodeStarted(line)); - } - }), + const runServer = (port = 0) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { + cwd: fixture, + env: { + PATH: bin, + T3_TEST_PORT: String(port), + }, + detached: false, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })), ), - Effect.forkScoped, - ); - const stderr = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.mkString, - Effect.forkScoped, - ); - const receipt = yield* Effect.raceFirst( - Deferred.await(ready), - Fiber.join(output).pipe( - Effect.flatMap(() => Fiber.join(stderr)), - Effect.flatMap((message) => - Effect.die(new Error(`Runner exited before listening: ${message}`)), - ), - ), - ); - // A failed PID assertion must still close the owned fixture server, including an npm child. - yield* Effect.addFinalizer(() => + }), + ); + const ready = yield* Deferred.make(); + const stdout: string[] = []; + const output = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => Effect.gen(function* () { - if (yield* child.isRunning) { - yield* Effect.callback((resume) => { - const connection = NodeNet.connect(receipt.port, "127.0.0.1"); - connection.on("error", () => undefined); - connection.once("close", () => resume(Effect.void)); - return Effect.sync(() => connection.destroy()); - }); - yield* child.exitCode; + stdout.push(line); + if (stdout.length === 1) { + yield* Deferred.succeed(ready, decodeStarted(line)); } - }).pipe(Effect.orDie), - ); - assert.equal(receipt.pid, child.pid); - assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); - yield* child.kill({ killSignal: "SIGTERM" }); - assert.equal(yield* child.exitCode, 0); - yield* Fiber.join(output); - assert.include(stdout, "graceful shutdown"); - return receipt.port; - }).pipe(Effect.scoped); + }), + ), + Effect.forkScoped, + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkScoped, + ); + const receipt = yield* Effect.raceFirst( + Deferred.await(ready), + Fiber.join(output).pipe( + Effect.flatMap(() => Fiber.join(stderr)), + Effect.flatMap((message) => + Effect.die(new Error(`Runner exited before listening: ${message}`)), + ), + ), + ); + // A failed PID assertion must still close the owned fixture server. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* child.isRunning) { + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(receipt.port, "127.0.0.1"); + connection.on("error", () => undefined); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + yield* child.exitCode; + } + }).pipe(Effect.orDie), + ); + assert.equal(receipt.pid, child.pid); + assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); + yield* child.kill({ killSignal: "SIGTERM" }); + assert.equal(yield* child.exitCode, 0); + yield* Fiber.join(output); + assert.include(stdout, "graceful shutdown"); + return receipt.port; + }).pipe(Effect.scoped); - const port = yield* runServer(); - assert.equal(yield* runServer(port), port); - const calls = (yield* fs.readFileString(callsPath)) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - assert.deepEqual(calls, [expectedCall, expectedCall]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + const port = yield* runServer(); + assert.equal(yield* runServer(port), port); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }, ); @@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => { ); }, ); - -describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( - "remote runner install diagnostics", - () => { - const decodeArguments = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Array(Schema.String)), - ); - const cases = (["npx", "npm"] as const).flatMap((packageManager) => - ( - [ - "etarget", - "network", - "empty-success", - "success", - "failed-with-path", - "existing-cli", - "node-override", - ] as const - ).map((mode) => ({ packageManager, mode })), - ); - - it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "installer-calls.jsonl"); - const packageSpec = "t3@0.0.39-nightly.20260905.1286"; - const args = ["serve", "a path with spaces"]; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node -process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); -`, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString(callsPath, ""); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); -const mode = process.env.T3_TEST_MODE; -if (mode === "success" || mode === "failed-with-path") { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} -if (mode === "etarget" || mode === "failed-with-path") { - process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); - process.exitCode = 42; -} else if (mode === "network") { - process.stderr.write("npm error code ENETUNREACH\\n"); - process.exitCode = 43; -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); - if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); - - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", ...args], { - cwd: fixture, - extendEnv: false, - env: { - PATH: bin, - T3_TEST_MODE: mode, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - }, - stdin: Stream.make( - new TextEncoder().encode( - buildRemoteT3RunnerScript({ - packageSpec, - ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), - }), - ), - ), - }), - ); - const { stdout, stderr, exitCode } = yield* Effect.all( - { - stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), - stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), - exitCode: child.exitCode, - }, - { concurrency: "unbounded" }, - ); - const installFailed = - mode === "etarget" || mode === "network" || mode === "failed-with-path"; - const missingExecutable = mode === "empty-success"; - assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); - if (installFailed || missingExecutable) { - assert.equal(stdout, ""); - } else { - assert.deepEqual(decodeArguments(stdout), args); - } - if (installFailed) { - const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; - assert.include(stderr, `npm error code ${npmError}\n`); - assert.include(stderr, `Remote host could not install ${packageSpec}.`); - assert.notInclude(stderr, "Remote host installed"); - assert.notInclude(stderr, "Install a C toolchain"); - } else if (missingExecutable) { - assert.include(stderr, `Remote host installed ${packageSpec}`); - assert.include(stderr, "npm produced no t3 executable"); - assert.include(stderr, "Install a C toolchain"); - } else { - assert.equal(stderr, ""); - } - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; - const calls = yield* fs.readFileString(callsPath); - if (usesInstaller) { - assert.deepEqual( - calls - .trim() - .split("\n") - .map((line) => decodeArguments(line)), - [expectedCall], - ); - } else { - assert.equal(calls, ""); - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - }, -); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 182a8eb4a0ce7c997d9f377636cdb9ea48c60711..980107d19a8222f1084fbe587d986d6c051913df 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -23,6 +23,7 @@ import { buildRemoteStopScript, buildRemoteT3RunnerScript, SshInvalidArchiveVersionError, + SshMissingRunnerError, describeReadinessCause, issueRemotePairingToken, launchOrReuseRemoteServer, @@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { return command._tag === "StandardCommand" ? command.args : []; } +const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; +const NODE_SCRIPT = { + nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", +} as const; + describe("ssh tunnel scripts", () => { - it("builds the remote t3 runner with npx and npm fallbacks", () => { - const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); - - assert.include(script, "T3_NODE_SCRIPT_PATH=''"); - assert.include(script, 'exec t3 "$@"'); - assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); - assert.include(script, "could not install 't3@latest'"); - assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); - assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); - assert.include(script, "npm produced no t3 executable"); - assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); - assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); - assert.include(script, "remote_node_satisfies_engine()"); - assert.include(script, "function satisfiesSemverRange"); - assert.include(script, "satisfiesSemverRange(rawVersion, range)"); - assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); - assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); - assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); - assert.include(script, 'eval "$(fnm env --shell bash)"'); - assert.include(script, "fnm use --silent-if-unchanged"); - assert.include(script, "fnm use default"); - assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); - assert.include(script, 'NVM_DIR="$HOME/.nvm"'); - assert.include(script, "nvm use --silent default"); - assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); - assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); - }); - - it("installs and runs the release archive when an archive version is set", () => { - const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); + it("installs and runs the release archive without Node, npm, or npx", () => { + const script = buildRemoteT3RunnerScript(ARCHIVE); assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); + assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include( script, "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", @@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => { assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); assert.include(script, "SHA256SUMS"); assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); + assert.notInclude(script, "npx"); + assert.notInclude(script, "npm exec"); + assert.notInclude(script, "t3@latest"); + assert.notInclude(script, 'exec t3 "$@"'); // Concurrent launches serialize on a per-version mkdir lock and recheck // the completion marker after acquiring it. assert.include( @@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => { script.indexOf('"$T3_STAGING/t3" --version'), script.indexOf('> "$T3_STAGING/.install-complete"'), ); - // The archive branch execs before any of the Node discovery runs. + // Node discovery is defined for the dev path but only ever invoked inside + // the node-script branch, which the archive path skips entirely. + assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1); assert.isBelow( - script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), - script.indexOf("prepend_path_if_dir()"), + script.indexOf("ensure_remote_node_path || true"), + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + ); + assert.isBelow( + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + script.indexOf("T3_ARCHIVE_VERSION="), ); const launch = buildRemoteLaunchScript({ - archiveVersion: "1.2.3-preview.20260911.4", + ...ARCHIVE, releaseBaseUrl: "https://mirror.example/t3/", }); assert.include(launch, "T3_ARCHIVE_MODE=1"); @@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => { assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); - assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); + assert.include(buildRemoteLaunchScript(NODE_SCRIPT), "T3_ARCHIVE_MODE=0"); }); it("rejects archive versions that are not a single exact version segment", () => { @@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => { ); } assert.include( - buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), + buildRemoteT3RunnerScript(ARCHIVE), "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", ); }); + it("refuses to build a runner with neither an archive version nor a node script", () => { + for (const input of [undefined, {}, { archiveVersion: " " }, { nodeScriptPath: null }]) { + assert.throws(() => buildRemoteT3RunnerScript(input), SshMissingRunnerError); + } + assert.throws(() => buildRemoteLaunchScript(), SshMissingRunnerError); + }); + it("does not hard-code a remote node engine range", () => { - const script = buildRemoteT3RunnerScript(); + const script = buildRemoteT3RunnerScript(NODE_SCRIPT); assert.include(script, "T3_NODE_ENGINE_RANGE=''"); assert.notInclude(script, TEST_NODE_ENGINE_RANGE); }); - it("shell-quotes package specs in the remote t3 runner", () => { - const script = buildRemoteT3RunnerScript({ - packageSpec: "t3@nightly; touch /tmp/t3-owned", - }); - - assert.include( - script, - "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", - ); - assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); - }); - it("builds the remote t3 runner with a node script override", () => { const script = buildRemoteT3RunnerScript({ - nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, }); assert.include( @@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => { "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", ); assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); + assert.include(script, "T3_ARCHIVE_VERSION=''"); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); + assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(script, "remote_node_satisfies_engine()"); + assert.include(script, "function satisfiesSemverRange"); + assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); + assert.include(script, 'eval "$(fnm env --shell bash)"'); + assert.include(script, "fnm use --silent-if-unchanged"); + assert.include(script, "fnm use default"); + assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); + assert.include(script, 'NVM_DIR="$HOME/.nvm"'); + assert.include(script, "nvm use --silent default"); + assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); + assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); + assert.notInclude(script, "npx"); }); it("uses the remote t3 runner for launch and pairing scripts", () => { @@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => { username: "julius", port: 2222, } as const; + const launch = buildRemoteLaunchScript(ARCHIVE); + const devLaunch = buildRemoteLaunchScript({ + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, + }); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + launch, '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', ); - assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); - assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()"); - assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); + assert.include(launch, "RUNNER_CHANGED=1"); + assert.include(launch, "ensure_remote_node_path()"); + assert.include(launch, "if ! ensure_remote_node_path; then"); + assert.include(devLaunch, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(devLaunch, "does not satisfy required range "); + assert.include(launch, 'kill "$REMOTE_PID" 2>/dev/null || true'); + assert.include(launch, "wait_ready"); + assert.include(launch, '"$RUNNER_FILE" serve --host 127.0.0.1'); + assert.include(launch, '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.notInclude(launch, "server-home"); + assert.include(launch, "Remote T3 server did not become ready"); + assert.include(launch, 'wait_ready "60000"'); + assert.include(launch, 'if [ -s "$LOG_FILE" ]; then'); + assert.include(launch, "It wrote nothing to %s"); + assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), - `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`, - ); - assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), - "does not satisfy required range ", - ); - assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); - assert.include(buildRemoteLaunchScript(), "wait_ready"); - assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); - assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemoteLaunchScript(), "server-home"); - assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); - assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); - assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); - assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); - assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); - assert.include( - buildRemotePairingScript(target), + buildRemotePairingScript(target, ARCHIVE), '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', ); - assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemotePairingScript(target), "server-home"); - assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); + assert.include( + buildRemotePairingScript(target, ARCHIVE), + 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"', + ); + assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home"); + assert.include( + buildRemotePairingScript(target, ARCHIVE), + "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", + ); assert.include( buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', @@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include( - buildRemoteLaunchScript(), + launch, 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', ); - assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); - assert.include( - buildRemoteLaunchScript(), - 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', - ); - assert.include( - buildRemoteLaunchScript(), - "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))", - ); - assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); - assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); - assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); - assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); - assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); + assert.include(launch, "resolve_default_runtime_port()"); + assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); + assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); + assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); + assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); + assert.include(launch, 'rm -f "$PID_FILE"'); + assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); + assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then'); assert.isBelow( - buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), - buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), + launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), + launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), ); assert.isBelow( - buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), - buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), + launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), + launch.indexOf('elif [ -n "$REMOTE_PID" ]'), ); }); @@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* launchOrReuseRemoteServer(target); + const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE); assert.equal(result.remotePort, 3774); assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]); }).pipe(Effect.provide(processLayer)); @@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + const fiber = yield* Effect.forkChild( + launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT), + ); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(75)); @@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("gives cold archive launches a larger budget than npm launches", () => { + it.effect("gives cold archive launches a larger budget than node-script launches", () => { const target = { alias: "devbox", hostname: "devbox.example.com", @@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - launchOrReuseRemoteServer(target, undefined, { - archiveVersion: "1.2.3-preview.20260911.4", - }), - ); + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE)); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(800)); @@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); const target = { alias: "devbox", @@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); yield* Effect.gen(function* () { const manager = yield* SshEnvironmentManager; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index d3df7d39307fb92e7a23ccbbf8bf248e61ea195c..135cb7decae9ab0efe85d17c4b4c5ce841d40926 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { - readonly packageSpec?: string; + /** + * Dev mode: run `node ` on the remote instead of a release archive. + * The only mode that needs Node on the remote. + */ readonly nodeScriptPath?: string | null; readonly nodeEngineRange?: string | null; /** - * Exact version whose release archive the remote installs and runs. Takes - * precedence over `packageSpec`; the remote then needs neither Node nor npm. + * Exact version whose self-contained release archive the remote installs + * and runs. Required unless `nodeScriptPath` is set; the remote then needs + * neither Node nor npm. */ readonly archiveVersion?: string | null; readonly releaseBaseUrl?: string | null; } export interface SshEnvironmentManagerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { }; } +function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean { + return Boolean(runner?.nodeScriptPath?.trim()); +} + function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { - if (runner?.archiveVersion?.trim()) { - return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; - } if (runner?.nodeScriptPath?.trim()) { return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; } - if (runner?.packageSpec?.trim()) { - return { runner: "package", packageSpec: runner.packageSpec.trim() }; + if (runner?.archiveVersion?.trim()) { + return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; } - return { runner: "default" }; + return { runner: "archive" }; } interface SshAuthOperationInput { @@ -424,144 +428,117 @@ ensure_remote_node_path() { const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu -T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ -if [ -n "$T3_ARCHIVE_VERSION" ]; then - # Self-contained release archive: no Node, npm, or compiler on the remote. - # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. - T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ - T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" - t3_runtime_ready() { - [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] - } - if ! t3_runtime_ready; then - mkdir -p "$HOME/.t3/runtime/versions" - # Concurrent launches (two clients, a retry racing a slow first run) must - # not both install: mkdir is the atomic lock and the ready check repeats - # under it. - T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" - # mkdir is the only portable atomic exclusive create (mv would silently - # nest a candidate inside an existing lock). The owner publishes its pid - # right after, so a lock with a live owner is never reclaimed however - # slow its download is, and a lock whose owner is dead is reclaimed at - # once. A lock with no pid at all is a crash between mkdir and the pid - # write; it is reclaimed after a short grace so a live owner has time to - # publish. - T3_LOCK_WAITED=0 - T3_LOCK_UNOWNED=0 - while ! mkdir "$T3_LOCK" 2>/dev/null; do - T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" - if [ -n "$T3_LOCK_OWNER" ]; then - T3_LOCK_UNOWNED=0 - if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then - rm -rf "$T3_LOCK" - continue - fi - else - T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) - if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then - rm -rf "$T3_LOCK" - continue - fi - fi - if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then - printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 - exit 1 - fi - sleep 1 - T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) - done - printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" - trap 'rm -rf "$T3_LOCK"' EXIT - fi - if ! t3_runtime_ready; then - case "$(uname -s)" in - Darwin) T3_PLATFORM="darwin" ;; - Linux) T3_PLATFORM="linux" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; - esac - case "$(uname -m)" in - arm64 | aarch64) T3_ARCH="arm64" ;; - x86_64 | amd64) T3_ARCH="x64" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; - esac - T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" - T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" - trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT - t3_fetch() { - if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" - elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" - else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - } - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ - T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" - if command -v sha256sum >/dev/null 2>&1; then - T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - else - T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - fi - if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then - printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 - rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" - # Prove the binary runs here (libc, arch) before marking it ready, or every - # later launch would exec a broken install instead of retrying. - if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then - printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 - fi - printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" - rm -rf "$T3_RUNTIME_DIR" - mv "$T3_STAGING" "$T3_RUNTIME_DIR" - fi - if [ -n "\${T3_LOCK:-}" ]; then - rm -rf "$T3_LOCK" - trap - EXIT - fi - exec "$T3_RUNTIME_DIR/t3" "$@" -fi @@T3_NODE_ENV_SCRIPT@@ -ensure_remote_node_path || true T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ if [ -n "$T3_NODE_SCRIPT_PATH" ]; then + # Dev mode: a source checkout on the remote. This is the only path that + # needs Node, so Node discovery runs here and nowhere else. + ensure_remote_node_path || true if ! command -v node >/dev/null 2>&1; then printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 exit 1 fi exec node "$T3_NODE_SCRIPT_PATH" "$@" fi -if command -v t3 >/dev/null 2>&1; then - exec t3 "$@" +T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ +if [ -z "$T3_ARCHIVE_VERSION" ]; then + printf 'No t3 release version was provided for the remote runtime.\\n' >&2 + exit 1 fi -# npm extracts a package before it runs the native builds of its dependencies, -# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves -# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without -# running anything at all, which the caller only ever sees as a server that -# never becomes ready. Resolve the CLI once up front so that install failure is -# reported here, with npm's own output on stderr. -require_installed_t3_cli() { - if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then - printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 - fi - if [ -n "$T3_CLI_PATH" ]; then - return 0 - fi - printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 +# Self-contained release archive: no Node, npm, or compiler on the remote. +# Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. +T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ +T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" +t3_runtime_ready() { + [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] } -# The launcher records this PID, so exec the CLI without an npm wrapper process. -if command -v npx >/dev/null 2>&1; then - require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" +if ! t3_runtime_ready; then + mkdir -p "$HOME/.t3/runtime/versions" + # Concurrent launches (two clients, a retry racing a slow first run) must + # not both install: mkdir is the atomic lock and the ready check repeats + # under it. + T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" + # mkdir is the only portable atomic exclusive create (mv would silently + # nest a candidate inside an existing lock). The owner publishes its pid + # right after, so a lock with a live owner is never reclaimed however + # slow its download is, and a lock whose owner is dead is reclaimed at + # once. A lock with no pid at all is a crash between mkdir and the pid + # write; it is reclaimed after a short grace so a live owner has time to + # publish. + T3_LOCK_WAITED=0 + T3_LOCK_UNOWNED=0 + while ! mkdir "$T3_LOCK" 2>/dev/null; do + T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" + if [ -n "$T3_LOCK_OWNER" ]; then + T3_LOCK_UNOWNED=0 + if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then + rm -rf "$T3_LOCK" + continue + fi + else + T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) + if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then + rm -rf "$T3_LOCK" + continue + fi + fi + if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then + printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 + exit 1 + fi + sleep 1 + T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) + done + printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" + trap 'rm -rf "$T3_LOCK"' EXIT fi -if command -v npm >/dev/null 2>&1; then - require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" +if ! t3_runtime_ready; then + case "$(uname -s)" in + Darwin) T3_PLATFORM="darwin" ;; + Linux) T3_PLATFORM="linux" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) T3_ARCH="arm64" ;; + x86_64 | amd64) T3_ARCH="x64" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; + esac + T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" + T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" + trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT + t3_fetch() { + if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" + else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + } + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ + T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" + if command -v sha256sum >/dev/null 2>&1; then + T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + else + T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + fi + if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then + printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 + rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" + # Prove the binary runs here (libc, arch) before marking it ready, or every + # later launch would exec a broken install instead of retrying. + if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then + printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 + fi + printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" + rm -rf "$T3_RUNTIME_DIR" + mv "$T3_STAGING" "$T3_RUNTIME_DIR" fi -printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 -exit 1 +if [ -n "\${T3_LOCK:-}" ]; then + rm -rf "$T3_LOCK" + trap - EXIT +fi +exec "$T3_RUNTIME_DIR/t3" "$@" `; const REMOTE_LAUNCH_SCRIPT = `set -eu @@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError()( + "SshMissingRunnerError", + {}, +) { + override get message(): string { + return "A remote t3 runner needs an archive version or a node script path."; + } +} + export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { - const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; const archiveVersion = input?.archiveVersion?.trim() || ""; + if (nodeScriptPath === "" && archiveVersion === "") { + throw new SshMissingRunnerError(); + } if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { throw new SshInvalidArchiveVersionError({ archiveVersion }); } @@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string ); return stripTrailingNewlines( applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { - T3_PACKAGE_SPEC: packageSpec, T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), @@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { - T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", + T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1", T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), @@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), - timeoutMs: runner?.archiveVersion?.trim() - ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS - : REMOTE_LAUNCH_TIMEOUT_MS, + timeoutMs: isNodeScriptRunner(runner) + ? REMOTE_LAUNCH_TIMEOUT_MS + : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT stdin: buildRemotePairingScript(target, runner), // Pairing may be the first command on a cold remote, so it can install // the archive on the way. - ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), + ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(resolvedTarget), key, }); - const packageSpec = options.resolveCliPackageSpec?.(); const runner = - options.resolveCliRunner === undefined - ? packageSpec === undefined - ? undefined - : { packageSpec } - : yield* options.resolveCliRunner; + options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner; yield* Effect.logDebug("ssh.environment.runner.resolved", { ...sshTargetLogFields(resolvedTarget), ...sshRunnerLogFields(runner), diff --git a/apps/marketing/public/install.ps1 b/scripts/install.ps1 similarity index 97% rename from apps/marketing/public/install.ps1 rename to scripts/install.ps1 index 4aa46d327b876c70c722d037d0b3259075bdec21..a779854717ad6659d7b910c22d95a3f9a3132144 100644 --- a/apps/marketing/public/install.ps1 +++ b/scripts/install.ps1 @@ -79,7 +79,7 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 404) { - Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead" + Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" } throw } diff --git a/apps/marketing/public/install.sh b/scripts/install.sh similarity index 96% rename from apps/marketing/public/install.sh rename to scripts/install.sh index 19263563402a30aa92362a22f962e4ab7330005a..22e4b11867a121de9c061e2ead106fb32bcee4c2 100755 --- a/apps/marketing/public/install.sh +++ b/scripts/install.sh @@ -110,7 +110,7 @@ else fetch_status=0 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? if [ "$fetch_status" -eq 44 ]; then - fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead" + fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" elif [ "$fetch_status" -ne 0 ]; then fail "could not download the release checksums" fi -- 2.54.0