| author | |
| committer | |
| log | 932df4ed17ff9465053e3a1bbfdd6d2ba23500f0 |
| tree | da6f833ed955a6fcb44f4f0adf152c684e2e8fe3 |
| parent | 63859aa072249c64153ef20faeb46018154724b8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 115 | |
| 116 | 116 | 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. |
| 117 | 117 | |
| 118 | #### SSH Launch Troubleshooting | |
| 119 | ||
| 120 | The 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 | ||
| 122 | The 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 | ||
| 128 | 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: | |
| 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 | ||
| 139 | 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: | |
| 140 | ||
| 141 | ```bash | |
| 142 | ssh user@example.com 'sh -lc "command -v node && node --version"' | |
| 143 | ``` | |
| 144 | ||
| 145 | 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: | |
| 146 | ||
| 147 | ```bash | |
| 148 | nvm alias default 24 | |
| 149 | ``` | |
| 150 | ||
| 151 | With mise/asdf/fnm/nodenv, make sure the tool's shim directory is installed and points at a Node version satisfying the range above. | |
| 152 | ||
| 153 | 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. | |
| 154 | ||
| 118 | 155 | ## How Pairing Works |
| 119 | 156 | |
| 120 | 157 | The 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 | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 12 | 12 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 13 | 13 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 14 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | |
| 14 | 15 | |
| 15 | 16 | import type { DesktopSettings as DesktopSettingsValue } from "./settings/DesktopAppSettings.ts"; |
| 16 | 17 | import * as DesktopIpc from "./ipc/DesktopIpc.ts"; |
| ... | ... | @@ -65,7 +66,10 @@ const resolveDesktopSshCliRunner = ( |
| 65 | 66 | ): RemoteT3RunnerOptions => { |
| 66 | 67 | const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); |
| 67 | 68 | if (environment.isDevelopment && devRemoteEntryPath !== undefined) { |
| 68 | return { nodeScriptPath: devRemoteEntryPath }; | |
| 69 | return { | |
| 70 | nodeScriptPath: devRemoteEntryPath, | |
| 71 | nodeEngineRange: serverPackageJson.engines.node, | |
| 72 | }; | |
| 69 | 73 | } |
| 70 | 74 | return { |
| 71 | 75 | packageSpec: resolveRemoteT3CliPackageSpec({ |
| ... | ... | @@ -73,6 +77,7 @@ const resolveDesktopSshCliRunner = ( |
| 73 | 77 | updateChannel: settings.updateChannel, |
| 74 | 78 | isDevelopment: environment.isDevelopment, |
| 75 | 79 | }), |
| 80 | nodeEngineRange: serverPackageJson.engines.node, | |
| 76 | 81 | }; |
| 77 | 82 | }; |
| 78 | 83 |
apps/server/src/provider/Layers/ClaudeProvider.ts+2-2| ... | ... | @@ -18,6 +18,7 @@ import { |
| 18 | 18 | getProviderOptionCurrentValue, |
| 19 | 19 | getProviderOptionDescriptors, |
| 20 | 20 | } from "@t3tools/shared/model"; |
| 21 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 21 | 22 | import { |
| 22 | 23 | query as claudeQuery, |
| 23 | 24 | type SlashCommand as ClaudeSlashCommand, |
| ... | ... | @@ -36,7 +37,6 @@ import { |
| 36 | 37 | spawnAndCollect, |
| 37 | 38 | type ServerProviderDraft, |
| 38 | 39 | } from "../providerSnapshot.ts"; |
| 39 | import { compareCliVersions } from "../cliVersion.ts"; | |
| 40 | 40 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 41 | 41 | |
| 42 | 42 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| ... | ... | @@ -180,7 +180,7 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [ |
| 180 | 180 | ]; |
| 181 | 181 | |
| 182 | 182 | function 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 | } |
| 185 | 185 | |
| 186 | 186 | function getBuiltInClaudeModelsForVersion( |
apps/server/src/provider/Layers/OpenCodeProvider.ts+2-2| ... | ... | @@ -10,6 +10,7 @@ import * as DateTime from "effect/DateTime"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | |
| 12 | 12 | import { createModelCapabilities } from "@t3tools/shared/model"; |
| 13 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 13 | 14 | import { |
| 14 | 15 | buildServerProvider, |
| 15 | 16 | nonEmptyTrimmed, |
| ... | ... | @@ -17,7 +18,6 @@ import { |
| 17 | 18 | providerModelsFromSettings, |
| 18 | 19 | type ServerProviderDraft, |
| 19 | 20 | } from "../providerSnapshot.ts"; |
| 20 | import { compareCliVersions } from "../cliVersion.ts"; | |
| 21 | 21 | import { |
| 22 | 22 | OpenCodeRuntime, |
| 23 | 23 | openCodeRuntimeErrorDetail, |
| ... | ... | @@ -383,7 +383,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu |
| 383 | 383 | null, |
| 384 | 384 | ); |
| 385 | 385 | } |
| 386 | if (compareCliVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { | |
| 386 | if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { | |
| 387 | 387 | return buildServerProvider({ |
| 388 | 388 | presentation: OPENCODE_PRESENTATION, |
| 389 | 389 | enabled: openCodeSettings.enabled, |
apps/server/src/provider/cliVersion.test.ts deleted-17| ... | ... | @@ -1,17 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { compareCliVersions, normalizeCliVersion } from "./cliVersion.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | interface ParsedCliSemver { | |
| 2 | readonly major: number; | |
| 3 | readonly minor: number; | |
| 4 | readonly patch: number; | |
| 5 | readonly prerelease: ReadonlyArray<string>; | |
| 6 | } | |
| 7 | ||
| 8 | const CLI_VERSION_NUMBER_SEGMENT = /^\d+$/; | |
| 9 | ||
| 10 | export 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 | ||
| 24 | function 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 | ||
| 63 | function 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 | ||
| 79 | export 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 | 3 | type ServerProvider, |
| 4 | 4 | type ServerProviderVersionAdvisory, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | import { compareSemverVersions } from "@t3tools/shared/semver"; | |
| 6 | 7 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 7 | 8 | import * as DateTime from "effect/DateTime"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -11,8 +12,6 @@ import * as Option from "effect/Option"; |
| 11 | 12 | import * as Schema from "effect/Schema"; |
| 12 | 13 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 13 | 14 | |
| 14 | import { compareCliVersions } from "./cliVersion.ts"; | |
| 15 | ||
| 16 | 15 | const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; |
| 17 | 16 | const LATEST_VERSION_TIMEOUT_MS = 4_000; |
| 18 | 17 | const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; |
| ... | ... | @@ -365,7 +364,7 @@ function deriveVersionAdvisory(input: { |
| 365 | 364 | if (!input.latestVersion) { |
| 366 | 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 | 368 | return { |
| 370 | 369 | status: "behind_latest", |
| 371 | 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 | |
| 7 | 7 | import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; |
| 8 | 8 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 9 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | |
| 9 | 10 | |
| 10 | 11 | import { TextGenerationError } from "@t3tools/contracts"; |
| 11 | 12 | import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; |
| ... | ... | @@ -16,7 +17,6 @@ import { |
| 16 | 17 | buildThreadTitlePrompt, |
| 17 | 18 | } from "./TextGenerationPrompts.ts"; |
| 18 | 19 | import { |
| 19 | extractJsonObject, | |
| 20 | 20 | sanitizeCommitSubject, |
| 21 | 21 | sanitizePrTitle, |
| 22 | 22 | sanitizeThreadTitle, |
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1-1| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | } from "@t3tools/contracts"; |
| 14 | 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 15 | 15 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 16 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | |
| 16 | 17 | |
| 17 | 18 | import { ServerConfig } from "../config.ts"; |
| 18 | 19 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| ... | ... | @@ -24,7 +25,6 @@ import { |
| 24 | 25 | } from "./TextGenerationPrompts.ts"; |
| 25 | 26 | import { type TextGenerationShape } from "./TextGeneration.ts"; |
| 26 | 27 | import { |
| 27 | extractJsonObject, | |
| 28 | 28 | sanitizeCommitSubject, |
| 29 | 29 | sanitizePrTitle, |
| 30 | 30 | sanitizeThreadTitle, |
apps/server/src/textGeneration/TextGenerationUtils.ts+1-49| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 1 | 2 | import * as Schema from "effect/Schema"; |
| 2 | 3 | |
| 3 | import { TextGenerationError } from "@t3tools/contracts"; | |
| 4 | 4 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 5 | 5 | |
| 6 | 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 | 19 | return `${truncated}\n\n[truncated]`; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | export 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 | 22 | /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ |
| 71 | 23 | export function sanitizeCommitSubject(raw: string): string { |
| 72 | 24 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; |
packages/shared/package.json+4| ... | ... | @@ -28,6 +28,10 @@ |
| 28 | 28 | "types": "./src/shell.ts", |
| 29 | 29 | "import": "./src/shell.ts" |
| 30 | 30 | }, |
| 31 | "./semver": { | |
| 32 | "types": "./src/semver.ts", | |
| 33 | "import": "./src/semver.ts" | |
| 34 | }, | |
| 31 | 35 | "./Net": { |
| 32 | 36 | "types": "./src/Net.ts", |
| 33 | 37 | "import": "./src/Net.ts" |
packages/shared/src/schemaJson.test.ts created+31| ... | ... | @@ -0,0 +1,31 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { extractJsonObject } from "./schemaJson.ts"; | |
| 4 | ||
| 5 | describe("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 | \`\`\` | |
| 15 | Done.`), | |
| 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 | 97 | export const fromLenientJson = <S extends Schema.Top>(schema: S) => |
| 98 | 98 | Schema.String.pipe(Schema.decodeTo(schema, fromLenientJsonString)); |
| 99 | 99 | |
| 100 | export 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 | 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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | interface ParsedSemver { | |
| 2 | readonly major: number; | |
| 3 | readonly minor: number; | |
| 4 | readonly patch: number; | |
| 5 | readonly prerelease: ReadonlyArray<string>; | |
| 6 | } | |
| 7 | ||
| 8 | const SEMVER_NUMBER_SEGMENT = /^\d+$/; | |
| 9 | ||
| 10 | export 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 | ||
| 24 | export 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 | ||
| 63 | function 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 | ||
| 79 | export 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 | */ | |
| 135 | export 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 | 20 | buildRemoteT3RunnerScript, |
| 21 | 21 | describeReadinessCause, |
| 22 | 22 | issueRemotePairingToken, |
| 23 | launchOrReuseRemoteServer, | |
| 23 | 24 | REMOTE_PICK_PORT_SCRIPT, |
| 24 | 25 | SshEnvironmentManager, |
| 25 | 26 | waitForHttpReady, |
| 26 | 27 | } from "./tunnel.ts"; |
| 27 | 28 | |
| 29 | const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; | |
| 30 | ||
| 28 | 31 | const makeSuccessfulProcess = (stdout: string) => { |
| 29 | 32 | const stdoutStream = Stream.make(new TextEncoder().encode(stdout)); |
| 30 | 33 | return ChildProcessSpawner.makeHandle({ |
| ... | ... | @@ -87,13 +90,34 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> { |
| 87 | 90 | |
| 88 | 91 | describe("ssh tunnel scripts", () => { |
| 89 | 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 }); | |
| 91 | 94 | |
| 92 | 95 | assert.include(script, "T3_NODE_SCRIPT_PATH=''"); |
| 93 | 96 | assert.include(script, 'exec t3 "$@"'); |
| 94 | 97 | assert.include(script, "exec npx --yes 't3@latest' \"$@\""); |
| 95 | 98 | assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); |
| 96 | 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 | }); |
| 98 | 122 | |
| 99 | 123 | it("shell-quotes package specs in the remote t3 runner", () => { |
| ... | ... | @@ -127,10 +151,20 @@ describe("ssh tunnel scripts", () => { |
| 127 | 151 | } as const; |
| 128 | 152 | |
| 129 | 153 | assert.include( |
| 130 | buildRemoteLaunchScript(), | |
| 154 | buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), | |
| 131 | 155 | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', |
| 132 | 156 | ); |
| 133 | 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 | 168 | assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); |
| 135 | 169 | assert.include(buildRemoteLaunchScript(), "wait_ready"); |
| 136 | 170 | assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); |
| ... | ... | @@ -156,17 +190,48 @@ describe("ssh tunnel scripts", () => { |
| 156 | 190 | 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', |
| 157 | 191 | ); |
| 158 | 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 | 204 | assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); |
| 205 | assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); | |
| 160 | 206 | assert.isBelow( |
| 161 | 207 | buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), |
| 162 | 208 | buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), |
| 163 | 209 | ); |
| 164 | 210 | assert.isBelow( |
| 165 | buildRemoteLaunchScript().indexOf('DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port'), | |
| 211 | buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), | |
| 166 | 212 | buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), |
| 167 | 213 | ); |
| 168 | 214 | }); |
| 169 | 215 | |
| 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 | 235 | it("allows the remote port picker to run without a state file path", () => { |
| 171 | 236 | assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); |
| 172 | 237 | }); |
| ... | ... | @@ -232,6 +297,34 @@ describe("ssh tunnel scripts", () => { |
| 232 | 297 | "expiresAt": "2026-04-29T01:01:20.994Z" |
| 233 | 298 | } |
| 234 | 299 | |
| 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 | 3 | DesktopSshEnvironmentTarget, |
| 4 | 4 | } from "@t3tools/contracts"; |
| 5 | 5 | import * as NetService from "@t3tools/shared/Net"; |
| 6 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 6 | import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; | |
| 7 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | |
| 7 | 8 | import * as Context from "effect/Context"; |
| 8 | 9 | import * as Deferred from "effect/Deferred"; |
| 9 | 10 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -58,6 +59,7 @@ const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; |
| 58 | 59 | export interface RemoteT3RunnerOptions { |
| 59 | 60 | readonly packageSpec?: string; |
| 60 | 61 | readonly nodeScriptPath?: string | null; |
| 62 | readonly nodeEngineRange?: string | null; | |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | 65 | export interface SshEnvironmentManagerOptions { |
| ... | ... | @@ -166,6 +168,50 @@ const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunc |
| 166 | 168 | const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult)); |
| 167 | 169 | const decodeRemoteHttpError = Schema.decodeEffect(Schema.fromJsonString(RemoteHttpError)); |
| 168 | 170 | |
| 171 | const 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 | ||
| 191 | const decodeRemoteLaunchOutput = (stdout: string) => | |
| 192 | decodeRemoteJsonOutput(stdout, decodeRemoteLaunchResult); | |
| 193 | ||
| 194 | const decodeRemotePairingOutput = (stdout: string) => | |
| 195 | decodeRemoteJsonOutput(stdout, decodeRemotePairingResult); | |
| 196 | ||
| 197 | const 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 | ||
| 210 | function buildRemoteNodeEngineCheckScript(): string { | |
| 211 | return `${satisfiesSemverRange.toString()} | |
| 212 | (${remoteNodeEngineCheckMain.toString()})();`; | |
| 213 | } | |
| 214 | ||
| 169 | 215 | export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { |
| 170 | 216 | const cleaned = stderr.trim(); |
| 171 | 217 | return cleaned.length > 0 ? cleaned : fallbackMessage; |
| ... | ... | @@ -299,10 +345,108 @@ function probe() { |
| 299 | 345 | })().catch(() => process.exit(1)); |
| 300 | 346 | `; |
| 301 | 347 | |
| 348 | export 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 | ||
| 357 | remote_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@@ | |
| 364 | NODE | |
| 365 | } | |
| 366 | ||
| 367 | ensure_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 | ||
| 302 | 440 | export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh |
| 303 | 441 | set -eu |
| 442 | @@T3_NODE_ENV_SCRIPT@@ | |
| 443 | ensure_remote_node_path || true | |
| 304 | 444 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ |
| 305 | 445 | if [ -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 | 450 | exec node "$T3_NODE_SCRIPT_PATH" "$@" |
| 307 | 451 | fi |
| 308 | 452 | if command -v t3 >/dev/null 2>&1; then |
| ... | ... | @@ -314,11 +458,12 @@ fi |
| 314 | 458 | if command -v npm >/dev/null 2>&1; then |
| 315 | 459 | exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" |
| 316 | 460 | fi |
| 317 | 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 | |
| 461 | 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 | |
| 318 | 462 | exit 1 |
| 319 | 463 | `; |
| 320 | 464 | |
| 321 | 465 | export const REMOTE_LAUNCH_SCRIPT = `set -eu |
| 466 | @@T3_NODE_ENV_SCRIPT@@ | |
| 322 | 467 | STATE_KEY="$1" |
| 323 | 468 | STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" |
| 324 | 469 | DEFAULT_SERVER_HOME="$HOME/.t3" |
| ... | ... | @@ -343,6 +488,10 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then |
| 343 | 488 | fi |
| 344 | 489 | mv "$RUNNER_NEXT" "$RUNNER_FILE" |
| 345 | 490 | chmod 700 "$RUNNER_FILE" |
| 491 | if ! 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 | |
| 494 | fi | |
| 346 | 495 | pick_port() { |
| 347 | 496 | node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' |
| 348 | 497 | @@T3_PICK_PORT_SCRIPT@@ |
| ... | ... | @@ -366,18 +515,18 @@ resolve_default_runtime_port() { |
| 366 | 515 | const fs = require("node:fs"); |
| 367 | 516 | const runtimePath = process.argv[2] ?? ""; |
| 368 | 517 | try { |
| 369 | const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); | |
| 370 | const pid = Number(runtime.pid); | |
| 371 | const port = Number(runtime.port); | |
| 372 | if (!Number.isInteger(pid) || !Number.isInteger(port)) { | |
| 373 | process.exit(1); | |
| 374 | } | |
| 518 | 	 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); | |
| 519 | 	 const pid = Number(runtime.pid); | |
| 520 | 	 const port = Number(runtime.port); | |
| 521 | 	 if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) { | |
| 522 | 	 process.exit(1); | |
| 523 | 	 } | |
| 375 | 524 | const origin = new URL(String(runtime.origin ?? "")); |
| 376 | 525 | if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { |
| 377 | 526 | process.exit(1); |
| 378 | 527 | } |
| 379 | 528 | process.kill(pid, 0); |
| 380 | process.stdout.write(String(port)); | |
| 529 | process.stdout.write(\`\${pid} \${port}\`); | |
| 381 | 530 | } catch { |
| 382 | 531 | process.exit(1); |
| 383 | 532 | } |
| ... | ... | @@ -386,18 +535,34 @@ NODE |
| 386 | 535 | REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" |
| 387 | 536 | REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" |
| 388 | 537 | REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" |
| 389 | DEFAULT_REMOTE_PORT="$(resolve_default_runtime_port 2>/dev/null || true)" | |
| 538 | DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" | |
| 539 | DEFAULT_RUNTIME_PID="" | |
| 540 | DEFAULT_REMOTE_PORT="" | |
| 541 | if [ -n "$DEFAULT_RUNTIME_INFO" ]; then | |
| 542 | DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" | |
| 543 | DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" | |
| 544 | fi | |
| 390 | 545 | if [ -n "$DEFAULT_REMOTE_PORT" ]; then |
| 391 | 546 | REMOTE_PORT="$DEFAULT_REMOTE_PORT" |
| 392 | 547 | if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then |
| 393 | if [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then | |
| 394 | kill "$REMOTE_PID" 2>/dev/null || true | |
| 395 | wait_for_pid_exit "$REMOTE_PID" | |
| 548 | if [ "$REMOTE_MANAGED" = "managed" ]; then | |
| 549 | PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" | |
| 550 | if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then | |
| 551 | kill "$PID_TO_STOP" 2>/dev/null || true | |
| 552 | wait_for_pid_exit "$PID_TO_STOP" | |
| 553 | fi | |
| 554 | REMOTE_PID="" | |
| 555 | REMOTE_PORT="$DEFAULT_REMOTE_PORT" | |
| 556 | REMOTE_MANAGED="external" | |
| 557 | rm -f "$PID_FILE" | |
| 558 | printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" | |
| 559 | printf 'external\\n' >"$MANAGED_FILE" | |
| 560 | else | |
| 561 | printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" | |
| 562 | printf 'external\\n' >"$MANAGED_FILE" | |
| 563 | REMOTE_PID="" | |
| 564 | REMOTE_MANAGED="external" | |
| 396 | 565 | fi |
| 397 | printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" | |
| 398 | printf 'external\\n' >"$MANAGED_FILE" | |
| 399 | REMOTE_PID="" | |
| 400 | REMOTE_MANAGED="external" | |
| 401 | 566 | else |
| 402 | 567 | REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" |
| 403 | 568 | REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" |
| ... | ... | @@ -429,7 +594,7 @@ else |
| 429 | 594 | REMOTE_PORT="" |
| 430 | 595 | REMOTE_MANAGED="" |
| 431 | 596 | fi |
| 432 | if [ -z "$REMOTE_PID" ] || [ -z "$REMOTE_PORT" ]; then | |
| 597 | if [ -z "$REMOTE_PORT" ]; then | |
| 433 | 598 | REMOTE_PORT="$(pick_port)" || true |
| 434 | 599 | if [ -z "$REMOTE_PORT" ]; then |
| 435 | 600 | 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 | 664 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { |
| 500 | 665 | T3_PACKAGE_SPEC: packageSpec, |
| 501 | 666 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), |
| 667 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), | |
| 668 | }), | |
| 669 | ); | |
| 670 | } | |
| 671 | ||
| 672 | function 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 | } |
| 505 | 680 | |
| 506 | 681 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { |
| 507 | 682 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { |
| 683 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), | |
| 508 | 684 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), |
| 509 | 685 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), |
| 510 | 686 | T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), |
| ... | ... | @@ -566,7 +742,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo |
| 566 | 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 | 746 | Effect.mapError( |
| 571 | 747 | (cause) => |
| 572 | 748 | new SshLaunchError({ |
| ... | ... | @@ -623,7 +799,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT |
| 623 | 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 | 803 | Effect.mapError( |
| 628 | 804 | (cause) => |
| 629 | 805 | new SshPairingError({ |