| author | |
| committer | |
| log | 13c134c10be163fa5345dd7c39371b6089e352bb |
| tree | 42eb6f2fadf6d8351fa3309b6264b81914fd35e6 |
| parent | 8f90b380f676735f1e3026b5173c45c22d265583 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -17,6 +17,7 @@ import * as Electron from "electron"; |
| 17 | 17 | ||
| 18 | import * as NetService from "@t3tools/shared/Net"; | 18 | import * as NetService from "@t3tools/shared/Net"; |
| 19 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 19 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 20 | import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; | ||
| 20 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | 21 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 21 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | 22 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 22 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 23 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| ... | @@ -97,6 +98,11 @@ const resolveDesktopSshCliRunner = ( | ... | @@ -97,6 +98,11 @@ const resolveDesktopSshCliRunner = ( |
| 97 | nodeEngineRange: serverPackageJson.engines.node, | 98 | nodeEngineRange: serverPackageJson.engines.node, |
| 98 | }; | 99 | }; |
| 99 | } | 100 | } |
| 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 | } | ||
| 100 | return { | 106 | return { |
| 101 | packageSpec: resolveRemoteT3CliPackageSpec({ | 107 | packageSpec: resolveRemoteT3CliPackageSpec({ |
| 102 | appVersion: environment.appVersion, | 108 | appVersion: environment.appVersion, |
apps/server/src/bin.ts+2| ... | @@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts"; | ... | @@ -20,6 +20,7 @@ import { serviceCommand } from "./cli/service.ts"; |
| 20 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; | 20 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; |
| 21 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; | 21 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; |
| 22 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; | 22 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 23 | import { sshHelperCommand } from "./cli/sshHelper.ts"; | ||
| 23 | import { themeCommand } from "./cli/theme.ts"; | 24 | import { themeCommand } from "./cli/theme.ts"; |
| 24 | import { triageCommand } from "./cli/triage.ts"; | 25 | import { triageCommand } from "./cli/triage.ts"; |
| 25 | 26 | ||
| ... | @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ... | @@ -64,6 +65,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 64 | serviceLauncherCommand, | 65 | serviceLauncherCommand, |
| 65 | claudeHistoryCommand, | 66 | claudeHistoryCommand, |
| 66 | servicePreflightCommand, | 67 | servicePreflightCommand, |
| 68 | sshHelperCommand, | ||
| 67 | themeCommand, | 69 | themeCommand, |
| 68 | triageCommand, | 70 | triageCommand, |
| 69 | cloudEnabled ? connectCommand : connectUnavailableCommand, | 71 | 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. | ||
| 6 | import * as NodeFS from "node:fs"; | ||
| 7 | import * as NodeHttp from "node:http"; | ||
| 8 | import * as NodeNet from "node:net"; | ||
| 9 | |||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import { 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 | |||
| 21 | const 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. */ | ||
| 32 | const 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 | |||
| 53 | const 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. */ | ||
| 73 | const 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. */ | ||
| 91 | const 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 | |||
| 123 | export 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 @@ | ... | @@ -1,10 +1,12 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as NetService from "@t3tools/shared/Net"; | 3 | import * as NetService from "@t3tools/shared/Net"; |
| 4 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | 5 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; | 8 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 9 | import * as Result from "effect/Result"; | 11 | import * as Result from "effect/Result"; |
| 10 | import * as Sink from "effect/Sink"; | 12 | import * as Sink from "effect/Sink"; |
| ... | @@ -20,6 +22,7 @@ import { | ... | @@ -20,6 +22,7 @@ import { |
| 20 | buildRemotePairingScript, | 22 | buildRemotePairingScript, |
| 21 | buildRemoteStopScript, | 23 | buildRemoteStopScript, |
| 22 | buildRemoteT3RunnerScript, | 24 | buildRemoteT3RunnerScript, |
| 25 | SshInvalidArchiveVersionError, | ||
| 23 | describeReadinessCause, | 26 | describeReadinessCause, |
| 24 | issueRemotePairingToken, | 27 | issueRemotePairingToken, |
| 25 | launchOrReuseRemoteServer, | 28 | launchOrReuseRemoteServer, |
| ... | @@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => { | ... | @@ -130,6 +133,80 @@ describe("ssh tunnel scripts", () => { |
| 130 | assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); | 133 | assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); |
| 131 | }); | 134 | }); |
| 132 | 135 | ||
| 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 | |||
| 133 | it("does not hard-code a remote node engine range", () => { | 210 | it("does not hard-code a remote node engine range", () => { |
| 134 | const script = buildRemoteT3RunnerScript(); | 211 | const script = buildRemoteT3RunnerScript(); |
| 135 | 212 | ||
| ... | @@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => { | ... | @@ -282,6 +359,33 @@ describe("ssh tunnel scripts", () => { |
| 282 | }).pipe(Effect.provide(processLayer)); | 359 | }).pipe(Effect.provide(processLayer)); |
| 283 | }); | 360 | }); |
| 284 | 361 | ||
| 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 | |||
| 285 | it("allows the remote port picker to run without a state file path", () => { | 389 | it("allows the remote port picker to run without a state file path", () => { |
| 286 | assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); | 390 | assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); |
| 287 | }); | 391 | }); |
| ... | @@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => { | ... | @@ -588,3 +692,116 @@ describe("ssh tunnel scripts", () => { |
| 588 | }), | 692 | }), |
| 589 | ); | 693 | ); |
| 590 | }); | 694 | }); |
| 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. | ||
| 699 | describe("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 { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | describeReadinessCause, | 6 | describeReadinessCause, |
| 7 | waitForHttpReady as waitForHttpReadyShared, | 7 | waitForHttpReady as waitForHttpReadyShared, |
| 8 | } from "@t3tools/shared/httpReadiness"; | 8 | } from "@t3tools/shared/httpReadiness"; |
| 9 | import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease"; | ||
| 9 | import * as NetService from "@t3tools/shared/Net"; | 10 | import * as NetService from "@t3tools/shared/Net"; |
| 10 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; | 11 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 11 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | 12 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; |
| ... | @@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; | ... | @@ -56,12 +57,28 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; |
| 56 | const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; | 57 | const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; |
| 57 | const REMOTE_READY_TIMEOUT_MS = 60_000; | 58 | const REMOTE_READY_TIMEOUT_MS = 60_000; |
| 58 | const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; | 59 | const 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. | ||
| 66 | const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30; | ||
| 67 | const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240; | ||
| 68 | const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360; | ||
| 69 | const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; | ||
| 59 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; | 70 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; |
| 60 | 71 | ||
| 61 | export interface RemoteT3RunnerOptions { | 72 | export interface RemoteT3RunnerOptions { |
| 62 | readonly packageSpec?: string; | 73 | readonly packageSpec?: string; |
| 63 | readonly nodeScriptPath?: string | null; | 74 | readonly nodeScriptPath?: string | null; |
| 64 | readonly nodeEngineRange?: string | null; | 75 | 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; | ||
| 65 | } | 82 | } |
| 66 | 83 | ||
| 67 | export interface SshEnvironmentManagerOptions { | 84 | export interface SshEnvironmentManagerOptions { |
| ... | @@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { | ... | @@ -108,6 +125,9 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { |
| 108 | } | 125 | } |
| 109 | 126 | ||
| 110 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { | 127 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { |
| 128 | if (runner?.archiveVersion?.trim()) { | ||
| 129 | return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; | ||
| 130 | } | ||
| 111 | if (runner?.nodeScriptPath?.trim()) { | 131 | if (runner?.nodeScriptPath?.trim()) { |
| 112 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; | 132 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; |
| 113 | } | 133 | } |
| ... | @@ -404,6 +424,103 @@ ensure_remote_node_path() { | ... | @@ -404,6 +424,103 @@ ensure_remote_node_path() { |
| 404 | 424 | ||
| 405 | const REMOTE_RUNNER_SCRIPT = `#!/bin/sh | 425 | const REMOTE_RUNNER_SCRIPT = `#!/bin/sh |
| 406 | set -eu | 426 | set -eu |
| 427 | T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ | ||
| 428 | if [ -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" "$@" | ||
| 523 | fi | ||
| 407 | @@T3_NODE_ENV_SCRIPT@@ | 524 | @@T3_NODE_ENV_SCRIPT@@ |
| 408 | ensure_remote_node_path || true | 525 | ensure_remote_node_path || true |
| 409 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ | 526 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ |
| ... | @@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then | ... | @@ -473,16 +590,30 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then |
| 473 | fi | 590 | fi |
| 474 | mv "$RUNNER_NEXT" "$RUNNER_FILE" | 591 | mv "$RUNNER_NEXT" "$RUNNER_FILE" |
| 475 | chmod 700 "$RUNNER_FILE" | 592 | chmod 700 "$RUNNER_FILE" |
| 476 | if ! ensure_remote_node_path; then | 593 | T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@ |
| 594 | if [ "$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 | ||
| 599 | elif ! ensure_remote_node_path; then | ||
| 477 | printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 | 600 | printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 |
| 478 | exit 1 | 601 | exit 1 |
| 479 | fi | 602 | fi |
| 480 | pick_port() { | 603 | pick_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 | ||
| 481 | node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' | 608 | node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' |
| 482 | @@T3_PICK_PORT_SCRIPT@@ | 609 | @@T3_PICK_PORT_SCRIPT@@ |
| 483 | NODE | 610 | NODE |
| 484 | } | 611 | } |
| 485 | wait_ready() { | 612 | wait_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 | ||
| 486 | node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' | 617 | node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE' |
| 487 | @@T3_WAIT_READY_SCRIPT@@ | 618 | @@T3_WAIT_READY_SCRIPT@@ |
| 488 | NODE | 619 | NODE |
| ... | @@ -496,6 +627,10 @@ wait_for_pid_exit() { | ... | @@ -496,6 +627,10 @@ wait_for_pid_exit() { |
| 496 | done | 627 | done |
| 497 | } | 628 | } |
| 498 | resolve_default_runtime_port() { | 629 | resolve_default_runtime_port() { |
| 630 | if [ "$T3_ARCHIVE_MODE" = "1" ]; then | ||
| 631 | "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE" | ||
| 632 | return | ||
| 633 | fi | ||
| 499 | node - "$DEFAULT_RUNTIME_FILE" <<'NODE' | 634 | node - "$DEFAULT_RUNTIME_FILE" <<'NODE' |
| 500 | const fs = require("node:fs"); | 635 | const fs = require("node:fs"); |
| 501 | const runtimePath = process.argv[2] ?? ""; | 636 | const runtimePath = process.argv[2] ?? ""; |
| ... | @@ -582,7 +717,11 @@ fi | ... | @@ -582,7 +717,11 @@ fi |
| 582 | if [ -z "$REMOTE_PORT" ]; then | 717 | if [ -z "$REMOTE_PORT" ]; then |
| 583 | REMOTE_PORT="$(pick_port)" || true | 718 | REMOTE_PORT="$(pick_port)" || true |
| 584 | if [ -z "$REMOTE_PORT" ]; then | 719 | 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 | ||
| 586 | exit 1 | 725 | exit 1 |
| 587 | fi | 726 | fi |
| 588 | 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 & | 727 | 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 | ... | @@ -650,13 +789,42 @@ if [ -f "$LOG_FILE" ]; then |
| 650 | fi | 789 | fi |
| 651 | `; | 790 | `; |
| 652 | 791 | ||
| 792 | export 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. | ||
| 804 | const 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 | |||
| 653 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { | 807 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { |
| 654 | const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); | 808 | const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); |
| 655 | const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; | 809 | 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 | ); | ||
| 656 | return stripTrailingNewlines( | 819 | return stripTrailingNewlines( |
| 657 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { | 820 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { |
| 658 | T3_PACKAGE_SPEC: packageSpec, | 821 | T3_PACKAGE_SPEC: packageSpec, |
| 659 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), | 822 | 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), | ||
| 660 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), | 828 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), |
| 661 | }), | 829 | }), |
| 662 | ); | 830 | ); |
| ... | @@ -673,6 +841,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string | ... | @@ -673,6 +841,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string |
| 673 | 841 | ||
| 674 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { | 842 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { |
| 675 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { | 843 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { |
| 844 | T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", | ||
| 676 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), | 845 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), |
| 677 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), | 846 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), |
| 678 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), | 847 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), |
| ... | @@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo | ... | @@ -725,7 +894,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo |
| 725 | const result = yield* runSshCommand(target, { | 894 | const result = yield* runSshCommand(target, { |
| 726 | remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], | 895 | remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], |
| 727 | stdin: buildRemoteLaunchScript(runner), | 896 | 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, | ||
| 729 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | 900 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 730 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | 901 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 731 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | 902 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |
| ... | @@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT | ... | @@ -783,6 +954,9 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT |
| 783 | const result = yield* runSshCommand(target, { | 954 | const result = yield* runSshCommand(target, { |
| 784 | remoteCommandArgs: ["sh", "-s"], | 955 | remoteCommandArgs: ["sh", "-s"], |
| 785 | stdin: buildRemotePairingScript(target, runner), | 956 | 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 } : {}), | ||
| 786 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | 960 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 787 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | 961 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 788 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | 962 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |