diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 939b88c7d0d0..0b273fe7822a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,6 +17,7 @@ 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" }; @@ -97,6 +98,11 @@ 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, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 9f9d4645c9d0..668723a79b2d 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts"; import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; +import { sshHelperCommand } from "./cli/sshHelper.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serviceLauncherCommand, claudeHistoryCommand, servicePreflightCommand, + sshHelperCommand, themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, diff --git a/apps/server/src/cli/sshHelper.ts b/apps/server/src/cli/sshHelper.ts new file mode 100644 index 000000000000..55428538bd8d --- /dev/null +++ b/apps/server/src/cli/sshHelper.ts @@ -0,0 +1,126 @@ +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalTimers:off +// @effect-diagnostics globalDateInEffect:off +// The helpers mirror the inline Node snippets the SSH launch script used to +// run, byte for byte in behaviour, so they stay on plain Node APIs. +import * as NodeFS from "node:fs"; +import * as NodeHttp from "node:http"; +import * as NodeNet from "node:net"; + +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +/** + * Small helpers the SSH launch script needs on the remote host. The script + * used to run these as inline `node -` snippets; archive-distributed runtimes + * have no Node on the remote, so the executable provides them instead. Output + * and exit codes match the snippets exactly because the shell script parses + * them. + */ + +const tryPort = (port: number) => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(error ? false : port)); + }); + }); + +/** Prints the first free loopback port from the preferred one, scanning `window` ports. */ +const pickPort = Command.make("pick-port", { + portFile: Argument.string("port-file"), + defaultPort: Argument.integer("default-port"), + scanWindow: Argument.integer("scan-window"), +}).pipe( + Command.withHandler(({ portFile, defaultPort, scanWindow }) => + Effect.promise(async () => { + const raw = NodeFS.existsSync(portFile) ? NodeFS.readFileSync(portFile, "utf8").trim() : ""; + const preferred = Number.parseInt(raw, 10); + const start = Number.isInteger(preferred) ? preferred : defaultPort; + for (let port = start; port < start + scanWindow; port += 1) { + if (await tryPort(port)) { + process.stdout.write(String(port)); + return; + } + } + process.exitCode = 1; + }), + ), +); + +const probe = (port: number, probeTimeoutMs: number) => + new Promise((resolve) => { + const request = NodeHttp.get( + { hostname: "127.0.0.1", port, path: "/", timeout: probeTimeoutMs }, + (response) => { + response.resume(); + response.once("end", () => { + const status = response.statusCode ?? 0; + resolve(status >= 200 && status < 300); + }); + }, + ); + request.once("timeout", () => { + request.destroy(); + resolve(false); + }); + request.once("error", () => resolve(false)); + }); + +/** Exits 0 once the loopback server answers, 1 when the deadline passes first. */ +const waitReady = Command.make("wait-ready", { + port: Argument.integer("port"), + timeoutMs: Argument.integer("timeout-ms"), + probeTimeoutMs: Argument.integer("probe-timeout-ms"), +}).pipe( + Command.withHandler(({ port, timeoutMs, probeTimeoutMs }) => + Effect.promise(async () => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probe(port, probeTimeoutMs)) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + process.exitCode = 1; + }), + ), +); + +/** Prints ` ` for a live default-home server, or exits 1. */ +const runtimePort = Command.make("runtime-port", { + runtimeFile: Argument.string("runtime-file"), +}).pipe( + Command.withHandler(({ runtimeFile }) => + Effect.sync(() => { + try { + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the shell snippet's loose parse. + const runtime = JSON.parse(NodeFS.readFileSync(runtimeFile, "utf8")) as { + pid?: unknown; + port?: unknown; + origin?: unknown; + }; + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { + process.exitCode = 1; + return; + } + const origin = new URL(String(runtime.origin ?? "")); + if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { + process.exitCode = 1; + return; + } + process.kill(pid, 0); + process.stdout.write(`${pid} ${port}`); + } catch { + process.exitCode = 1; + } + }), + ), +); + +export const sshHelperCommand = Command.make("__ssh-helper").pipe( + Command.unlisted, + Command.withSubcommands([pickPort, waitReady, runtimePort]), +); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index e2536ba92017..182a8eb4a0ce 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,10 +1,12 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; @@ -20,6 +22,7 @@ import { buildRemotePairingScript, buildRemoteStopScript, buildRemoteT3RunnerScript, + SshInvalidArchiveVersionError, describeReadinessCause, issueRemotePairingToken, launchOrReuseRemoteServer, @@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => { 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" }); + + assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); + assert.include( + script, + "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", + ); + assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"'); + 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" "$@"'); + // Concurrent launches serialize on a per-version mkdir lock and recheck + // the completion marker after acquiring it. + assert.include( + script, + 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"', + ); + // mkdir is the exclusive create; the pid follows atomically. A dead owner + // is reclaimed at once, a never-published owner after a short grace. + assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do'); + assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"'); + assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then'); + assert.include(script, 'if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then'); + assert.include(script, 'if [ "$T3_LOCK_WAITED" -ge 360 ]; then'); + assert.include(script, '"$T3_STAGING/SHA256SUMS" 30'); + assert.include(script, '"$T3_STAGING/$T3_ARCHIVE" 240'); + assert.notInclude(script, "T3_LOCK_CANDIDATE"); + assert.notInclude(script, "-mmin"); + assert.equal(script.split("if ! t3_runtime_ready; then").length - 1, 2); + assert.isBelow( + script.indexOf('"$T3_STAGING/t3" --version'), + script.indexOf('> "$T3_STAGING/.install-complete"'), + ); + // The archive branch execs before any of the Node discovery runs. + assert.isBelow( + script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), + script.indexOf("prepend_path_if_dir()"), + ); + + const launch = buildRemoteLaunchScript({ + archiveVersion: "1.2.3-preview.20260911.4", + releaseBaseUrl: "https://mirror.example/t3/", + }); + assert.include(launch, "T3_ARCHIVE_MODE=1"); + assert.include(launch, "T3_RELEASE_BASE_URL='https://mirror.example/t3'"); + 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"); + }); + + it("rejects archive versions that are not a single exact version segment", () => { + for (const archiveVersion of [ + "../other", + "1.2.3/evil", + "1.2.3\\evil", + "1.2.3-preview.1 x", + "1.2.3-preview.1\nrm -rf /", + "v1.2.3", + ]) { + assert.throws( + () => buildRemoteT3RunnerScript({ archiveVersion }), + SshInvalidArchiveVersionError, + undefined, + archiveVersion, + ); + } + assert.include( + buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), + "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", + ); + }); + it("does not hard-code a remote node engine range", () => { const script = buildRemoteT3RunnerScript(); @@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("gives cold archive launches a larger budget than npm launches", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 800_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + 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", + }), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(800)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); @@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => { }), ); }); + +// The archive runner is generated shell; string assertions cannot prove the +// lock excludes concurrent installers. Run the real script against a tiny +// fake archive served from a file:// mirror. +describe("archive runner script", () => { + const hostPlatform = HostProcessPlatform.defaultValue(); + const hostArch = HostProcessArchitecture.defaultValue(); + const windowsHost = hostPlatform === "win32"; + const archiveVersion = "1.2.3-preview.20260911.4"; + + const runRunner = (home: string, runner: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("sh", [runner, "--version"], { + env: { PATH: process.env.PATH ?? "", HOME: home }, + extendEnv: false, + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + child.stdout.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.stderr.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, exitCode }; + }); + + // A fake "executable" that answers --version, packed the way the release + // workflow packs the real archive: one top-level directory named after the + // stem, checksummed in SHA256SUMS. + const makeMirror = Effect.fn("makeMirror")(function* (root: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = hostPlatform === "darwin" ? "darwin" : "linux"; + const arch = hostArch === "arm64" ? "arm64" : "x64"; + const stem = `t3-${archiveVersion}-${platform}-${arch}`; + const stage = `${root}/stage/${stem}`; + const release = `${root}/mirror/v${archiveVersion}`; + const script = [ + "set -eu", + `mkdir -p '${stage}' '${release}'`, + `printf '#!/bin/sh\\necho t3 v${archiveVersion}\\n' > '${stage}/t3'`, + `chmod +x '${stage}/t3'`, + `tar -czf '${release}/${stem}.tar.gz' -C '${root}/stage' '${stem}'`, + `cd '${release}' && (sha256sum '${stem}.tar.gz' 2>/dev/null || shasum -a 256 '${stem}.tar.gz') > SHA256SUMS`, + ].join("\n"); + const child = yield* spawner.spawn(ChildProcess.make("sh", ["-c", script])); + assert.equal(Number(yield* child.exitCode), 0); + return `file://${root}/mirror`; + }); + + it.effect.skipIf(windowsHost)( + "installs once when several launches race, and reclaims stale locks", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-archive-runner-" }); + const releaseBaseUrl = yield* makeMirror(root); + const runner = `${root}/run-t3.sh`; + yield* fs.writeFileString( + runner, + buildRemoteT3RunnerScript({ archiveVersion, releaseBaseUrl }), + ); + const home = `${root}/home`; + yield* fs.makeDirectory(home, { recursive: true }); + + const results = yield* Effect.all( + [runRunner(home, runner), runRunner(home, runner), runRunner(home, runner)], + { concurrency: "unbounded" }, + ); + for (const result of results) { + assert.equal(result.exitCode, 0, result.stderr); + assert.include(result.stdout, `t3 v${archiveVersion}`); + } + const versionsDir = `${home}/.t3/runtime/versions`; + assert.deepEqual(yield* fs.readDirectory(versionsDir), [archiveVersion]); + assert.equal( + (yield* fs.readFileString(`${versionsDir}/${archiveVersion}/.install-complete`)).trim(), + archiveVersion, + ); + + // A lock left by a crashed installer (dead pid) must not block the + // next launch, and neither must one that never published a pid. + const lock = `${versionsDir}/.${archiveVersion}.install.lock`; + yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); + yield* fs.makeDirectory(lock); + yield* fs.writeFileString(`${lock}/pid`, "999999\n"); + const afterDead = yield* runRunner(home, runner); + assert.equal(afterDead.exitCode, 0, afterDead.stderr); + + yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true }); + yield* fs.makeDirectory(lock); + const afterUnowned = yield* runRunner(home, runner); + assert.equal(afterUnowned.exitCode, 0, afterUnowned.stderr); + assert.isFalse(yield* fs.exists(lock)); + }).pipe(Effect.provide(NodeServices.layer)), + 60_000, + ); +}); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 9cb6b25e4121..d3df7d39307f 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -6,6 +6,7 @@ import { describeReadinessCause, waitForHttpReady as waitForHttpReadyShared, } from "@t3tools/shared/httpReadiness"; +import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease"; import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; @@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; +// A cold archive launch also downloads and unpacks a ~70 MB release archive +// and may wait on another installer's lock. The budgets nest: the checksum +// file is tiny and the archive download is bounded; a waiter outlasts both +// downloads plus extraction so it can reuse the result; and the SSH command +// outlasts an install (own or waited-for) plus readiness, with slack for +// verification and extraction, which have no timeout of their own. +const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30; +const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240; +const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360; +const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { readonly packageSpec?: string; 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. + */ + readonly archiveVersion?: string | null; + readonly releaseBaseUrl?: string | null; } export interface SshEnvironmentManagerOptions { @@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { } 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() }; } @@ -404,6 +424,103 @@ 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@@ @@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then fi mv "$RUNNER_NEXT" "$RUNNER_FILE" chmod 700 "$RUNNER_FILE" -if ! ensure_remote_node_path; then +T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@ +if [ "$T3_ARCHIVE_MODE" = "1" ]; then + # The archive ships the helpers below inside the executable; the remote + # needs no Node at all. Resolving the runner once here also downloads the + # archive before the port and readiness probes rely on it. + "$RUNNER_FILE" --version >/dev/null +elif ! ensure_remote_node_path; 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 pick_port() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" + return + fi node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' @@T3_PICK_PORT_SCRIPT@@ NODE } wait_ready() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" + return + fi node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' @@T3_WAIT_READY_SCRIPT@@ NODE @@ -496,6 +627,10 @@ wait_for_pid_exit() { done } resolve_default_runtime_port() { + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" + return + fi node - "$DEFAULT_RUNTIME_FILE" <<'NODE' const fs = require("node:fs"); const runtimePath = process.argv[2] ?? ""; @@ -582,7 +717,11 @@ fi if [ -z "$REMOTE_PORT" ]; then REMOTE_PORT="$(pick_port)" || true if [ -z "$REMOTE_PORT" ]; then - printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 + if [ "$T3_ARCHIVE_MODE" = "1" ]; then + printf 'Failed to find an available port on the remote host.\\n' >&2 + else + printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 + fi exit 1 fi nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & @@ -650,13 +789,42 @@ if [ -f "$LOG_FILE" ]; then fi `; +export class SshInvalidArchiveVersionError extends Schema.TaggedError()( + "SshInvalidArchiveVersionError", + { archiveVersion: Schema.String }, +) { + override get message(): string { + return `'${this.archiveVersion}' is not an exact t3 version and cannot name a runtime directory.`; + } +} + +// The version becomes a directory name the runner removes and recreates, so +// it must be one exact SemVer segment: no separators, no `..`, no shell +// metacharacters beyond what SemVer allows. +const EXACT_ARCHIVE_VERSION = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + 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 (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { + throw new SshInvalidArchiveVersionError({ archiveVersion }); + } + // Strip the `/v` the helper appends: the script builds URLs itself. + const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace( + /\/v$/u, + "", + ); 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), + T3_ARCHIVE_LOCK_WAIT_SECONDS: String(REMOTE_ARCHIVE_LOCK_WAIT_SECONDS), + T3_ARCHIVE_DOWNLOAD_SECONDS: String(REMOTE_ARCHIVE_DOWNLOAD_SECONDS), + T3_ARCHIVE_CHECKSUMS_SECONDS: String(REMOTE_ARCHIVE_CHECKSUMS_SECONDS), T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), }), ); @@ -673,6 +841,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_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), @@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), - timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, + timeoutMs: runner?.archiveVersion?.trim() + ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS + : REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], 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 } : {}), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),