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....@@ -115,6 +115,43 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint.
115115
116SSH 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.116SSH 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
118## How Pairing Works155## How Pairing Works
119156
120The remote device does not need a long-lived secret up front.157The 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";...@@ -11,6 +11,7 @@ import * as Electron from "electron";
11import * as NetService from "@t3tools/shared/Net";11import * as NetService from "@t3tools/shared/Net";
12import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";12import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
13import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";13import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
14import serverPackageJson from "../../server/package.json" with { type: "json" };
1415
15import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";16import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts";
16import * as DesktopIpc from "./ipc/DesktopIpc.ts";17import * as DesktopIpc from "./ipc/DesktopIpc.ts";
...@@ -65,7 +66,10 @@ const resolveDesktopSshCliRunner = (...@@ -65,7 +66,10 @@ const resolveDesktopSshCliRunner = (
65): RemoteT3RunnerOptions => {66): RemoteT3RunnerOptions => {
66 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);67 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
67 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {68 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
68 return { nodeScriptPath: devRemoteEntryPath };69 return {
70 nodeScriptPath: devRemoteEntryPath,
71 nodeEngineRange: serverPackageJson.engines.node,
72 };
69 }73 }
70 return {74 return {
71 packageSpec: resolveRemoteT3CliPackageSpec({75 packageSpec: resolveRemoteT3CliPackageSpec({
...@@ -73,6 +77,7 @@ const resolveDesktopSshCliRunner = (...@@ -73,6 +77,7 @@ const resolveDesktopSshCliRunner = (
73 updateChannel: settings.updateChannel,77 updateChannel: settings.updateChannel,
74 isDevelopment: environment.isDevelopment,78 isDevelopment: environment.isDevelopment,
75 }),79 }),
80 nodeEngineRange: serverPackageJson.engines.node,
76 };81 };
77};82};
7883
apps/server/src/provider/Layers/ClaudeProvider.ts+2-2
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 getProviderOptionCurrentValue,18 getProviderOptionCurrentValue,
19 getProviderOptionDescriptors,19 getProviderOptionDescriptors,
20} from "@t3tools/shared/model";20} from "@t3tools/shared/model";
21import { compareSemverVersions } from "@t3tools/shared/semver";
21import {22import {
22 query as claudeQuery,23 query as claudeQuery,
23 type SlashCommand as ClaudeSlashCommand,24 type SlashCommand as ClaudeSlashCommand,
...@@ -36,7 +37,6 @@ import {...@@ -36,7 +37,6 @@ import {
36 spawnAndCollect,37 spawnAndCollect,
37 type ServerProviderDraft,38 type ServerProviderDraft,
38} from "../providerSnapshot.ts";39} from "../providerSnapshot.ts";
39import { compareCliVersions } from "../cliVersion.ts";
40import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";40import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
4141
42const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({42const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
...@@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
180];180];
181181
182function supportsClaudeOpus47(version: string | null | undefined): boolean {182function 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;
184}184}
185185
186function getBuiltInClaudeModelsForVersion(186function getBuiltInClaudeModelsForVersion(
apps/server/src/provider/Layers/OpenCodeProvider.ts+2-2
...@@ -10,6 +10,7 @@ import * as DateTime from "effect/DateTime";...@@ -10,6 +10,7 @@ import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
1111
12import { createModelCapabilities } from "@t3tools/shared/model";12import { createModelCapabilities } from "@t3tools/shared/model";
13import { compareSemverVersions } from "@t3tools/shared/semver";
13import {14import {
14 buildServerProvider,15 buildServerProvider,
15 nonEmptyTrimmed,16 nonEmptyTrimmed,
...@@ -17,7 +18,6 @@ import {...@@ -17,7 +18,6 @@ import {
17 providerModelsFromSettings,18 providerModelsFromSettings,
18 type ServerProviderDraft,19 type ServerProviderDraft,
19} from "../providerSnapshot.ts";20} from "../providerSnapshot.ts";
20import { compareCliVersions } from "../cliVersion.ts";
21import {21import {
22 OpenCodeRuntime,22 OpenCodeRuntime,
23 openCodeRuntimeErrorDetail,23 openCodeRuntimeErrorDetail,
...@@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
383 null,383 null,
384 );384 );
385 }385 }
386 if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {386 if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) {
387 return buildServerProvider({387 return buildServerProvider({
388 presentation: OPENCODE_PRESENTATION,388 presentation: OPENCODE_PRESENTATION,
389 enabled: openCodeSettings.enabled,389 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 {...@@ -3,6 +3,7 @@ import {
3 type ServerProvider,3 type ServerProvider,
4 type ServerProviderVersionAdvisory,4 type ServerProviderVersionAdvisory,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { compareSemverVersions } from "@t3tools/shared/semver";
6import { resolveCommandPath } from "@t3tools/shared/shell";7import { resolveCommandPath } from "@t3tools/shared/shell";
7import * as DateTime from "effect/DateTime";8import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
...@@ -11,8 +12,6 @@ import * as Option from "effect/Option";...@@ -11,8 +12,6 @@ import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
12import { HttpClient, HttpClientRequest } from "effect/unstable/http";13import { HttpClient, HttpClientRequest } from "effect/unstable/http";
1314
14import { compareCliVersions } from "./cliVersion.ts";
15
16const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;15const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000;
17const LATEST_VERSION_TIMEOUT_MS = 4_000;16const LATEST_VERSION_TIMEOUT_MS = 4_000;
18const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";17const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings.";
...@@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: {...@@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: {
365 if (!input.latestVersion) {364 if (!input.latestVersion) {
366 return { status: "unknown", message: null };365 return { status: "unknown", message: null };
367 }366 }
368 if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) {367 if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) {
369 return {368 return {
370 status: "behind_latest",369 status: "behind_latest",
371 message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,370 message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE,
apps/server/src/textGeneration/CursorTextGeneration.ts+1-1
...@@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
66
7import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";7import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
8import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";8import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
9import { extractJsonObject } from "@t3tools/shared/schemaJson";
910
10import { TextGenerationError } from "@t3tools/contracts";11import { TextGenerationError } from "@t3tools/contracts";
11import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";12import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";
...@@ -16,7 +17,6 @@ import {...@@ -16,7 +17,6 @@ import {
16 buildThreadTitlePrompt,17 buildThreadTitlePrompt,
17} from "./TextGenerationPrompts.ts";18} from "./TextGenerationPrompts.ts";
18import {19import {
19 extractJsonObject,
20 sanitizeCommitSubject,20 sanitizeCommitSubject,
21 sanitizePrTitle,21 sanitizePrTitle,
22 sanitizeThreadTitle,22 sanitizeThreadTitle,
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1-1
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
15import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";15import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
16import { extractJsonObject } from "@t3tools/shared/schemaJson";
1617
17import { ServerConfig } from "../config.ts";18import { ServerConfig } from "../config.ts";
18import { resolveAttachmentPath } from "../attachmentStore.ts";19import { resolveAttachmentPath } from "../attachmentStore.ts";
...@@ -24,7 +25,6 @@ import {...@@ -24,7 +25,6 @@ import {
24} from "./TextGenerationPrompts.ts";25} from "./TextGenerationPrompts.ts";
25import { type TextGenerationShape } from "./TextGeneration.ts";26import { type TextGenerationShape } from "./TextGeneration.ts";
26import {27import {
27 extractJsonObject,
28 sanitizeCommitSubject,28 sanitizeCommitSubject,
29 sanitizePrTitle,29 sanitizePrTitle,
30 sanitizeThreadTitle,30 sanitizeThreadTitle,
apps/server/src/textGeneration/TextGenerationUtils.ts+1-49
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { TextGenerationError } from "@t3tools/contracts";
1import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
23
3import { TextGenerationError } from "@t3tools/contracts";
4const isTextGenerationError = Schema.is(TextGenerationError);4const isTextGenerationError = Schema.is(TextGenerationError);
55
6/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */6/** 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 {...@@ -19,54 +19,6 @@ export function limitSection(value: string, maxChars: number): string {
19 return `${truncated}\n\n[truncated]`;19 return `${truncated}\n\n[truncated]`;
20}20}
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
70/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */22/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
71export function sanitizeCommitSubject(raw: string): string {23export function sanitizeCommitSubject(raw: string): string {
72 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";24 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
packages/shared/package.json+4
...@@ -28,6 +28,10 @@...@@ -28,6 +28,10 @@
28 "types": "./src/shell.ts",28 "types": "./src/shell.ts",
29 "import": "./src/shell.ts"29 "import": "./src/shell.ts"
30 },30 },
31 "./semver": {
32 "types": "./src/semver.ts",
33 "import": "./src/semver.ts"
34 },
31 "./Net": {35 "./Net": {
32 "types": "./src/Net.ts",36 "types": "./src/Net.ts",
33 "import": "./src/Net.ts"37 "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(...@@ -97,6 +97,54 @@ export const prettyJsonString = SchemaGetter.parseJson<string>().compose(
97export const fromLenientJson = <S extends Schema.Top>(schema: S) =>97export const fromLenientJson = <S extends Schema.Top>(schema: S) =>
98 Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString));98 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
100/**148/**
101 * Build a JSON string schema that encodes with stable 2-space formatting.149 * Build a JSON string schema that encodes with stable 2-space formatting.
102 *150 *
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 {...@@ -20,11 +20,14 @@ import {
20 buildRemoteT3RunnerScript,20 buildRemoteT3RunnerScript,
21 describeReadinessCause,21 describeReadinessCause,
22 issueRemotePairingToken,22 issueRemotePairingToken,
23 launchOrReuseRemoteServer,
23 REMOTE_PICK_PORT_SCRIPT,24 REMOTE_PICK_PORT_SCRIPT,
24 SshEnvironmentManager,25 SshEnvironmentManager,
25 waitForHttpReady,26 waitForHttpReady,
26} from "./tunnel.ts";27} from "./tunnel.ts";
2728
29const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10";
30
28const makeSuccessfulProcess = (stdout: string) => {31const makeSuccessfulProcess = (stdout: string) => {
29 const stdoutStream = Stream.make(new TextEncoder().encode(stdout));32 const stdoutStream = Stream.make(new TextEncoder().encode(stdout));
30 return ChildProcessSpawner.makeHandle({33 return ChildProcessSpawner.makeHandle({
...@@ -87,13 +90,34 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {...@@ -87,13 +90,34 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {
8790
88describe("ssh tunnel scripts", () => {91describe("ssh tunnel scripts", () => {
89 it("builds the remote t3 runner with npx and npm fallbacks", () => {92 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
92 assert.include(script, "T3_NODE_SCRIPT_PATH=''");95 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
93 assert.include(script, 'exec t3 "$@"');96 assert.include(script, 'exec t3 "$@"');
94 assert.include(script, "exec npx --yes 't3@latest' \"$@\"");97 assert.include(script, "exec npx --yes 't3@latest' \"$@\"");
95 assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\"");98 assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\"");
96 assert.include(script, "could not install 't3@latest'");99 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);
97 });121 });
98122
99 it("shell-quotes package specs in the remote t3 runner", () => {123 it("shell-quotes package specs in the remote t3 runner", () => {
...@@ -127,10 +151,20 @@ describe("ssh tunnel scripts", () => {...@@ -127,10 +151,20 @@ describe("ssh tunnel scripts", () => {
127 } as const;151 } as const;
128152
129 assert.include(153 assert.include(
130 buildRemoteLaunchScript(),154 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
131 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',155 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',
132 );156 );
133 assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1");157 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 );
134 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');168 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');
135 assert.include(buildRemoteLaunchScript(), "wait_ready");169 assert.include(buildRemoteLaunchScript(), "wait_ready");
136 assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1');170 assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1');
...@@ -156,17 +190,48 @@ describe("ssh tunnel scripts", () => {...@@ -156,17 +190,48 @@ describe("ssh tunnel scripts", () => {
156 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',190 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',
157 );191 );
158 assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()");192 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"');
159 assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\"");204 assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\"");
205 assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then');
160 assert.isBelow(206 assert.isBelow(
161 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),207 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
162 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),208 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
163 );209 );
164 assert.isBelow(210 assert.isBelow(
165 buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'),211 buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),
166 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),212 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),
167 );213 );
168 });214 });
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
170 it("allows the remote port picker to run without a state file path", () => {235 it("allows the remote port picker to run without a state file path", () => {
171 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');236 assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";');
172 });237 });
...@@ -232,6 +297,34 @@ describe("ssh tunnel scripts", () => {...@@ -232,6 +297,34 @@ describe("ssh tunnel scripts", () => {
232 "expiresAt": "2026-04-29T01:01:20.994Z"297 "expiresAt": "2026-04-29T01:01:20.994Z"
233}298}
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
235`),328`),
236 ),329 ),
237 );330 );
packages/ssh/src/tunnel.ts+196-20
...@@ -3,7 +3,8 @@ import type {...@@ -3,7 +3,8 @@ import type {
3 DesktopSshEnvironmentTarget,3 DesktopSshEnvironmentTarget,
4} from "@t3tools/contracts";4} from "@t3tools/contracts";
5import * as NetService from "@t3tools/shared/Net";5import * 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";
7import * as Context from "effect/Context";8import * as Context from "effect/Context";
8import * as Deferred from "effect/Deferred";9import * as Deferred from "effect/Deferred";
9import * as Duration from "effect/Duration";10import * as Duration from "effect/Duration";
...@@ -58,6 +59,7 @@ const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;...@@ -58,6 +59,7 @@ const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
58export interface RemoteT3RunnerOptions {59export interface RemoteT3RunnerOptions {
59 readonly packageSpec?: string;60 readonly packageSpec?: string;
60 readonly nodeScriptPath?: string | null;61 readonly nodeScriptPath?: string | null;
62 readonly nodeEngineRange?: string | null;
61}63}
6264
63export interface SshEnvironmentManagerOptions {65export interface SshEnvironmentManagerOptions {
...@@ -166,6 +168,50 @@ const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunc...@@ -166,6 +168,50 @@ const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunc
166const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));168const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
167const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError));169const 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
169export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {215export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
170 const cleaned = stderr.trim();216 const cleaned = stderr.trim();
171 return cleaned.length > 0 ? cleaned : fallbackMessage;217 return cleaned.length > 0 ? cleaned : fallbackMessage;
...@@ -299,10 +345,108 @@ function probe() {...@@ -299,10 +345,108 @@ function probe() {
299})().catch(() => process.exit(1));345})().catch(() => process.exit(1));
300`;346`;
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
302export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh440export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
303set -eu441set -eu
442@@T3_NODE_ENV_SCRIPT@@
443ensure_remote_node_path || true
304T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@444T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
305if [ -n "$T3_NODE_SCRIPT_PATH" ]; then445if [ -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
306 exec node "$T3_NODE_SCRIPT_PATH" "$@"450 exec node "$T3_NODE_SCRIPT_PATH" "$@"
307fi451fi
308if command -v t3 >/dev/null 2>&1; then452if command -v t3 >/dev/null 2>&1; then
...@@ -314,11 +458,12 @@ fi...@@ -314,11 +458,12 @@ fi
314if command -v npm >/dev/null 2>&1; then458if command -v npm >/dev/null 2>&1; then
315 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@"459 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@"
316fi460fi
317printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\\n' >&2461printf '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
318exit 1462exit 1
319`;463`;
320464
321export const REMOTE_LAUNCH_SCRIPT = `set -eu465export const REMOTE_LAUNCH_SCRIPT = `set -eu
466@@T3_NODE_ENV_SCRIPT@@
322STATE_KEY="$1"467STATE_KEY="$1"
323STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"468STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
324DEFAULT_SERVER_HOME="$HOME/.t3"469DEFAULT_SERVER_HOME="$HOME/.t3"
...@@ -343,6 +488,10 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then...@@ -343,6 +488,10 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
343fi488fi
344mv "$RUNNER_NEXT" "$RUNNER_FILE"489mv "$RUNNER_NEXT" "$RUNNER_FILE"
345chmod 700 "$RUNNER_FILE"490chmod 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
346pick_port() {495pick_port() {
347 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'496 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'
348@@T3_PICK_PORT_SCRIPT@@497@@T3_PICK_PORT_SCRIPT@@
...@@ -366,18 +515,18 @@ resolve_default_runtime_port() {...@@ -366,18 +515,18 @@ resolve_default_runtime_port() {
366const fs = require("node:fs");515const fs = require("node:fs");
367const runtimePath = process.argv[2] ?? "";516const runtimePath = process.argv[2] ?? "";
368try {517try {
369 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));518 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
370 const pid = Number(runtime.pid);519 const pid = Number(runtime.pid);
371 const port = Number(runtime.port);520 const port = Number(runtime.port);
372 if (!Number.isInteger(pid) || !Number.isInteger(port)) {521 if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) {
373 process.exit(1);522 process.exit(1);
374 }523 }
375 const origin = new URL(String(runtime.origin ?? ""));524 const origin = new URL(String(runtime.origin ?? ""));
376 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {525 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {
377 process.exit(1);526 process.exit(1);
378 }527 }
379 process.kill(pid, 0);528 process.kill(pid, 0);
380 process.stdout.write(String(port));529 process.stdout.write(\`\${pid} \${port}\`);
381} catch {530} catch {
382 process.exit(1);531 process.exit(1);
383}532}
...@@ -386,18 +535,34 @@ NODE...@@ -386,18 +535,34 @@ NODE
386REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"535REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
387REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"536REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
388REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"537REMOTE_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
390if [ -n "$DEFAULT_REMOTE_PORT" ]; then545if [ -n "$DEFAULT_REMOTE_PORT" ]; then
391 REMOTE_PORT="$DEFAULT_REMOTE_PORT"546 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
392 if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then547 if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
393 if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then548 if [ "$REMOTE_MANAGED" = "managed" ]; then
394 kill "$REMOTE_PID" 2>/dev/null || true549 PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"
395 wait_for_pid_exit "$REMOTE_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"
396 fi565 fi
397 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
398 printf 'external\\n' >"$MANAGED_FILE"
399 REMOTE_PID=""
400 REMOTE_MANAGED="external"
401 else566 else
402 REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"567 REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
403 REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"568 REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
...@@ -429,7 +594,7 @@ else...@@ -429,7 +594,7 @@ else
429 REMOTE_PORT=""594 REMOTE_PORT=""
430 REMOTE_MANAGED=""595 REMOTE_MANAGED=""
431fi596fi
432if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then597if [ -z "$REMOTE_PORT" ]; then
433 REMOTE_PORT="$(pick_port)" || true598 REMOTE_PORT="$(pick_port)" || true
434 if [ -z "$REMOTE_PORT" ]; then599 if [ -z "$REMOTE_PORT" ]; then
435 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2600 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...@@ -499,12 +664,23 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string
499 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {664 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
500 T3_PACKAGE_SPEC: packageSpec,665 T3_PACKAGE_SPEC: packageSpec,
501 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),666 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()),
502 }),677 }),
503 );678 );
504}679}
505680
506export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {681export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
507 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {682 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
683 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
508 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),684 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
509 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),685 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
510 T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),686 T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),
...@@ -566,7 +742,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo...@@ -566,7 +742,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
566 stdout: result.stdout,742 stdout: result.stdout,
567 });743 });
568 }744 }
569 const parsed = yield* decodeRemoteLaunchResult(result.stdout).pipe(745 const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe(
570 Effect.mapError(746 Effect.mapError(
571 (cause) =>747 (cause) =>
572 new SshLaunchError({748 new SshLaunchError({
...@@ -623,7 +799,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT...@@ -623,7 +799,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
623 stdout: result.stdout,799 stdout: result.stdout,
624 });800 });
625 }801 }
626 const parsed = yield* decodeRemotePairingResult(result.stdout).pipe(802 const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe(
627 Effect.mapError(803 Effect.mapError(
628 (cause) =>804 (cause) =>
629 new SshPairingError({805 new SshPairingError({