diff --git a/REMOTE.md b/REMOTE.md index 61a1cc3b5cc9052f617c475cc5c42ea47142d4e8..56510e62890fa42c2a912ee85a0bdaa77b462ca4 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 f71ff1fbe67871c878e5d43e9de9eed7b61e3fc4..0bc1badff2d1882e2dbf3eb2966edbe0f83d09a9 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 27bff1b0b698d2d98fc688dd1d5a2c8210634fe7..f787129af89ec0603174e647a9ca6a9e80eb125c 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 b247e8586c0e7aa8877d7e562c0b761e06c523d4..dea95c990d2aa90f311d6d9ca16483ea11429be5 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 ffb42cf5ccdb616b718f434fb1db707061fb3307..0000000000000000000000000000000000000000 --- 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 6308a2ff52585d829c8f3a1cb76468bfc9541a2b..0000000000000000000000000000000000000000 --- 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 a733506766df554f3a2fef2fef8e2c9bb8bc4337..7f5e9d94dc5f531163f5c977187a8095e57737b5 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 22b4924a19f68a369b5372094ff953cd9c3e06ea..c4ef1af21d107a84409a8ecce1322eab0916932a 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 df68ca3fee01063f74af7a747f35c921d75f871e..b865b2e5ef57e4c5e44f8d364faee1b0deffff09 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 f1bc0cb9ddc2b11f3a7e339a177d40283f0747b9..a786f81b2c880d720c4153c72e31b6428e29ef97 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -1,6 +1,6 @@ -import * as Schema from "effect/Schema"; - import { TextGenerationError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + 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 f410a98b381ccd667645ff964ea40040adbc1f40..c499bf3c6ec7fdbb484bfe524a208b6b5d5d9601 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 0000000000000000000000000000000000000000..7f3db7034b58832090bab2032ed925c7770601e2 --- /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 32abdcd7234ec25a77d2f861f5f8edfab4f64005..0d879913ebf6a4b88ee08e23a83afd50c3318fde 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 0000000000000000000000000000000000000000..b3c629740a7fb69f2935ac4d22829d89fd96e8dc --- /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 0000000000000000000000000000000000000000..de213764504e92afd3f4b3cd5e2665231e3f0492 --- /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 0219396e7dac3c6af8937d1c54e860e1f8194f5d..80e684d8611f0ed3973b07966ebacb676c044980 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 f0e189efaeae82301996fefa6b63a0dcbd267674..5ee5c684779964dcb46487fa69451c8e959f77ce 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({