authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:21-07:00
log13c134c10be163fa5345dd7c39371b6089e352bb
tree42eb6f2fadf6d8351fa3309b6264b81914fd35e6
parent8f90b380f676735f1e3026b5173c45c22d265583
signature Signed by PGP key B5690EEEBB952194

feat(ssh): run preview builds on remotes from the release archive (#11319)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 528 insertions(+), 3 deletions(-)

apps/desktop/src/main.ts+6
......@@ -17,6 +17,7 @@ import * as Electron from "electron";
1717
1818import * as NetService from "@t3tools/shared/Net";
1919import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
20import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease";
2021import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
2122import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
2223import serverPackageJson from "../../server/package.json" with { type: "json" };
......@@ -97,6 +98,11 @@ const resolveDesktopSshCliRunner = (
9798 nodeEngineRange: serverPackageJson.engines.node,
9899 };
99100 }
101 // Preview builds ship as self-contained archives, so the remote runs the
102 // same version this app is on without Node or npm.
103 if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) {
104 return { archiveVersion: environment.appVersion };
105 }
100106 return {
101107 packageSpec: resolveRemoteT3CliPackageSpec({
102108 appVersion: environment.appVersion,
apps/server/src/bin.ts+2
......@@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts";
2020import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
2121import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
2222import { servicePreflightCommand } from "./cli/servicePreflight.ts";
23import { sshHelperCommand } from "./cli/sshHelper.ts";
2324import { themeCommand } from "./cli/theme.ts";
2425import { triageCommand } from "./cli/triage.ts";
2526
......@@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
6465 serviceLauncherCommand,
6566 claudeHistoryCommand,
6667 servicePreflightCommand,
68 sshHelperCommand,
6769 themeCommand,
6870 triageCommand,
6971 cloudEnabled ? connectCommand : connectUnavailableCommand,
apps/server/src/cli/sshHelper.ts created+126
......@@ -0,0 +1,126 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// @effect-diagnostics globalTimers:off
3// @effect-diagnostics globalDateInEffect:off
4// The helpers mirror the inline Node snippets the SSH launch script used to
5// run, byte for byte in behaviour, so they stay on plain Node APIs.
6import * as NodeFS from "node:fs";
7import * as NodeHttp from "node:http";
8import * as NodeNet from "node:net";
9
10import * as Effect from "effect/Effect";
11import { Argument, Command } from "effect/unstable/cli";
12
13/**
14 * Small helpers the SSH launch script needs on the remote host. The script
15 * used to run these as inline `node -` snippets; archive-distributed runtimes
16 * have no Node on the remote, so the executable provides them instead. Output
17 * and exit codes match the snippets exactly because the shell script parses
18 * them.
19 */
20
21const tryPort = (port: number) =>
22 new Promise<number | false>((resolve) => {
23 const server = NodeNet.createServer();
24 server.unref();
25 server.once("error", () => resolve(false));
26 server.listen(port, "127.0.0.1", () => {
27 server.close((error) => resolve(error ? false : port));
28 });
29 });
30
31/** Prints the first free loopback port from the preferred one, scanning `window` ports. */
32const pickPort = Command.make("pick-port", {
33 portFile: Argument.string("port-file"),
34 defaultPort: Argument.integer("default-port"),
35 scanWindow: Argument.integer("scan-window"),
36}).pipe(
37 Command.withHandler(({ portFile, defaultPort, scanWindow }) =>
38 Effect.promise(async () => {
39 const raw = NodeFS.existsSync(portFile) ? NodeFS.readFileSync(portFile, "utf8").trim() : "";
40 const preferred = Number.parseInt(raw, 10);
41 const start = Number.isInteger(preferred) ? preferred : defaultPort;
42 for (let port = start; port < start + scanWindow; port += 1) {
43 if (await tryPort(port)) {
44 process.stdout.write(String(port));
45 return;
46 }
47 }
48 process.exitCode = 1;
49 }),
50 ),
51);
52
53const probe = (port: number, probeTimeoutMs: number) =>
54 new Promise<boolean>((resolve) => {
55 const request = NodeHttp.get(
56 { hostname: "127.0.0.1", port, path: "/", timeout: probeTimeoutMs },
57 (response) => {
58 response.resume();
59 response.once("end", () => {
60 const status = response.statusCode ?? 0;
61 resolve(status >= 200 && status < 300);
62 });
63 },
64 );
65 request.once("timeout", () => {
66 request.destroy();
67 resolve(false);
68 });
69 request.once("error", () => resolve(false));
70 });
71
72/** Exits 0 once the loopback server answers, 1 when the deadline passes first. */
73const waitReady = Command.make("wait-ready", {
74 port: Argument.integer("port"),
75 timeoutMs: Argument.integer("timeout-ms"),
76 probeTimeoutMs: Argument.integer("probe-timeout-ms"),
77}).pipe(
78 Command.withHandler(({ port, timeoutMs, probeTimeoutMs }) =>
79 Effect.promise(async () => {
80 const deadline = Date.now() + timeoutMs;
81 while (Date.now() < deadline) {
82 if (await probe(port, probeTimeoutMs)) return;
83 await new Promise((resolve) => setTimeout(resolve, 100));
84 }
85 process.exitCode = 1;
86 }),
87 ),
88);
89
90/** Prints `<pid> <port>` for a live default-home server, or exits 1. */
91const runtimePort = Command.make("runtime-port", {
92 runtimeFile: Argument.string("runtime-file"),
93}).pipe(
94 Command.withHandler(({ runtimeFile }) =>
95 Effect.sync(() => {
96 try {
97 // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors the shell snippet's loose parse.
98 const runtime = JSON.parse(NodeFS.readFileSync(runtimeFile, "utf8")) as {
99 pid?: unknown;
100 port?: unknown;
101 origin?: unknown;
102 };
103 const pid = Number(runtime.pid);
104 const port = Number(runtime.port);
105 if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) {
106 process.exitCode = 1;
107 return;
108 }
109 const origin = new URL(String(runtime.origin ?? ""));
110 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {
111 process.exitCode = 1;
112 return;
113 }
114 process.kill(pid, 0);
115 process.stdout.write(`${pid} ${port}`);
116 } catch {
117 process.exitCode = 1;
118 }
119 }),
120 ),
121);
122
123export const sshHelperCommand = Command.make("__ssh-helper").pipe(
124 Command.unlisted,
125 Command.withSubcommands([pickPort, waitReady, runtimePort]),
126);
packages/ssh/src/tunnel.test.ts+217
......@@ -1,10 +1,12 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as NetService from "@t3tools/shared/Net";
4import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
45import * as Deferred from "effect/Deferred";
56import * as Duration from "effect/Duration";
67import * as Effect from "effect/Effect";
78import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
810import * as Layer from "effect/Layer";
911import * as Result from "effect/Result";
1012import * as Sink from "effect/Sink";
......@@ -20,6 +22,7 @@ import {
2022 buildRemotePairingScript,
2123 buildRemoteStopScript,
2224 buildRemoteT3RunnerScript,
25 SshInvalidArchiveVersionError,
2326 describeReadinessCause,
2427 issueRemotePairingToken,
2528 launchOrReuseRemoteServer,
......@@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => {
130133 assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available");
131134 });
132135
136 it("installs and runs the release archive when an archive version is set", () => {
137 const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" });
138
139 assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
140 assert.include(
141 script,
142 "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'",
143 );
144 assert.include(script, 'T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"');
145 assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"');
146 assert.include(script, "SHA256SUMS");
147 assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"');
148 // Concurrent launches serialize on a per-version mkdir lock and recheck
149 // the completion marker after acquiring it.
150 assert.include(
151 script,
152 'T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"',
153 );
154 // mkdir is the exclusive create; the pid follows atomically. A dead owner
155 // is reclaimed at once, a never-published owner after a short grace.
156 assert.include(script, 'while ! mkdir "$T3_LOCK" 2>/dev/null; do');
157 assert.include(script, 'mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"');
158 assert.include(script, 'if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then');
159 assert.include(script, 'if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then');
160 assert.include(script, 'if [ "$T3_LOCK_WAITED" -ge 360 ]; then');
161 assert.include(script, '"$T3_STAGING/SHA256SUMS" 30');
162 assert.include(script, '"$T3_STAGING/$T3_ARCHIVE" 240');
163 assert.notInclude(script, "T3_LOCK_CANDIDATE");
164 assert.notInclude(script, "-mmin");
165 assert.equal(script.split("if ! t3_runtime_ready; then").length - 1, 2);
166 assert.isBelow(
167 script.indexOf('"$T3_STAGING/t3" --version'),
168 script.indexOf('> "$T3_STAGING/.install-complete"'),
169 );
170 // The archive branch execs before any of the Node discovery runs.
171 assert.isBelow(
172 script.indexOf('exec "$T3_RUNTIME_DIR/t3"'),
173 script.indexOf("prepend_path_if_dir()"),
174 );
175
176 const launch = buildRemoteLaunchScript({
177 archiveVersion: "1.2.3-preview.20260911.4",
178 releaseBaseUrl: "https://mirror.example/t3/",
179 });
180 assert.include(launch, "T3_ARCHIVE_MODE=1");
181 assert.include(launch, "T3_RELEASE_BASE_URL='https://mirror.example/t3'");
182 assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"');
183 assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"');
184 assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"');
185 assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0");
186 });
187
188 it("rejects archive versions that are not a single exact version segment", () => {
189 for (const archiveVersion of [
190 "../other",
191 "1.2.3/evil",
192 "1.2.3\\evil",
193 "1.2.3-preview.1 x",
194 "1.2.3-preview.1\nrm -rf /",
195 "v1.2.3",
196 ]) {
197 assert.throws(
198 () => buildRemoteT3RunnerScript({ archiveVersion }),
199 SshInvalidArchiveVersionError,
200 undefined,
201 archiveVersion,
202 );
203 }
204 assert.include(
205 buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }),
206 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
207 );
208 });
209
133210 it("does not hard-code a remote node engine range", () => {
134211 const script = buildRemoteT3RunnerScript();
135212
......@@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => {
282359 }).pipe(Effect.provide(processLayer));
283360 });
284361
362 it.effect("gives cold archive launches a larger budget than npm launches", () => {
363 const target = {
364 alias: "devbox",
365 hostname: "devbox.example.com",
366 username: "julius",
367 port: 2222,
368 } as const;
369 const spawner = ChildProcessSpawner.make(() =>
370 Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 800_000)),
371 );
372 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
373 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
374
375 return Effect.gen(function* () {
376 const fiber = yield* Effect.forkChild(
377 launchOrReuseRemoteServer(target, undefined, {
378 archiveVersion: "1.2.3-preview.20260911.4",
379 }),
380 );
381 yield* Effect.yieldNow;
382 yield* TestClock.adjust(Duration.seconds(800));
383
384 const result = yield* Fiber.join(fiber);
385 assert.equal(result.remotePort, 3774);
386 }).pipe(Effect.provide(processLayer));
387 });
388
285389 it("allows the remote port picker to run without a state file path", () => {
286390 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');
287391 });
......@@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => {
588692 }),
589693 );
590694});
695
696// The archive runner is generated shell; string assertions cannot prove the
697// lock excludes concurrent installers. Run the real script against a tiny
698// fake archive served from a file:// mirror.
699describe("archive runner script", () => {
700 const hostPlatform = HostProcessPlatform.defaultValue();
701 const hostArch = HostProcessArchitecture.defaultValue();
702 const windowsHost = hostPlatform === "win32";
703 const archiveVersion = "1.2.3-preview.20260911.4";
704
705 const runRunner = (home: string, runner: string) =>
706 Effect.gen(function* () {
707 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
708 const child = yield* spawner.spawn(
709 ChildProcess.make("sh", [runner, "--version"], {
710 env: { PATH: process.env.PATH ?? "", HOME: home },
711 extendEnv: false,
712 }),
713 );
714 const [stdout, stderr, exitCode] = yield* Effect.all(
715 [
716 child.stdout.pipe(
717 Stream.decodeText(),
718 Stream.runFold(
719 () => "",
720 (acc, chunk) => acc + chunk,
721 ),
722 ),
723 child.stderr.pipe(
724 Stream.decodeText(),
725 Stream.runFold(
726 () => "",
727 (acc, chunk) => acc + chunk,
728 ),
729 ),
730 child.exitCode.pipe(Effect.map(Number)),
731 ],
732 { concurrency: "unbounded" },
733 );
734 return { stdout, stderr, exitCode };
735 });
736
737 // A fake "executable" that answers --version, packed the way the release
738 // workflow packs the real archive: one top-level directory named after the
739 // stem, checksummed in SHA256SUMS.
740 const makeMirror = Effect.fn("makeMirror")(function* (root: string) {
741 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
742 const platform = hostPlatform === "darwin" ? "darwin" : "linux";
743 const arch = hostArch === "arm64" ? "arm64" : "x64";
744 const stem = `t3-${archiveVersion}-${platform}-${arch}`;
745 const stage = `${root}/stage/${stem}`;
746 const release = `${root}/mirror/v${archiveVersion}`;
747 const script = [
748 "set -eu",
749 `mkdir -p '${stage}' '${release}'`,
750 `printf '#!/bin/sh\\necho t3 v${archiveVersion}\\n' > '${stage}/t3'`,
751 `chmod +x '${stage}/t3'`,
752 `tar -czf '${release}/${stem}.tar.gz' -C '${root}/stage' '${stem}'`,
753 `cd '${release}' && (sha256sum '${stem}.tar.gz' 2>/dev/null || shasum -a 256 '${stem}.tar.gz') > SHA256SUMS`,
754 ].join("\n");
755 const child = yield* spawner.spawn(ChildProcess.make("sh", ["-c", script]));
756 assert.equal(Number(yield* child.exitCode), 0);
757 return `file://${root}/mirror`;
758 });
759
760 it.effect.skipIf(windowsHost)(
761 "installs once when several launches race, and reclaims stale locks",
762 () =>
763 Effect.gen(function* () {
764 const fs = yield* FileSystem.FileSystem;
765 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-archive-runner-" });
766 const releaseBaseUrl = yield* makeMirror(root);
767 const runner = `${root}/run-t3.sh`;
768 yield* fs.writeFileString(
769 runner,
770 buildRemoteT3RunnerScript({ archiveVersion, releaseBaseUrl }),
771 );
772 const home = `${root}/home`;
773 yield* fs.makeDirectory(home, { recursive: true });
774
775 const results = yield* Effect.all(
776 [runRunner(home, runner), runRunner(home, runner), runRunner(home, runner)],
777 { concurrency: "unbounded" },
778 );
779 for (const result of results) {
780 assert.equal(result.exitCode, 0, result.stderr);
781 assert.include(result.stdout, `t3 v${archiveVersion}`);
782 }
783 const versionsDir = `${home}/.t3/runtime/versions`;
784 assert.deepEqual(yield* fs.readDirectory(versionsDir), [archiveVersion]);
785 assert.equal(
786 (yield* fs.readFileString(`${versionsDir}/${archiveVersion}/.install-complete`)).trim(),
787 archiveVersion,
788 );
789
790 // A lock left by a crashed installer (dead pid) must not block the
791 // next launch, and neither must one that never published a pid.
792 const lock = `${versionsDir}/.${archiveVersion}.install.lock`;
793 yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true });
794 yield* fs.makeDirectory(lock);
795 yield* fs.writeFileString(`${lock}/pid`, "999999\n");
796 const afterDead = yield* runRunner(home, runner);
797 assert.equal(afterDead.exitCode, 0, afterDead.stderr);
798
799 yield* fs.remove(`${versionsDir}/${archiveVersion}`, { recursive: true });
800 yield* fs.makeDirectory(lock);
801 const afterUnowned = yield* runRunner(home, runner);
802 assert.equal(afterUnowned.exitCode, 0, afterUnowned.stderr);
803 assert.isFalse(yield* fs.exists(lock));
804 }).pipe(Effect.provide(NodeServices.layer)),
805 60_000,
806 );
807});
packages/ssh/src/tunnel.ts+177-3
......@@ -6,6 +6,7 @@ import {
66 describeReadinessCause,
77 waitForHttpReady as waitForHttpReadyShared,
88} from "@t3tools/shared/httpReadiness";
9import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease";
910import * as NetService from "@t3tools/shared/Net";
1011import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
1112import { satisfiesSemverRange } from "@t3tools/shared/semver";
......@@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
5657const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
5758const REMOTE_READY_TIMEOUT_MS = 60_000;
5859const REMOTE_LAUNCH_TIMEOUT_MS = 90_000;
60// A cold archive launch also downloads and unpacks a ~70 MB release archive
61// and may wait on another installer's lock. The budgets nest: the checksum
62// file is tiny and the archive download is bounded; a waiter outlasts both
63// downloads plus extraction so it can reuse the result; and the SSH command
64// outlasts an install (own or waited-for) plus readiness, with slack for
65// verification and extraction, which have no timeout of their own.
66const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30;
67const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240;
68const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360;
69const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000;
5970const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
6071
6172export interface RemoteT3RunnerOptions {
6273 readonly packageSpec?: string;
6374 readonly nodeScriptPath?: string | null;
6475 readonly nodeEngineRange?: string | null;
76 /**
77 * Exact version whose release archive the remote installs and runs. Takes
78 * precedence over `packageSpec`; the remote then needs neither Node nor npm.
79 */
80 readonly archiveVersion?: string | null;
81 readonly releaseBaseUrl?: string | null;
6582}
6683
6784export interface SshEnvironmentManagerOptions {
......@@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
108125}
109126
110127function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {
128 if (runner?.archiveVersion?.trim()) {
129 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
130 }
111131 if (runner?.nodeScriptPath?.trim()) {
112132 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };
113133 }
......@@ -404,6 +424,103 @@ ensure_remote_node_path() {
404424
405425const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
406426set -eu
427T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@
428if [ -n "$T3_ARCHIVE_VERSION" ]; then
429 # Self-contained release archive: no Node, npm, or compiler on the remote.
430 # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it.
431 T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@
432 T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"
433 t3_runtime_ready() {
434 [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ]
435 }
436 if ! t3_runtime_ready; then
437 mkdir -p "$HOME/.t3/runtime/versions"
438 # Concurrent launches (two clients, a retry racing a slow first run) must
439 # not both install: mkdir is the atomic lock and the ready check repeats
440 # under it.
441 T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"
442 # mkdir is the only portable atomic exclusive create (mv would silently
443 # nest a candidate inside an existing lock). The owner publishes its pid
444 # right after, so a lock with a live owner is never reclaimed however
445 # slow its download is, and a lock whose owner is dead is reclaimed at
446 # once. A lock with no pid at all is a crash between mkdir and the pid
447 # write; it is reclaimed after a short grace so a live owner has time to
448 # publish.
449 T3_LOCK_WAITED=0
450 T3_LOCK_UNOWNED=0
451 while ! mkdir "$T3_LOCK" 2>/dev/null; do
452 T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)"
453 if [ -n "$T3_LOCK_OWNER" ]; then
454 T3_LOCK_UNOWNED=0
455 if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then
456 rm -rf "$T3_LOCK"
457 continue
458 fi
459 else
460 T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1))
461 if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then
462 rm -rf "$T3_LOCK"
463 continue
464 fi
465 fi
466 if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then
467 printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2
468 exit 1
469 fi
470 sleep 1
471 T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1))
472 done
473 printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"
474 trap 'rm -rf "$T3_LOCK"' EXIT
475 fi
476 if ! t3_runtime_ready; then
477 case "$(uname -s)" in
478 Darwin) T3_PLATFORM="darwin" ;;
479 Linux) T3_PLATFORM="linux" ;;
480 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;;
481 esac
482 case "$(uname -m)" in
483 arm64 | aarch64) T3_ARCH="arm64" ;;
484 x86_64 | amd64) T3_ARCH="x64" ;;
485 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;;
486 esac
487 T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"
488 T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")"
489 trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT
490 t3_fetch() {
491 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2"
492 elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2"
493 else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1
494 fi
495 }
496 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@
497 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@
498 T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)"
499 if command -v sha256sum >/dev/null 2>&1; then
500 T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
501 else
502 T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
503 fi
504 if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
505 printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
506 fi
507 tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
508 rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
509 # Prove the binary runs here (libc, arch) before marking it ready, or every
510 # later launch would exec a broken install instead of retrying.
511 if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then
512 printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1
513 fi
514 printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete"
515 rm -rf "$T3_RUNTIME_DIR"
516 mv "$T3_STAGING" "$T3_RUNTIME_DIR"
517 fi
518 if [ -n "\${T3_LOCK:-}" ]; then
519 rm -rf "$T3_LOCK"
520 trap - EXIT
521 fi
522 exec "$T3_RUNTIME_DIR/t3" "$@"
523fi
407524@@T3_NODE_ENV_SCRIPT@@
408525ensure_remote_node_path || true
409526T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
......@@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
473590fi
474591mv "$RUNNER_NEXT" "$RUNNER_FILE"
475592chmod 700 "$RUNNER_FILE"
476if ! ensure_remote_node_path; then
593T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@
594if [ "$T3_ARCHIVE_MODE" = "1" ]; then
595 # The archive ships the helpers below inside the executable; the remote
596 # needs no Node at all. Resolving the runner once here also downloads the
597 # archive before the port and readiness probes rely on it.
598 "$RUNNER_FILE" --version >/dev/null
599elif ! ensure_remote_node_path; then
477600 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
478601 exit 1
479602fi
480603pick_port() {
604 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
605 "$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@"
606 return
607 fi
481608 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'
482609@@T3_PICK_PORT_SCRIPT@@
483610NODE
484611}
485612wait_ready() {
613 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
614 "$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@"
615 return
616 fi
486617 node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE'
487618@@T3_WAIT_READY_SCRIPT@@
488619NODE
......@@ -496,6 +627,10 @@ wait_for_pid_exit() {
496627 done
497628}
498629resolve_default_runtime_port() {
630 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
631 "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"
632 return
633 fi
499634 node - "$DEFAULT_RUNTIME_FILE" <<'NODE'
500635const fs = require("node:fs");
501636const runtimePath = process.argv[2] ?? "";
......@@ -582,7 +717,11 @@ fi
582717if [ -z "$REMOTE_PORT" ]; then
583718 REMOTE_PORT="$(pick_port)" || true
584719 if [ -z "$REMOTE_PORT" ]; then
585 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2
720 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
721 printf 'Failed to find an available port on the remote host.\\n' >&2
722 else
723 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2
724 fi
586725 exit 1
587726 fi
588727 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
650789fi
651790`;
652791
792export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalidArchiveVersionError>()(
793 "SshInvalidArchiveVersionError",
794 { archiveVersion: Schema.String },
795) {
796 override get message(): string {
797 return `'${this.archiveVersion}' is not an exact t3 version and cannot name a runtime directory.`;
798 }
799}
800
801// The version becomes a directory name the runner removes and recreates, so
802// it must be one exact SemVer segment: no separators, no `..`, no shell
803// metacharacters beyond what SemVer allows.
804const EXACT_ARCHIVE_VERSION =
805 /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
806
653807export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {
654808 const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest");
655809 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";
810 const archiveVersion = input?.archiveVersion?.trim() || "";
811 if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) {
812 throw new SshInvalidArchiveVersionError({ archiveVersion });
813 }
814 // Strip the `/v<version>` the helper appends: the script builds URLs itself.
815 const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace(
816 /\/v$/u,
817 "",
818 );
656819 return stripTrailingNewlines(
657820 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
658821 T3_PACKAGE_SPEC: packageSpec,
659822 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
823 T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion),
824 T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl),
825 T3_ARCHIVE_LOCK_WAIT_SECONDS: String(REMOTE_ARCHIVE_LOCK_WAIT_SECONDS),
826 T3_ARCHIVE_DOWNLOAD_SECONDS: String(REMOTE_ARCHIVE_DOWNLOAD_SECONDS),
827 T3_ARCHIVE_CHECKSUMS_SECONDS: String(REMOTE_ARCHIVE_CHECKSUMS_SECONDS),
660828 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
661829 }),
662830 );
......@@ -673,6 +841,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string
673841
674842export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
675843 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
844 T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0",
676845 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
677846 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
678847 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
......@@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
725894 const result = yield* runSshCommand(target, {
726895 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],
727896 stdin: buildRemoteLaunchScript(runner),
728 timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS,
897 timeoutMs: runner?.archiveVersion?.trim()
898 ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS
899 : REMOTE_LAUNCH_TIMEOUT_MS,
729900 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
730901 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
731902 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
......@@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
783954 const result = yield* runSshCommand(target, {
784955 remoteCommandArgs: ["sh", "-s"],
785956 stdin: buildRemotePairingScript(target, runner),
957 // Pairing may be the first command on a cold remote, so it can install
958 // the archive on the way.
959 ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}),
786960 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
787961 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
788962 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),