authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-08 16:17:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-08 16:17:29-07:00
log932df4ed17ff9465053e3a1bbfdd6d2ba23500f0
treeda6f833ed955a6fcb44f4f0adf152c684e2e8fe3
parent63859aa072249c64153ef20faeb46018154724b8
signature Signed by PGP key B5690EEEBB952194

fix(ssh): fix reconnect issues and node binary path resolution (#2504)

Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: codex <codex@users.noreply.github.com>

17 files changed, 701 insertions(+), 222 deletions(-)

REMOTE.md+37
......@@ -115,6 +115,43 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint.
115115
116116SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments.
117117
118#### SSH Launch Troubleshooting
119
120The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch/<host-key>/`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop.
121
122The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement:
123
124```text
125^22.16 || ^23.11 || >=24.10
126```
127
128During SSH launch, T3 Code first checks whether `node` is already available on `PATH`. If it is missing, the launcher tries common non-interactive shell locations and version-manager shims/activation hooks:
129
130- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
131- Volta via `~/.volta/bin`
132- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh`
133- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh`
134- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh`
135- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -`
136- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts`
137- installed nvm versions under `$NVM_DIR/versions/node/*/bin`
138
139If launch fails with `node: command not found`, a port-scan failure, or a message that the remote Node version does not satisfy the required range, SSH into the host and check the same non-interactive shell path T3 Code uses:
140
141```bash
142ssh user@example.com 'sh -lc "command -v node && node --version"'
143```
144
145If that does not print a compatible Node version, configure your version manager for non-interactive shells or install a compatible Node binary in one of the searched locations. For example, with nvm you may need a default alias:
146
147```bash
148nvm alias default 24
149```
150
151With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above.
152
153If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.
154
118155## How Pairing Works
119156
120157The remote device does not need a long-lived secret up front.
apps/desktop/src/main.ts+6-1
......@@ -11,6 +11,7 @@ import * as Electron from "electron";
1111import * as NetService from "@t3tools/shared/Net";
1212import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
1313import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
14import serverPackageJson from "../../server/package.json" with { type: "json" };
1415
1516import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
1617import * as DesktopIpc from "./ipc/DesktopIpc.ts";
......@@ -65,7 +66,10 @@ const resolveDesktopSshCliRunner = (
6566): RemoteT3RunnerOptions => {
6667 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
6768 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
68 return { nodeScriptPath: devRemoteEntryPath };
69 return {
70 nodeScriptPath: devRemoteEntryPath,
71 nodeEngineRange: serverPackageJson.engines.node,
72 };
6973 }
7074 return {
7175 packageSpec: resolveRemoteT3CliPackageSpec({
......@@ -73,6 +77,7 @@ const resolveDesktopSshCliRunner = (
7377 updateChannel: settings.updateChannel,
7478 isDevelopment: environment.isDevelopment,
7579 }),
80 nodeEngineRange: serverPackageJson.engines.node,
7681 };
7782};
7883
apps/server/src/provider/Layers/ClaudeProvider.ts+2-2
......@@ -18,6 +18,7 @@ import {
1818 getProviderOptionCurrentValue,
1919 getProviderOptionDescriptors,
2020} from "@t3tools/shared/model";
21import { compareSemverVersions } from "@t3tools/shared/semver";
2122import {
2223 query as claudeQuery,
2324 type SlashCommand as ClaudeSlashCommand,
......@@ -36,7 +37,6 @@ import {
3637 spawnAndCollect,
3738 type ServerProviderDraft,
3839} from "../providerSnapshot.ts";
39import { compareCliVersions } from "../cliVersion.ts";
4040import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
4141
4242const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
......@@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
180180];
181181
182182function supportsClaudeOpus47(version: string | null | undefined): boolean {
183 return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
183 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false;
184184}
185185
186186function getBuiltInClaudeModelsForVersion(
apps/server/src/provider/Layers/OpenCodeProvider.ts+2-2
......@@ -10,6 +10,7 @@ import * as DateTime from "effect/DateTime";
1010import * as Effect from "effect/Effect";
1111
1212import { createModelCapabilities } from "@t3tools/shared/model";
13import { compareSemverVersions } from "@t3tools/shared/semver";
1314import {
1415 buildServerProvider,
1516 nonEmptyTrimmed,
......@@ -17,7 +18,6 @@ import {
1718 providerModelsFromSettings,
1819 type ServerProviderDraft,
1920} from "../providerSnapshot.ts";
20import { compareCliVersions } from "../cliVersion.ts";
2121import {
2222 OpenCodeRuntime,
2323 openCodeRuntimeErrorDetail,
......@@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
383383 null,
384384 );
385385 }
386 if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
386 if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
387387 return buildServerProvider({
388388 presentation: OPENCODE_PRESENTATION,
389389 enabled: openCodeSettings.enabled,
apps/server/src/provider/cliVersion.test.ts deleted-17
......@@ -1,17 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts";
4
5describe("cliVersion", () => {
6 it("normalizes versions with a missing patch segment", () => {
7 assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0");
8 });
9
10 it("compares prerelease versions before stable versions", () => {
11 assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0);
12 });
13
14 it("rejects malformed numeric segments", () => {
15 assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0);
16 });
17});
apps/server/src/provider/cliVersion.ts deleted-123
......@@ -1,123 +0,0 @@
1interface ParsedCliSemver {
2 readonly major: number;
3 readonly minor: number;
4 readonly patch: number;
5 readonly prerelease: ReadonlyArray<string>;
6}
7
8const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/;
9
10export function normalizeCliVersion(version: string): string {
11 const [main, prerelease] = version.trim().split("-", 2);
12 const segments = (main ?? "")
13 .split(".")
14 .map((segment) => segment.trim())
15 .filter((segment) => segment.length > 0);
16
17 if (segments.length === 2) {
18 segments.push("0");
19 }
20
21 return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join(".");
22}
23
24function parseCliSemver(version: string): ParsedCliSemver | null {
25 const normalized = normalizeCliVersion(version);
26 const [main = "", prerelease] = normalized.split("-", 2);
27 const segments = main.split(".");
28 if (segments.length !== 3) {
29 return null;
30 }
31
32 const [majorSegment, minorSegment, patchSegment] = segments;
33 if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) {
34 return null;
35 }
36 if (
37 !CLI_VERSION_NUMBER_SEGMENT.test(majorSegment) ||
38 !CLI_VERSION_NUMBER_SEGMENT.test(minorSegment) ||
39 !CLI_VERSION_NUMBER_SEGMENT.test(patchSegment)
40 ) {
41 return null;
42 }
43
44 const major = Number.parseInt(majorSegment, 10);
45 const minor = Number.parseInt(minorSegment, 10);
46 const patch = Number.parseInt(patchSegment, 10);
47 if (![major, minor, patch].every(Number.isInteger)) {
48 return null;
49 }
50
51 return {
52 major,
53 minor,
54 patch,
55 prerelease:
56 prerelease
57 ?.split(".")
58 .map((segment) => segment.trim())
59 .filter((segment) => segment.length > 0) ?? [],
60 };
61}
62
63function comparePrereleaseIdentifier(left: string, right: string): number {
64 const leftNumeric = /^\d+$/.test(left);
65 const rightNumeric = /^\d+$/.test(right);
66
67 if (leftNumeric && rightNumeric) {
68 return Number.parseInt(left, 10) - Number.parseInt(right, 10);
69 }
70 if (leftNumeric) {
71 return -1;
72 }
73 if (rightNumeric) {
74 return 1;
75 }
76 return left.localeCompare(right);
77}
78
79export function compareCliVersions(left: string, right: string): number {
80 const parsedLeft = parseCliSemver(left);
81 const parsedRight = parseCliSemver(right);
82 if (!parsedLeft || !parsedRight) {
83 return left.localeCompare(right);
84 }
85
86 if (parsedLeft.major !== parsedRight.major) {
87 return parsedLeft.major - parsedRight.major;
88 }
89 if (parsedLeft.minor !== parsedRight.minor) {
90 return parsedLeft.minor - parsedRight.minor;
91 }
92 if (parsedLeft.patch !== parsedRight.patch) {
93 return parsedLeft.patch - parsedRight.patch;
94 }
95
96 if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) {
97 return 0;
98 }
99 if (parsedLeft.prerelease.length === 0) {
100 return 1;
101 }
102 if (parsedRight.prerelease.length === 0) {
103 return -1;
104 }
105
106 const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length);
107 for (let index = 0; index < length; index += 1) {
108 const leftIdentifier = parsedLeft.prerelease[index];
109 const rightIdentifier = parsedRight.prerelease[index];
110 if (leftIdentifier === undefined) {
111 return -1;
112 }
113 if (rightIdentifier === undefined) {
114 return 1;
115 }
116 const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier);
117 if (comparison !== 0) {
118 return comparison;
119 }
120 }
121
122 return 0;
123}
apps/server/src/provider/providerMaintenance.ts+2-3
......@@ -3,6 +3,7 @@ import {
33 type ServerProvider,
44 type ServerProviderVersionAdvisory,
55} from "@t3tools/contracts";
6import { compareSemverVersions } from "@t3tools/shared/semver";
67import { resolveCommandPath } from "@t3tools/shared/shell";
78import * as DateTime from "effect/DateTime";
89import * as Effect from "effect/Effect";
......@@ -11,8 +12,6 @@ import * as Option from "effect/Option";
1112import * as Schema from "effect/Schema";
1213import { HttpClient, HttpClientRequest } from "effect/unstable/http";
1314
14import { compareCliVersions } from "./cliVersion.ts";
15
1615const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
1716const LATEST_VERSION_TIMEOUT_MS = 4_000;
1817const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
......@@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: {
365364 if (!input.latestVersion) {
366365 return { status: "unknown", message: null };
367366 }
368 if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) {
367 if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) {
369368 return {
370369 status: "behind_latest",
371370 message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
apps/server/src/textGeneration/CursorTextGeneration.ts+1-1
......@@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
66
77import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
88import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
9import { extractJsonObject } from "@t3tools/shared/schemaJson";
910
1011import { TextGenerationError } from "@t3tools/contracts";
1112import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";
......@@ -16,7 +17,6 @@ import {
1617 buildThreadTitlePrompt,
1718} from "./TextGenerationPrompts.ts";
1819import {
19 extractJsonObject,
2020 sanitizeCommitSubject,
2121 sanitizePrTitle,
2222 sanitizeThreadTitle,
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1-1
......@@ -13,6 +13,7 @@ import {
1313} from "@t3tools/contracts";
1414import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1515import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
16import { extractJsonObject } from "@t3tools/shared/schemaJson";
1617
1718import { ServerConfig } from "../config.ts";
1819import { resolveAttachmentPath } from "../attachmentStore.ts";
......@@ -24,7 +25,6 @@ import {
2425} from "./TextGenerationPrompts.ts";
2526import { type TextGenerationShape } from "./TextGeneration.ts";
2627import {
27 extractJsonObject,
2828 sanitizeCommitSubject,
2929 sanitizePrTitle,
3030 sanitizeThreadTitle,
apps/server/src/textGeneration/TextGenerationUtils.ts+1-49
......@@ -1,6 +1,6 @@
1import { TextGenerationError } from "@t3tools/contracts";
12import * as Schema from "effect/Schema";
23
3import { TextGenerationError } from "@t3tools/contracts";
44const isTextGenerationError = Schema.is(TextGenerationError);
55
66/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
......@@ -19,54 +19,6 @@ export function limitSection(value: string, maxChars: number): string {
1919 return `${truncated}\n\n[truncated]`;
2020}
2121
22export function extractJsonObject(raw: string): string {
23 const trimmed = raw.trim();
24 if (trimmed.length === 0) {
25 return trimmed;
26 }
27
28 const start = trimmed.indexOf("{");
29 if (start < 0) {
30 return trimmed;
31 }
32
33 let depth = 0;
34 let inString = false;
35 let escaping = false;
36 for (let index = start; index < trimmed.length; index += 1) {
37 const char = trimmed[index];
38 if (inString) {
39 if (escaping) {
40 escaping = false;
41 } else if (char === "\\") {
42 escaping = true;
43 } else if (char === '"') {
44 inString = false;
45 }
46 continue;
47 }
48
49 if (char === '"') {
50 inString = true;
51 continue;
52 }
53
54 if (char === "{") {
55 depth += 1;
56 continue;
57 }
58
59 if (char === "}") {
60 depth -= 1;
61 if (depth === 0) {
62 return trimmed.slice(start, index + 1);
63 }
64 }
65 }
66
67 return trimmed.slice(start);
68}
69
7022/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
7123export function sanitizeCommitSubject(raw: string): string {
7224 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
packages/shared/package.json+4
......@@ -28,6 +28,10 @@
2828 "types": "./src/shell.ts",
2929 "import": "./src/shell.ts"
3030 },
31 "./semver": {
32 "types": "./src/semver.ts",
33 "import": "./src/semver.ts"
34 },
3135 "./Net": {
3236 "types": "./src/Net.ts",
3337 "import": "./src/Net.ts"
packages/shared/src/schemaJson.test.ts created+31
......@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vitest";
2
3import { extractJsonObject } from "./schemaJson.ts";
4
5describe("schemaJson helpers", () => {
6 it("extracts a balanced JSON object from surrounding text", () => {
7 expect(
8 extractJsonObject(`Sure, here is the JSON:
9\`\`\`json
10{
11 "subject": "Update README",
12 "body": ""
13}
14\`\`\`
15Done.`),
16 ).toBe(`{
17 "subject": "Update README",
18 "body": ""
19}`);
20 });
21
22 it("ignores braces inside strings while finding the object boundary", () => {
23 expect(
24 extractJsonObject('prefix {"message":"literal } brace","nested":{"ok":true}} suffix'),
25 ).toBe('{"message":"literal } brace","nested":{"ok":true}}');
26 });
27
28 it("returns trimmed input when no JSON object starts", () => {
29 expect(extractJsonObject(" no structured output ")).toBe("no structured output");
30 });
31});
packages/shared/src/schemaJson.ts+48
......@@ -97,6 +97,54 @@ export const prettyJsonString = SchemaGetter.parseJson<string>().compose(
9797export const fromLenientJson = <S extends Schema.Top>(schema: S) =>
9898 Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString));
9999
100export function extractJsonObject(raw: string): string {
101 const trimmed = raw.trim();
102 if (trimmed.length === 0) {
103 return trimmed;
104 }
105
106 const start = trimmed.indexOf("{");
107 if (start < 0) {
108 return trimmed;
109 }
110
111 let depth = 0;
112 let inString = false;
113 let escaping = false;
114 for (let index = start; index < trimmed.length; index += 1) {
115 const char = trimmed[index];
116 if (inString) {
117 if (escaping) {
118 escaping = false;
119 } else if (char === "\\") {
120 escaping = true;
121 } else if (char === '"') {
122 inString = false;
123 }
124 continue;
125 }
126
127 if (char === '"') {
128 inString = true;
129 continue;
130 }
131
132 if (char === "{") {
133 depth += 1;
134 continue;
135 }
136
137 if (char === "}") {
138 depth -= 1;
139 if (depth === 0) {
140 return trimmed.slice(start, index + 1);
141 }
142 }
143 }
144
145 return trimmed.slice(start);
146}
147
100148/**
101149 * Build a JSON string schema that encodes with stable 2-space formatting.
102150 *
packages/shared/src/semver.test.ts created+58
......@@ -0,0 +1,58 @@
1import { describe, expect, it } from "vitest";
2
3import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts";
4
5describe("semver helpers", () => {
6 it("matches supported range groups", () => {
7 const range = "^22.16 || ^23.11 || >=24.10";
8
9 expect(satisfiesSemverRange("22.16.0", range)).toBe(true);
10 expect(satisfiesSemverRange("23.11.1", range)).toBe(true);
11 expect(satisfiesSemverRange("24.10.0", range)).toBe(true);
12 expect(satisfiesSemverRange("22.15.9", range)).toBe(false);
13 expect(satisfiesSemverRange("23.10.9", range)).toBe(false);
14 expect(satisfiesSemverRange("24.9.9", range)).toBe(false);
15 });
16
17 it("normalizes versions with a missing patch segment", () => {
18 expect(normalizeSemverVersion("2.1")).toBe("2.1.0");
19 });
20
21 it("compares prerelease versions before stable versions", () => {
22 expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0);
23 });
24
25 it("falls back to lexical comparison for malformed numeric segments", () => {
26 expect(compareSemverVersions("1.2.3abc", "1.2.10")).toBeGreaterThan(0);
27 });
28
29 it("supports comparison comparators", () => {
30 expect(satisfiesSemverRange("24.9.0", ">=24.0 <24.10")).toBe(true);
31 expect(satisfiesSemverRange("24.10.0", ">=24.0 <24.10")).toBe(false);
32 });
33
34 it("honors caret range upper bounds for zero-major versions", () => {
35 expect(satisfiesSemverRange("0.2.3", "^0.2.3")).toBe(true);
36 expect(satisfiesSemverRange("0.2.9", "^0.2.3")).toBe(true);
37 expect(satisfiesSemverRange("0.3.0", "^0.2.3")).toBe(false);
38 expect(satisfiesSemverRange("0.5.0", "^0.2.3")).toBe(false);
39 expect(satisfiesSemverRange("0.0.3", "^0.0.3")).toBe(true);
40 expect(satisfiesSemverRange("0.0.4", "^0.0.3")).toBe(false);
41 });
42
43 it("rejects invalid versions and unsupported range syntax", () => {
44 expect(satisfiesSemverRange("not-a-version", ">=24.0")).toBe(false);
45 expect(satisfiesSemverRange("24.10.0", "~24.10")).toBe(false);
46 });
47
48 it("keeps the range checker stringifiable and executable as plain JavaScript", () => {
49 const source = satisfiesSemverRange.toString();
50 const recreated = Function(`return (${source});`)() as typeof satisfiesSemverRange;
51
52 expect(source).toContain("function satisfiesSemverRange");
53 expect(source).not.toContain(": string");
54 expect(source).not.toContain(": boolean");
55 expect(recreated("24.10.0", ">=24.10")).toBe(true);
56 expect(recreated("24.9.9", ">=24.10")).toBe(false);
57 });
58});
packages/shared/src/semver.ts created+216
......@@ -0,0 +1,216 @@
1interface ParsedSemver {
2 readonly major: number;
3 readonly minor: number;
4 readonly patch: number;
5 readonly prerelease: ReadonlyArray<string>;
6}
7
8const SEMVER_NUMBER_SEGMENT = /^\d+$/;
9
10export function normalizeSemverVersion(version: string): string {
11 const [main, prerelease] = version.trim().split("-", 2);
12 const segments = (main ?? "")
13 .split(".")
14 .map((segment) => segment.trim())
15 .filter((segment) => segment.length > 0);
16
17 if (segments.length === 2) {
18 segments.push("0");
19 }
20
21 return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join(".");
22}
23
24export function parseSemver(value: string): ParsedSemver | null {
25 const normalized = normalizeSemverVersion(value).replace(/^v/, "");
26 const [main = "", prerelease] = normalized.split("-", 2);
27 const segments = main.split(".");
28 if (segments.length !== 3) {
29 return null;
30 }
31
32 const [majorSegment, minorSegment, patchSegment] = segments;
33 if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) {
34 return null;
35 }
36 if (
37 !SEMVER_NUMBER_SEGMENT.test(majorSegment) ||
38 !SEMVER_NUMBER_SEGMENT.test(minorSegment) ||
39 !SEMVER_NUMBER_SEGMENT.test(patchSegment)
40 ) {
41 return null;
42 }
43
44 const major = Number.parseInt(majorSegment, 10);
45 const minor = Number.parseInt(minorSegment, 10);
46 const patch = Number.parseInt(patchSegment, 10);
47 if (![major, minor, patch].every(Number.isInteger)) {
48 return null;
49 }
50
51 return {
52 major,
53 minor,
54 patch,
55 prerelease:
56 prerelease
57 ?.split(".")
58 .map((segment) => segment.trim())
59 .filter((segment) => segment.length > 0) ?? [],
60 };
61}
62
63function comparePrereleaseIdentifier(left: string, right: string): number {
64 const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left);
65 const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right);
66
67 if (leftNumeric && rightNumeric) {
68 return Number.parseInt(left, 10) - Number.parseInt(right, 10);
69 }
70 if (leftNumeric) {
71 return -1;
72 }
73 if (rightNumeric) {
74 return 1;
75 }
76 return left.localeCompare(right);
77}
78
79export function compareSemverVersions(left: string, right: string): number {
80 const parsedLeft = parseSemver(left);
81 const parsedRight = parseSemver(right);
82 if (!parsedLeft || !parsedRight) {
83 return left.localeCompare(right);
84 }
85
86 if (parsedLeft.major !== parsedRight.major) {
87 return parsedLeft.major - parsedRight.major;
88 }
89 if (parsedLeft.minor !== parsedRight.minor) {
90 return parsedLeft.minor - parsedRight.minor;
91 }
92 if (parsedLeft.patch !== parsedRight.patch) {
93 return parsedLeft.patch - parsedRight.patch;
94 }
95
96 if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) {
97 return 0;
98 }
99 if (parsedLeft.prerelease.length === 0) {
100 return 1;
101 }
102 if (parsedRight.prerelease.length === 0) {
103 return -1;
104 }
105
106 const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length);
107 for (let index = 0; index < length; index += 1) {
108 const leftIdentifier = parsedLeft.prerelease[index];
109 const rightIdentifier = parsedRight.prerelease[index];
110 if (leftIdentifier === undefined) {
111 return -1;
112 }
113 if (rightIdentifier === undefined) {
114 return 1;
115 }
116 const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier);
117 if (comparison !== 0) {
118 return comparison;
119 }
120 }
121
122 return 0;
123}
124
125/**
126 * Small semver range checker for CLI/runtime gates.
127 *
128 * Keep the function body valid plain JavaScript: SSH startup stringifies this
129 * function and runs it on remote Node versions before TypeScript support is known.
130 *
131 * @param rawVersion Version string, with or without a leading `v`.
132 * @param range Space-separated comparators, with `||` range groups.
133 * @returns Whether `rawVersion` satisfies the supported range syntax.
134 */
135export const satisfiesSemverRange: (rawVersion: string, range: string) => boolean =
136 function satisfiesSemverRange(rawVersion, range) {
137 const normalizedVersion = String(rawVersion).trim().replace(/^v/, "");
138 const versionMatch = normalizedVersion.match(
139 /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?$/,
140 );
141 if (!versionMatch) {
142 return false;
143 }
144
145 const version = {
146 major: Number(versionMatch[1]),
147 minor: Number(versionMatch[2] || 0),
148 patch: Number(versionMatch[3] || 0),
149 };
150
151 return range.split("||").some((group) => {
152 const comparators = group.trim().split(/\s+/).filter(Boolean);
153 if (comparators.length === 0) {
154 return false;
155 }
156 return comparators.every((comparator) => {
157 const match = comparator.trim().match(/^(\^|>=|>|<=|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/);
158 if (!match) {
159 return false;
160 }
161 const targetVersion = match[2];
162 if (targetVersion === undefined) {
163 return false;
164 }
165 const targetMatch = targetVersion.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
166 if (!targetMatch) {
167 return false;
168 }
169 const target = {
170 major: Number(targetMatch[1]),
171 minor: Number(targetMatch[2] || 0),
172 patch: Number(targetMatch[3] || 0),
173 };
174 const compared =
175 version.major !== target.major
176 ? version.major > target.major
177 ? 1
178 : -1
179 : version.minor !== target.minor
180 ? version.minor > target.minor
181 ? 1
182 : -1
183 : version.patch !== target.patch
184 ? version.patch > target.patch
185 ? 1
186 : -1
187 : 0;
188 const operator = match[1] || "=";
189 switch (operator) {
190 case "^":
191 if (compared < 0) {
192 return false;
193 }
194 if (target.major > 0) {
195 return version.major === target.major;
196 }
197 if (target.minor > 0) {
198 return version.major === 0 && version.minor === target.minor;
199 }
200 return version.major === 0 && version.minor === 0 && version.patch === target.patch;
201 case ">=":
202 return compared >= 0;
203 case ">":
204 return compared > 0;
205 case "<=":
206 return compared <= 0;
207 case "<":
208 return compared < 0;
209 case "=":
210 return compared === 0;
211 default:
212 return false;
213 }
214 });
215 });
216 };
packages/ssh/src/tunnel.test.ts+96-3
......@@ -20,11 +20,14 @@ import {
2020 buildRemoteT3RunnerScript,
2121 describeReadinessCause,
2222 issueRemotePairingToken,
23 launchOrReuseRemoteServer,
2324 REMOTE_PICK_PORT_SCRIPT,
2425 SshEnvironmentManager,
2526 waitForHttpReady,
2627} from "./tunnel.ts";
2728
29const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10";
30
2831const makeSuccessfulProcess = (stdout: string) => {
2932 const stdoutStream = Stream.make(new TextEncoder().encode(stdout));
3033 return ChildProcessSpawner.makeHandle({
......@@ -87,13 +90,34 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {
8790
8891describe("ssh tunnel scripts", () => {
8992 it("builds the remote t3 runner with npx and npm fallbacks", () => {
90 const script = buildRemoteT3RunnerScript();
93 const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE });
9194
9295 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
9396 assert.include(script, 'exec t3 "$@"');
9497 assert.include(script, "exec npx --yes 't3@latest' \"$@\"");
9598 assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\"");
9699 assert.include(script, "could not install 't3@latest'");
100 assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"');
101 assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`);
102 assert.include(script, "remote_node_satisfies_engine()");
103 assert.include(script, "function satisfiesSemverRange");
104 assert.include(script, "satisfiesSemverRange(rawVersion, range)");
105 assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"');
106 assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"');
107 assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"');
108 assert.include(script, 'eval "$(fnm env --use-on-cd --shell sh)"');
109 assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"');
110 assert.include(script, 'NVM_DIR="$HOME/.nvm"');
111 assert.include(script, "nvm use --silent default");
112 assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin');
113 assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available");
114 });
115
116 it("does not hard-code a remote node engine range", () => {
117 const script = buildRemoteT3RunnerScript();
118
119 assert.include(script, "T3_NODE_ENGINE_RANGE=''");
120 assert.notInclude(script, TEST_NODE_ENGINE_RANGE);
97121 });
98122
99123 it("shell-quotes package specs in the remote t3 runner", () => {
......@@ -127,10 +151,20 @@ describe("ssh tunnel scripts", () => {
127151 } as const;
128152
129153 assert.include(
130 buildRemoteLaunchScript(),
154 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
131155 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',
132156 );
133157 assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1");
158 assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()");
159 assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then");
160 assert.include(
161 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
162 `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`,
163 );
164 assert.include(
165 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
166 "does not satisfy required range ",
167 );
134168 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');
135169 assert.include(buildRemoteLaunchScript(), "wait_ready");
136170 assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1');
......@@ -156,17 +190,48 @@ describe("ssh tunnel scripts", () => {
156190 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',
157191 );
158192 assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()");
193 assert.include(
194 buildRemoteLaunchScript(),
195 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port',
196 );
197 assert.include(
198 buildRemoteLaunchScript(),
199 "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))",
200 );
201 assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"');
202 assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"');
203 assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"');
159204 assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\"");
205 assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then');
160206 assert.isBelow(
161207 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
162208 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
163209 );
164210 assert.isBelow(
165 buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'),
211 buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),
166212 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),
167213 );
168214 });
169215
216 it.effect("accepts launch JSON after remote shell startup noise", () => {
217 const target = {
218 alias: "devbox",
219 hostname: "devbox.example.com",
220 username: "julius",
221 port: 2222,
222 } as const;
223 const spawner = ChildProcessSpawner.make(() =>
224 Effect.succeed(makeSuccessfulProcess('loaded nvm default\n{"remotePort":3774}\n')),
225 );
226 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
227 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
228
229 return Effect.gen(function* () {
230 const result = yield* launchOrReuseRemoteServer(target);
231 assert.equal(result.remotePort, 3774);
232 }).pipe(Effect.provide(processLayer));
233 });
234
170235 it("allows the remote port picker to run without a state file path", () => {
171236 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');
172237 });
......@@ -232,6 +297,34 @@ describe("ssh tunnel scripts", () => {
232297 "expiresAt": "2026-04-29T01:01:20.994Z"
233298}
234299
300`),
301 ),
302 );
303 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
304 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
305 return Effect.gen(function* () {
306 const result = yield* issueRemotePairingToken(target);
307 assert.equal(result.credential, "LCL4R2TPHDKQ");
308 }).pipe(Effect.provide(processLayer));
309 });
310
311 it.effect("accepts pretty-printed pairing JSON after remote shell startup noise", () => {
312 const target = {
313 alias: "devbox",
314 hostname: "devbox.example.com",
315 username: "julius",
316 port: 2222,
317 } as const;
318 const spawner = ChildProcessSpawner.make(() =>
319 Effect.succeed(
320 makeSuccessfulProcess(`loaded nvm default
321{
322 "id": "88941235-6ed5-4184-a2ff-5339e2075958",
323 "credential": "LCL4R2TPHDKQ",
324 "role": "client",
325 "expiresAt": "2026-04-29T01:01:20.994Z"
326}
327
235328`),
236329 ),
237330 );
packages/ssh/src/tunnel.ts+196-20
......@@ -3,7 +3,8 @@ import type {
33 DesktopSshEnvironmentTarget,
44} from "@t3tools/contracts";
55import * as NetService from "@t3tools/shared/Net";
6import { fromLenientJson } from "@t3tools/shared/schemaJson";
6import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
7import { satisfiesSemverRange } from "@t3tools/shared/semver";
78import * as Context from "effect/Context";
89import * as Deferred from "effect/Deferred";
910import * as Duration from "effect/Duration";
......@@ -58,6 +59,7 @@ const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
5859export interface RemoteT3RunnerOptions {
5960 readonly packageSpec?: string;
6061 readonly nodeScriptPath?: string | null;
62 readonly nodeEngineRange?: string | null;
6163}
6264
6365export interface SshEnvironmentManagerOptions {
......@@ -166,6 +168,50 @@ const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunc
166168const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
167169const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError));
168170
171const decodeRemoteJsonOutput = <A, E>(
172 stdout: string,
173 decode: (input: string) => Effect.Effect<A, E>,
174): Effect.Effect<A, E> =>
175 decode(stdout).pipe(
176 Effect.catch((error) =>
177 Effect.gen(function* () {
178 const jsonObject = extractJsonObject(stdout);
179 if (jsonObject === stdout.trim()) {
180 return yield* Effect.fail(error);
181 }
182 const exit = yield* Effect.exit(decode(jsonObject));
183 if (Exit.isSuccess(exit)) {
184 return exit.value;
185 }
186 return yield* Effect.fail(error);
187 }),
188 ),
189 );
190
191const decodeRemoteLaunchOutput = (stdout: string) =>
192 decodeRemoteJsonOutput(stdout, decodeRemoteLaunchResult);
193
194const decodeRemotePairingOutput = (stdout: string) =>
195 decodeRemoteJsonOutput(stdout, decodeRemotePairingResult);
196
197const remoteNodeEngineCheckMain = function remoteNodeEngineCheckMain() {
198 const range = process.argv[2] || "";
199 const rawVersion =
200 process.versions && process.versions.node ? process.versions.node : process.version;
201
202 if (!satisfiesSemverRange(rawVersion, range)) {
203 process.stderr.write(
204 "Remote node " + rawVersion + " does not satisfy required range " + range + ".\n",
205 );
206 process.exit(1);
207 }
208};
209
210function buildRemoteNodeEngineCheckScript(): string {
211 return `${satisfiesSemverRange.toString()}
212(${remoteNodeEngineCheckMain.toString()})();`;
213}
214
169215export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
170216 const cleaned = stderr.trim();
171217 return cleaned.length > 0 ? cleaned : fallbackMessage;
......@@ -299,10 +345,108 @@ function probe() {
299345})().catch(() => process.exit(1));
300346`;
301347
348export const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() {
349 if [ -d "$1" ]; then
350 case ":$PATH:" in
351 *":$1:"*) ;;
352 *) PATH="$1:$PATH" ;;
353 esac
354 fi
355}
356
357remote_node_satisfies_engine() {
358 T3_NODE_ENGINE_RANGE=@@T3_NODE_ENGINE_RANGE@@
359 if [ -z "$T3_NODE_ENGINE_RANGE" ]; then
360 return 0
361 fi
362 node - "$T3_NODE_ENGINE_RANGE" <<'NODE'
363@@T3_NODE_ENGINE_CHECK_SCRIPT@@
364NODE
365}
366
367ensure_remote_node_path() {
368 if command -v node >/dev/null 2>&1 && remote_node_satisfies_engine >/dev/null 2>&1; then
369 return 0
370 fi
371
372 prepend_path_if_dir "$HOME/.local/bin"
373 prepend_path_if_dir "$HOME/bin"
374 prepend_path_if_dir "/opt/homebrew/bin"
375 prepend_path_if_dir "/usr/local/bin"
376 prepend_path_if_dir "/usr/bin"
377 prepend_path_if_dir "/bin"
378
379 if [ -z "\${VOLTA_HOME:-}" ]; then
380 VOLTA_HOME="$HOME/.volta"
381 fi
382 export VOLTA_HOME
383 prepend_path_if_dir "$VOLTA_HOME/bin"
384
385 prepend_path_if_dir "$HOME/.asdf/shims"
386 prepend_path_if_dir "$HOME/.asdf/bin"
387 if [ ! -x "$HOME/.asdf/shims/node" ] && [ -s "$HOME/.asdf/asdf.sh" ]; then
388 # shellcheck disable=SC1090
389 . "$HOME/.asdf/asdf.sh"
390 fi
391
392 prepend_path_if_dir "$HOME/.local/share/mise/shims"
393 prepend_path_if_dir "$HOME/.mise/shims"
394 if ! command -v node >/dev/null 2>&1 && command -v mise >/dev/null 2>&1; then
395 eval "$(mise activate sh)" >/dev/null 2>&1 || true
396 fi
397
398 if [ -z "\${FNM_DIR:-}" ]; then
399 FNM_DIR="$HOME/.local/share/fnm"
400 fi
401 export FNM_DIR
402 prepend_path_if_dir "$FNM_DIR"
403 prepend_path_if_dir "$HOME/.fnm"
404 if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then
405 eval "$(fnm env --use-on-cd --shell sh)" >/dev/null 2>&1 || eval "$(fnm env --shell sh)" >/dev/null 2>&1 || true
406 fi
407
408 prepend_path_if_dir "$HOME/.nodenv/bin"
409 prepend_path_if_dir "$HOME/.nodenv/shims"
410 if ! command -v node >/dev/null 2>&1 && command -v nodenv >/dev/null 2>&1; then
411 eval "$(nodenv init -)" >/dev/null 2>&1 || true
412 fi
413
414 if [ -z "\${NVM_DIR:-}" ]; then
415 NVM_DIR="$HOME/.nvm"
416 fi
417 export NVM_DIR
418
419 if [ -s "$NVM_DIR/nvm.sh" ]; then
420 # shellcheck disable=SC1090
421 . "$NVM_DIR/nvm.sh"
422 if ! command -v node >/dev/null 2>&1 && command -v nvm >/dev/null 2>&1; then
423 nvm use --silent default >/dev/null 2>&1 || nvm use --silent node >/dev/null 2>&1 || nvm use --silent --lts >/dev/null 2>&1 || true
424 fi
425 fi
426
427 if ! command -v node >/dev/null 2>&1 && [ -d "$NVM_DIR/versions/node" ]; then
428 for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin; do
429 if [ -x "$T3_NODE_BIN/node" ]; then
430 PATH="$T3_NODE_BIN:$PATH"
431 export PATH
432 fi
433 done
434 fi
435
436 command -v node >/dev/null 2>&1 && remote_node_satisfies_engine
437}
438`;
439
302440export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
303441set -eu
442@@T3_NODE_ENV_SCRIPT@@
443ensure_remote_node_path || true
304444T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
305445if [ -n "$T3_NODE_SCRIPT_PATH" ]; then
446 if ! command -v node >/dev/null 2>&1; then
447 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
448 exit 1
449 fi
306450 exec node "$T3_NODE_SCRIPT_PATH" "$@"
307451fi
308452if command -v t3 >/dev/null 2>&1; then
......@@ -314,11 +458,12 @@ fi
314458if command -v npm >/dev/null 2>&1; then
315459 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@"
316460fi
317printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\\n' >&2
461printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
318462exit 1
319463`;
320464
321465export const REMOTE_LAUNCH_SCRIPT = `set -eu
466@@T3_NODE_ENV_SCRIPT@@
322467STATE_KEY="$1"
323468STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
324469DEFAULT_SERVER_HOME="$HOME/.t3"
......@@ -343,6 +488,10 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
343488fi
344489mv "$RUNNER_NEXT" "$RUNNER_FILE"
345490chmod 700 "$RUNNER_FILE"
491if ! ensure_remote_node_path; then
492 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
493 exit 1
494fi
346495pick_port() {
347496 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'
348497@@T3_PICK_PORT_SCRIPT@@
......@@ -366,18 +515,18 @@ resolve_default_runtime_port() {
366515const fs = require("node:fs");
367516const runtimePath = process.argv[2] ?? "";
368517try {
369 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
370 const pid = Number(runtime.pid);
371 const port = Number(runtime.port);
372 if (!Number.isInteger(pid) || !Number.isInteger(port)) {
373 process.exit(1);
374 }
518 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
519 const pid = Number(runtime.pid);
520 const port = Number(runtime.port);
521 if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) {
522 process.exit(1);
523 }
375524 const origin = new URL(String(runtime.origin ?? ""));
376525 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {
377526 process.exit(1);
378527 }
379528 process.kill(pid, 0);
380 process.stdout.write(String(port));
529 process.stdout.write(\`\${pid} \${port}\`);
381530} catch {
382531 process.exit(1);
383532}
......@@ -386,18 +535,34 @@ NODE
386535REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
387536REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
388537REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
389DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port 2>/dev/null || true)"
538DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)"
539DEFAULT_RUNTIME_PID=""
540DEFAULT_REMOTE_PORT=""
541if [ -n "$DEFAULT_RUNTIME_INFO" ]; then
542 DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}"
543 DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }"
544fi
390545if [ -n "$DEFAULT_REMOTE_PORT" ]; then
391546 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
392547 if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
393 if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
394 kill "$REMOTE_PID" 2>/dev/null || true
395 wait_for_pid_exit "$REMOTE_PID"
548 if [ "$REMOTE_MANAGED" = "managed" ]; then
549 PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"
550 if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then
551 kill "$PID_TO_STOP" 2>/dev/null || true
552 wait_for_pid_exit "$PID_TO_STOP"
553 fi
554 REMOTE_PID=""
555 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
556 REMOTE_MANAGED="external"
557 rm -f "$PID_FILE"
558 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
559 printf 'external\\n' >"$MANAGED_FILE"
560 else
561 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
562 printf 'external\\n' >"$MANAGED_FILE"
563 REMOTE_PID=""
564 REMOTE_MANAGED="external"
396565 fi
397 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
398 printf 'external\\n' >"$MANAGED_FILE"
399 REMOTE_PID=""
400 REMOTE_MANAGED="external"
401566 else
402567 REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
403568 REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
......@@ -429,7 +594,7 @@ else
429594 REMOTE_PORT=""
430595 REMOTE_MANAGED=""
431596fi
432if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then
597if [ -z "$REMOTE_PORT" ]; then
433598 REMOTE_PORT="$(pick_port)" || true
434599 if [ -z "$REMOTE_PORT" ]; then
435600 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2
......@@ -499,12 +664,23 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string
499664 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
500665 T3_PACKAGE_SPEC: packageSpec,
501666 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
667 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
668 }),
669 );
670}
671
672function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string {
673 return stripTrailingNewlines(
674 applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, {
675 T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""),
676 T3_NODE_ENGINE_CHECK_SCRIPT: stripTrailingNewlines(buildRemoteNodeEngineCheckScript()),
502677 }),
503678 );
504679}
505680
506681export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
507682 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
683 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
508684 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
509685 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
510686 T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),
......@@ -566,7 +742,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
566742 stdout: result.stdout,
567743 });
568744 }
569 const parsed = yield* decodeRemoteLaunchResult(result.stdout).pipe(
745 const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe(
570746 Effect.mapError(
571747 (cause) =>
572748 new SshLaunchError({
......@@ -623,7 +799,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
623799 stdout: result.stdout,
624800 });
625801 }
626 const parsed = yield* decodeRemotePairingResult(result.stdout).pipe(
802 const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe(
627803 Effect.mapError(
628804 (cause) =>
629805 new SshPairingError({