diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0b273fe7822a5c7271c2a766a60dbc6a189d0c9e..0d626a51955dd9e63038fc77ff0769a989d4463f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,8 +17,6 @@ import * as Electron from "electron"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; -import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; import serverPackageJson from "../../server/package.json" with { type: "json" }; @@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( }), ); +// The remote runs the exact release this app is on, from its self-contained +// archive, so it needs neither Node nor npm. Development points the remote at +// a source checkout instead so the two sides can be iterated together. const resolveDesktopSshCliRunner = ( environment: DesktopEnvironment.DesktopEnvironment["Service"], - settings: DesktopAppSettings.DesktopSettings, ): RemoteT3RunnerOptions => { const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); if (environment.isDevelopment && devRemoteEntryPath !== undefined) { @@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = ( nodeEngineRange: serverPackageJson.engines.node, }; } - // Preview builds ship as self-contained archives, so the remote runs the - // same version this app is on without Node or npm. - if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { - return { archiveVersion: environment.appVersion }; - } - return { - packageSpec: resolveRemoteT3CliPackageSpec({ - appVersion: environment.appVersion, - updateChannel: settings.updateChannel, - isDevelopment: environment.isDevelopment, - }), - nodeEngineRange: serverPackageJson.engines.node, - }; + return { archiveVersion: environment.appVersion }; }; const desktopSshEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const settings = yield* DesktopAppSettings.DesktopAppSettings; return DesktopSshEnvironment.layer({ - resolveCliRunner: settings.get.pipe( - Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), - ), + resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), }); }), ); diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 3b135eb2508f01bca53fc0246b14763a30993034..b0094f6867bc00c1599bb3c5ae55b0bef3e1e45b 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} export interface DesktopSshEnvironmentLayerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ - ...(options.resolveCliPackageSpec === undefined + SshTunnel.SshEnvironmentManager.layer( + options.resolveCliRunner === undefined ? {} - : { resolveCliPackageSpec: options.resolveCliPackageSpec }), - ...(options.resolveCliRunner === undefined - ? {} - : { resolveCliRunner: options.resolveCliRunner }), - }), + : { resolveCliRunner: options.resolveCliRunner }, + ), ), ); diff --git a/apps/marketing/.gitignore b/apps/marketing/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..254b88f1a73ffd739e47bbeaac5754ce93f5af68 --- /dev/null +++ b/apps/marketing/.gitignore @@ -0,0 +1,2 @@ +/public/install.sh +/public/install.ps1 diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 80a36fc54af4688d2af40680e3e987d54a7f927c..511552d74fbd5b9ec702dc169a37a11b834ef763 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -4,8 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "astro dev", - "build": "astro build", + "stage:install-scripts": "node scripts/stage-install-scripts.mjs", + "dev": "node scripts/stage-install-scripts.mjs && astro dev", + "build": "node scripts/stage-install-scripts.mjs && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/marketing/public/install.ps1 b/apps/marketing/public/install.ps1 deleted file mode 100644 index 4aa46d327b876c70c722d037d0b3259075bdec21..0000000000000000000000000000000000000000 --- a/apps/marketing/public/install.ps1 +++ /dev/null @@ -1,120 +0,0 @@ -# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs -# only PowerShell 5.1+; no Node, npm, or compiler. -# -# irm https://t3.codes/install.ps1 | iex -# -# Environment: -# T3CODE_CHANNEL release train to follow: stable, nightly, or preview -# (default: stable; preview is a maintainers' test train) -# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) -# T3CODE_HOME T3 home directory (default: ~\.t3) -# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) -# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) -# -# The archive is unpacked into $T3CODE_HOME\runtime\versions\, the -# same layout `t3 service install` uses, so the service reuses this download. -$ErrorActionPreference = "Stop" -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -$repo = "pingdotgg/t3code" -$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } -$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } -$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } - -function Fail([string] $message) { - Write-Error "t3 install: $message" - exit 1 -} - -# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell -# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts -# may lack. -$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } -$arch = switch ($rawArch) { - "AMD64" { "x64" } - "ARM64" { "arm64" } - default { Fail "unsupported architecture $rawArch" } -} - -$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } -$version = $env:T3CODE_VERSION -if (-not $version) { - # Tags are v; the channel is the prerelease identifier, or none for - # stable. Only tags of the requested train are considered, so a stable - # install can never pick up a nightly or preview build by accident. - $tagPattern = switch ($channel) { - "stable" { '^v\d+\.\d+\.\d+$' } - "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } - "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } - default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } - } - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } - $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name - if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } - $version = $tag.Substring(1) -} -if ($version -match '-preview\.') { - Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." - if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { - Fail "refusing a preview build that was not explicitly requested" - } -} - -$stem = "t3-$version-win32-$arch" -$archive = "$stem.zip" -$versionsDir = Join-Path $t3Home "runtime\versions" -$targetDir = Join-Path $versionsDir $version -$marker = Join-Path $targetDir ".install-complete" - -if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { - Write-Host "t3 $version is already installed at $targetDir" -} else { - New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null - $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) - New-Item -ItemType Directory -Path $staging | Out-Null - try { - Write-Host "Downloading $archive..." - try { - Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing - } catch { - $status = $_.Exception.Response.StatusCode.value__ - if ($status -eq 404) { - Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead" - } - throw - } - Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing - - $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) - if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } - $expected = ($expected -split "\s+")[0].ToLowerInvariant() - $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() - if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } - - Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force - # The archive wraps everything in one directory named after its stem. - Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging - Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force - - & (Join-Path $staging "t3.exe") --version | Out-Null - if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } - Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline - - if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } - Move-Item $staging $targetDir - } catch { - if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } - throw - } -} - -New-Item -ItemType Directory -Force -Path $binDir | Out-Null -$shim = Join-Path $binDir "t3.cmd" -# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt -# non-ASCII characters in the user's home path. -[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) -Write-Host "Installed t3 $version" -Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" -if (($env:PATH -split ";") -notcontains $binDir) { - Write-Host "Add $binDir to your PATH to run t3." -} diff --git a/apps/marketing/public/install.sh b/apps/marketing/public/install.sh deleted file mode 100755 index 19263563402a30aa92362a22f962e4ab7330005a..0000000000000000000000000000000000000000 --- a/apps/marketing/public/install.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/bin/sh -# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, -# sha256sum or shasum, and curl or wget; no Node, npm, or compiler. -# -# curl -fsSL https://t3.codes/install.sh | sh -# -# Environment: -# T3CODE_CHANNEL release train to follow: stable, nightly, or preview -# (default: stable; preview is a maintainers' test train) -# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) -# T3CODE_HOME T3 home directory (default: ~/.t3) -# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) -# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) -# -# The archive is unpacked into $T3CODE_HOME/runtime/versions/, the -# same layout `t3 service install` uses, so the service reuses this download -# instead of fetching the release again. -set -eu - -repo="pingdotgg/t3code" -base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" -t3_home="${T3CODE_HOME:-$HOME/.t3}" -bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" - -fail() { - printf 't3 install: %s\n' "$1" >&2 - exit 1 -} - -# Exit 44 on a 404 so callers can tell "no such asset" from a network failure. -fetch() { - if command -v curl >/dev/null 2>&1; then - status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 - case "$status" in - 2??) return 0 ;; - 404) return 44 ;; - *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; - esac - elif command -v wget >/dev/null 2>&1; then - wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 - if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi - cat "$2.headers" >&2; rm -f "$2.headers"; return 1 - else - fail "curl or wget is required" - fi -} - -case "$(uname -s)" in - Darwin) platform="darwin" ;; - Linux) platform="linux" ;; - *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; -esac -case "$(uname -m)" in - arm64 | aarch64) arch="arm64" ;; - x86_64 | amd64) arch="x64" ;; - *) fail "unsupported architecture $(uname -m)" ;; -esac -command -v tar >/dev/null 2>&1 || fail "tar is required" -if command -v sha256sum >/dev/null 2>&1; then - checksum() { sha256sum "$1" | cut -d' ' -f1; } -elif command -v shasum >/dev/null 2>&1; then - checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } -else - fail "sha256sum or shasum is required" -fi - -channel="${T3CODE_CHANNEL:-stable}" -version="${T3CODE_VERSION:-}" -if [ -z "$version" ]; then - # Tags are v; the channel is the prerelease identifier, or none for - # stable. Only tags of the requested train are considered, so a stable - # install can never pick up a nightly or preview build by accident. - case "$channel" in - stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; - nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; - *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; - esac - tmp_index="$(mktemp)" - fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" - version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" - rm -f "$tmp_index" - [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" -fi -case "$version" in - *-preview.*) - printf '%s\n' \ - "t3 ${version} is a preview build." \ - " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ - " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ - " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 - if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then - fail "refusing a preview build that was not explicitly requested" - fi - ;; -esac - -stem="t3-${version}-${platform}-${arch}" -archive="${stem}.tar.gz" -versions_dir="${t3_home}/runtime/versions" -target_dir="${versions_dir}/${version}" - -if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then - printf 't3 %s is already installed at %s\n' "$version" "$target_dir" -else - mkdir -p "$versions_dir" - staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" - trap 'rm -rf "$staging"' EXIT - - printf 'Downloading %s...\n' "$archive" - fetch_status=0 - fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? - if [ "$fetch_status" -eq 44 ]; then - fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead" - elif [ "$fetch_status" -ne 0 ]; then - fail "could not download the release checksums" - fi - fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" - - expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" - [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" - actual="$(checksum "${staging}/${archive}")" - [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" - - tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 - rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" - "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" - printf '%s\n' "$version" > "${staging}/.install-complete" - - rm -rf "$target_dir" - mv "$staging" "$target_dir" - trap - EXIT -fi - -mkdir -p "$bin_dir" -ln -sfn "${target_dir}/t3" "${bin_dir}/t3" -printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" -case ":${PATH}:" in - *":${bin_dir}:"*) ;; - *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; -esac diff --git a/apps/marketing/scripts/stage-install-scripts.mjs b/apps/marketing/scripts/stage-install-scripts.mjs new file mode 100644 index 0000000000000000000000000000000000000000..80f458e8acaae73c612da0f5b1bb45b897256934 --- /dev/null +++ b/apps/marketing/scripts/stage-install-scripts.mjs @@ -0,0 +1,15 @@ +// The CLI install scripts live in scripts/ at the repo root with the rest of +// the release tooling; the site serves them at /install.sh and /install.ps1. +// Copy them into public/ before every Astro build and dev server so the two +// never drift. The copies are gitignored. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); +const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); +const publicDir = NodePath.join(marketingDir, "public"); +NodeFS.mkdirSync(publicDir, { recursive: true }); +for (const name of ["install.sh", "install.ps1"]) { + NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); +} diff --git a/apps/server/package.json b/apps/server/package.json index 1892f7f3d6d7e972ee82346a1d5f8b3d6296569a..f3ee6cda3aef0a139ebfb484cdfddb420a32649b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,7 +16,7 @@ "type": "module", "scripts": { "dev": "node --watch src/bin.ts", - "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", + "build:bundle": "vp pack", "build:exe": "node scripts/cli.ts build-exe", "start": "node dist/bin.mjs", "typecheck": "tsc --noEmit", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 09866c316eea82bcb3645869eaa6882f6d28a6c7..06c22738853e3bd019cc1cd7cb56ef957ba6eac0 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -284,11 +284,7 @@ const publishCmd = Command.make( const packageJsonPath = path.join(serverDir, "package.json"); // Assert build assets exist - for (const relPath of [ - "dist/bin.mjs", - "dist/service-launcher.mjs", - "dist/client/index.html", - ]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index b682c4ce44ce526af8e98e510e535c539662817e..50bdb1f5fbd3024441e77024eddde438971893b1 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { Effect.sync(() => { installOptions.push(options); return { - program: ["/test/node", "/test/service-launcher.mjs"], - launcherPath: "/test/service-launcher.mjs", + program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"], baseDir: "/test/t3", unitPath: serviceStatus.unitPath, logPath: serviceStatus.logPath, diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts index 696fc55c45f011db10a54cc893fdc852e9bdbea3..280177fc28079bf444188068d95b6e19867d7748 100644 --- a/apps/server/src/cli/serviceLauncher.ts +++ b/apps/server/src/cli/serviceLauncher.ts @@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli"; import { main as runServiceLauncher } from "../serviceLauncher.ts"; /** - * Hosts the service launcher inside the CLI executable. Archive-distributed - * runtimes have no Node on the machine to run `service-launcher.mjs`, so the - * service manager runs `t3 __service-launcher` and the launcher spawns the - * server from the same executable. + * Hosts the service launcher inside the CLI executable. The service manager + * runs `t3 __service-launcher` and the launcher spawns the server from the + * same executable, so the machine needs no Node to run either. * * The launcher owns SIGTERM handling and the process lifetime: it must finish * stopping its child before the process exits, so it runs detached from the diff --git a/apps/server/src/cli/update.ts b/apps/server/src/cli/update.ts index dfcebf07e4eed0bbfec7bd584f41bf8b86774381..62a66b985022fbd039910b612e5763e081f60dba 100644 --- a/apps/server/src/cli/update.ts +++ b/apps/server/src/cli/update.ts @@ -1,7 +1,6 @@ import { HostProcessArchitecture, HostProcessEnvironment, - HostProcessExecutablePath, HostProcessInvokedAs, HostProcessIsExecutable, HostProcessPlatform, @@ -12,7 +11,6 @@ import { CLI_RELEASE_CHANNELS, cliReleaseIndexPageUrl, cliReleaseChannelOf, - isArchiveDistributedVersion, newestCliReleaseVersion, type CliReleaseChannel, } from "@t3tools/shared/cliRelease"; @@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun Effect.map((result) => (result.code === 0 ? result.stdout : "")), Effect.orElseSucceed(() => ""), ); - return /__service-launcher|service-launcher\.mjs/.test(command); + return /__service-launcher/.test(command); } return false; }); @@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { const runner = yield* ProcessRunner.ProcessRunner; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; - const execPath = yield* HostProcessExecutablePath; const environment = yield* HostProcessEnvironment; const httpClient = yield* HttpClient.HttpClient; const service = yield* BootService.BootService; @@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { ? serviceVersion : currentVersion; - // Only archive-distributed versions install without Node and npm on the - // machine. Until nightly and stable ship archives, updating onto them from - // here would reintroduce the dependency this command exists to remove. - if (!isArchiveDistributedVersion(targetVersion)) { - return yield* new CliUpdateError({ - reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`, - }); - } if (executableCurrent && serviceCurrent) { yield* Console.log( serviceVersion !== undefined @@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { validate: (paths) => runner .run({ - command: pinnedRuntimeCommand(paths, execPath).command, - args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"], + command: pinnedRuntimeCommand(paths).command, + args: [...pinnedRuntimeCommand(paths).args, "--version"], timeout: Duration.seconds(30), }) .pipe( diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 52a2fdbb9abe9cbc3a463d5a865d7f360a89ad86..54ef1ffdd5f5b8d330cca2209aa8ef2b618365ee 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { - HostProcessArguments, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -23,24 +23,25 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; -it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); +const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; +const linuxPlan = { + program: [linuxRuntime, "__service-launcher"], + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", +}; - expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); +it("runs the pinned runtime's own executable as the systemd launcher", () => { + const unit = BootService.renderBootServiceUnit(linuxPlan); + + expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); expect(unit).toContain("KillMode=mixed"); - expect(unit).not.toContain("versions/1.2.3"); + expect(unit).not.toContain("node"); }); it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ - program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`], - launcherPath: `${baseDir}/runtime/service-launcher.mjs`, + program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], baseDir, logPath: `${baseDir}/userdata/logs/boot-service.log`, unitPath: "/home/theo/.config/systemd/user/t3code.service", @@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); }); -it("runs archive-distributed runtimes as their own executable", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); - - expect(unit).toContain( - "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", - ); - expect(unit).not.toContain("node"); -}); - it("survives the kernel OOM-killing a greedy agent child", () => { - const unit = BootService.renderBootServiceUnit({ - program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", - baseDir: "/home/theo/.t3", - logPath: "/home/theo/.t3/userdata/logs/boot-service.log", - unitPath: "/home/theo/.config/systemd/user/t3code.service", - }); + const unit = BootService.renderBootServiceUnit(linuxPlan); expect(unit).toContain("OOMPolicy=continue"); }); +const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; const macPlan = { - program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], - launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", + program: [macRuntime, "__service-launcher"], baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", @@ -104,12 +84,13 @@ const macInstallerPath = "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; -it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { +it("runs the pinned runtime's own executable as the launch agent", () => { const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); - expect(plist).toContain("/opt/homebrew/bin/node"); - expect(plist).toContain("/Users/theo/.t3/runtime/service-launcher.mjs"); - expect(plist).not.toContain("versions/1.2.3"); + expect(plist).toContain( + ` \n ${macRuntime}\n __service-launcher\n `, + ); + expect(plist).not.toContain("node"); }); it("preserves the installer's provider search path in the launch agent", () => { @@ -150,23 +131,18 @@ it("escapes XML in host paths", () => { const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", - usePinnedLauncher = false, installerPath = macInstallerPath, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); const baseDir = path.join(home, ".t3"); - const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); - yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + // A complete pinned runtime is already present, so install only validates + // it and never downloads a release archive. + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); - yield* fs.writeFileString(runtime.entryPath, "export {};\n"); - yield* fs.writeFileString( - path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), - "export const source = 'pinned runtime';\n", - ); + yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( } return { stdout: - input.args[1] === "--version" + input.args[0] === "--version" ? "t3 v1.2.3\n" : input.command === "loginctl" && input.args[0] === "show-user" ? `${control.linger}\n` @@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( baseDir, logsDir: path.join(baseDir, "userdata", "logs"), cliVersion: "1.2.3", - host: { - execPath: "/usr/bin/node", - ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), - }, + host: { execPath: "/usr/bin/t3" }, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provide( Layer.mergeAll( Layer.succeed(HostProcessPlatform, platform), Layer.succeed(HostProcessUserId, 501), - Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), - Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), + Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("no release download expected")), + ), ConfigProvider.layer( ConfigProvider.fromEnv({ env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, @@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(error.message).toContain("last login session ends"); expect(yield* fs.exists(before.unitPath)).toBe(false); expect(yield* fs.exists(statePath)).toBe(false); - expect( - commands.some((command) => command.startsWith("npm ") || command.includes("--version")), - ).toBe(false); + expect(commands.some((command) => command.includes("--version"))).toBe(false); expect( commands.some( (command) => command.includes("daemon-reload") || command.includes("restart"), @@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); + const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + `ExecStart=${runtime.entryPath} __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", @@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect((yield* service.status).current).toBe(false); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); // The stop can block up to systemd's 90s TimeoutStopSec; the runner's // 60s default would cancel it mid-shutdown. expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( @@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs, statePath, commands, control } = yield* makeHarness(platform); const plan = yield* service.install(); - const launcher = yield* fs.readFileString(plan.launcherPath); const unit = yield* fs.readFileString(plan.unitPath); control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; commands.length = 0; @@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { targetVersion: "1.2.3", }); expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); - expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); expect( commands.filter( @@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); - it.effect("copies the launcher from the prepared pinned runtime", () => - Effect.gen(function* () { - const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install(); - - expect(yield* fs.readFileString(plan.launcherPath)).toBe( - "export const source = 'pinned runtime';\n", - ); - }), - ); - it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); @@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); + const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); const path = yield* Path.Path; const plan = yield* service.install(); @@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); - expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); + expect(yield* fs.readFileString(plan.unitPath)).toContain( + ` ${runtime.entryPath}\n __service-launcher`, + ); expect(yield* service.status).toMatchObject({ current: true, installedVersion: "1.2.3", }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); - expect(commands.some((command) => command.startsWith("npm "))).toBe(false); expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); // A bootout can block up to the plist's 90s ExitTimeOut; the runner's // 60s default would cancel it and let bootstrap race a loaded job. @@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, ""); + const { service, fs } = yield* makeHarness("darwin", ""); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { - const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); + const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( @@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service, fs } = yield* makeHarness( "darwin", - false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); const plan = yield* service.install(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 94046b2e07c1068b3dfcd17cd31cba9f339b7071..cb712879bbddbe94b34b54ea3406b34a74a47346 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -26,7 +26,6 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; import { - SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, compareExactServiceVersions, @@ -86,7 +85,6 @@ export interface BootServicePlan { * subcommand so the machine never needs Node. */ readonly program: ReadonlyArray; - readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; readonly unitPath: string; @@ -533,7 +531,6 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly launcherSourcePath?: string; } export const make = Effect.fn("cloud.boot_service.make")(function* (input: { @@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; - // Archive-distributed versions download from GitHub Releases; npm versions - // never touch HTTP, so callers without a client still work. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }); const unitPath = detectedManager?.unitPath ?? ""; const logPath = path.join(input.logsDir, "boot-service.log"); - const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); - const launcherSourcePath = - host.launcherSourcePath ?? - path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { @@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ); }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // The executable hosts the launcher as a hidden subcommand of itself, so + // the unit runs the pinned runtime directly. const plan: BootServicePlan = { - program: - runtimePaths.layout === "archive" - ? [runtimePaths.entryPath, "__service-launcher"] - : [host.execPath, launcherPath], - launcherPath, + program: [runtimePaths.entryPath, "__service-launcher"], baseDir: input.baseDir, logPath, unitPath, @@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, host.execPath).command, - args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], + command: pinnedRuntimeCommand(runtime).command, + args: [...pinnedRuntimeCommand(runtime).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - // Archive runtimes host the launcher in the executable itself; there is - // no standalone script to copy. - const launcherSource = - runtimePaths.layout === "archive" - ? undefined - : yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); @@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - if (launcherSource !== undefined) { - yield* writeDurably(launcherPath, launcherSource); - } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (!(yield* fs.exists(unitPath))) { return { supported: true, installed: false, current: false, unitPath, logPath }; } - const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = - yield* Effect.all([ - fs.readFileString(unitPath), - runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), - fs.exists(runtimePaths.entryPath), - fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), - fs.readFileString(statePath).pipe(Effect.option), - ]); + const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ + fs.readFileString(unitPath), + fs.exists(runtimePaths.entryPath), + fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), + fs.readFileString(statePath).pipe(Effect.option), + ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; const installedVersion = Option.isSome(stateText) ? serviceStateActiveVersion(stateText.value) @@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { current: problems.length === 0 && normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && - launcherExists && runtimeEntryExists && Option.isSome(runtimeSentinel) && runtimeSentinel.value.trim() === input.cliVersion && diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index b542907b4d01f92831aaf81712b67062df600ab4..e4b16b8f7190b3f08ae0bdb8ef7e0e790a425710 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -17,16 +16,38 @@ import { PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; -const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => +// Every install fetches the release archive, checks it against SHA256SUMS, +// and unpacks it with tar. The fake client serves both files; the fake runner +// stands in for tar and drops the executable where extraction would. +const version = "1.2.3"; +const archiveName = `t3-${version}-linux-x64.tar.gz`; +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); +const validChecksums = archiveHex(archiveBytes).pipe( + Effect.map((hex) => `${hex} ${archiveName}\n`), +); +const releaseHttpClient = (checksums: string, requests: string[] = []) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); +const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - const prefixIndex = input.args.indexOf("--prefix"); - const stagingDir = input.args[prefixIndex + 1]; - if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); - const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { - it.effect("installs through pnpm when its Node runtime has no npm executable", () => + it.effect("installs the verified release archive as the runtime executable", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); - const commands: Array = []; - const install = successfulRunner(fs, path); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; const paths = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input); - return input.command === "npm" - ? Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: "npm", - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - }), - }), - ) - : install.run(input); - }, - }), + httpClient: releaseHttpClient(yield* validChecksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), validate: (staging) => fs.exists(staging.entryPath).pipe( Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), Effect.orDie, ), }); - assert.deepEqual( - commands.map((command) => command.command), - ["npm", "pnpm"], - ); - assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${version}/SHA256SUMS`, + `https://releases.example/download/v${version}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); }), ); - it.effect("does not try a different installer for npm permission failures", () => + it.effect("refuses an archive whose checksum does not match the release", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); const commands: string[] = []; - yield* ensurePinnedRuntimeInstalled({ + const error = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: ProcessRunner.ProcessRunner.of({ - run: (input) => { - commands.push(input.command); - return Effect.fail( - new ProcessRunner.ProcessSpawnError({ - command: input.command, - argumentCount: input.args.length, - cause: PlatformError.systemError({ - _tag: "PermissionDenied", - module: "ChildProcess", - method: "spawn", - }), - }), - ); - }, - }), - validate: () => Effect.die("must not validate a failed install"), + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), }).pipe(Effect.flip); - assert.deepEqual(commands, ["npm"]); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); }), ); @@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: (staging) => Effect.gen(function* () { validatedDirectory = staging.versionDir; @@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.notEqual(validatedDirectory, finalPaths.versionDir); assert.deepEqual(installed, finalPaths); assert.isTrue(yield* fs.exists(finalPaths.entryPath)); - assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); + assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`); }), ); @@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), }).pipe(Effect.flip); @@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums), + runner: extractingRunner(fs, path), validate: () => Effect.void, }); @@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); + const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); - yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); + yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`); let validations = 0; + const requests: string[] = []; yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", - runner: successfulRunner(fs, path), + httpClient: releaseHttpClient(yield* validChecksums, requests), + runner: extractingRunner(fs, path), validate: (paths) => Effect.gen(function* () { validations += 1; @@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }).pipe(Effect.flip); assert.equal(validations, 1); + assert.deepEqual(requests, []); assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); }), ); @@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { }); const install = yield* ensurePinnedRuntimeInstalled({ baseDir, - version: "1.2.3", + version, fs, path, platform: "linux", arch: "x64", + httpClient: releaseHttpClient(yield* validChecksums), runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); @@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); - - // Archive-distributed versions never touch npm: the release archive is - // fetched, checked against SHA256SUMS, and unpacked with tar. - const archiveVersion = "1.3.0-preview.20260911.7"; - const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`; - const archiveBytes = new TextEncoder().encode("not really a tarball"); - const archiveHex = (bytes: Uint8Array) => - Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( - Effect.map((digest) => - Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - ); - const releaseHttpClient = (checksums: string, requests: string[]) => - HttpClient.make((request) => { - requests.push(request.url); - const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; - return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); - }); - const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) => - ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.gen(function* () { - commands.push(input.command); - const targetIndex = input.args.indexOf("-C"); - const stagingDir = input.args[targetIndex + 1]; - if (input.command !== "tar" || stagingDir === undefined) { - return yield* Effect.die(`unexpected command ${input.command}`); - } - yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); - return { - stdout: "", - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), - }); - - it.effect("installs archive-distributed versions from the verified release archive", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); - const requests: string[] = []; - const commands: string[] = []; - const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`; - const paths = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(checksums, requests), - releaseBaseUrl: "https://releases.example/download", - runner: extractingRunner(fs, path, commands), - validate: (staging) => - fs.exists(staging.entryPath).pipe( - Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), - Effect.orDie, - ), - }); - assert.equal(paths.layout, "archive"); - assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: paths.entryPath, - args: [], - }); - assert.deepEqual(requests, [ - `https://releases.example/download/v${archiveVersion}/SHA256SUMS`, - `https://releases.example/download/v${archiveVersion}/${archiveName}`, - ]); - assert.deepEqual(commands, ["tar"]); - assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`); - assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); - }), - ); - - it.effect("refuses an archive whose checksum does not match the release", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); - const commands: string[] = []; - const error = yield* ensurePinnedRuntimeInstalled({ - baseDir, - version: archiveVersion, - fs, - path, - platform: "linux", - arch: "x64", - httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []), - runner: extractingRunner(fs, path, commands), - validate: () => Effect.die("must not validate an unverified archive"), - }).pipe(Effect.flip); - assert.instanceOf(error, PinnedRuntimeInstallError); - assert.equal(error.step, "verifying the t3 release archive checksum"); - assert.deepEqual(commands, []); - assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); - }), - ); - - it("runs npm layouts through the host Node", () => { - const paths = pinnedRuntimePaths( - { join: (...parts: string[]) => parts.join("/") } as Path.Path, - "/home/theo/.t3", - "1.2.3", - "linux", - ); - assert.equal(paths.layout, "npm"); - assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { - command: "/usr/bin/node", - args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"], - }); - }); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index c4e02e41ddf7f02c027d8e258b8402aca7d3d085..686d5cec9d2a36593c92cddd153aa3936e03dcca 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -15,26 +14,21 @@ import { cliArchivePlatformKey, cliArchiveTarCommand, cliReleaseDownloadBaseUrl, - isArchiveDistributedVersion, parseChecksums, } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` installed into - * /runtime/versions/. The boot service points its unit or - * launch agent here, and server self-update installs the target version here before - * switching over, never `npx t3`, whose cache is ephemeral and whose - * registry fetch at boot would make startup depend on the network. - * - * Two layouts exist. npm-distributed versions are `npm install`ed and run as - * ` node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the - * self-contained release archive unpacked in place and run as `./t3`, which - * needs neither Node nor npm on the machine. The layout is decided by the - * version string alone so every consumer agrees without probing the disk. + * A pinned runtime is an exact t3 release archive unpacked into + * /runtime/versions/: the self-contained executable, the + * web client, and the native packages beside it. The boot service points its + * unit or launch agent at the executable, and server self-update installs the + * target version here before switching over. The runtime never depends on a + * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` + * package for people who prefer `npx t3` or `npm install -g t3`, and even a + * CLI installed that way pins an archive when it sets up the service. */ - const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; @@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); -export type PinnedRuntimeLayout = "npm" | "archive"; - export interface PinnedRuntimePaths { - readonly layout: PinnedRuntimeLayout; readonly versionDir: string; - /** - * `bin.mjs` for npm layouts, the executable itself for archives. Existence - * of this file is what marks a runtime as present. - */ + /** The executable. Its existence is what marks a runtime as present. */ readonly entryPath: string; readonly sentinelPath: string; } -/** The exact command that runs a pinned runtime, given the Node hosting the caller. */ -export function pinnedRuntimeCommand( - paths: PinnedRuntimePaths, - nodePath: string, -): { readonly command: string; readonly args: ReadonlyArray } { - return paths.layout === "archive" - ? { command: paths.entryPath, args: [] } - : { command: nodePath, args: [paths.entryPath] }; +/** The exact command that runs a pinned runtime. */ +export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { + readonly command: string; + readonly args: ReadonlyArray; +} { + return { command: paths.entryPath, args: [] }; } export function pinnedRuntimePaths( @@ -72,20 +58,10 @@ export function pinnedRuntimePaths( platform: NodeJS.Platform, ): PinnedRuntimePaths { const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); - const sentinelPath = path.join(versionDir, ".install-complete"); - if (isArchiveDistributedVersion(version)) { - return { - layout: "archive", - versionDir, - entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), - sentinelPath, - }; - } return { - layout: "npm", versionDir, - entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), - sentinelPath, + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), + sentinelPath: path.join(versionDir, ".install-complete"), }; } @@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete - * install is already there, and returns its paths. The sentinel is written - * only after the install step exits 0; checking the entry file alone is not - * enough. npm extracts files before running native builds (node-pty), and tar - * writes the executable before the last native package, so a killed install - * leaves a plausible-looking but broken tree behind. + * Installs the t3 release archive for `version` into the pinned runtime + * directory unless a complete install is already there, and returns its + * paths. The sentinel is written only after extraction and validation + * succeed; checking the entry file alone is not enough, since tar writes the + * executable before the last native package and a killed install leaves a + * plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput { ) => Effect.Effect; readonly platform: NodeJS.Platform; readonly arch: string; - /** Archive-distributed versions download from here; npm versions never need it. */ - readonly httpClient?: HttpClient.HttpClient | undefined; + readonly httpClient: HttpClient.HttpClient; readonly releaseBaseUrl?: string | undefined; } -const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* ( - input: PinnedRuntimeInstallInput, - stagingDir: string, -) { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* input.runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - input.runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); -}); - const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( httpClient: HttpClient.HttpClient, url: string, @@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun }); } const httpClient = input.httpClient; - if (httpClient === undefined) { - return yield* new PinnedRuntimeInstallError({ - step: "downloading the t3 release archive (no HTTP client available)", - }); - } const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); const fileName = cliArchiveFileName(input.version, platformKey); @@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ); const stagingPaths: PinnedRuntimePaths = { - layout: paths.layout, versionDir: stagingDir, entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - if (paths.layout === "archive") { - yield* installFromArchive(input, stagingDir); - } else { - yield* installFromNpm(input, stagingDir); - } + yield* installFromArchive(input, stagingDir); yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index e6d8010f19d734f10aa5ce2683f5d0a502aee909..8ca0baa64a323fc7ca031bc955223e2bed13d2a3 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -1,13 +1,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; @@ -25,6 +26,28 @@ interface HarnessOptions { readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } +// The staged runtime is a release archive: the fake client serves SHA256SUMS +// and the tarball, and the fake runner stands in for tar before it answers +// the staged preflight. +const archiveBytes = new TextEncoder().encode("not really a tarball"); +const releaseHttpClient = (order: string[]) => + HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/SHA256SUMS")) { + const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); + const hex = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + return HttpClientResponse.fromWeb( + request, + new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`), + ); + } + order.push("download"); + return HttpClientResponse.fromWeb(request, new Response(archiveBytes)); + }), + ); + const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( options: HarnessOptions = {}, ) { @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const runner = ProcessRunner.ProcessRunner.of({ run: (input) => Effect.gen(function* () { - if (input.command === "npm") { - order.push("install"); - const prefix = input.args[input.args.indexOf("--prefix") + 1]; - if (prefix === undefined) return yield* Effect.die("missing npm prefix"); - const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); - yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); + if (input.command === "tar") { + order.push("extract"); + const stagingDir = input.args[input.args.indexOf("-C") + 1]; + if (stagingDir === undefined) return yield* Effect.die("missing tar target"); + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); return { stdout: "", stderr: "", @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( run: () => Effect.die("unexpected desktop app update run"), }, ), - Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), + Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessArchitecture, "x64"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); return { selfUpdate, order }; @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { method: "boot-service", updateId: "launcher-id", }); - expect(order).toEqual(["install", "preflight", "accept"]); + expect(order).toEqual(["download", "extract", "preflight", "accept"]); }), ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 734c8ca732fd42a9a0233204b8acc69867f70d86..a388f8a0b4d73199496af8e6ef2b27600b4eb337 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,11 +6,7 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { - HostProcessArchitecture, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const execPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const arch = yield* HostProcessArchitecture; // Archive-distributed targets download from GitHub Releases. The client is // optional so callers without one (tests, npm-only hosts) still construct. - const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const httpClient = yield* HttpClient.HttpClient; const releaseBaseUrl = Option.getOrUndefined( yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), ); @@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { validate: (runtime) => runner .run({ - command: pinnedRuntimeCommand(runtime, execPath).command, + command: pinnedRuntimeCommand(runtime).command, args: [ - ...pinnedRuntimeCommand(runtime, execPath).args, + ...pinnedRuntimeCommand(runtime).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index bb61866a93ffce0332ab71291b17664ac70a4196..89610ca918a3da9ea3e5042105f146a7796066ab 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; -export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; export const SERVICE_STATE_FILE = "service-state.json"; /** Written by the launcher just before an explicit stop kills its child, so the child can tell "the service is going away" from "the launcher is about diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts deleted file mode 100644 index b761998339235b5d267f30a0ef97baffcda1d6ea..0000000000000000000000000000000000000000 --- a/apps/server/src/service-launcher.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Standalone launcher entry for npm-distributed runtimes: `node -// service-launcher.mjs`. Archive runtimes reach the same code through the -// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run -// anything on import. -import { isEntrypoint } from "./entrypoint.ts"; -import { main } from "./serviceLauncher.ts"; - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 45c472af1fc48628048d63447cc28a188feda3f6..db1a6a8bee34b2bc6d532fe45e494a5c47203f85 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -75,6 +75,27 @@ it("rejects contradictory service state", () => { ); }); +// A pinned runtime is an executable at /t3. The tests stand one up +// as a Node shebang script so the launcher spawns it the way it spawns the +// real single-executable, IPC channel included. +const writeFakeRuntime = ( + fs: FileSystem.FileSystem, + path: Path.Path, + versionDir: string, + childSource: string, +) => + Effect.gen(function* () { + const entryPath = path.join(versionDir, "t3"); + yield* fs.makeDirectory(versionDir, { recursive: true }); + yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`); + yield* fs.chmod(entryPath, 0o755); + yield* fs.writeFileString( + path.join(versionDir, ".install-complete"), + `${path.basename(versionDir)}\n`, + ); + return entryPath; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { @@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); const statePath = path.join(root, "runtime", "service-state.json"); - const versionDir = path.join(root, "runtime", "versions", "1.0.0"); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); yield* Effect.promise(() => writeServiceState(statePath, { protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -148,11 +170,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -198,11 +221,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { @@ -257,11 +281,12 @@ if (context.update?.status === "pending") { } `; for (const version of ["1.0.0", "1.1.0"]) { - const versionDir = path.join(root, "runtime", "versions", version); - const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); - yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); - yield* fs.writeFileString(entryPath, childSource); - yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); } yield* Effect.promise(() => writeServiceState(statePath, { diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index fc3d8674e68e59bec56140f036be6fcc804eff58..a1e16a62716327324322ee4321077ef91f6191b2 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -1,13 +1,14 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics globalTimers:off -// This file is shipped as a standalone bundle and copied to a stable path by -// `t3 service update`. Keep runtime imports limited to Node built-ins. +// The launcher supervises the server child for the boot service and must keep +// working across server versions, so it stays on Node built-ins with no Effect +// runtime: it is the one part of the executable that cannot depend on the +// rest of it being loadable. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import * as NodeSea from "node:sea"; import type { PendingServiceUpdate, @@ -41,37 +42,24 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } -// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked -// release archives whose executable runs on its own, npm versions are a -// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node +// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose +// executable runs on its own. Kept inline so this file stays on Node // built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); - const archive = /-preview\.\d{8}\.\d+$/.test(version); // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: archive - ? NodePath.join(versionDir, executableName) - : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: NodePath.join(versionDir, executableName), sentinelPath: NodePath.join(versionDir, ".install-complete"), - executable: archive, }; }; -const runtimeSpawnArguments = (paths: ReturnType) => - paths.executable - ? { command: paths.entryPath, args: ["serve"] } - : { command: process.execPath, args: [paths.entryPath, "serve"] }; - -// An npm-layout runtime needs a Node interpreter. When the launcher itself is -// the single-executable, process.execPath is `t3`, which cannot run a -// bin.mjs, so the two layouts cannot be mixed within one service install. -const launcherIsExecutable = NodeSea.isSea(); - -const canLaunchRuntime = (paths: ReturnType) => - paths.executable || !launcherIsExecutable; +const runtimeSpawnArguments = (paths: ReturnType) => ({ + command: paths.entryPath, + args: ["serve"], +}); /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; @@ -505,12 +493,6 @@ export class Launcher { await reject("The requested database path is not absolute."); return; } - if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) { - await reject( - "This service runs from a self-contained t3 executable and cannot switch to an npm-installed version. Reinstall the service with the target version instead.", - ); - return; - } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; diff --git a/docs/operations/release.md b/docs/operations/release.md index 2641bccf3547f4f4757f39e59b1df465f98c4f57..54e3f5e55eaec700396805a08f7314e0bae0c351 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop - Nightly runs are always GitHub prereleases and never marked latest. - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. -- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. - - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`. +- Builds a self-contained CLI archive per platform (`t3---.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. + - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. @@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native dependency closure in `resources/server.asar`. Native modules and helper executables declared as unpacked by that archive must be present at the matching paths below `resources/server.asar.unpacked`. The Windows-native backend reads -the archive in place through Electron. Packaged Windows builds also ship a -Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies -and extracts that archive into `~/.t3/wsl-runtime/sha256-` inside -the selected distro, then reuses it for later launches of the same update. The -Windows-side `wsl-server-tree/` extraction remains a fallback and is -removed after the distro-local runtime passes preflight. +the archive in place through Electron. Packaged Windows builds also ship +`resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive +(`t3--linux-x64.tar.gz`) built by the `build_linux_cli` job and handed +to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs +the exact bytes a Linux user downloads. WSL verifies and extracts that archive +into `~/.t3/wsl-runtime/sha256-` inside the selected distro, +then reuses it for later launches of the same update. Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only native libraries and helper executables. Avoid enabling whole-package smart @@ -272,11 +273,12 @@ break: - On same-architecture Windows builds, the packaged primary cannot load the fff native library from inside `server.asar` through its `.unpacked` sibling. - The isolated, extracted sidecar cannot load the server entry with plain Node. -- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256 - sidecar, the sidecar digest does not match the emitted archive, or required - Linux runtime members are absent. -- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY, - pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings. +- A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256 + sidecar, or the sidecar digest does not match the emitted archive. +- The emitted WSL archive is not a Linux CLI release archive: it must unpack to + a single `t3--linux-` directory holding `t3`, `client/`, and + `node_modules/` with the Linux node-pty binary, and must not carry a loose + server bundle (`bin.mjs`). - The external Windows resource monitor is absent. - The unpacked Windows application contains more than 80 files. diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 06a32e533029cf7812f3d382db63b03d1be9147b..86b022cf1245e554835a0362177a9e6caa24ac66 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. It places `t3` in `~/.local/bin` and reuses the same download when you later run `t3 service install`. It follows the stable train by default; set `T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact -version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that -were only published to npm are refused with the `npm install` to run instead. +version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. `preview` is a third train that maintainers cut from unreleased branches to exercise the release pipeline. Those builds can be broken, receive no fixes, @@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the command tells you it is still on the old version so you can restart it yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or -`--allow-downgrade` to move backwards. Versions published only to npm cannot -be installed this way; the command says so and names the `npm install` to run -instead. +`--allow-downgrade` to move backwards. ## Platform support diff --git a/knip.jsonc b/knip.jsonc index e225298fabc0a464f8c3957107781ef794384930..d203e6405585998cc917301eb969ea274202be03 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -20,7 +20,6 @@ // Vite+ pack entries and the launcher used by installed background services. "entry": [ "src/bin.ts!", - "src/service-launcher.ts!", "src/claude-history-worker.ts!", "scripts/cli.ts", "src/provider/testFixtures/*.mjs", diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts index e988dabc799b5a05b638177476c49b5f9ff28796..c5109ebee3a06af19b7bf1196bfb1e9e208f4224 100644 --- a/packages/shared/src/cliRelease.test.ts +++ b/packages/shared/src/cliRelease.test.ts @@ -7,7 +7,6 @@ import { cliReleaseDownloadBaseUrl, cliReleaseChannelOf, cliReleaseIndexPageUrl, - isArchiveDistributedVersion, newestCliReleaseVersion, parseChecksums, } from "./cliRelease.ts"; @@ -56,12 +55,6 @@ describe("cliRelease", () => { expect(checksums.size).toBe(2); }); - it("treats only preview builds as archive-distributed", () => { - expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); - expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); - expect(isArchiveDistributedVersion("1.2.3")).toBe(false); - }); - it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { expect(cliArchiveTarCommand("linux", {})).toBe("tar"); expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts index 4bdc4749b6194edbc9002733eb8a328a569aa82e..614b0d48bc0384f8a9f53bbb1cb1bcf72b8e400e 100644 --- a/packages/shared/src/cliRelease.ts +++ b/packages/shared/src/cliRelease.ts @@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap { return checksums; } -/** Whether a version was published from a release train that ships archives. */ -export function isArchiveDistributedVersion(version: string): boolean { - return /-preview\.\d{8}\.\d+$/.test(version); -} - export type CliReleaseChannel = "stable" | "nightly" | "preview"; export const CLI_RELEASE_CHANNELS: ReadonlyArray = [ "stable", diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e5b621f87aa8cdf426493ed1e6e66d1a3f4810f0..02e86b699a35b4b6d17f6baace44f8098515e817 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -14,7 +14,6 @@ import { baseSshArgs, getLastNonEmptyOutputLine, parseSshResolveOutput, - resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; import { SshCommandError } from "./errors.ts"; @@ -99,41 +98,6 @@ describe("ssh command", () => { }), ); - it.effect("resolves the remote t3 package spec from the desktop release channel", () => - Effect.sync(() => { - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17", - updateChannel: "latest", - }), - "t3@0.0.17", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.17-nightly.20260415.44", - updateChannel: "nightly", - }), - "t3@0.0.17-nightly.20260415.44", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "nightly", - isDevelopment: true, - }), - "t3@nightly", - ); - assert.equal( - resolveRemoteT3CliPackageSpec({ - appVersion: "0.0.0-dev", - updateChannel: "latest", - isDevelopment: true, - }), - "t3@nightly", - ); - }), - ); - it.effect("reads the last non-empty ssh output line", () => Effect.sync(() => { assert.equal( diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 7a94670370b2fe6ae5cbffb9fea8ee973cb22569..fc388114351d3ba694565ddf11a9ba449f32867b 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "node:crypto"; -import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; +import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; -const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; @@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi ), ); }); - -export function resolveRemoteT3CliPackageSpec(input: { - readonly appVersion: string; - readonly updateChannel: DesktopUpdateChannel; - readonly isDevelopment?: boolean; -}): string { - const appVersion = input.appVersion.trim(); - if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { - return `t3@${appVersion}`; - } - - if (input.isDevelopment) { - return "t3@nightly"; - } - - return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; -} diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 82061c0d8c84d9900c6eccf499ebbea1ba3bb9a6..f26bbc39415e4d5b9d6994776ea2e01780c6abfa 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( "remote runner process ownership", () => { - it.live.each(["npx", "npm"] as const)( - "keeps the server PID and graceful shutdown through the %s fallback", - (packageManager) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "package-manager-calls.jsonl"); - const packageSpec = "t3@0.0.35"; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node + it.live("keeps the server PID and graceful shutdown through the node-script runner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node import * as net from "node:net"; const server = net.createServer((socket) => { socket.end(); @@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { }) + "\\n"); }); `, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -const childProcess = require("node:child_process"); -const args = process.argv.slice(2); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); -if (args.includes("--package")) { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} else { - const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); - child.once("exit", (code) => { process.exitCode = code ?? 1; }); -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); + ); + yield* fs.chmod(cliPath, 0o700); - const runServer = (port = 0) => - Effect.gen(function* () { - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { - cwd: fixture, - env: { - PATH: bin, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - T3_TEST_PORT: String(port), - }, - detached: false, - stdin: Stream.make( - new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), - ), - }), - ); - const ready = yield* Deferred.make(); - const stdout: string[] = []; - const output = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.splitLines, - Stream.runForEach((line) => - Effect.gen(function* () { - stdout.push(line); - if (stdout.length === 1) { - yield* Deferred.succeed(ready, decodeStarted(line)); - } - }), + const runServer = (port = 0) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { + cwd: fixture, + env: { + PATH: bin, + T3_TEST_PORT: String(port), + }, + detached: false, + stdin: Stream.make( + new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })), ), - Effect.forkScoped, - ); - const stderr = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.mkString, - Effect.forkScoped, - ); - const receipt = yield* Effect.raceFirst( - Deferred.await(ready), - Fiber.join(output).pipe( - Effect.flatMap(() => Fiber.join(stderr)), - Effect.flatMap((message) => - Effect.die(new Error(`Runner exited before listening: ${message}`)), - ), - ), - ); - // A failed PID assertion must still close the owned fixture server, including an npm child. - yield* Effect.addFinalizer(() => + }), + ); + const ready = yield* Deferred.make(); + const stdout: string[] = []; + const output = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => Effect.gen(function* () { - if (yield* child.isRunning) { - yield* Effect.callback((resume) => { - const connection = NodeNet.connect(receipt.port, "127.0.0.1"); - connection.on("error", () => undefined); - connection.once("close", () => resume(Effect.void)); - return Effect.sync(() => connection.destroy()); - }); - yield* child.exitCode; + stdout.push(line); + if (stdout.length === 1) { + yield* Deferred.succeed(ready, decodeStarted(line)); } - }).pipe(Effect.orDie), - ); - assert.equal(receipt.pid, child.pid); - assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); - yield* child.kill({ killSignal: "SIGTERM" }); - assert.equal(yield* child.exitCode, 0); - yield* Fiber.join(output); - assert.include(stdout, "graceful shutdown"); - return receipt.port; - }).pipe(Effect.scoped); + }), + ), + Effect.forkScoped, + ); + const stderr = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.mkString, + Effect.forkScoped, + ); + const receipt = yield* Effect.raceFirst( + Deferred.await(ready), + Fiber.join(output).pipe( + Effect.flatMap(() => Fiber.join(stderr)), + Effect.flatMap((message) => + Effect.die(new Error(`Runner exited before listening: ${message}`)), + ), + ), + ); + // A failed PID assertion must still close the owned fixture server. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (yield* child.isRunning) { + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(receipt.port, "127.0.0.1"); + connection.on("error", () => undefined); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + yield* child.exitCode; + } + }).pipe(Effect.orDie), + ); + assert.equal(receipt.pid, child.pid); + assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); + yield* child.kill({ killSignal: "SIGTERM" }); + assert.equal(yield* child.exitCode, 0); + yield* Fiber.join(output); + assert.include(stdout, "graceful shutdown"); + return receipt.port; + }).pipe(Effect.scoped); - const port = yield* runServer(); - assert.equal(yield* runServer(port), port); - const calls = (yield* fs.readFileString(callsPath)) - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - assert.deepEqual(calls, [expectedCall, expectedCall]); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + const port = yield* runServer(); + assert.equal(yield* runServer(port), port); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }, ); @@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => { ); }, ); - -describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( - "remote runner install diagnostics", - () => { - const decodeArguments = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Array(Schema.String)), - ); - const cases = (["npx", "npm"] as const).flatMap((packageManager) => - ( - [ - "etarget", - "network", - "empty-success", - "success", - "failed-with-path", - "existing-cli", - "node-override", - ] as const - ).map((mode) => ({ packageManager, mode })), - ); - - it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); - const bin = path.join(fixture, "bin"); - const cliPath = path.join(fixture, "installed cli.mjs"); - const callsPath = path.join(fixture, "installer-calls.jsonl"); - const packageSpec = "t3@0.0.39-nightly.20260905.1286"; - const args = ["serve", "a path with spaces"]; - yield* fs.makeDirectory(bin); - yield* fs.symlink(process.execPath, path.join(bin, "node")); - yield* fs.writeFileString( - cliPath, - `#!/usr/bin/env node -process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); -`, - ); - yield* fs.chmod(cliPath, 0o700); - yield* fs.writeFileString(callsPath, ""); - yield* fs.writeFileString( - path.join(bin, packageManager), - `#!/usr/bin/env node -const fs = require("node:fs"); -fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); -const mode = process.env.T3_TEST_MODE; -if (mode === "success" || mode === "failed-with-path") { - process.stdout.write(process.env.T3_TEST_CLI + "\\n"); -} -if (mode === "etarget" || mode === "failed-with-path") { - process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); - process.exitCode = 42; -} else if (mode === "network") { - process.stderr.write("npm error code ENETUNREACH\\n"); - process.exitCode = 43; -} -`, - ); - yield* fs.chmod(path.join(bin, packageManager), 0o700); - if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); - - const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-s", "--", ...args], { - cwd: fixture, - extendEnv: false, - env: { - PATH: bin, - T3_TEST_MODE: mode, - T3_TEST_CLI: cliPath, - T3_TEST_CALLS: callsPath, - }, - stdin: Stream.make( - new TextEncoder().encode( - buildRemoteT3RunnerScript({ - packageSpec, - ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), - }), - ), - ), - }), - ); - const { stdout, stderr, exitCode } = yield* Effect.all( - { - stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), - stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), - exitCode: child.exitCode, - }, - { concurrency: "unbounded" }, - ); - const installFailed = - mode === "etarget" || mode === "network" || mode === "failed-with-path"; - const missingExecutable = mode === "empty-success"; - assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); - if (installFailed || missingExecutable) { - assert.equal(stdout, ""); - } else { - assert.deepEqual(decodeArguments(stdout), args); - } - if (installFailed) { - const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; - assert.include(stderr, `npm error code ${npmError}\n`); - assert.include(stderr, `Remote host could not install ${packageSpec}.`); - assert.notInclude(stderr, "Remote host installed"); - assert.notInclude(stderr, "Install a C toolchain"); - } else if (missingExecutable) { - assert.include(stderr, `Remote host installed ${packageSpec}`); - assert.include(stderr, "npm produced no t3 executable"); - assert.include(stderr, "Install a C toolchain"); - } else { - assert.equal(stderr, ""); - } - const expectedCall = [ - ...(packageManager === "npm" ? ["exec"] : []), - "--yes", - "--package", - packageSpec, - "--", - "sh", - "-c", - "command -v t3", - ]; - const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; - const calls = yield* fs.readFileString(callsPath); - if (usesInstaller) { - assert.deepEqual( - calls - .trim() - .split("\n") - .map((line) => decodeArguments(line)), - [expectedCall], - ); - } else { - assert.equal(calls, ""); - } - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - }, -); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 182a8eb4a0ce7c997d9f377636cdb9ea48c60711..980107d19a8222f1084fbe587d986d6c051913df 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -23,6 +23,7 @@ import { buildRemoteStopScript, buildRemoteT3RunnerScript, SshInvalidArchiveVersionError, + SshMissingRunnerError, describeReadinessCause, issueRemotePairingToken, launchOrReuseRemoteServer, @@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { return command._tag === "StandardCommand" ? command.args : []; } +const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; +const NODE_SCRIPT = { + nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", +} as const; + describe("ssh tunnel scripts", () => { - it("builds the remote t3 runner with npx and npm fallbacks", () => { - const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); - - assert.include(script, "T3_NODE_SCRIPT_PATH=''"); - assert.include(script, 'exec t3 "$@"'); - assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); - assert.include(script, "could not install 't3@latest'"); - assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); - assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); - assert.include(script, "npm produced no t3 executable"); - 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 --shell bash)"'); - assert.include(script, "fnm use --silent-if-unchanged"); - assert.include(script, "fnm use default"); - 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("installs and runs the release archive when an archive version is set", () => { - const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); + it("installs and runs the release archive without Node, npm, or npx", () => { + const script = buildRemoteT3RunnerScript(ARCHIVE); assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); + assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include( script, "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", @@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => { assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); assert.include(script, "SHA256SUMS"); assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); + assert.notInclude(script, "npx"); + assert.notInclude(script, "npm exec"); + assert.notInclude(script, "t3@latest"); + assert.notInclude(script, 'exec t3 "$@"'); // Concurrent launches serialize on a per-version mkdir lock and recheck // the completion marker after acquiring it. assert.include( @@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => { script.indexOf('"$T3_STAGING/t3" --version'), script.indexOf('> "$T3_STAGING/.install-complete"'), ); - // The archive branch execs before any of the Node discovery runs. + // Node discovery is defined for the dev path but only ever invoked inside + // the node-script branch, which the archive path skips entirely. + assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1); assert.isBelow( - script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), - script.indexOf("prepend_path_if_dir()"), + script.indexOf("ensure_remote_node_path || true"), + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + ); + assert.isBelow( + script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), + script.indexOf("T3_ARCHIVE_VERSION="), ); const launch = buildRemoteLaunchScript({ - archiveVersion: "1.2.3-preview.20260911.4", + ...ARCHIVE, releaseBaseUrl: "https://mirror.example/t3/", }); assert.include(launch, "T3_ARCHIVE_MODE=1"); @@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => { assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); - assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); + assert.include(buildRemoteLaunchScript(NODE_SCRIPT), "T3_ARCHIVE_MODE=0"); }); it("rejects archive versions that are not a single exact version segment", () => { @@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => { ); } assert.include( - buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), + buildRemoteT3RunnerScript(ARCHIVE), "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", ); }); + it("refuses to build a runner with neither an archive version nor a node script", () => { + for (const input of [undefined, {}, { archiveVersion: " " }, { nodeScriptPath: null }]) { + assert.throws(() => buildRemoteT3RunnerScript(input), SshMissingRunnerError); + } + assert.throws(() => buildRemoteLaunchScript(), SshMissingRunnerError); + }); + it("does not hard-code a remote node engine range", () => { - const script = buildRemoteT3RunnerScript(); + const script = buildRemoteT3RunnerScript(NODE_SCRIPT); assert.include(script, "T3_NODE_ENGINE_RANGE=''"); assert.notInclude(script, TEST_NODE_ENGINE_RANGE); }); - it("shell-quotes package specs in the remote t3 runner", () => { - const script = buildRemoteT3RunnerScript({ - packageSpec: "t3@nightly; touch /tmp/t3-owned", - }); - - assert.include( - script, - "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", - ); - assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); - }); - it("builds the remote t3 runner with a node script override", () => { const script = buildRemoteT3RunnerScript({ - nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, }); assert.include( @@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => { "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", ); assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); + assert.include(script, "T3_ARCHIVE_VERSION=''"); + 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 --shell bash)"'); + assert.include(script, "fnm use --silent-if-unchanged"); + assert.include(script, "fnm use default"); + 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"); + assert.notInclude(script, "npx"); }); it("uses the remote t3 runner for launch and pairing scripts", () => { @@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => { username: "julius", port: 2222, } as const; + const launch = buildRemoteLaunchScript(ARCHIVE); + const devLaunch = buildRemoteLaunchScript({ + ...NODE_SCRIPT, + nodeEngineRange: TEST_NODE_ENGINE_RANGE, + }); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + launch, '[ -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(launch, "RUNNER_CHANGED=1"); + assert.include(launch, "ensure_remote_node_path()"); + assert.include(launch, "if ! ensure_remote_node_path; then"); + assert.include(devLaunch, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); + assert.include(devLaunch, "does not satisfy required range "); + assert.include(launch, 'kill "$REMOTE_PID" 2>/dev/null || true'); + assert.include(launch, "wait_ready"); + assert.include(launch, '"$RUNNER_FILE" serve --host 127.0.0.1'); + assert.include(launch, '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.notInclude(launch, "server-home"); + assert.include(launch, "Remote T3 server did not become ready"); + assert.include(launch, 'wait_ready "60000"'); + assert.include(launch, 'if [ -s "$LOG_FILE" ]; then'); + assert.include(launch, "It wrote nothing to %s"); + assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); 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'); - assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemoteLaunchScript(), "server-home"); - assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); - assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); - assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); - assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); - assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); - assert.include( - buildRemotePairingScript(target), + buildRemotePairingScript(target, ARCHIVE), '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', ); - assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemotePairingScript(target), "server-home"); - assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); + assert.include( + buildRemotePairingScript(target, ARCHIVE), + 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"', + ); + assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home"); + assert.include( + buildRemotePairingScript(target, ARCHIVE), + "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", + ); assert.include( buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', @@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include( - buildRemoteLaunchScript(), + launch, '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.include(launch, "resolve_default_runtime_port()"); + assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); + assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); + assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); + assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); + assert.include(launch, 'rm -f "$PID_FILE"'); + assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); + assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then'); assert.isBelow( - buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), - buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), + launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), + launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), ); assert.isBelow( - buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), - buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), + launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), + launch.indexOf('elif [ -n "$REMOTE_PID" ]'), ); }); @@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* launchOrReuseRemoteServer(target); + const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE); assert.equal(result.remotePort, 3774); assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]); }).pipe(Effect.provide(processLayer)); @@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + const fiber = yield* Effect.forkChild( + launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT), + ); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(75)); @@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("gives cold archive launches a larger budget than npm launches", () => { + it.effect("gives cold archive launches a larger budget than node-script launches", () => { const target = { alias: "devbox", hostname: "devbox.example.com", @@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); return Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - launchOrReuseRemoteServer(target, undefined, { - archiveVersion: "1.2.3-preview.20260911.4", - }), - ); + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE)); yield* Effect.yieldNow; yield* TestClock.adjust(Duration.seconds(800)); @@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); const target = { alias: "devbox", @@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), ); yield* Effect.gen(function* () { const manager = yield* SshEnvironmentManager; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index d3df7d39307fb92e7a23ccbbf8bf248e61ea195c..135cb7decae9ab0efe85d17c4b4c5ce841d40926 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { - readonly packageSpec?: string; + /** + * Dev mode: run `node ` on the remote instead of a release archive. + * The only mode that needs Node on the remote. + */ readonly nodeScriptPath?: string | null; readonly nodeEngineRange?: string | null; /** - * Exact version whose release archive the remote installs and runs. Takes - * precedence over `packageSpec`; the remote then needs neither Node nor npm. + * Exact version whose self-contained release archive the remote installs + * and runs. Required unless `nodeScriptPath` is set; the remote then needs + * neither Node nor npm. */ readonly archiveVersion?: string | null; readonly releaseBaseUrl?: string | null; } export interface SshEnvironmentManagerOptions { - readonly resolveCliPackageSpec?: () => string; readonly resolveCliRunner?: Effect.Effect; } @@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { }; } +function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean { + return Boolean(runner?.nodeScriptPath?.trim()); +} + function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { - if (runner?.archiveVersion?.trim()) { - return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; - } if (runner?.nodeScriptPath?.trim()) { return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; } - if (runner?.packageSpec?.trim()) { - return { runner: "package", packageSpec: runner.packageSpec.trim() }; + if (runner?.archiveVersion?.trim()) { + return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; } - return { runner: "default" }; + return { runner: "archive" }; } interface SshAuthOperationInput { @@ -424,144 +428,117 @@ ensure_remote_node_path() { const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu -T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ -if [ -n "$T3_ARCHIVE_VERSION" ]; then - # Self-contained release archive: no Node, npm, or compiler on the remote. - # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. - T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ - T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" - t3_runtime_ready() { - [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] - } - if ! t3_runtime_ready; then - mkdir -p "$HOME/.t3/runtime/versions" - # Concurrent launches (two clients, a retry racing a slow first run) must - # not both install: mkdir is the atomic lock and the ready check repeats - # under it. - T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" - # mkdir is the only portable atomic exclusive create (mv would silently - # nest a candidate inside an existing lock). The owner publishes its pid - # right after, so a lock with a live owner is never reclaimed however - # slow its download is, and a lock whose owner is dead is reclaimed at - # once. A lock with no pid at all is a crash between mkdir and the pid - # write; it is reclaimed after a short grace so a live owner has time to - # publish. - T3_LOCK_WAITED=0 - T3_LOCK_UNOWNED=0 - while ! mkdir "$T3_LOCK" 2>/dev/null; do - T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" - if [ -n "$T3_LOCK_OWNER" ]; then - T3_LOCK_UNOWNED=0 - if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then - rm -rf "$T3_LOCK" - continue - fi - else - T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) - if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then - rm -rf "$T3_LOCK" - continue - fi - fi - if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then - printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 - exit 1 - fi - sleep 1 - T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) - done - printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" - trap 'rm -rf "$T3_LOCK"' EXIT - fi - if ! t3_runtime_ready; then - case "$(uname -s)" in - Darwin) T3_PLATFORM="darwin" ;; - Linux) T3_PLATFORM="linux" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; - esac - case "$(uname -m)" in - arm64 | aarch64) T3_ARCH="arm64" ;; - x86_64 | amd64) T3_ARCH="x64" ;; - *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; - esac - T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" - T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" - trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT - t3_fetch() { - if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" - elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" - else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - } - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ - t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ - T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" - if command -v sha256sum >/dev/null 2>&1; then - T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - else - T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" - fi - if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then - printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 - fi - tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 - rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" - # Prove the binary runs here (libc, arch) before marking it ready, or every - # later launch would exec a broken install instead of retrying. - if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then - printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 - fi - printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" - rm -rf "$T3_RUNTIME_DIR" - mv "$T3_STAGING" "$T3_RUNTIME_DIR" - fi - if [ -n "\${T3_LOCK:-}" ]; then - rm -rf "$T3_LOCK" - trap - EXIT - fi - exec "$T3_RUNTIME_DIR/t3" "$@" -fi @@T3_NODE_ENV_SCRIPT@@ -ensure_remote_node_path || true T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ if [ -n "$T3_NODE_SCRIPT_PATH" ]; then + # Dev mode: a source checkout on the remote. This is the only path that + # needs Node, so Node discovery runs here and nowhere else. + ensure_remote_node_path || true 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 - exec t3 "$@" +T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ +if [ -z "$T3_ARCHIVE_VERSION" ]; then + printf 'No t3 release version was provided for the remote runtime.\\n' >&2 + exit 1 fi -# npm extracts a package before it runs the native builds of its dependencies, -# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves -# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without -# running anything at all, which the caller only ever sees as a server that -# never becomes ready. Resolve the CLI once up front so that install failure is -# reported here, with npm's own output on stderr. -require_installed_t3_cli() { - if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then - printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 - fi - if [ -n "$T3_CLI_PATH" ]; then - return 0 - fi - printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 - return 1 +# Self-contained release archive: no Node, npm, or compiler on the remote. +# Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. +T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ +T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" +t3_runtime_ready() { + [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] } -# The launcher records this PID, so exec the CLI without an npm wrapper process. -if command -v npx >/dev/null 2>&1; then - require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" +if ! t3_runtime_ready; then + mkdir -p "$HOME/.t3/runtime/versions" + # Concurrent launches (two clients, a retry racing a slow first run) must + # not both install: mkdir is the atomic lock and the ready check repeats + # under it. + T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" + # mkdir is the only portable atomic exclusive create (mv would silently + # nest a candidate inside an existing lock). The owner publishes its pid + # right after, so a lock with a live owner is never reclaimed however + # slow its download is, and a lock whose owner is dead is reclaimed at + # once. A lock with no pid at all is a crash between mkdir and the pid + # write; it is reclaimed after a short grace so a live owner has time to + # publish. + T3_LOCK_WAITED=0 + T3_LOCK_UNOWNED=0 + while ! mkdir "$T3_LOCK" 2>/dev/null; do + T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" + if [ -n "$T3_LOCK_OWNER" ]; then + T3_LOCK_UNOWNED=0 + if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then + rm -rf "$T3_LOCK" + continue + fi + else + T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) + if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then + rm -rf "$T3_LOCK" + continue + fi + fi + if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then + printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 + exit 1 + fi + sleep 1 + T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) + done + printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" + trap 'rm -rf "$T3_LOCK"' EXIT fi -if command -v npm >/dev/null 2>&1; then - require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 - exec "$T3_CLI_PATH" "$@" +if ! t3_runtime_ready; then + case "$(uname -s)" in + Darwin) T3_PLATFORM="darwin" ;; + Linux) T3_PLATFORM="linux" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; + esac + case "$(uname -m)" in + arm64 | aarch64) T3_ARCH="arm64" ;; + x86_64 | amd64) T3_ARCH="x64" ;; + *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; + esac + T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" + T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" + trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT + t3_fetch() { + if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" + else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + } + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ + t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ + T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" + if command -v sha256sum >/dev/null 2>&1; then + T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + else + T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" + fi + if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then + printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 + fi + tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 + rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" + # Prove the binary runs here (libc, arch) before marking it ready, or every + # later launch would exec a broken install instead of retrying. + if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then + printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 + fi + printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" + rm -rf "$T3_RUNTIME_DIR" + mv "$T3_STAGING" "$T3_RUNTIME_DIR" fi -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 +if [ -n "\${T3_LOCK:-}" ]; then + rm -rf "$T3_LOCK" + trap - EXIT +fi +exec "$T3_RUNTIME_DIR/t3" "$@" `; const REMOTE_LAUNCH_SCRIPT = `set -eu @@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError()( + "SshMissingRunnerError", + {}, +) { + override get message(): string { + return "A remote t3 runner needs an archive version or a node script path."; + } +} + export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { - const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; const archiveVersion = input?.archiveVersion?.trim() || ""; + if (nodeScriptPath === "" && archiveVersion === "") { + throw new SshMissingRunnerError(); + } if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { throw new SshInvalidArchiveVersionError({ archiveVersion }); } @@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string ); return stripTrailingNewlines( applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { - T3_PACKAGE_SPEC: packageSpec, T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), @@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { - T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", + T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1", T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), @@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), - timeoutMs: runner?.archiveVersion?.trim() - ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS - : REMOTE_LAUNCH_TIMEOUT_MS, + timeoutMs: isNodeScriptRunner(runner) + ? REMOTE_LAUNCH_TIMEOUT_MS + : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT stdin: buildRemotePairingScript(target, runner), // Pairing may be the first command on a cold remote, so it can install // the archive on the way. - ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), + ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(resolvedTarget), key, }); - const packageSpec = options.resolveCliPackageSpec?.(); const runner = - options.resolveCliRunner === undefined - ? packageSpec === undefined - ? undefined - : { packageSpec } - : yield* options.resolveCliRunner; + options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner; yield* Effect.logDebug("ssh.environment.runner.resolved", { ...sshTargetLogFields(resolvedTarget), ...sshRunnerLogFields(runner), diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..a779854717ad6659d7b910c22d95a3f9a3132144 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,120 @@ +# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs +# only PowerShell 5.1+; no Node, npm, or compiler. +# +# irm https://t3.codes/install.ps1 | iex +# +# Environment: +# T3CODE_CHANNEL release train to follow: stable, nightly, or preview +# (default: stable; preview is a maintainers' test train) +# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) +# T3CODE_HOME T3 home directory (default: ~\.t3) +# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME\runtime\versions\, the +# same layout `t3 service install` uses, so the service reuses this download. +$ErrorActionPreference = "Stop" +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$repo = "pingdotgg/t3code" +$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } +$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } +$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } + +function Fail([string] $message) { + Write-Error "t3 install: $message" + exit 1 +} + +# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell +# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts +# may lack. +$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$arch = switch ($rawArch) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + default { Fail "unsupported architecture $rawArch" } +} + +$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } +$version = $env:T3CODE_VERSION +if (-not $version) { + # Tags are v; the channel is the prerelease identifier, or none for + # stable. Only tags of the requested train are considered, so a stable + # install can never pick up a nightly or preview build by accident. + $tagPattern = switch ($channel) { + "stable" { '^v\d+\.\d+\.\d+$' } + "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } + "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } + default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } + } + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } + $version = $tag.Substring(1) +} +if ($version -match '-preview\.') { + Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." + if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { + Fail "refusing a preview build that was not explicitly requested" + } +} + +$stem = "t3-$version-win32-$arch" +$archive = "$stem.zip" +$versionsDir = Join-Path $t3Home "runtime\versions" +$targetDir = Join-Path $versionsDir $version +$marker = Join-Path $targetDir ".install-complete" + +if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { + Write-Host "t3 $version is already installed at $targetDir" +} else { + New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null + $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $staging | Out-Null + try { + Write-Host "Downloading $archive..." + try { + Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + } catch { + $status = $_.Exception.Response.StatusCode.value__ + if ($status -eq 404) { + Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" + } + throw + } + Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing + + $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) + if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } + $expected = ($expected -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } + + Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force + # The archive wraps everything in one directory named after its stem. + Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging + Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force + + & (Join-Path $staging "t3.exe") --version | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } + Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline + + if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } + Move-Item $staging $targetDir + } catch { + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } + throw + } +} + +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +$shim = Join-Path $binDir "t3.cmd" +# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt +# non-ASCII characters in the user's home path. +[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) +Write-Host "Installed t3 $version" +Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" +if (($env:PATH -split ";") -notcontains $binDir) { + Write-Host "Add $binDir to your PATH to run t3." +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..22e4b11867a121de9c061e2ead106fb32bcee4c2 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, +# sha256sum or shasum, and curl or wget; no Node, npm, or compiler. +# +# curl -fsSL https://t3.codes/install.sh | sh +# +# Environment: +# T3CODE_CHANNEL release train to follow: stable, nightly, or preview +# (default: stable; preview is a maintainers' test train) +# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) +# T3CODE_HOME T3 home directory (default: ~/.t3) +# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME/runtime/versions/, the +# same layout `t3 service install` uses, so the service reuses this download +# instead of fetching the release again. +set -eu + +repo="pingdotgg/t3code" +base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" +t3_home="${T3CODE_HOME:-$HOME/.t3}" +bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" + +fail() { + printf 't3 install: %s\n' "$1" >&2 + exit 1 +} + +# Exit 44 on a 404 so callers can tell "no such asset" from a network failure. +fetch() { + if command -v curl >/dev/null 2>&1; then + status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 + case "$status" in + 2??) return 0 ;; + 404) return 44 ;; + *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; + esac + elif command -v wget >/dev/null 2>&1; then + wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 + if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi + cat "$2.headers" >&2; rm -f "$2.headers"; return 1 + else + fail "curl or wget is required" + fi +} + +case "$(uname -s)" in + Darwin) platform="darwin" ;; + Linux) platform="linux" ;; + *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; +esac +case "$(uname -m)" in + arm64 | aarch64) arch="arm64" ;; + x86_64 | amd64) arch="x64" ;; + *) fail "unsupported architecture $(uname -m)" ;; +esac +command -v tar >/dev/null 2>&1 || fail "tar is required" +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + fail "sha256sum or shasum is required" +fi + +channel="${T3CODE_CHANNEL:-stable}" +version="${T3CODE_VERSION:-}" +if [ -z "$version" ]; then + # Tags are v; the channel is the prerelease identifier, or none for + # stable. Only tags of the requested train are considered, so a stable + # install can never pick up a nightly or preview build by accident. + case "$channel" in + stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; + nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; + *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; + esac + tmp_index="$(mktemp)" + fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + rm -f "$tmp_index" + [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" +fi +case "$version" in + *-preview.*) + printf '%s\n' \ + "t3 ${version} is a preview build." \ + " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ + " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ + " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 + if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then + fail "refusing a preview build that was not explicitly requested" + fi + ;; +esac + +stem="t3-${version}-${platform}-${arch}" +archive="${stem}.tar.gz" +versions_dir="${t3_home}/runtime/versions" +target_dir="${versions_dir}/${version}" + +if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then + printf 't3 %s is already installed at %s\n' "$version" "$target_dir" +else + mkdir -p "$versions_dir" + staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" + trap 'rm -rf "$staging"' EXIT + + printf 'Downloading %s...\n' "$archive" + fetch_status=0 + fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? + if [ "$fetch_status" -eq 44 ]; then + fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" + elif [ "$fetch_status" -ne 0 ]; then + fail "could not download the release checksums" + fi + fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" + + expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" + [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" + actual="$(checksum "${staging}/${archive}")" + [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" + + tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 + rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" + "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" + printf '%s\n' "$version" > "${staging}/.install-complete" + + rm -rf "$target_dir" + mv "$staging" "$target_dir" + trap - EXIT +fi + +mkdir -p "$bin_dir" +ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" +case ":${PATH}:" in + *":${bin_dir}:"*) ;; + *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; +esac