diff --git a/REMOTE.md b/REMOTE.md index 61a1cc3b5cc..56510e62890 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -115,6 +115,43 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. SSH 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. +#### SSH Launch Troubleshooting + +The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch//`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop. + +The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement: + +```text +^22.16 || ^23.11 || >=24.10 +``` + +During 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: + +- `~/.local/bin`, `~/bin`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin` +- Volta via `~/.volta/bin` +- asdf via `~/.asdf/shims`, `~/.asdf/bin`, or `~/.asdf/asdf.sh` +- mise via `~/.local/share/mise/shims`, `~/.mise/shims`, or `mise activate sh` +- fnm via `fnm env --use-on-cd --shell sh` or `fnm env --shell sh` +- nodenv via `~/.nodenv/bin`, `~/.nodenv/shims`, or `nodenv init -` +- nvm via `$NVM_DIR/nvm.sh`, then `nvm use default`, `nvm use node`, or `nvm use --lts` +- installed nvm versions under `$NVM_DIR/versions/node/*/bin` + +If 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: + +```bash +ssh user@example.com 'sh -lc "command -v node && node --version"' +``` + +If 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: + +```bash +nvm alias default 24 +``` + +With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above. + +If 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. + ## How Pairing Works The remote device does not need a long-lived secret up front. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f71ff1fbe67..0bc1badff2d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -11,6 +11,7 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; +import serverPackageJson from "../../server/package.json" with { type: "json" }; import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; @@ -65,7 +66,10 @@ const resolveDesktopSshCliRunner = ( ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { - return { nodeScriptPath: devRemoteEntryPath }; + return { + nodeScriptPath: devRemoteEntryPath, + nodeEngineRange: serverPackageJson.engines.node, + }; } return { packageSpec: resolveRemoteT3CliPackageSpec({ @@ -73,6 +77,7 @@ const resolveDesktopSshCliRunner = ( updateChannel: settings.updateChannel, isDevelopment: environment.isDevelopment, }), + nodeEngineRange: serverPackageJson.engines.node, }; }; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 27bff1b0b69..f787129af89 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -18,6 +18,7 @@ import { getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, @@ -36,7 +37,6 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { compareCliVersions } from "../cliVersion.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ]; function supportsClaudeOpus47(version: string | null | undefined): boolean { - return version ? compareCliVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; } function getBuiltInClaudeModelsForVersion( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index b247e8586c0..dea95c990d2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -10,6 +10,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, nonEmptyTrimmed, @@ -17,7 +18,6 @@ import { providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { compareCliVersions } from "../cliVersion.ts"; import { OpenCodeRuntime, openCodeRuntimeErrorDetail, @@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu null, ); } - if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { + if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: openCodeSettings.enabled, diff --git a/apps/server/src/provider/cliVersion.test.ts b/apps/server/src/provider/cliVersion.test.ts deleted file mode 100644 index ffb42cf5ccd..00000000000 --- a/apps/server/src/provider/cliVersion.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; - -describe("cliVersion", () => { - it("normalizes versions with a missing patch segment", () => { - assert.strictEqual(normalizeCliVersion("2.1"), "2.1.0"); - }); - - it("compares prerelease versions before stable versions", () => { - assert.isTrue(compareCliVersions("2.1.111-beta.1", "2.1.111") < 0); - }); - - it("rejects malformed numeric segments", () => { - assert.isTrue(compareCliVersions("1.2.3abc", "1.2.10") > 0); - }); -}); diff --git a/apps/server/src/provider/cliVersion.ts b/apps/server/src/provider/cliVersion.ts deleted file mode 100644 index 6308a2ff525..00000000000 --- a/apps/server/src/provider/cliVersion.ts +++ /dev/null @@ -1,123 +0,0 @@ -interface ParsedCliSemver { - readonly major: number; - readonly minor: number; - readonly patch: number; - readonly prerelease: ReadonlyArray; -} - -const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/; - -export function normalizeCliVersion(version: string): string { - const [main, prerelease] = version.trim().split("-", 2); - const segments = (main ?? "") - .split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0); - - if (segments.length === 2) { - segments.push("0"); - } - - return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); -} - -function parseCliSemver(version: string): ParsedCliSemver | null { - const normalized = normalizeCliVersion(version); - const [main = "", prerelease] = normalized.split("-", 2); - const segments = main.split("."); - if (segments.length !== 3) { - return null; - } - - const [majorSegment, minorSegment, patchSegment] = segments; - if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { - return null; - } - if ( - !CLI_VERSION_NUMBER_SEGMENT.test(majorSegment) || - !CLI_VERSION_NUMBER_SEGMENT.test(minorSegment) || - !CLI_VERSION_NUMBER_SEGMENT.test(patchSegment) - ) { - return null; - } - - const major = Number.parseInt(majorSegment, 10); - const minor = Number.parseInt(minorSegment, 10); - const patch = Number.parseInt(patchSegment, 10); - if (![major, minor, patch].every(Number.isInteger)) { - return null; - } - - return { - major, - minor, - patch, - prerelease: - prerelease - ?.split(".") - .map((segment) => segment.trim()) - .filter((segment) => segment.length > 0) ?? [], - }; -} - -function comparePrereleaseIdentifier(left: string, right: string): number { - const leftNumeric = /^\d+$/.test(left); - const rightNumeric = /^\d+$/.test(right); - - if (leftNumeric && rightNumeric) { - return Number.parseInt(left, 10) - Number.parseInt(right, 10); - } - if (leftNumeric) { - return -1; - } - if (rightNumeric) { - return 1; - } - return left.localeCompare(right); -} - -export function compareCliVersions(left: string, right: string): number { - const parsedLeft = parseCliSemver(left); - const parsedRight = parseCliSemver(right); - if (!parsedLeft || !parsedRight) { - return left.localeCompare(right); - } - - if (parsedLeft.major !== parsedRight.major) { - return parsedLeft.major - parsedRight.major; - } - if (parsedLeft.minor !== parsedRight.minor) { - return parsedLeft.minor - parsedRight.minor; - } - if (parsedLeft.patch !== parsedRight.patch) { - return parsedLeft.patch - parsedRight.patch; - } - - if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { - return 0; - } - if (parsedLeft.prerelease.length === 0) { - return 1; - } - if (parsedRight.prerelease.length === 0) { - return -1; - } - - const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); - for (let index = 0; index < length; index += 1) { - const leftIdentifier = parsedLeft.prerelease[index]; - const rightIdentifier = parsedRight.prerelease[index]; - if (leftIdentifier === undefined) { - return -1; - } - if (rightIdentifier === undefined) { - return 1; - } - const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); - if (comparison !== 0) { - return comparison; - } - } - - return 0; -} diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index a733506766d..7f5e9d94dc5 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,6 +3,7 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -11,8 +12,6 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { compareCliVersions } from "./cliVersion.ts"; - const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; @@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: { if (!input.latestVersion) { return { status: "unknown", message: null }; } - if (compareCliVersions(input.currentVersion, input.latestVersion) < 0) { + if (compareSemverVersions(input.currentVersion, input.latestVersion) < 0) { return { status: "behind_latest", message: PROVIDER_UPDATE_ACTION_TOAST_MESSAGE, diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 22b4924a19f..c4ef1af21d1 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { TextGenerationError } from "@t3tools/contracts"; import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; @@ -16,7 +17,6 @@ import { buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; import { - extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index df68ca3fee0..b865b2e5ef5 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; import { ServerConfig } from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; @@ -24,7 +25,6 @@ import { } from "./TextGenerationPrompts.ts"; import { type TextGenerationShape } from "./TextGeneration.ts"; import { - extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index f1bc0cb9ddc..a786f81b2c8 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -1,6 +1,6 @@ +import { TextGenerationError } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { TextGenerationError } from "@t3tools/contracts"; const isTextGenerationError = Schema.is(TextGenerationError); /** 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 { return `${truncated}\n\n[truncated]`; } -export function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const start = trimmed.indexOf("{"); - if (start < 0) { - return trimmed; - } - - let depth = 0; - let inString = false; - let escaping = false; - for (let index = start; index < trimmed.length; index += 1) { - const char = trimmed[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "{") { - depth += 1; - continue; - } - - if (char === "}") { - depth -= 1; - if (depth === 0) { - return trimmed.slice(start, index + 1); - } - } - } - - return trimmed.slice(start); -} - /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ export function sanitizeCommitSubject(raw: string): string { const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; diff --git a/packages/shared/package.json b/packages/shared/package.json index f410a98b381..c499bf3c6ec 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -28,6 +28,10 @@ "types": "./src/shell.ts", "import": "./src/shell.ts" }, + "./semver": { + "types": "./src/semver.ts", + "import": "./src/semver.ts" + }, "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts new file mode 100644 index 00000000000..7f3db7034b5 --- /dev/null +++ b/packages/shared/src/schemaJson.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { extractJsonObject } from "./schemaJson.ts"; + +describe("schemaJson helpers", () => { + it("extracts a balanced JSON object from surrounding text", () => { + expect( + extractJsonObject(`Sure, here is the JSON: +\`\`\`json +{ + "subject": "Update README", + "body": "" +} +\`\`\` +Done.`), + ).toBe(`{ + "subject": "Update README", + "body": "" +}`); + }); + + it("ignores braces inside strings while finding the object boundary", () => { + expect( + extractJsonObject('prefix {"message":"literal } brace","nested":{"ok":true}} suffix'), + ).toBe('{"message":"literal } brace","nested":{"ok":true}}'); + }); + + it("returns trimmed input when no JSON object starts", () => { + expect(extractJsonObject(" no structured output ")).toBe("no structured output"); + }); +}); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 32abdcd7234..0d879913ebf 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -97,6 +97,54 @@ export const prettyJsonString = SchemaGetter.parseJson().compose( export const fromLenientJson = (schema: S) => Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + /** * Build a JSON string schema that encodes with stable 2-space formatting. * diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts new file mode 100644 index 00000000000..b3c629740a7 --- /dev/null +++ b/packages/shared/src/semver.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts"; + +describe("semver helpers", () => { + it("matches supported range groups", () => { + const range = "^22.16 || ^23.11 || >=24.10"; + + expect(satisfiesSemverRange("22.16.0", range)).toBe(true); + expect(satisfiesSemverRange("23.11.1", range)).toBe(true); + expect(satisfiesSemverRange("24.10.0", range)).toBe(true); + expect(satisfiesSemverRange("22.15.9", range)).toBe(false); + expect(satisfiesSemverRange("23.10.9", range)).toBe(false); + expect(satisfiesSemverRange("24.9.9", range)).toBe(false); + }); + + it("normalizes versions with a missing patch segment", () => { + expect(normalizeSemverVersion("2.1")).toBe("2.1.0"); + }); + + it("compares prerelease versions before stable versions", () => { + expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); + }); + + it("falls back to lexical comparison for malformed numeric segments", () => { + expect(compareSemverVersions("1.2.3abc", "1.2.10")).toBeGreaterThan(0); + }); + + it("supports comparison comparators", () => { + expect(satisfiesSemverRange("24.9.0", ">=24.0 <24.10")).toBe(true); + expect(satisfiesSemverRange("24.10.0", ">=24.0 <24.10")).toBe(false); + }); + + it("honors caret range upper bounds for zero-major versions", () => { + expect(satisfiesSemverRange("0.2.3", "^0.2.3")).toBe(true); + expect(satisfiesSemverRange("0.2.9", "^0.2.3")).toBe(true); + expect(satisfiesSemverRange("0.3.0", "^0.2.3")).toBe(false); + expect(satisfiesSemverRange("0.5.0", "^0.2.3")).toBe(false); + expect(satisfiesSemverRange("0.0.3", "^0.0.3")).toBe(true); + expect(satisfiesSemverRange("0.0.4", "^0.0.3")).toBe(false); + }); + + it("rejects invalid versions and unsupported range syntax", () => { + expect(satisfiesSemverRange("not-a-version", ">=24.0")).toBe(false); + expect(satisfiesSemverRange("24.10.0", "~24.10")).toBe(false); + }); + + it("keeps the range checker stringifiable and executable as plain JavaScript", () => { + const source = satisfiesSemverRange.toString(); + const recreated = Function(`return (${source});`)() as typeof satisfiesSemverRange; + + expect(source).toContain("function satisfiesSemverRange"); + expect(source).not.toContain(": string"); + expect(source).not.toContain(": boolean"); + expect(recreated("24.10.0", ">=24.10")).toBe(true); + expect(recreated("24.9.9", ">=24.10")).toBe(false); + }); +}); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts new file mode 100644 index 00000000000..de213764504 --- /dev/null +++ b/packages/shared/src/semver.ts @@ -0,0 +1,216 @@ +interface ParsedSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: ReadonlyArray; +} + +const SEMVER_NUMBER_SEGMENT = /^\d+$/; + +export function normalizeSemverVersion(version: string): string { + const [main, prerelease] = version.trim().split("-", 2); + const segments = (main ?? "") + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + + if (segments.length === 2) { + segments.push("0"); + } + + return prerelease ? `${segments.join(".")}-${prerelease}` : segments.join("."); +} + +export function parseSemver(value: string): ParsedSemver | null { + const normalized = normalizeSemverVersion(value).replace(/^v/, ""); + const [main = "", prerelease] = normalized.split("-", 2); + const segments = main.split("."); + if (segments.length !== 3) { + return null; + } + + const [majorSegment, minorSegment, patchSegment] = segments; + if (majorSegment === undefined || minorSegment === undefined || patchSegment === undefined) { + return null; + } + if ( + !SEMVER_NUMBER_SEGMENT.test(majorSegment) || + !SEMVER_NUMBER_SEGMENT.test(minorSegment) || + !SEMVER_NUMBER_SEGMENT.test(patchSegment) + ) { + return null; + } + + const major = Number.parseInt(majorSegment, 10); + const minor = Number.parseInt(minorSegment, 10); + const patch = Number.parseInt(patchSegment, 10); + if (![major, minor, patch].every(Number.isInteger)) { + return null; + } + + return { + major, + minor, + patch, + prerelease: + prerelease + ?.split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) ?? [], + }; +} + +function comparePrereleaseIdentifier(left: string, right: string): number { + const leftNumeric = SEMVER_NUMBER_SEGMENT.test(left); + const rightNumeric = SEMVER_NUMBER_SEGMENT.test(right); + + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) { + return -1; + } + if (rightNumeric) { + return 1; + } + return left.localeCompare(right); +} + +export function compareSemverVersions(left: string, right: string): number { + const parsedLeft = parseSemver(left); + const parsedRight = parseSemver(right); + if (!parsedLeft || !parsedRight) { + return left.localeCompare(right); + } + + if (parsedLeft.major !== parsedRight.major) { + return parsedLeft.major - parsedRight.major; + } + if (parsedLeft.minor !== parsedRight.minor) { + return parsedLeft.minor - parsedRight.minor; + } + if (parsedLeft.patch !== parsedRight.patch) { + return parsedLeft.patch - parsedRight.patch; + } + + if (parsedLeft.prerelease.length === 0 && parsedRight.prerelease.length === 0) { + return 0; + } + if (parsedLeft.prerelease.length === 0) { + return 1; + } + if (parsedRight.prerelease.length === 0) { + return -1; + } + + const length = Math.max(parsedLeft.prerelease.length, parsedRight.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = parsedLeft.prerelease[index]; + const rightIdentifier = parsedRight.prerelease[index]; + if (leftIdentifier === undefined) { + return -1; + } + if (rightIdentifier === undefined) { + return 1; + } + const comparison = comparePrereleaseIdentifier(leftIdentifier, rightIdentifier); + if (comparison !== 0) { + return comparison; + } + } + + return 0; +} + +/** + * Small semver range checker for CLI/runtime gates. + * + * Keep the function body valid plain JavaScript: SSH startup stringifies this + * function and runs it on remote Node versions before TypeScript support is known. + * + * @param rawVersion Version string, with or without a leading `v`. + * @param range Space-separated comparators, with `||` range groups. + * @returns Whether `rawVersion` satisfies the supported range syntax. + */ +export const satisfiesSemverRange: (rawVersion: string, range: string) => boolean = + function satisfiesSemverRange(rawVersion, range) { + const normalizedVersion = String(rawVersion).trim().replace(/^v/, ""); + const versionMatch = normalizedVersion.match( + /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?$/, + ); + if (!versionMatch) { + return false; + } + + const version = { + major: Number(versionMatch[1]), + minor: Number(versionMatch[2] || 0), + patch: Number(versionMatch[3] || 0), + }; + + return range.split("||").some((group) => { + const comparators = group.trim().split(/\s+/).filter(Boolean); + if (comparators.length === 0) { + return false; + } + return comparators.every((comparator) => { + const match = comparator.trim().match(/^(\^|>=|>|<=|<|=)?\s*v?(\d+(?:\.\d+){0,2})$/); + if (!match) { + return false; + } + const targetVersion = match[2]; + if (targetVersion === undefined) { + return false; + } + const targetMatch = targetVersion.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!targetMatch) { + return false; + } + const target = { + major: Number(targetMatch[1]), + minor: Number(targetMatch[2] || 0), + patch: Number(targetMatch[3] || 0), + }; + const compared = + version.major !== target.major + ? version.major > target.major + ? 1 + : -1 + : version.minor !== target.minor + ? version.minor > target.minor + ? 1 + : -1 + : version.patch !== target.patch + ? version.patch > target.patch + ? 1 + : -1 + : 0; + const operator = match[1] || "="; + switch (operator) { + case "^": + if (compared < 0) { + return false; + } + if (target.major > 0) { + return version.major === target.major; + } + if (target.minor > 0) { + return version.major === 0 && version.minor === target.minor; + } + return version.major === 0 && version.minor === 0 && version.patch === target.patch; + case ">=": + return compared >= 0; + case ">": + return compared > 0; + case "<=": + return compared <= 0; + case "<": + return compared < 0; + case "=": + return compared === 0; + default: + return false; + } + }); + }); + }; diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 0219396e7da..80e684d8611 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -20,11 +20,14 @@ import { buildRemoteT3RunnerScript, describeReadinessCause, issueRemotePairingToken, + launchOrReuseRemoteServer, REMOTE_PICK_PORT_SCRIPT, SshEnvironmentManager, waitForHttpReady, } from "./tunnel.ts"; +const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; + const makeSuccessfulProcess = (stdout: string) => { const stdoutStream = Stream.make(new TextEncoder().encode(stdout)); return ChildProcessSpawner.makeHandle({ @@ -87,13 +90,34 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { describe("ssh tunnel scripts", () => { it("builds the remote t3 runner with npx and npm fallbacks", () => { - const script = buildRemoteT3RunnerScript(); + const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include(script, 'exec t3 "$@"'); assert.include(script, "exec npx --yes 't3@latest' \"$@\""); assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); assert.include(script, "could not install 't3@latest'"); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); + assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(script, "remote_node_satisfies_engine()"); + assert.include(script, "function satisfiesSemverRange"); + assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); + assert.include(script, 'eval "$(fnm env --use-on-cd --shell sh)"'); + assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); + assert.include(script, 'NVM_DIR="$HOME/.nvm"'); + assert.include(script, "nvm use --silent default"); + assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); + assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); + }); + + it("does not hard-code a remote node engine range", () => { + const script = buildRemoteT3RunnerScript(); + + assert.include(script, "T3_NODE_ENGINE_RANGE=''"); + assert.notInclude(script, TEST_NODE_ENGINE_RANGE); }); it("shell-quotes package specs in the remote t3 runner", () => { @@ -127,10 +151,20 @@ describe("ssh tunnel scripts", () => { } as const; assert.include( - buildRemoteLaunchScript(), + buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', ); assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); + assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()"); + assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); + assert.include( + buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`, + ); + assert.include( + buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + "does not satisfy required range ", + ); assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteLaunchScript(), "wait_ready"); assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); @@ -156,17 +190,48 @@ describe("ssh tunnel scripts", () => { 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', ); assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); + assert.include( + buildRemoteLaunchScript(), + 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', + ); + assert.include( + buildRemoteLaunchScript(), + "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))", + ); + assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); + assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); + assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); + assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); assert.isBelow( buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), ); assert.isBelow( - buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'), + buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), ); }); + it.effect("accepts launch JSON after remote shell startup noise", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeSuccessfulProcess('loaded nvm default\n{"remotePort":3774}\n')), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); + + return Effect.gen(function* () { + const result = yield* launchOrReuseRemoteServer(target); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); @@ -232,6 +297,34 @@ describe("ssh tunnel scripts", () => { "expiresAt": "2026-04-29T01:01:20.994Z" } +`), + ), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); + return Effect.gen(function* () { + const result = yield* issueRemotePairingToken(target); + assert.equal(result.credential, "LCL4R2TPHDKQ"); + }).pipe(Effect.provide(processLayer)); + }); + + it.effect("accepts pretty-printed pairing JSON after remote shell startup noise", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed( + makeSuccessfulProcess(`loaded nvm default +{ + "id": "88941235-6ed5-4184-a2ff-5339e2075958", + "credential": "LCL4R2TPHDKQ", + "role": "client", + "expiresAt": "2026-04-29T01:01:20.994Z" +} + `), ), ); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index f0e189efaea..5ee5c684779 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -3,7 +3,8 @@ import type { DesktopSshEnvironmentTarget, } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; -import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -58,6 +59,7 @@ const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { readonly packageSpec?: string; readonly nodeScriptPath?: string | null; + readonly nodeEngineRange?: string | null; } export interface SshEnvironmentManagerOptions { @@ -166,6 +168,50 @@ const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunc const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError)); +const decodeRemoteJsonOutput = ( + stdout: string, + decode: (input: string) => Effect.Effect, +): Effect.Effect => + decode(stdout).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const jsonObject = extractJsonObject(stdout); + if (jsonObject === stdout.trim()) { + return yield* Effect.fail(error); + } + const exit = yield* Effect.exit(decode(jsonObject)); + if (Exit.isSuccess(exit)) { + return exit.value; + } + return yield* Effect.fail(error); + }), + ), + ); + +const decodeRemoteLaunchOutput = (stdout: string) => + decodeRemoteJsonOutput(stdout, decodeRemoteLaunchResult); + +const decodeRemotePairingOutput = (stdout: string) => + decodeRemoteJsonOutput(stdout, decodeRemotePairingResult); + +const remoteNodeEngineCheckMain = function remoteNodeEngineCheckMain() { + const range = process.argv[2] || ""; + const rawVersion = + process.versions && process.versions.node ? process.versions.node : process.version; + + if (!satisfiesSemverRange(rawVersion, range)) { + process.stderr.write( + "Remote node " + rawVersion + " does not satisfy required range " + range + ".\n", + ); + process.exit(1); + } +}; + +function buildRemoteNodeEngineCheckScript(): string { + return `${satisfiesSemverRange.toString()} +(${remoteNodeEngineCheckMain.toString()})();`; +} + export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { const cleaned = stderr.trim(); return cleaned.length > 0 ? cleaned : fallbackMessage; @@ -299,10 +345,108 @@ function probe() { })().catch(() => process.exit(1)); `; +export const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { + if [ -d "$1" ]; then + case ":$PATH:" in + *":$1:"*) ;; + *) PATH="$1:$PATH" ;; + esac + fi +} + +remote_node_satisfies_engine() { + T3_NODE_ENGINE_RANGE=@@T3_NODE_ENGINE_RANGE@@ + if [ -z "$T3_NODE_ENGINE_RANGE" ]; then + return 0 + fi + node - "$T3_NODE_ENGINE_RANGE" <<'NODE' +@@T3_NODE_ENGINE_CHECK_SCRIPT@@ +NODE +} + +ensure_remote_node_path() { + if command -v node >/dev/null 2>&1 && remote_node_satisfies_engine >/dev/null 2>&1; then + return 0 + fi + + prepend_path_if_dir "$HOME/.local/bin" + prepend_path_if_dir "$HOME/bin" + prepend_path_if_dir "/opt/homebrew/bin" + prepend_path_if_dir "/usr/local/bin" + prepend_path_if_dir "/usr/bin" + prepend_path_if_dir "/bin" + + if [ -z "\${VOLTA_HOME:-}" ]; then + VOLTA_HOME="$HOME/.volta" + fi + export VOLTA_HOME + prepend_path_if_dir "$VOLTA_HOME/bin" + + prepend_path_if_dir "$HOME/.asdf/shims" + prepend_path_if_dir "$HOME/.asdf/bin" + if [ ! -x "$HOME/.asdf/shims/node" ] && [ -s "$HOME/.asdf/asdf.sh" ]; then + # shellcheck disable=SC1090 + . "$HOME/.asdf/asdf.sh" + fi + + prepend_path_if_dir "$HOME/.local/share/mise/shims" + prepend_path_if_dir "$HOME/.mise/shims" + if ! command -v node >/dev/null 2>&1 && command -v mise >/dev/null 2>&1; then + eval "$(mise activate sh)" >/dev/null 2>&1 || true + fi + + if [ -z "\${FNM_DIR:-}" ]; then + FNM_DIR="$HOME/.local/share/fnm" + fi + export FNM_DIR + prepend_path_if_dir "$FNM_DIR" + prepend_path_if_dir "$HOME/.fnm" + if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then + eval "$(fnm env --use-on-cd --shell sh)" >/dev/null 2>&1 || eval "$(fnm env --shell sh)" >/dev/null 2>&1 || true + fi + + prepend_path_if_dir "$HOME/.nodenv/bin" + prepend_path_if_dir "$HOME/.nodenv/shims" + if ! command -v node >/dev/null 2>&1 && command -v nodenv >/dev/null 2>&1; then + eval "$(nodenv init -)" >/dev/null 2>&1 || true + fi + + if [ -z "\${NVM_DIR:-}" ]; then + NVM_DIR="$HOME/.nvm" + fi + export NVM_DIR + + if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck disable=SC1090 + . "$NVM_DIR/nvm.sh" + if ! command -v node >/dev/null 2>&1 && command -v nvm >/dev/null 2>&1; then + 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 + fi + fi + + if ! command -v node >/dev/null 2>&1 && [ -d "$NVM_DIR/versions/node" ]; then + for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin; do + if [ -x "$T3_NODE_BIN/node" ]; then + PATH="$T3_NODE_BIN:$PATH" + export PATH + fi + done + fi + + command -v node >/dev/null 2>&1 && remote_node_satisfies_engine +} +`; + export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu +@@T3_NODE_ENV_SCRIPT@@ +ensure_remote_node_path || true T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ if [ -n "$T3_NODE_SCRIPT_PATH" ]; then + if ! command -v node >/dev/null 2>&1; then + printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 + exit 1 + fi exec node "$T3_NODE_SCRIPT_PATH" "$@" fi if command -v t3 >/dev/null 2>&1; then @@ -314,11 +458,12 @@ fi if command -v npm >/dev/null 2>&1; then exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" fi -printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\\n' >&2 +printf '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 exit 1 `; export const REMOTE_LAUNCH_SCRIPT = `set -eu +@@T3_NODE_ENV_SCRIPT@@ STATE_KEY="$1" STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" DEFAULT_SERVER_HOME="$HOME/.t3" @@ -343,6 +488,10 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then fi mv "$RUNNER_NEXT" "$RUNNER_FILE" chmod 700 "$RUNNER_FILE" +if ! ensure_remote_node_path; then + printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 + exit 1 +fi pick_port() { node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' @@T3_PICK_PORT_SCRIPT@@ @@ -366,18 +515,18 @@ resolve_default_runtime_port() { const fs = require("node:fs"); const runtimePath = process.argv[2] ?? ""; try { - const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); - const pid = Number(runtime.pid); - const port = Number(runtime.port); - if (!Number.isInteger(pid) || !Number.isInteger(port)) { - process.exit(1); - } + const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { + process.exit(1); + } const origin = new URL(String(runtime.origin ?? "")); if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { process.exit(1); } process.kill(pid, 0); - process.stdout.write(String(port)); + process.stdout.write(\`\${pid} \${port}\`); } catch { process.exit(1); } @@ -386,18 +535,34 @@ NODE REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" -DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port 2>/dev/null || true)" +DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" +DEFAULT_RUNTIME_PID="" +DEFAULT_REMOTE_PORT="" +if [ -n "$DEFAULT_RUNTIME_INFO" ]; then + DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" + DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" +fi if [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_PORT="$DEFAULT_REMOTE_PORT" if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then - if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then - kill "$REMOTE_PID" 2>/dev/null || true - wait_for_pid_exit "$REMOTE_PID" + if [ "$REMOTE_MANAGED" = "managed" ]; then + PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" + if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then + kill "$PID_TO_STOP" 2>/dev/null || true + wait_for_pid_exit "$PID_TO_STOP" + fi + REMOTE_PID="" + REMOTE_PORT="$DEFAULT_REMOTE_PORT" + REMOTE_MANAGED="external" + rm -f "$PID_FILE" + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + printf 'external\\n' >"$MANAGED_FILE" + else + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + printf 'external\\n' >"$MANAGED_FILE" + REMOTE_PID="" + REMOTE_MANAGED="external" fi - printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" - printf 'external\\n' >"$MANAGED_FILE" - REMOTE_PID="" - REMOTE_MANAGED="external" else REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" @@ -429,7 +594,7 @@ else REMOTE_PORT="" REMOTE_MANAGED="" fi -if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then +if [ -z "$REMOTE_PORT" ]; then REMOTE_PORT="$(pick_port)" || true if [ -z "$REMOTE_PORT" ]; then printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 @@ -499,12 +664,23 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { T3_PACKAGE_SPEC: packageSpec, T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), + T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), + }), + ); +} + +function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string { + return stripTrailingNewlines( + applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, { + T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""), + T3_NODE_ENGINE_CHECK_SCRIPT: stripTrailingNewlines(buildRemoteNodeEngineCheckScript()), }), ); } export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { + T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), @@ -566,7 +742,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo stdout: result.stdout, }); } - const parsed = yield* decodeRemoteLaunchResult(result.stdout).pipe( + const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe( Effect.mapError( (cause) => new SshLaunchError({ @@ -623,7 +799,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT stdout: result.stdout, }); } - const parsed = yield* decodeRemotePairingResult(result.stdout).pipe( + const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe( Effect.mapError( (cause) => new SshPairingError({