authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 21:24:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 21:24:22-07:00
logaf6c138a0de297f94ddef3133e2f47d017b46e94
tree0ac168ff249cfda0d1d00b0de7b7a5d5cc77b5c2
parentc7f23c4668ce3ff2e1b2f63fe51759d1bcc159f8
signature Signed by PGP key B5690EEEBB952194

feat(server): manage runtimes as release archives only, never from npm (#11510)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

34 files changed, 884 insertions(+), 1421 deletions(-)

apps/desktop/src/main.ts+5-20
...@@ -17,8 +17,6 @@ import * as Electron from "electron";...@@ -17,8 +17,6 @@ import * as Electron from "electron";
1717
18import * as NetService from "@t3tools/shared/Net";18import * as NetService from "@t3tools/shared/Net";
19import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";19import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
20import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease";
21import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
22import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";20import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
23import serverPackageJson from "../../server/package.json" with { type: "json" };21import serverPackageJson from "../../server/package.json" with { type: "json" };
2422
...@@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap(...@@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap(
87 }),85 }),
88);86);
8987
88// The remote runs the exact release this app is on, from its self-contained
89// archive, so it needs neither Node nor npm. Development points the remote at
90// a source checkout instead so the two sides can be iterated together.
90const resolveDesktopSshCliRunner = (91const resolveDesktopSshCliRunner = (
91 environment: DesktopEnvironment.DesktopEnvironment["Service"],92 environment: DesktopEnvironment.DesktopEnvironment["Service"],
92 settings: DesktopAppSettings.DesktopSettings,
93): RemoteT3RunnerOptions => {93): RemoteT3RunnerOptions => {
94 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);94 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
95 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {95 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
...@@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = (...@@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = (
98 nodeEngineRange: serverPackageJson.engines.node,98 nodeEngineRange: serverPackageJson.engines.node,
99 };99 };
100 }100 }
101 // Preview builds ship as self-contained archives, so the remote runs the101 return { archiveVersion: environment.appVersion };
102 // same version this app is on without Node or npm.
103 if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) {
104 return { archiveVersion: environment.appVersion };
105 }
106 return {
107 packageSpec: resolveRemoteT3CliPackageSpec({
108 appVersion: environment.appVersion,
109 updateChannel: settings.updateChannel,
110 isDevelopment: environment.isDevelopment,
111 }),
112 nodeEngineRange: serverPackageJson.engines.node,
113 };
114};102};
115103
116const desktopSshEnvironmentLayer = Layer.unwrap(104const desktopSshEnvironmentLayer = Layer.unwrap(
117 Effect.gen(function* () {105 Effect.gen(function* () {
118 const environment = yield* DesktopEnvironment.DesktopEnvironment;106 const environment = yield* DesktopEnvironment.DesktopEnvironment;
119 const settings = yield* DesktopAppSettings.DesktopAppSettings;
120 return DesktopSshEnvironment.layer({107 return DesktopSshEnvironment.layer({
121 resolveCliRunner: settings.get.pipe(108 resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)),
122 Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)),
123 ),
124 });109 });
125 }),110 }),
126);111);
apps/desktop/src/ssh/DesktopSshEnvironment.ts+4-8
...@@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service<...@@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service<
69>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}69>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}
7070
71export interface DesktopSshEnvironmentLayerOptions {71export interface DesktopSshEnvironmentLayerOptions {
72 readonly resolveCliPackageSpec?: () => string;
73 readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;72 readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;
74}73}
7574
...@@ -168,13 +167,10 @@ export const make = Effect.gen(function* () {...@@ -168,13 +167,10 @@ export const make = Effect.gen(function* () {
168export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>167export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
169 Layer.effect(DesktopSshEnvironment, make).pipe(168 Layer.effect(DesktopSshEnvironment, make).pipe(
170 Layer.provide(169 Layer.provide(
171 SshTunnel.SshEnvironmentManager.layer({170 SshTunnel.SshEnvironmentManager.layer(
172 ...(options.resolveCliPackageSpec === undefined171 options.resolveCliRunner === undefined
173 ? {}172 ? {}
174 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),173 : { resolveCliRunner: options.resolveCliRunner },
175 ...(options.resolveCliRunner === undefined174 ),
176 ? {}
177 : { resolveCliRunner: options.resolveCliRunner }),
178 }),
179 ),175 ),
180 );176 );
apps/marketing/.gitignore created+2
...@@ -0,0 +1,2 @@
1/public/install.sh
2/public/install.ps1
apps/marketing/package.json+3-2
...@@ -4,8 +4,9 @@...@@ -4,8 +4,9 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
7 "dev": "astro dev",7 "stage:install-scripts": "node scripts/stage-install-scripts.mjs",
8 "build": "astro build",8 "dev": "node scripts/stage-install-scripts.mjs && astro dev",
9 "build": "node scripts/stage-install-scripts.mjs && astro build",
9 "preview": "astro preview",10 "preview": "astro preview",
10 "typecheck": "astro check"11 "typecheck": "astro check"
11 },12 },
apps/marketing/public/install.ps1 deleted-120
...@@ -1,120 +0,0 @@
1# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs
2# only PowerShell 5.1+; no Node, npm, or compiler.
3#
4# irm https://t3.codes/install.ps1 | iex
5#
6# Environment:
7# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
8# (default: stable; preview is a maintainers' test train)
9# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
10# T3CODE_HOME T3 home directory (default: ~\.t3)
11# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
12# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
13#
14# The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the
15# same layout `t3 service install` uses, so the service reuses this download.
16$ErrorActionPreference = "Stop"
17[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
18
19$repo = "pingdotgg/t3code"
20$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
21$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" }
22$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" }
23
24function Fail([string] $message) {
25 Write-Error "t3 install: $message"
26 exit 1
27}
28
29# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell
30# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts
31# may lack.
32$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
33$arch = switch ($rawArch) {
34 "AMD64" { "x64" }
35 "ARM64" { "arm64" }
36 default { Fail "unsupported architecture $rawArch" }
37}
38
39$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
40$version = $env:T3CODE_VERSION
41if (-not $version) {
42 # Tags are v<semver>; the channel is the prerelease identifier, or none for
43 # stable. Only tags of the requested train are considered, so a stable
44 # install can never pick up a nightly or preview build by accident.
45 $tagPattern = switch ($channel) {
46 "stable" { '^v\d+\.\d+\.\d+$' }
47 "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' }
48 "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' }
49 default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" }
50 }
51 $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" }
52 $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name
53 if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" }
54 $version = $tag.Substring(1)
55}
56if ($version -match '-preview\.') {
57 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."
58 if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) {
59 Fail "refusing a preview build that was not explicitly requested"
60 }
61}
62
63$stem = "t3-$version-win32-$arch"
64$archive = "$stem.zip"
65$versionsDir = Join-Path $t3Home "runtime\versions"
66$targetDir = Join-Path $versionsDir $version
67$marker = Join-Path $targetDir ".install-complete"
68
69if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
70 Write-Host "t3 $version is already installed at $targetDir"
71} else {
72 New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null
73 $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName())
74 New-Item -ItemType Directory -Path $staging | Out-Null
75 try {
76 Write-Host "Downloading $archive..."
77 try {
78 Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing
79 } catch {
80 $status = $_.Exception.Response.StatusCode.value__
81 if ($status -eq 404) {
82 Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead"
83 }
84 throw
85 }
86 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing
87
88 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
89 if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" }
90 $expected = ($expected -split "\s+")[0].ToLowerInvariant()
91 $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant()
92 if ($actual -ne $expected) { Fail "checksum mismatch for $archive" }
93
94 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force
95 # The archive wraps everything in one directory named after its stem.
96 Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging
97 Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force
98
99 & (Join-Path $staging "t3.exe") --version | Out-Null
100 if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" }
101 Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline
102
103 if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force }
104 Move-Item $staging $targetDir
105 } catch {
106 if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
107 throw
108 }
109}
110
111New-Item -ItemType Directory -Force -Path $binDir | Out-Null
112$shim = Join-Path $binDir "t3.cmd"
113# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt
114# non-ASCII characters in the user's home path.
115[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false))
116Write-Host "Installed t3 $version"
117Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')"
118if (($env:PATH -split ";") -notcontains $binDir) {
119 Write-Host "Add $binDir to your PATH to run t3."
120}
apps/marketing/public/install.sh deleted-140
...@@ -1,140 +0,0 @@
1#!/bin/sh
2# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar,
3# sha256sum or shasum, and curl or wget; no Node, npm, or compiler.
4#
5# curl -fsSL https://t3.codes/install.sh | sh
6#
7# Environment:
8# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
9# (default: stable; preview is a maintainers' test train)
10# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
11# T3CODE_HOME T3 home directory (default: ~/.t3)
12# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)
13# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
14#
15# The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the
16# same layout `t3 service install` uses, so the service reuses this download
17# instead of fetching the release again.
18set -eu
19
20repo="pingdotgg/t3code"
21base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}"
22t3_home="${T3CODE_HOME:-$HOME/.t3}"
23bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
24
25fail() {
26 printf 't3 install: %s\n' "$1" >&2
27 exit 1
28}
29
30# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
31fetch() {
32 if command -v curl >/dev/null 2>&1; then
33 status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1
34 case "$status" in
35 2??) return 0 ;;
36 404) return 44 ;;
37 *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;;
38 esac
39 elif command -v wget >/dev/null 2>&1; then
40 wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0
41 if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi
42 cat "$2.headers" >&2; rm -f "$2.headers"; return 1
43 else
44 fail "curl or wget is required"
45 fi
46}
47
48case "$(uname -s)" in
49 Darwin) platform="darwin" ;;
50 Linux) platform="linux" ;;
51 *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;;
52esac
53case "$(uname -m)" in
54 arm64 | aarch64) arch="arm64" ;;
55 x86_64 | amd64) arch="x64" ;;
56 *) fail "unsupported architecture $(uname -m)" ;;
57esac
58command -v tar >/dev/null 2>&1 || fail "tar is required"
59if command -v sha256sum >/dev/null 2>&1; then
60 checksum() { sha256sum "$1" | cut -d' ' -f1; }
61elif command -v shasum >/dev/null 2>&1; then
62 checksum() { shasum -a 256 "$1" | cut -d' ' -f1; }
63else
64 fail "sha256sum or shasum is required"
65fi
66
67channel="${T3CODE_CHANNEL:-stable}"
68version="${T3CODE_VERSION:-}"
69if [ -z "$version" ]; then
70 # Tags are v<semver>; the channel is the prerelease identifier, or none for
71 # stable. Only tags of the requested train are considered, so a stable
72 # install can never pick up a nightly or preview build by accident.
73 case "$channel" in
74 stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;;
75 nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;;
76 *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;;
77 esac
78 tmp_index="$(mktemp)"
79 fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index"
80 version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
81 rm -f "$tmp_index"
82 [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION"
83fi
84case "$version" in
85 *-preview.*)
86 printf '%s\n' \
87 "t3 ${version} is a preview build." \
88 " Preview builds are cut by maintainers from unreleased branches to exercise the release" \
89 " pipeline. They can be broken, receive no fixes, and are never offered as updates." \
90 " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2
91 if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then
92 fail "refusing a preview build that was not explicitly requested"
93 fi
94 ;;
95esac
96
97stem="t3-${version}-${platform}-${arch}"
98archive="${stem}.tar.gz"
99versions_dir="${t3_home}/runtime/versions"
100target_dir="${versions_dir}/${version}"
101
102if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then
103 printf 't3 %s is already installed at %s\n' "$version" "$target_dir"
104else
105 mkdir -p "$versions_dir"
106 staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")"
107 trap 'rm -rf "$staging"' EXIT
108
109 printf 'Downloading %s...\n' "$archive"
110 fetch_status=0
111 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$?
112 if [ "$fetch_status" -eq 44 ]; then
113 fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead"
114 elif [ "$fetch_status" -ne 0 ]; then
115 fail "could not download the release checksums"
116 fi
117 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"
118
119 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
120 [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS"
121 actual="$(checksum "${staging}/${archive}")"
122 [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}"
123
124 tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1
125 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS"
126 "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run"
127 printf '%s\n' "$version" > "${staging}/.install-complete"
128
129 rm -rf "$target_dir"
130 mv "$staging" "$target_dir"
131 trap - EXIT
132fi
133
134mkdir -p "$bin_dir"
135ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
136printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3"
137case ":${PATH}:" in
138 *":${bin_dir}:"*) ;;
139 *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;;
140esac
apps/marketing/scripts/stage-install-scripts.mjs created+15
...@@ -0,0 +1,15 @@
1// The CLI install scripts live in scripts/ at the repo root with the rest of
2// the release tooling; the site serves them at /install.sh and /install.ps1.
3// Copy them into public/ before every Astro build and dev server so the two
4// never drift. The copies are gitignored.
5import * as NodeFS from "node:fs";
6import * as NodePath from "node:path";
7import * as NodeURL from "node:url";
8
9const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)));
10const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir));
11const publicDir = NodePath.join(marketingDir, "public");
12NodeFS.mkdirSync(publicDir, { recursive: true });
13for (const name of ["install.sh", "install.ps1"]) {
14 NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name));
15}
apps/server/package.json+1-1
...@@ -16,7 +16,7 @@...@@ -16,7 +16,7 @@
16 "type": "module",16 "type": "module",
17 "scripts": {17 "scripts": {
18 "dev": "node --watch src/bin.ts",18 "dev": "node --watch src/bin.ts",
19 "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean",19 "build:bundle": "vp pack",
20 "build:exe": "node scripts/cli.ts build-exe",20 "build:exe": "node scripts/cli.ts build-exe",
21 "start": "node dist/bin.mjs",21 "start": "node dist/bin.mjs",
22 "typecheck": "tsc --noEmit",22 "typecheck": "tsc --noEmit",
apps/server/scripts/cli.ts+1-5
...@@ -284,11 +284,7 @@ const publishCmd = Command.make(...@@ -284,11 +284,7 @@ const publishCmd = Command.make(
284 const packageJsonPath = path.join(serverDir, "package.json");284 const packageJsonPath = path.join(serverDir, "package.json");
285285
286 // Assert build assets exist286 // Assert build assets exist
287 for (const relPath of [287 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
288 "dist/bin.mjs",
289 "dist/service-launcher.mjs",
290 "dist/client/index.html",
291 ]) {
292 const abs = path.join(serverDir, relPath);288 const abs = path.join(serverDir, relPath);
293 if (!(yield* fs.exists(abs))) {289 if (!(yield* fs.exists(abs))) {
294 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });290 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
apps/server/src/cli/service.test.ts+1-2
...@@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {...@@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {
105 Effect.sync(() => {105 Effect.sync(() => {
106 installOptions.push(options);106 installOptions.push(options);
107 return {107 return {
108 program: ["/test/node", "/test/service-launcher.mjs"],108 program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"],
109 launcherPath: "/test/service-launcher.mjs",
110 baseDir: "/test/t3",109 baseDir: "/test/t3",
111 unitPath: serviceStatus.unitPath,110 unitPath: serviceStatus.unitPath,
112 logPath: serviceStatus.logPath,111 logPath: serviceStatus.logPath,
apps/server/src/cli/serviceLauncher.ts+3-4
...@@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli";...@@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli";
4import { main as runServiceLauncher } from "../serviceLauncher.ts";4import { main as runServiceLauncher } from "../serviceLauncher.ts";
55
6/**6/**
7 * Hosts the service launcher inside the CLI executable. Archive-distributed7 * Hosts the service launcher inside the CLI executable. The service manager
8 * runtimes have no Node on the machine to run `service-launcher.mjs`, so the8 * runs `t3 __service-launcher` and the launcher spawns the server from the
9 * service manager runs `t3 __service-launcher` and the launcher spawns the9 * same executable, so the machine needs no Node to run either.
10 * server from the same executable.
11 *10 *
12 * The launcher owns SIGTERM handling and the process lifetime: it must finish11 * The launcher owns SIGTERM handling and the process lifetime: it must finish
13 * stopping its child before the process exits, so it runs detached from the12 * stopping its child before the process exits, so it runs detached from the
apps/server/src/cli/update.ts+3-14
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import {1import {
2 HostProcessArchitecture,2 HostProcessArchitecture,
3 HostProcessEnvironment,3 HostProcessEnvironment,
4 HostProcessExecutablePath,
5 HostProcessInvokedAs,4 HostProcessInvokedAs,
6 HostProcessIsExecutable,5 HostProcessIsExecutable,
7 HostProcessPlatform,6 HostProcessPlatform,
...@@ -12,7 +11,6 @@ import {...@@ -12,7 +11,6 @@ import {
12 CLI_RELEASE_CHANNELS,11 CLI_RELEASE_CHANNELS,
13 cliReleaseIndexPageUrl,12 cliReleaseIndexPageUrl,
14 cliReleaseChannelOf,13 cliReleaseChannelOf,
15 isArchiveDistributedVersion,
16 newestCliReleaseVersion,14 newestCliReleaseVersion,
17 type CliReleaseChannel,15 type CliReleaseChannel,
18} from "@t3tools/shared/cliRelease";16} from "@t3tools/shared/cliRelease";
...@@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun...@@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun
320 Effect.map((result) => (result.code === 0 ? result.stdout : "")),318 Effect.map((result) => (result.code === 0 ? result.stdout : "")),
321 Effect.orElseSucceed(() => ""),319 Effect.orElseSucceed(() => ""),
322 );320 );
323 return /__service-launcher|service-launcher\.mjs/.test(command);321 return /__service-launcher/.test(command);
324 }322 }
325 return false;323 return false;
326});324});
...@@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
338 const runner = yield* ProcessRunner.ProcessRunner;336 const runner = yield* ProcessRunner.ProcessRunner;
339 const platform = yield* HostProcessPlatform;337 const platform = yield* HostProcessPlatform;
340 const arch = yield* HostProcessArchitecture;338 const arch = yield* HostProcessArchitecture;
341 const execPath = yield* HostProcessExecutablePath;
342 const environment = yield* HostProcessEnvironment;339 const environment = yield* HostProcessEnvironment;
343 const httpClient = yield* HttpClient.HttpClient;340 const httpClient = yield* HttpClient.HttpClient;
344 const service = yield* BootService.BootService;341 const service = yield* BootService.BootService;
...@@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
410 ? serviceVersion407 ? serviceVersion
411 : currentVersion;408 : currentVersion;
412409
413 // Only archive-distributed versions install without Node and npm on the
414 // machine. Until nightly and stable ship archives, updating onto them from
415 // here would reintroduce the dependency this command exists to remove.
416 if (!isArchiveDistributedVersion(targetVersion)) {
417 return yield* new CliUpdateError({
418 reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`,
419 });
420 }
421 if (executableCurrent && serviceCurrent) {410 if (executableCurrent && serviceCurrent) {
422 yield* Console.log(411 yield* Console.log(
423 serviceVersion !== undefined412 serviceVersion !== undefined
...@@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {...@@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
480 validate: (paths) =>469 validate: (paths) =>
481 runner470 runner
482 .run({471 .run({
483 command: pinnedRuntimeCommand(paths, execPath).command,472 command: pinnedRuntimeCommand(paths).command,
484 args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"],473 args: [...pinnedRuntimeCommand(paths).args, "--version"],
485 timeout: Duration.seconds(30),474 timeout: Duration.seconds(30),
486 })475 })
487 .pipe(476 .pipe(
apps/server/src/cloud/bootService.test.ts+45-82
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import {3import {
4 HostProcessArguments,
5 HostProcessExecutablePath,4 HostProcessExecutablePath,
6 HostProcessPlatform,5 HostProcessPlatform,
7 HostProcessUserId,6 HostProcessUserId,
...@@ -12,6 +11,7 @@ import * as Effect from "effect/Effect";...@@ -12,6 +11,7 @@ import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";13import * as Path from "effect/Path";
14import { HttpClient } from "effect/unstable/http";
15import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";15import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1616
17import * as ProcessRunner from "../processRunner.ts";17import * as ProcessRunner from "../processRunner.ts";
...@@ -23,24 +23,25 @@ import {...@@ -23,24 +23,25 @@ import {
23 serviceStateHasPendingUpdate,23 serviceStateHasPendingUpdate,
24} from "./serviceProtocol.ts";24} from "./serviceProtocol.ts";
2525
26it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {26const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3";
27 const unit = BootService.renderBootServiceUnit({27const linuxPlan = {
28 program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"],28 program: [linuxRuntime, "__service-launcher"],
29 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",29 baseDir: "/home/theo/.t3",
30 baseDir: "/home/theo/.t3",30 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
31 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",31 unitPath: "/home/theo/.config/systemd/user/t3code.service",
32 unitPath: "/home/theo/.config/systemd/user/t3code.service",32};
33 });33
34it("runs the pinned runtime's own executable as the systemd launcher", () => {
35 const unit = BootService.renderBootServiceUnit(linuxPlan);
3436
35 expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs");37 expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`);
36 expect(unit).toContain("KillMode=mixed");38 expect(unit).toContain("KillMode=mixed");
37 expect(unit).not.toContain("versions/1.2.3");39 expect(unit).not.toContain("node");
38});40});
3941
40it("reads the served T3 home back out of a rendered unit or plist", () => {42it("reads the served T3 home back out of a rendered unit or plist", () => {
41 const plan = (baseDir: string) => ({43 const plan = (baseDir: string) => ({
42 program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`],44 program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"],
43 launcherPath: `${baseDir}/runtime/service-launcher.mjs`,
44 baseDir,45 baseDir,
45 logPath: `${baseDir}/userdata/logs/boot-service.log`,46 logPath: `${baseDir}/userdata/logs/boot-service.log`,
46 unitPath: "/home/theo/.config/systemd/user/t3code.service",47 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", () => {...@@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => {
66 expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined();67 expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined();
67});68});
6869
69it("runs archive-distributed runtimes as their own executable", () => {
70 const unit = BootService.renderBootServiceUnit({
71 program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"],
72 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
73 baseDir: "/home/theo/.t3",
74 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
75 unitPath: "/home/theo/.config/systemd/user/t3code.service",
76 });
77
78 expect(unit).toContain(
79 "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher",
80 );
81 expect(unit).not.toContain("node");
82});
83
84it("survives the kernel OOM-killing a greedy agent child", () => {70it("survives the kernel OOM-killing a greedy agent child", () => {
85 const unit = BootService.renderBootServiceUnit({71 const unit = BootService.renderBootServiceUnit(linuxPlan);
86 program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"],
87 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
88 baseDir: "/home/theo/.t3",
89 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
90 unitPath: "/home/theo/.config/systemd/user/t3code.service",
91 });
9272
93 expect(unit).toContain("OOMPolicy=continue");73 expect(unit).toContain("OOMPolicy=continue");
94});74});
9575
76const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3";
96const macPlan = {77const macPlan = {
97 program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"],78 program: [macRuntime, "__service-launcher"],
98 launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs",
99 baseDir: "/Users/theo/.t3",79 baseDir: "/Users/theo/.t3",
100 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",80 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
101 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",81 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
...@@ -104,12 +84,13 @@ const macInstallerPath =...@@ -104,12 +84,13 @@ const macInstallerPath =
104 "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin";84 "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin";
105const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath };85const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath };
10686
107it("keeps launchd pinned to the stable launcher rather than a versioned server", () => {87it("runs the pinned runtime's own executable as the launch agent", () => {
108 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);88 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
10989
110 expect(plist).toContain("<string>/opt/homebrew/bin/node</string>");90 expect(plist).toContain(
111 expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>");91 ` <array>\n <string>${macRuntime}</string>\n <string>__service-launcher</string>\n </array>`,
112 expect(plist).not.toContain("versions/1.2.3");92 );
93 expect(plist).not.toContain("node</string>");
113});94});
11495
115it("preserves the installer's provider search path in the launch agent", () => {96it("preserves the installer's provider search path in the launch agent", () => {
...@@ -150,23 +131,18 @@ it("escapes XML in host paths", () => {...@@ -150,23 +131,18 @@ it("escapes XML in host paths", () => {
150131
151const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (132const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
152 platform: NodeJS.Platform = "linux",133 platform: NodeJS.Platform = "linux",
153 usePinnedLauncher = false,
154 installerPath = macInstallerPath,134 installerPath = macInstallerPath,
155) {135) {
156 const fs = yield* FileSystem.FileSystem;136 const fs = yield* FileSystem.FileSystem;
157 const path = yield* Path.Path;137 const path = yield* Path.Path;
158 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });138 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
159 const baseDir = path.join(home, ".t3");139 const baseDir = path.join(home, ".t3");
160 const sourceLauncher = path.join(home, "service-launcher.mjs");
161 const statePath = path.join(baseDir, "runtime", "service-state.json");140 const statePath = path.join(baseDir, "runtime", "service-state.json");
162 yield* fs.writeFileString(sourceLauncher, "export {};\n");141 // A complete pinned runtime is already present, so install only validates
163 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");142 // it and never downloads a release archive.
143 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform);
164 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });144 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
165 yield* fs.writeFileString(runtime.entryPath, "export {};\n");145 yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n");
166 yield* fs.writeFileString(
167 path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"),
168 "export const source = 'pinned runtime';\n",
169 );
170 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");146 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
171147
172 const commands: string[] = [];148 const commands: string[] = [];
...@@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
204 }180 }
205 return {181 return {
206 stdout:182 stdout:
207 input.args[1] === "--version"183 input.args[0] === "--version"
208 ? "t3 v1.2.3\n"184 ? "t3 v1.2.3\n"
209 : input.command === "loginctl" && input.args[0] === "show-user"185 : input.command === "loginctl" && input.args[0] === "show-user"
210 ? `${control.linger}\n`186 ? `${control.linger}\n`
...@@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (...@@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
230 baseDir,206 baseDir,
231 logsDir: path.join(baseDir, "userdata", "logs"),207 logsDir: path.join(baseDir, "userdata", "logs"),
232 cliVersion: "1.2.3",208 cliVersion: "1.2.3",
233 host: {209 host: { execPath: "/usr/bin/t3" },
234 execPath: "/usr/bin/node",
235 ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
236 },
237 }).pipe(210 }).pipe(
238 Effect.provideService(ProcessRunner.ProcessRunner, runner),211 Effect.provideService(ProcessRunner.ProcessRunner, runner),
239 Effect.provide(212 Effect.provide(
240 Layer.mergeAll(213 Layer.mergeAll(
241 Layer.succeed(HostProcessPlatform, platform),214 Layer.succeed(HostProcessPlatform, platform),
242 Layer.succeed(HostProcessUserId, 501),215 Layer.succeed(HostProcessUserId, 501),
243 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),216 Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"),
244 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),217 Layer.succeed(
218 HttpClient.HttpClient,
219 HttpClient.make(() => Effect.die("no release download expected")),
220 ),
245 ConfigProvider.layer(221 ConfigProvider.layer(
246 ConfigProvider.fromEnv({222 ConfigProvider.fromEnv({
247 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },223 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },
...@@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
275 expect(error.message).toContain("last login session ends");251 expect(error.message).toContain("last login session ends");
276 expect(yield* fs.exists(before.unitPath)).toBe(false);252 expect(yield* fs.exists(before.unitPath)).toBe(false);
277 expect(yield* fs.exists(statePath)).toBe(false);253 expect(yield* fs.exists(statePath)).toBe(false);
278 expect(254 expect(commands.some((command) => command.includes("--version"))).toBe(false);
279 commands.some((command) => command.startsWith("npm ") || command.includes("--version")),
280 ).toBe(false);
281 expect(255 expect(
282 commands.some(256 commands.some(
283 (command) => command.includes("daemon-reload") || command.includes("restart"),257 (command) => command.includes("daemon-reload") || command.includes("restart"),
...@@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
350324
351 it.effect("installs, reports current state, and uninstalls", () =>325 it.effect("installs, reports current state, and uninstalls", () =>
352 Effect.gen(function* () {326 Effect.gen(function* () {
353 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();327 const { service, fs, statePath, timeouts, runtime } = yield* makeHarness();
354 const plan = yield* service.install();328 const plan = yield* service.install();
355329
356 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({330 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
357 protocol: SERVICE_LAUNCHER_PROTOCOL,331 protocol: SERVICE_LAUNCHER_PROTOCOL,
358 activeVersion: "1.2.3",332 activeVersion: "1.2.3",
359 });333 });
360 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");334 expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]);
335 expect(yield* fs.readFileString(plan.unitPath)).toContain(
336 `ExecStart=${runtime.entryPath} __service-launcher`,
337 );
361 expect(yield* service.status).toMatchObject({338 expect(yield* service.status).toMatchObject({
362 current: true,339 current: true,
363 installedVersion: "1.2.3",340 installedVersion: "1.2.3",
...@@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
378 expect((yield* service.status).current).toBe(false);355 expect((yield* service.status).current).toBe(false);
379 expect(yield* service.uninstall).toBe(true);356 expect(yield* service.uninstall).toBe(true);
380 expect((yield* service.status).installed).toBe(false);357 expect((yield* service.status).installed).toBe(false);
381 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
382 // The stop can block up to systemd's 90s TimeoutStopSec; the runner's358 // The stop can block up to systemd's 90s TimeoutStopSec; the runner's
383 // 60s default would cancel it mid-shutdown.359 // 60s default would cancel it mid-shutdown.
384 expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(360 expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
...@@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
432 Effect.gen(function* () {408 Effect.gen(function* () {
433 const { service, fs, statePath, commands, control } = yield* makeHarness(platform);409 const { service, fs, statePath, commands, control } = yield* makeHarness(platform);
434 const plan = yield* service.install();410 const plan = yield* service.install();
435 const launcher = yield* fs.readFileString(plan.launcherPath);
436 const unit = yield* fs.readFileString(plan.unitPath);411 const unit = yield* fs.readFileString(plan.unitPath);
437 control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`;412 control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`;
438 commands.length = 0;413 commands.length = 0;
...@@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
445 targetVersion: "1.2.3",420 targetVersion: "1.2.3",
446 });421 });
447 expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop);422 expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop);
448 expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher);
449 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);423 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
450 expect(424 expect(
451 commands.filter(425 commands.filter(
...@@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
495 }),469 }),
496 );470 );
497471
498 it.effect("copies the launcher from the prepared pinned runtime", () =>
499 Effect.gen(function* () {
500 const { service, fs } = yield* makeHarness("linux", true);
501 const plan = yield* service.install();
502
503 expect(yield* fs.readFileString(plan.launcherPath)).toBe(
504 "export const source = 'pinned runtime';\n",
505 );
506 }),
507 );
508
509 it.effect("restarts an installed service when repair fails", () =>472 it.effect("restarts an installed service when repair fails", () =>
510 Effect.gen(function* () {473 Effect.gen(function* () {
511 const { service, commands, control } = yield* makeHarness();474 const { service, commands, control } = yield* makeHarness();
...@@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
572535
573 it.effect("installs, reports current state, and uninstalls on macOS", () =>536 it.effect("installs, reports current state, and uninstalls on macOS", () =>
574 Effect.gen(function* () {537 Effect.gen(function* () {
575 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");538 const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin");
576 const path = yield* Path.Path;539 const path = yield* Path.Path;
577 const plan = yield* service.install();540 const plan = yield* service.install();
578541
...@@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
588 protocol: SERVICE_LAUNCHER_PROTOCOL,551 protocol: SERVICE_LAUNCHER_PROTOCOL,
589 activeVersion: "1.2.3",552 activeVersion: "1.2.3",
590 });553 });
591 expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n");554 expect(yield* fs.readFileString(plan.unitPath)).toContain(
555 ` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`,
556 );
592 expect(yield* service.status).toMatchObject({557 expect(yield* service.status).toMatchObject({
593 current: true,558 current: true,
594 installedVersion: "1.2.3",559 installedVersion: "1.2.3",
595 });560 });
596 expect(yield* service.uninstall).toBe(true);561 expect(yield* service.uninstall).toBe(true);
597 expect((yield* service.status).installed).toBe(false);562 expect((yield* service.status).installed).toBe(false);
598 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
599 expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);563 expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
600 // A bootout can block up to the plist's 90s ExitTimeOut; the runner's564 // A bootout can block up to the plist's 90s ExitTimeOut; the runner's
601 // 60s default would cancel it and let bootstrap race a loaded job.565 // 60s default would cancel it and let bootstrap race a loaded job.
...@@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
626590
627 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>591 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
628 Effect.gen(function* () {592 Effect.gen(function* () {
629 const { service, fs } = yield* makeHarness("darwin", false, "");593 const { service, fs } = yield* makeHarness("darwin", "");
630 const plan = yield* service.install();594 const plan = yield* service.install();
631595
632 expect(yield* fs.readFileString(plan.unitPath)).toContain(596 expect(yield* fs.readFileString(plan.unitPath)).toContain(
...@@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
638602
639 it.effect("adds missing provider directories to a minimal installer PATH", () =>603 it.effect("adds missing provider directories to a minimal installer PATH", () =>
640 Effect.gen(function* () {604 Effect.gen(function* () {
641 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");605 const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin");
642 const plan = yield* service.install();606 const plan = yield* service.install();
643607
644 expect(yield* fs.readFileString(plan.unitPath)).toContain(608 expect(yield* fs.readFileString(plan.unitPath)).toContain(
...@@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {...@@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
662 Effect.gen(function* () {626 Effect.gen(function* () {
663 const { service, fs } = yield* makeHarness(627 const { service, fs } = yield* makeHarness(
664 "darwin",628 "darwin",
665 false,
666 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",629 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
667 );630 );
668 const plan = yield* service.install();631 const plan = yield* service.install();
apps/server/src/cloud/bootService.ts+12-38
...@@ -26,7 +26,6 @@ import {...@@ -26,7 +26,6 @@ import {
26 PinnedRuntimeInstallError,26 PinnedRuntimeInstallError,
27} from "./pinnedRuntime.ts";27} from "./pinnedRuntime.ts";
28import {28import {
29 SERVICE_LAUNCHER_FILE,
30 SERVICE_LAUNCHER_PROTOCOL,29 SERVICE_LAUNCHER_PROTOCOL,
31 SERVICE_STATE_FILE,30 SERVICE_STATE_FILE,
32 compareExactServiceVersions,31 compareExactServiceVersions,
...@@ -86,7 +85,6 @@ export interface BootServicePlan {...@@ -86,7 +85,6 @@ export interface BootServicePlan {
86 * subcommand so the machine never needs Node.85 * subcommand so the machine never needs Node.
87 */86 */
88 readonly program: ReadonlyArray<string>;87 readonly program: ReadonlyArray<string>;
89 readonly launcherPath: string;
90 readonly baseDir: string;88 readonly baseDir: string;
91 readonly logPath: string;89 readonly logPath: string;
92 readonly unitPath: string;90 readonly unitPath: string;
...@@ -533,7 +531,6 @@ export class BootService extends Context.Service<...@@ -533,7 +531,6 @@ export class BootService extends Context.Service<
533531
534export interface BootServiceHost {532export interface BootServiceHost {
535 readonly execPath: string;533 readonly execPath: string;
536 readonly launcherSourcePath?: string;
537}534}
538535
539export const make = Effect.fn("cloud.boot_service.make")(function* (input: {536export 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: {...@@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
546 const platform = yield* HostProcessPlatform;543 const platform = yield* HostProcessPlatform;
547 const arch = yield* HostProcessArchitecture;544 const arch = yield* HostProcessArchitecture;
548 const uid = yield* HostProcessUserId;545 const uid = yield* HostProcessUserId;
549 // Archive-distributed versions download from GitHub Releases; npm versions546 const httpClient = yield* HttpClient.HttpClient;
550 // never touch HTTP, so callers without a client still work.
551 const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient));
552 const releaseBaseUrl = Option.getOrUndefined(547 const releaseBaseUrl = Option.getOrUndefined(
553 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),548 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
554 );549 );
...@@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
588 });583 });
589 const unitPath = detectedManager?.unitPath ?? "";584 const unitPath = detectedManager?.unitPath ?? "";
590 const logPath = path.join(input.logsDir, "boot-service.log");585 const logPath = path.join(input.logsDir, "boot-service.log");
591 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
592 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);586 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
593 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);587 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);
594 const launcherSourcePath =
595 host.launcherSourcePath ??
596 path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE);
597 const writeDurably = (filePath: string, contents: string) =>588 const writeDurably = (filePath: string, contents: string) =>
598 Effect.scoped(589 Effect.scoped(
599 Effect.gen(function* () {590 Effect.gen(function* () {
...@@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
613 );604 );
614 }),605 }),
615 ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));606 ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
607 // The executable hosts the launcher as a hidden subcommand of itself, so
608 // the unit runs the pinned runtime directly.
616 const plan: BootServicePlan = {609 const plan: BootServicePlan = {
617 program:610 program: [runtimePaths.entryPath, "__service-launcher"],
618 runtimePaths.layout === "archive"
619 ? [runtimePaths.entryPath, "__service-launcher"]
620 : [host.execPath, launcherPath],
621 launcherPath,
622 baseDir: input.baseDir,611 baseDir: input.baseDir,
623 logPath,612 logPath,
624 unitPath,613 unitPath,
...@@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
764 validate: (runtime) =>753 validate: (runtime) =>
765 runner754 runner
766 .run({755 .run({
767 command: pinnedRuntimeCommand(runtime, host.execPath).command,756 command: pinnedRuntimeCommand(runtime).command,
768 args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"],757 args: [...pinnedRuntimeCommand(runtime).args, "--version"],
769 timeout: Duration.seconds(30),758 timeout: Duration.seconds(30),
770 })759 })
771 .pipe(760 .pipe(
...@@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
803 : new BootServiceInstallError({ cause: error }),792 : new BootServiceInstallError({ cause: error }),
804 ),793 ),
805 );794 );
806 // Archive runtimes host the launcher in the executable itself; there is
807 // no standalone script to copy.
808 const launcherSource =
809 runtimePaths.layout === "archive"
810 ? undefined
811 : yield* fs
812 .readFileString(launcherSourcePath)
813 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
814
815 const installed = yield* fs795 const installed = yield* fs
816 .exists(unitPath)796 .exists(unitPath)
817 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));797 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
...@@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
844 yield* fs824 yield* fs
845 .makeDirectory(path.dirname(unitPath), { recursive: true })825 .makeDirectory(path.dirname(unitPath), { recursive: true })
846 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));826 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
847 if (launcherSource !== undefined) {
848 yield* writeDurably(launcherPath, launcherSource);
849 }
850 yield* writeDurably(827 yield* writeDurably(
851 statePath,828 statePath,
852 // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document.829 // @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: {...@@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
893 if (!(yield* fs.exists(unitPath))) {870 if (!(yield* fs.exists(unitPath))) {
894 return { supported: true, installed: false, current: false, unitPath, logPath };871 return { supported: true, installed: false, current: false, unitPath, logPath };
895 }872 }
896 const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] =873 const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([
897 yield* Effect.all([874 fs.readFileString(unitPath),
898 fs.readFileString(unitPath),875 fs.exists(runtimePaths.entryPath),
899 runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath),876 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
900 fs.exists(runtimePaths.entryPath),877 fs.readFileString(statePath).pipe(Effect.option),
901 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),878 ]);
902 fs.readFileString(statePath).pipe(Effect.option),
903 ]);
904 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;879 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
905 const installedVersion = Option.isSome(stateText)880 const installedVersion = Option.isSome(stateText)
906 ? serviceStateActiveVersion(stateText.value)881 ? serviceStateActiveVersion(stateText.value)
...@@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
920 current:895 current:
921 problems.length === 0 &&896 problems.length === 0 &&
922 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&897 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
923 launcherExists &&
924 runtimeEntryExists &&898 runtimeEntryExists &&
925 Option.isSome(runtimeSentinel) &&899 Option.isSome(runtimeSentinel) &&
926 runtimeSentinel.value.trim() === input.cliVersion &&900 runtimeSentinel.value.trim() === input.cliVersion &&
apps/server/src/cloud/pinnedRuntime.test.ts+79-193
...@@ -5,7 +5,6 @@ import * as Effect from "effect/Effect";...@@ -5,7 +5,6 @@ import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
7import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import { HttpClient, HttpClientResponse } from "effect/unstable/http";8import { HttpClient, HttpClientResponse } from "effect/unstable/http";
10import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1110
...@@ -17,16 +16,38 @@ import {...@@ -17,16 +16,38 @@ import {
17 PinnedRuntimeInstallError,16 PinnedRuntimeInstallError,
18} from "./pinnedRuntime.ts";17} from "./pinnedRuntime.ts";
1918
20const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>19// Every install fetches the release archive, checks it against SHA256SUMS,
20// and unpacks it with tar. The fake client serves both files; the fake runner
21// stands in for tar and drops the executable where extraction would.
22const version = "1.2.3";
23const archiveName = `t3-${version}-linux-x64.tar.gz`;
24const archiveBytes = new TextEncoder().encode("not really a tarball");
25const archiveHex = (bytes: Uint8Array) =>
26 Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe(
27 Effect.map((digest) =>
28 Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""),
29 ),
30 );
31const validChecksums = archiveHex(archiveBytes).pipe(
32 Effect.map((hex) => `${hex} ${archiveName}\n`),
33);
34const releaseHttpClient = (checksums: string, requests: string[] = []) =>
35 HttpClient.make((request) => {
36 requests.push(request.url);
37 const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes;
38 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body)));
39 });
40const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) =>
21 ProcessRunner.ProcessRunner.of({41 ProcessRunner.ProcessRunner.of({
22 run: (input) =>42 run: (input) =>
23 Effect.gen(function* () {43 Effect.gen(function* () {
24 const prefixIndex = input.args.indexOf("--prefix");44 commands.push(input.command);
25 const stagingDir = input.args[prefixIndex + 1];45 const targetIndex = input.args.indexOf("-C");
26 if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix");46 const stagingDir = input.args[targetIndex + 1];
27 const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs");47 if (input.command !== "tar" || stagingDir === undefined) {
28 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);48 return yield* Effect.die(`unexpected command ${input.command}`);
29 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);49 }
50 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
30 return {51 return {
31 stdout: "",52 stdout: "",
32 stderr: "",53 stderr: "",
...@@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>...@@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>
41 });62 });
4263
43it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {64it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
44 it.effect("installs through pnpm when its Node runtime has no npm executable", () =>65 it.effect("installs the verified release archive as the runtime executable", () =>
45 Effect.gen(function* () {66 Effect.gen(function* () {
46 const fs = yield* FileSystem.FileSystem;67 const fs = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;68 const path = yield* Path.Path;
48 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" });69 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" });
49 const commands: Array<ProcessRunner.ProcessRunInput> = [];70 const requests: string[] = [];
50 const install = successfulRunner(fs, path);71 const commands: string[] = [];
51 const paths = yield* ensurePinnedRuntimeInstalled({72 const paths = yield* ensurePinnedRuntimeInstalled({
52 baseDir,73 baseDir,
53 version: "1.2.3",74 version,
54 fs,75 fs,
55 path,76 path,
56 platform: "linux",77 platform: "linux",
57 arch: "x64",78 arch: "x64",
58 runner: ProcessRunner.ProcessRunner.of({79 httpClient: releaseHttpClient(yield* validChecksums, requests),
59 run: (input) => {80 releaseBaseUrl: "https://releases.example/download",
60 commands.push(input);81 runner: extractingRunner(fs, path, commands),
61 return input.command === "npm"
62 ? Effect.fail(
63 new ProcessRunner.ProcessSpawnError({
64 command: "npm",
65 argumentCount: input.args.length,
66 cause: PlatformError.systemError({
67 _tag: "NotFound",
68 module: "ChildProcess",
69 method: "spawn",
70 }),
71 }),
72 )
73 : install.run(input);
74 },
75 }),
76 validate: (staging) =>82 validate: (staging) =>
77 fs.exists(staging.entryPath).pipe(83 fs.exists(staging.entryPath).pipe(
78 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))),84 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))),
79 Effect.orDie,85 Effect.orDie,
80 ),86 ),
81 });87 });
82 assert.deepEqual(88 assert.equal(paths.entryPath, path.join(paths.versionDir, "t3"));
83 commands.map((command) => command.command),89 assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] });
84 ["npm", "pnpm"],90 assert.deepEqual(requests, [
85 );91 `https://releases.example/download/v${version}/SHA256SUMS`,
86 assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]);92 `https://releases.example/download/v${version}/${archiveName}`,
87 assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n");93 ]);
94 assert.deepEqual(commands, ["tar"]);
95 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`);
96 assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive")));
88 }),97 }),
89 );98 );
9099
91 it.effect("does not try a different installer for npm permission failures", () =>100 it.effect("refuses an archive whose checksum does not match the release", () =>
92 Effect.gen(function* () {101 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;102 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;103 const path = yield* Path.Path;
95 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" });104 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" });
96 const commands: string[] = [];105 const commands: string[] = [];
97 yield* ensurePinnedRuntimeInstalled({106 const error = yield* ensurePinnedRuntimeInstalled({
98 baseDir,107 baseDir,
99 version: "1.2.3",108 version,
100 fs,109 fs,
101 path,110 path,
102 platform: "linux",111 platform: "linux",
103 arch: "x64",112 arch: "x64",
104 runner: ProcessRunner.ProcessRunner.of({113 httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`),
105 run: (input) => {114 runner: extractingRunner(fs, path, commands),
106 commands.push(input.command);115 validate: () => Effect.die("must not validate an unverified archive"),
107 return Effect.fail(
108 new ProcessRunner.ProcessSpawnError({
109 command: input.command,
110 argumentCount: input.args.length,
111 cause: PlatformError.systemError({
112 _tag: "PermissionDenied",
113 module: "ChildProcess",
114 method: "spawn",
115 }),
116 }),
117 );
118 },
119 }),
120 validate: () => Effect.die("must not validate a failed install"),
121 }).pipe(Effect.flip);116 }).pipe(Effect.flip);
122 assert.deepEqual(commands, ["npm"]);117 assert.instanceOf(error, PinnedRuntimeInstallError);
118 assert.equal(error.step, "verifying the t3 release archive checksum");
119 assert.deepEqual(commands, []);
120 assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []);
123 }),121 }),
124 );122 );
125123
...@@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
128 const fs = yield* FileSystem.FileSystem;126 const fs = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;127 const path = yield* Path.Path;
130 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });128 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
131 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");129 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
132 let validatedDirectory = "";130 let validatedDirectory = "";
133131
134 const installed = yield* ensurePinnedRuntimeInstalled({132 const installed = yield* ensurePinnedRuntimeInstalled({
135 baseDir,133 baseDir,
136 version: "1.2.3",134 version,
137 fs,135 fs,
138 path,136 path,
139 platform: "linux",137 platform: "linux",
140 arch: "x64",138 arch: "x64",
141 runner: successfulRunner(fs, path),139 httpClient: releaseHttpClient(yield* validChecksums),
140 runner: extractingRunner(fs, path),
142 validate: (staging) =>141 validate: (staging) =>
143 Effect.gen(function* () {142 Effect.gen(function* () {
144 validatedDirectory = staging.versionDir;143 validatedDirectory = staging.versionDir;
...@@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
150 assert.notEqual(validatedDirectory, finalPaths.versionDir);149 assert.notEqual(validatedDirectory, finalPaths.versionDir);
151 assert.deepEqual(installed, finalPaths);150 assert.deepEqual(installed, finalPaths);
152 assert.isTrue(yield* fs.exists(finalPaths.entryPath));151 assert.isTrue(yield* fs.exists(finalPaths.entryPath));
153 assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n");152 assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`);
154 }),153 }),
155 );154 );
156155
...@@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
159 const fs = yield* FileSystem.FileSystem;158 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;159 const path = yield* Path.Path;
161 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });160 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
162 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");161 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
163162
164 yield* ensurePinnedRuntimeInstalled({163 yield* ensurePinnedRuntimeInstalled({
165 baseDir,164 baseDir,
166 version: "1.2.3",165 version,
167 fs,166 fs,
168 path,167 path,
169 platform: "linux",168 platform: "linux",
170 arch: "x64",169 arch: "x64",
171 runner: successfulRunner(fs, path),170 httpClient: releaseHttpClient(yield* validChecksums),
171 runner: extractingRunner(fs, path),
172 validate: () =>172 validate: () =>
173 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),173 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),
174 }).pipe(Effect.flip);174 }).pipe(Effect.flip);
...@@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
188 const fs = yield* FileSystem.FileSystem;188 const fs = yield* FileSystem.FileSystem;
189 const path = yield* Path.Path;189 const path = yield* Path.Path;
190 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });190 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
191 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");191 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
192 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });192 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });
193 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");193 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");
194194
195 yield* ensurePinnedRuntimeInstalled({195 yield* ensurePinnedRuntimeInstalled({
196 baseDir,196 baseDir,
197 version: "1.2.3",197 version,
198 fs,198 fs,
199 path,199 path,
200 platform: "linux",200 platform: "linux",
201 arch: "x64",201 arch: "x64",
202 runner: successfulRunner(fs, path),202 httpClient: releaseHttpClient(yield* validChecksums),
203 runner: extractingRunner(fs, path),
203 validate: () => Effect.void,204 validate: () => Effect.void,
204 });205 });
205206
...@@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
213 const fs = yield* FileSystem.FileSystem;214 const fs = yield* FileSystem.FileSystem;
214 const path = yield* Path.Path;215 const path = yield* Path.Path;
215 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });216 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
216 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");217 const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux");
217 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });218 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });
218 yield* fs.writeFileString(finalPaths.entryPath, "broken\n");219 yield* fs.writeFileString(finalPaths.entryPath, "broken\n");
219 yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n");220 yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`);
220221
221 let validations = 0;222 let validations = 0;
223 const requests: string[] = [];
222 yield* ensurePinnedRuntimeInstalled({224 yield* ensurePinnedRuntimeInstalled({
223 baseDir,225 baseDir,
224 version: "1.2.3",226 version,
225 fs,227 fs,
226 path,228 path,
227 platform: "linux",229 platform: "linux",
228 arch: "x64",230 arch: "x64",
229 runner: successfulRunner(fs, path),231 httpClient: releaseHttpClient(yield* validChecksums, requests),
232 runner: extractingRunner(fs, path),
230 validate: (paths) =>233 validate: (paths) =>
231 Effect.gen(function* () {234 Effect.gen(function* () {
232 validations += 1;235 validations += 1;
...@@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
238 }).pipe(Effect.flip);241 }).pipe(Effect.flip);
239242
240 assert.equal(validations, 1);243 assert.equal(validations, 1);
244 assert.deepEqual(requests, []);
241 assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n");245 assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n");
242 }),246 }),
243 );247 );
...@@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
253 });257 });
254 const install = yield* ensurePinnedRuntimeInstalled({258 const install = yield* ensurePinnedRuntimeInstalled({
255 baseDir,259 baseDir,
256 version: "1.2.3",260 version,
257 fs,261 fs,
258 path,262 path,
259 platform: "linux",263 platform: "linux",
260 arch: "x64",264 arch: "x64",
265 httpClient: releaseHttpClient(yield* validChecksums),
261 runner,266 runner,
262 validate: () => Effect.void,267 validate: () => Effect.void,
263 }).pipe(Effect.forkScoped);268 }).pipe(Effect.forkScoped);
...@@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {...@@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
268 assert.deepEqual(yield* fs.readDirectory(versionsDir), []);273 assert.deepEqual(yield* fs.readDirectory(versionsDir), []);
269 }),274 }),
270 );275 );
271
272 // Archive-distributed versions never touch npm: the release archive is
273 // fetched, checked against SHA256SUMS, and unpacked with tar.
274 const archiveVersion = "1.3.0-preview.20260911.7";
275 const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`;
276 const archiveBytes = new TextEncoder().encode("not really a tarball");
277 const archiveHex = (bytes: Uint8Array) =>
278 Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe(
279 Effect.map((digest) =>
280 Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""),
281 ),
282 );
283 const releaseHttpClient = (checksums: string, requests: string[]) =>
284 HttpClient.make((request) => {
285 requests.push(request.url);
286 const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes;
287 return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body)));
288 });
289 const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) =>
290 ProcessRunner.ProcessRunner.of({
291 run: (input) =>
292 Effect.gen(function* () {
293 commands.push(input.command);
294 const targetIndex = input.args.indexOf("-C");
295 const stagingDir = input.args[targetIndex + 1];
296 if (input.command !== "tar" || stagingDir === undefined) {
297 return yield* Effect.die(`unexpected command ${input.command}`);
298 }
299 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
300 return {
301 stdout: "",
302 stderr: "",
303 code: ChildProcessSpawner.ExitCode(0),
304 timedOut: false,
305 stdoutTruncated: false,
306 stderrTruncated: false,
307 stdoutInvalidUtf8: false,
308 stderrInvalidUtf8: false,
309 };
310 }),
311 });
312
313 it.effect("installs archive-distributed versions from the verified release archive", () =>
314 Effect.gen(function* () {
315 const fs = yield* FileSystem.FileSystem;
316 const path = yield* Path.Path;
317 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" });
318 const requests: string[] = [];
319 const commands: string[] = [];
320 const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`;
321 const paths = yield* ensurePinnedRuntimeInstalled({
322 baseDir,
323 version: archiveVersion,
324 fs,
325 path,
326 platform: "linux",
327 arch: "x64",
328 httpClient: releaseHttpClient(checksums, requests),
329 releaseBaseUrl: "https://releases.example/download",
330 runner: extractingRunner(fs, path, commands),
331 validate: (staging) =>
332 fs.exists(staging.entryPath).pipe(
333 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))),
334 Effect.orDie,
335 ),
336 });
337 assert.equal(paths.layout, "archive");
338 assert.equal(paths.entryPath, path.join(paths.versionDir, "t3"));
339 assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), {
340 command: paths.entryPath,
341 args: [],
342 });
343 assert.deepEqual(requests, [
344 `https://releases.example/download/v${archiveVersion}/SHA256SUMS`,
345 `https://releases.example/download/v${archiveVersion}/${archiveName}`,
346 ]);
347 assert.deepEqual(commands, ["tar"]);
348 assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`);
349 assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive")));
350 }),
351 );
352
353 it.effect("refuses an archive whose checksum does not match the release", () =>
354 Effect.gen(function* () {
355 const fs = yield* FileSystem.FileSystem;
356 const path = yield* Path.Path;
357 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" });
358 const commands: string[] = [];
359 const error = yield* ensurePinnedRuntimeInstalled({
360 baseDir,
361 version: archiveVersion,
362 fs,
363 path,
364 platform: "linux",
365 arch: "x64",
366 httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []),
367 runner: extractingRunner(fs, path, commands),
368 validate: () => Effect.die("must not validate an unverified archive"),
369 }).pipe(Effect.flip);
370 assert.instanceOf(error, PinnedRuntimeInstallError);
371 assert.equal(error.step, "verifying the t3 release archive checksum");
372 assert.deepEqual(commands, []);
373 assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []);
374 }),
375 );
376
377 it("runs npm layouts through the host Node", () => {
378 const paths = pinnedRuntimePaths(
379 { join: (...parts: string[]) => parts.join("/") } as Path.Path,
380 "/home/theo/.t3",
381 "1.2.3",
382 "linux",
383 );
384 assert.equal(paths.layout, "npm");
385 assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), {
386 command: "/usr/bin/node",
387 args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"],
388 });
389 });
390});276});
apps/server/src/cloud/pinnedRuntime.ts+25-108
...@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
3import * as Encoding from "effect/Encoding";3import * as Encoding from "effect/Encoding";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
8import * as Option from "effect/Option";7import * as Option from "effect/Option";
9import * as Semaphore from "effect/Semaphore";8import * as Semaphore from "effect/Semaphore";
...@@ -15,26 +14,21 @@ import {...@@ -15,26 +14,21 @@ import {
15 cliArchivePlatformKey,14 cliArchivePlatformKey,
16 cliArchiveTarCommand,15 cliArchiveTarCommand,
17 cliReleaseDownloadBaseUrl,16 cliReleaseDownloadBaseUrl,
18 isArchiveDistributedVersion,
19 parseChecksums,17 parseChecksums,
20} from "@t3tools/shared/cliRelease";18} from "@t3tools/shared/cliRelease";
2119
22import * as ProcessRunner from "../processRunner.ts";20import * as ProcessRunner from "../processRunner.ts";
2321
24/**22/**
25 * A pinned runtime is an exact `t3@<version>` installed into23 * A pinned runtime is an exact t3 release archive unpacked into
26 * <baseDir>/runtime/versions/<version>. The boot service points its unit or24 * <baseDir>/runtime/versions/<version>: the self-contained executable, the
27 * launch agent here, and server self-update installs the target version here before25 * web client, and the native packages beside it. The boot service points its
28 * switching over, never `npx t3`, whose cache is ephemeral and whose26 * unit or launch agent at the executable, and server self-update installs the
29 * registry fetch at boot would make startup depend on the network.27 * target version here before switching over. The runtime never depends on a
30 *28 * Node or npm on the machine; the only npm involvement in T3 Code is the `t3`
31 * Two layouts exist. npm-distributed versions are `npm install`ed and run as29 * package for people who prefer `npx t3` or `npm install -g t3`, and even a
32 * `<node> node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the30 * CLI installed that way pins an archive when it sets up the service.
33 * self-contained release archive unpacked in place and run as `./t3`, which
34 * needs neither Node nor npm on the machine. The layout is decided by the
35 * version string alone so every consumer agrees without probing the disk.
36 */31 */
37
38const PINNED_RUNTIME_DIR = "runtime";32const PINNED_RUNTIME_DIR = "runtime";
39const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);33const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
40const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";34const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
...@@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";...@@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
42// the complete install transaction across every caller in this process.36// the complete install transaction across every caller in this process.
43const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);37const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
4438
45export type PinnedRuntimeLayout = "npm" | "archive";
46
47export interface PinnedRuntimePaths {39export interface PinnedRuntimePaths {
48 readonly layout: PinnedRuntimeLayout;
49 readonly versionDir: string;40 readonly versionDir: string;
50 /**41 /** The executable. Its existence is what marks a runtime as present. */
51 * `bin.mjs` for npm layouts, the executable itself for archives. Existence
52 * of this file is what marks a runtime as present.
53 */
54 readonly entryPath: string;42 readonly entryPath: string;
55 readonly sentinelPath: string;43 readonly sentinelPath: string;
56}44}
5745
58/** The exact command that runs a pinned runtime, given the Node hosting the caller. */46/** The exact command that runs a pinned runtime. */
59export function pinnedRuntimeCommand(47export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): {
60 paths: PinnedRuntimePaths,48 readonly command: string;
61 nodePath: string,49 readonly args: ReadonlyArray<string>;
62): { readonly command: string; readonly args: ReadonlyArray<string> } {50} {
63 return paths.layout === "archive"51 return { command: paths.entryPath, args: [] };
64 ? { command: paths.entryPath, args: [] }
65 : { command: nodePath, args: [paths.entryPath] };
66}52}
6753
68export function pinnedRuntimePaths(54export function pinnedRuntimePaths(
...@@ -72,20 +58,10 @@ export function pinnedRuntimePaths(...@@ -72,20 +58,10 @@ export function pinnedRuntimePaths(
72 platform: NodeJS.Platform,58 platform: NodeJS.Platform,
73): PinnedRuntimePaths {59): PinnedRuntimePaths {
74 const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version);60 const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version);
75 const sentinelPath = path.join(versionDir, ".install-complete");
76 if (isArchiveDistributedVersion(version)) {
77 return {
78 layout: "archive",
79 versionDir,
80 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
81 sentinelPath,
82 };
83 }
84 return {61 return {
85 layout: "npm",
86 versionDir,62 versionDir,
87 entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),63 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
88 sentinelPath,64 sentinelPath: path.join(versionDir, ".install-complete"),
89 };65 };
90}66}
9167
...@@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne...@@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne
119}95}
12096
121/**97/**
122 * Installs `t3@<version>` into the pinned runtime directory unless a complete98 * Installs the t3 release archive for `version` into the pinned runtime
123 * install is already there, and returns its paths. The sentinel is written99 * directory unless a complete install is already there, and returns its
124 * only after the install step exits 0; checking the entry file alone is not100 * paths. The sentinel is written only after extraction and validation
125 * enough. npm extracts files before running native builds (node-pty), and tar101 * succeed; checking the entry file alone is not enough, since tar writes the
126 * writes the executable before the last native package, so a killed install102 * executable before the last native package and a killed install leaves a
127 * leaves a plausible-looking but broken tree behind.103 * plausible-looking but broken tree behind.
128 */104 */
129interface PinnedRuntimeInstallInput {105interface PinnedRuntimeInstallInput {
130 readonly baseDir: string;106 readonly baseDir: string;
...@@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput {...@@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput {
137 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;113 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
138 readonly platform: NodeJS.Platform;114 readonly platform: NodeJS.Platform;
139 readonly arch: string;115 readonly arch: string;
140 /** Archive-distributed versions download from here; npm versions never need it. */116 readonly httpClient: HttpClient.HttpClient;
141 readonly httpClient?: HttpClient.HttpClient | undefined;
142 readonly releaseBaseUrl?: string | undefined;117 readonly releaseBaseUrl?: string | undefined;
143}118}
144119
145const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* (
146 input: PinnedRuntimeInstallInput,
147 stagingDir: string,
148) {
149 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
150 const installArgs = [
151 "install",
152 "--prefix",
153 stagingDir,
154 "--no-fund",
155 "--no-audit",
156 `t3@${input.version}`,
157 ];
158 yield* input.runner
159 .run({
160 command: "npm",
161 args: installArgs,
162 // Native dependencies may compile from source on slower machines.
163 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
164 })
165 .pipe(
166 Effect.catchTags({
167 ProcessSpawnError: (error) =>
168 error.cause instanceof PlatformError.PlatformError &&
169 error.cause.reason._tag === "NotFound"
170 ? // pnpm-managed Node installations do not include npm. Keep npm
171 // installation semantics for the pinned runtime and native builds.
172 input.runner.run({
173 command: "pnpm",
174 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
175 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
176 })
177 : Effect.fail(error),
178 }),
179 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
180 Effect.filterOrFail(
181 (result) => result.code === 0,
182 (result) =>
183 new PinnedRuntimeInstallError({
184 step: installStep,
185 exitCode: Number(result.code),
186 stdoutLength: result.stdout.length,
187 stderrLength: result.stderr.length,
188 }),
189 ),
190 );
191});
192
193const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (120const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (
194 httpClient: HttpClient.HttpClient,121 httpClient: HttpClient.HttpClient,
195 url: string,122 url: string,
...@@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun...@@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun
227 });154 });
228 }155 }
229 const httpClient = input.httpClient;156 const httpClient = input.httpClient;
230 if (httpClient === undefined) {
231 return yield* new PinnedRuntimeInstallError({
232 step: "downloading the t3 release archive (no HTTP client available)",
233 });
234 }
235 const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl);157 const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl);
236 const fileName = cliArchiveFileName(input.version, platformKey);158 const fileName = cliArchiveFileName(input.version, platformKey);
237159
...@@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(...@@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
356 ),278 ),
357 );279 );
358 const stagingPaths: PinnedRuntimePaths = {280 const stagingPaths: PinnedRuntimePaths = {
359 layout: paths.layout,
360 versionDir: stagingDir,281 versionDir: stagingDir,
361 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),282 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),
362 sentinelPath: input.path.join(stagingDir, ".install-complete"),283 sentinelPath: input.path.join(stagingDir, ".install-complete"),
363 };284 };
364285
365 return yield* Effect.gen(function* () {286 return yield* Effect.gen(function* () {
366 if (paths.layout === "archive") {287 yield* installFromArchive(input, stagingDir);
367 yield* installFromArchive(input, stagingDir);
368 } else {
369 yield* installFromNpm(input, stagingDir);
370 }
371288
372 yield* input.validate(stagingPaths);289 yield* input.validate(stagingPaths);
373 yield* fs290 yield* fs
apps/server/src/cloud/selfUpdate.test.ts+33-10
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";2import { expect, it } from "@effect/vitest";
3import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";3import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";
4import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";4import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Cause from "effect/Cause";5import * as Cause from "effect/Cause";
6import * as Deferred from "effect/Deferred";6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
10import * as Path from "effect/Path";10import * as Path from "effect/Path";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
11import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";12import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1213
13import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
...@@ -25,6 +26,28 @@ interface HarnessOptions {...@@ -25,6 +26,28 @@ interface HarnessOptions {
25 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];26 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
26}27}
2728
29// The staged runtime is a release archive: the fake client serves SHA256SUMS
30// and the tarball, and the fake runner stands in for tar before it answers
31// the staged preflight.
32const archiveBytes = new TextEncoder().encode("not really a tarball");
33const releaseHttpClient = (order: string[]) =>
34 HttpClient.make((request) =>
35 Effect.gen(function* () {
36 if (request.url.endsWith("/SHA256SUMS")) {
37 const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes));
38 const hex = Array.from(new Uint8Array(digest), (byte) =>
39 byte.toString(16).padStart(2, "0"),
40 ).join("");
41 return HttpClientResponse.fromWeb(
42 request,
43 new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`),
44 );
45 }
46 order.push("download");
47 return HttpClientResponse.fromWeb(request, new Response(archiveBytes));
48 }),
49 );
50
28const makeHarness = Effect.fn("test.make_self_update_harness")(function* (51const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
29 options: HarnessOptions = {},52 options: HarnessOptions = {},
30) {53) {
...@@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (...@@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
35 const runner = ProcessRunner.ProcessRunner.of({58 const runner = ProcessRunner.ProcessRunner.of({
36 run: (input) =>59 run: (input) =>
37 Effect.gen(function* () {60 Effect.gen(function* () {
38 if (input.command === "npm") {61 if (input.command === "tar") {
39 order.push("install");62 order.push("extract");
40 const prefix = input.args[input.args.indexOf("--prefix") + 1];63 const stagingDir = input.args[input.args.indexOf("-C") + 1];
41 if (prefix === undefined) return yield* Effect.die("missing npm prefix");64 if (stagingDir === undefined) return yield* Effect.die("missing tar target");
42 const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");65 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
43 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
44 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
45 return {66 return {
46 stdout: "",67 stdout: "",
47 stderr: "",68 stderr: "",
...@@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (...@@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
99 run: () => Effect.die("unexpected desktop app update run"),120 run: () => Effect.die("unexpected desktop app update run"),
100 },121 },
101 ),122 ),
102 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),123 Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)),
124 Effect.provideService(HostProcessPlatform, "linux"),
125 Effect.provideService(HostProcessArchitecture, "x64"),
103 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),126 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
104 );127 );
105 return { selfUpdate, order };128 return { selfUpdate, order };
...@@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {...@@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
329 method: "boot-service",352 method: "boot-service",
330 updateId: "launcher-id",353 updateId: "launcher-id",
331 });354 });
332 expect(order).toEqual(["install", "preflight", "accept"]);355 expect(order).toEqual(["download", "extract", "preflight", "accept"]);
333 }),356 }),
334 );357 );
335358
apps/server/src/cloud/selfUpdate.ts+4-9
...@@ -6,11 +6,7 @@ import {...@@ -6,11 +6,7 @@ import {
6 type ServerSelfUpdateResult,6 type ServerSelfUpdateResult,
7 type ThreadId,7 type ThreadId,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
10 HostProcessArchitecture,
11 HostProcessExecutablePath,
12 HostProcessPlatform,
13} from "@t3tools/shared/hostProcess";
14import * as Cause from "effect/Cause";10import * as Cause from "effect/Cause";
15import * as Config from "effect/Config";11import * as Config from "effect/Config";
16import * as Context from "effect/Context";12import * as Context from "effect/Context";
...@@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {...@@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
180 const runner = yield* ProcessRunner.ProcessRunner;176 const runner = yield* ProcessRunner.ProcessRunner;
181 const fs = yield* FileSystem.FileSystem;177 const fs = yield* FileSystem.FileSystem;
182 const path = yield* Path.Path;178 const path = yield* Path.Path;
183 const execPath = yield* HostProcessExecutablePath;
184 const platform = yield* HostProcessPlatform;179 const platform = yield* HostProcessPlatform;
185 const arch = yield* HostProcessArchitecture;180 const arch = yield* HostProcessArchitecture;
186 // Archive-distributed targets download from GitHub Releases. The client is181 // Archive-distributed targets download from GitHub Releases. The client is
187 // optional so callers without one (tests, npm-only hosts) still construct.182 // optional so callers without one (tests, npm-only hosts) still construct.
188 const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient));183 const httpClient = yield* HttpClient.HttpClient;
189 const releaseBaseUrl = Option.getOrUndefined(184 const releaseBaseUrl = Option.getOrUndefined(
190 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),185 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
191 );186 );
...@@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {...@@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
241 validate: (runtime) =>236 validate: (runtime) =>
242 runner237 runner
243 .run({238 .run({
244 command: pinnedRuntimeCommand(runtime, execPath).command,239 command: pinnedRuntimeCommand(runtime).command,
245 args: [240 args: [
246 ...pinnedRuntimeCommand(runtime, execPath).args,241 ...pinnedRuntimeCommand(runtime).args,
247 "__service-preflight",242 "__service-preflight",
248 "--database-path",243 "--database-path",
249 serverConfig.dbPath,244 serverConfig.dbPath,
apps/server/src/cloud/serviceProtocol.ts-1
...@@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";...@@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
3/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */3/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */
4export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;4export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;
5export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";5export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
6export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
7export const SERVICE_STATE_FILE = "service-state.json";6export const SERVICE_STATE_FILE = "service-state.json";
8/** Written by the launcher just before an explicit stop kills its child, so7/** Written by the launcher just before an explicit stop kills its child, so
9 the child can tell "the service is going away" from "the launcher is about8 the child can tell "the service is going away" from "the launcher is about
apps/server/src/service-launcher.ts deleted-20
...@@ -1,20 +0,0 @@
1// Standalone launcher entry for npm-distributed runtimes: `node
2// service-launcher.mjs`. Archive runtimes reach the same code through the
3// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run
4// anything on import.
5import { isEntrypoint } from "./entrypoint.ts";
6import { main } from "./serviceLauncher.ts";
7
8if (
9 isEntrypoint({
10 moduleUrl: import.meta.url,
11 entryPath: process.argv[1],
12 runtimeMain: import.meta.main,
13 })
14) {
15 main().catch((cause: unknown) => {
16 const error = cause instanceof Error ? cause : new Error(String(cause));
17 process.stderr.write(`[service-launcher] ${error.message}\n`);
18 process.exitCode = 1;
19 });
20}
apps/server/src/serviceLauncher.test.ts+45-20
...@@ -75,6 +75,27 @@ it("rejects contradictory service state", () => {...@@ -75,6 +75,27 @@ it("rejects contradictory service state", () => {
75 );75 );
76});76});
7777
78// A pinned runtime is an executable at <versionDir>/t3. The tests stand one up
79// as a Node shebang script so the launcher spawns it the way it spawns the
80// real single-executable, IPC channel included.
81const writeFakeRuntime = (
82 fs: FileSystem.FileSystem,
83 path: Path.Path,
84 versionDir: string,
85 childSource: string,
86) =>
87 Effect.gen(function* () {
88 const entryPath = path.join(versionDir, "t3");
89 yield* fs.makeDirectory(versionDir, { recursive: true });
90 yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`);
91 yield* fs.chmod(entryPath, 0o755);
92 yield* fs.writeFileString(
93 path.join(versionDir, ".install-complete"),
94 `${path.basename(versionDir)}\n`,
95 );
96 return entryPath;
97 });
98
78it.layer(NodeServices.layer)("service state persistence", (it) => {99it.layer(NodeServices.layer)("service state persistence", (it) => {
79 it.effect("durably replaces and strictly reads one state document", () =>100 it.effect("durably replaces and strictly reads one state document", () =>
80 Effect.gen(function* () {101 Effect.gen(function* () {
...@@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {...@@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
98 const path = yield* Path.Path;119 const path = yield* Path.Path;
99 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" });120 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" });
100 const statePath = path.join(root, "runtime", "service-state.json");121 const statePath = path.join(root, "runtime", "service-state.json");
101 const versionDir = path.join(root, "runtime", "versions", "1.0.0");122 yield* writeFakeRuntime(
102 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");123 fs,
103 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });124 path,
104 yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n");125 path.join(root, "runtime", "versions", "1.0.0"),
105 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n");126 "setInterval(() => {}, 1_000);\n",
127 );
106 yield* Effect.promise(() =>128 yield* Effect.promise(() =>
107 writeServiceState(statePath, {129 writeServiceState(statePath, {
108 protocol: SERVICE_LAUNCHER_PROTOCOL,130 protocol: SERVICE_LAUNCHER_PROTOCOL,
...@@ -148,11 +170,12 @@ if (context.update?.status === "pending") {...@@ -148,11 +170,12 @@ if (context.update?.status === "pending") {
148}170}
149`;171`;
150 for (const version of ["1.0.0", "1.1.0"]) {172 for (const version of ["1.0.0", "1.1.0"]) {
151 const versionDir = path.join(root, "runtime", "versions", version);173 yield* writeFakeRuntime(
152 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");174 fs,
153 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });175 path,
154 yield* fs.writeFileString(entryPath, childSource);176 path.join(root, "runtime", "versions", version),
155 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);177 childSource,
178 );
156 }179 }
157 yield* Effect.promise(() =>180 yield* Effect.promise(() =>
158 writeServiceState(statePath, {181 writeServiceState(statePath, {
...@@ -198,11 +221,12 @@ if (context.update?.status === "pending") {...@@ -198,11 +221,12 @@ if (context.update?.status === "pending") {
198}221}
199`;222`;
200 for (const version of ["1.0.0", "1.1.0"]) {223 for (const version of ["1.0.0", "1.1.0"]) {
201 const versionDir = path.join(root, "runtime", "versions", version);224 yield* writeFakeRuntime(
202 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");225 fs,
203 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });226 path,
204 yield* fs.writeFileString(entryPath, childSource);227 path.join(root, "runtime", "versions", version),
205 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);228 childSource,
229 );
206 }230 }
207 yield* Effect.promise(() =>231 yield* Effect.promise(() =>
208 writeServiceState(statePath, {232 writeServiceState(statePath, {
...@@ -257,11 +281,12 @@ if (context.update?.status === "pending") {...@@ -257,11 +281,12 @@ if (context.update?.status === "pending") {
257}281}
258`;282`;
259 for (const version of ["1.0.0", "1.1.0"]) {283 for (const version of ["1.0.0", "1.1.0"]) {
260 const versionDir = path.join(root, "runtime", "versions", version);284 yield* writeFakeRuntime(
261 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");285 fs,
262 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });286 path,
263 yield* fs.writeFileString(entryPath, childSource);287 path.join(root, "runtime", "versions", version),
264 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);288 childSource,
289 );
265 }290 }
266 yield* Effect.promise(() =>291 yield* Effect.promise(() =>
267 writeServiceState(statePath, {292 writeServiceState(statePath, {
apps/server/src/serviceLauncher.ts+11-29
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2// @effect-diagnostics globalTimers:off2// @effect-diagnostics globalTimers:off
3// This file is shipped as a standalone bundle and copied to a stable path by3// The launcher supervises the server child for the boot service and must keep
4// `t3 service update`. Keep runtime imports limited to Node built-ins.4// working across server versions, so it stays on Node built-ins with no Effect
5// runtime: it is the one part of the executable that cannot depend on the
6// rest of it being loadable.
5import * as NodeChildProcess from "node:child_process";7import * as NodeChildProcess from "node:child_process";
6import * as NodeCrypto from "node:crypto";8import * as NodeCrypto from "node:crypto";
7import * as NodeFS from "node:fs";9import * as NodeFS from "node:fs";
8import * as NodeFSP from "node:fs/promises";10import * as NodeFSP from "node:fs/promises";
9import * as NodePath from "node:path";11import * as NodePath from "node:path";
10import * as NodeSea from "node:sea";
1112
12import type {13import type {
13 PendingServiceUpdate,14 PendingServiceUpdate,
...@@ -41,37 +42,24 @@ interface ManagedChild {...@@ -41,37 +42,24 @@ interface ManagedChild {
41 readonly process: NodeChildProcess.ChildProcess;42 readonly process: NodeChildProcess.ChildProcess;
42}43}
4344
44// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked45// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose
45// release archives whose executable runs on its own, npm versions are a46// executable runs on its own. Kept inline so this file stays on Node
46// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node
47// built-ins only.47// built-ins only.
48const runtimePaths = (baseDir: string, version: string) => {48const runtimePaths = (baseDir: string, version: string) => {
49 const versionDir = NodePath.join(baseDir, "runtime", "versions", version);49 const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
50 const archive = /-preview\.\d{8}\.\d+$/.test(version);
51 // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime.50 // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime.
52 const executableName = process.platform === "win32" ? "t3.exe" : "t3";51 const executableName = process.platform === "win32" ? "t3.exe" : "t3";
53 return {52 return {
54 versionDir,53 versionDir,
55 entryPath: archive54 entryPath: NodePath.join(versionDir, executableName),
56 ? NodePath.join(versionDir, executableName)
57 : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
58 sentinelPath: NodePath.join(versionDir, ".install-complete"),55 sentinelPath: NodePath.join(versionDir, ".install-complete"),
59 executable: archive,
60 };56 };
61};57};
6258
63const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) =>59const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) => ({
64 paths.executable60 command: paths.entryPath,
65 ? { command: paths.entryPath, args: ["serve"] }61 args: ["serve"],
66 : { command: process.execPath, args: [paths.entryPath, "serve"] };62});
67
68// An npm-layout runtime needs a Node interpreter. When the launcher itself is
69// the single-executable, process.execPath is `t3`, which cannot run a
70// bin.mjs, so the two layouts cannot be mixed within one service install.
71const launcherIsExecutable = NodeSea.isSea();
72
73const canLaunchRuntime = (paths: ReturnType<typeof runtimePaths>) =>
74 paths.executable || !launcherIsExecutable;
7563
76/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */64/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */
77const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const;65const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const;
...@@ -505,12 +493,6 @@ export class Launcher {...@@ -505,12 +493,6 @@ export class Launcher {
505 await reject("The requested database path is not absolute.");493 await reject("The requested database path is not absolute.");
506 return;494 return;
507 }495 }
508 if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) {
509 await reject(
510 "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.",
511 );
512 return;
513 }
514 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {496 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {
515 await reject("The requested target runtime is missing or incomplete.");497 await reject("The requested target runtime is missing or incomplete.");
516 return;498 return;
docs/operations/release.md+15-13
...@@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop
34 - Nightly runs are always GitHub prereleases and never marked latest.34 - Nightly runs are always GitHub prereleases and never marked latest.
35 - 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.35 - 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.
36- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.36- Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets.
37- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.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.37- Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.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.
38 - 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`.38 - 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`.
39 - 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`.39 - 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`.
40 - 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.40 - 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.
41 - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded.41 - 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...@@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native
251dependency closure in `resources/server.asar`. Native modules and helper251dependency closure in `resources/server.asar`. Native modules and helper
252executables declared as unpacked by that archive must be present at the matching252executables declared as unpacked by that archive must be present at the matching
253paths below `resources/server.asar.unpacked`. The Windows-native backend reads253paths below `resources/server.asar.unpacked`. The Windows-native backend reads
254the archive in place through Electron. Packaged Windows builds also ship a254the archive in place through Electron. Packaged Windows builds also ship
255Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies255`resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive
256and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside256(`t3-<version>-linux-x64.tar.gz`) built by the `build_linux_cli` job and handed
257the selected distro, then reuses it for later launches of the same update. The257to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs
258Windows-side `wsl-server-tree/<version>` extraction remains a fallback and is258the exact bytes a Linux user downloads. WSL verifies and extracts that archive
259removed after the distro-local runtime passes preflight.259into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside the selected distro,
260then reuses it for later launches of the same update.
260261
261Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only262Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only
262native libraries and helper executables. Avoid enabling whole-package smart263native libraries and helper executables. Avoid enabling whole-package smart
...@@ -272,11 +273,12 @@ break:...@@ -272,11 +273,12 @@ break:
272- On same-architecture Windows builds, the packaged primary cannot load the fff273- On same-architecture Windows builds, the packaged primary cannot load the fff
273 native library from inside `server.asar` through its `.unpacked` sibling.274 native library from inside `server.asar` through its `.unpacked` sibling.
274- The isolated, extracted sidecar cannot load the server entry with plain Node.275- The isolated, extracted sidecar cannot load the server entry with plain Node.
275- A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256276- A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256
276 sidecar, the sidecar digest does not match the emitted archive, or required277 sidecar, or the sidecar digest does not match the emitted archive.
277 Linux runtime members are absent.278- The emitted WSL archive is not a Linux CLI release archive: it must unpack to
278- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY,279 a single `t3-<version>-linux-<arch>` directory holding `t3`, `client/`, and
279 pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings.280 `node_modules/` with the Linux node-pty binary, and must not carry a loose
281 server bundle (`bin.mjs`).
280- The external Windows resource monitor is absent.282- The external Windows resource monitor is absent.
281- The unpacked Windows application contains more than 80 files.283- The unpacked Windows application contains more than 80 files.
282284
docs/user/background-service.md+2-5
...@@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead....@@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead.
39It places `t3` in `~/.local/bin` and reuses the same download when you later39It places `t3` in `~/.local/bin` and reuses the same download when you later
40run `t3 service install`. It follows the stable train by default; set40run `t3 service install`. It follows the stable train by default; set
41`T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact41`T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact
42version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that42version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror.
43were only published to npm are refused with the `npm install` to run instead.
4443
45`preview` is a third train that maintainers cut from unreleased branches to44`preview` is a third train that maintainers cut from unreleased branches to
46exercise the release pipeline. Those builds can be broken, receive no fixes,45exercise 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...@@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the
58command tells you it is still on the old version so you can restart it57command tells you it is still on the old version so you can restart it
59yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to58yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to
60pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or59pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or
61`--allow-downgrade` to move backwards. Versions published only to npm cannot60`--allow-downgrade` to move backwards.
62be installed this way; the command says so and names the `npm install` to run
63instead.
6461
65## Platform support62## Platform support
6663
knip.jsonc-1
...@@ -20,7 +20,6 @@...@@ -20,7 +20,6 @@
20 // Vite+ pack entries and the launcher used by installed background services.20 // Vite+ pack entries and the launcher used by installed background services.
21 "entry": [21 "entry": [
22 "src/bin.ts!",22 "src/bin.ts!",
23 "src/service-launcher.ts!",
24 "src/claude-history-worker.ts!",23 "src/claude-history-worker.ts!",
25 "scripts/cli.ts",24 "scripts/cli.ts",
26 "src/provider/testFixtures/*.mjs",25 "src/provider/testFixtures/*.mjs",
packages/shared/src/cliRelease.test.ts-7
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7 cliReleaseDownloadBaseUrl,7 cliReleaseDownloadBaseUrl,
8 cliReleaseChannelOf,8 cliReleaseChannelOf,
9 cliReleaseIndexPageUrl,9 cliReleaseIndexPageUrl,
10 isArchiveDistributedVersion,
11 newestCliReleaseVersion,10 newestCliReleaseVersion,
12 parseChecksums,11 parseChecksums,
13} from "./cliRelease.ts";12} from "./cliRelease.ts";
...@@ -56,12 +55,6 @@ describe("cliRelease", () => {...@@ -56,12 +55,6 @@ describe("cliRelease", () => {
56 expect(checksums.size).toBe(2);55 expect(checksums.size).toBe(2);
57 });56 });
5857
59 it("treats only preview builds as archive-distributed", () => {
60 expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true);
61 expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false);
62 expect(isArchiveDistributedVersion("1.2.3")).toBe(false);
63 });
64
65 it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => {58 it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => {
66 expect(cliArchiveTarCommand("linux", {})).toBe("tar");59 expect(cliArchiveTarCommand("linux", {})).toBe("tar");
67 expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe(60 expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe(
packages/shared/src/cliRelease.ts-5
...@@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> {...@@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> {
70 return checksums;70 return checksums;
71}71}
7272
73/** Whether a version was published from a release train that ships archives. */
74export function isArchiveDistributedVersion(version: string): boolean {
75 return /-preview\.\d{8}\.\d+$/.test(version);
76}
77
78export type CliReleaseChannel = "stable" | "nightly" | "preview";73export type CliReleaseChannel = "stable" | "nightly" | "preview";
79export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [74export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [
80 "stable",75 "stable",
packages/ssh/src/command.test.ts-36
...@@ -14,7 +14,6 @@ import {...@@ -14,7 +14,6 @@ import {
14 baseSshArgs,14 baseSshArgs,
15 getLastNonEmptyOutputLine,15 getLastNonEmptyOutputLine,
16 parseSshResolveOutput,16 parseSshResolveOutput,
17 resolveRemoteT3CliPackageSpec,
18 runSshCommand,17 runSshCommand,
19} from "./command.ts";18} from "./command.ts";
20import { SshCommandError } from "./errors.ts";19import { SshCommandError } from "./errors.ts";
...@@ -99,41 +98,6 @@ describe("ssh command", () => {...@@ -99,41 +98,6 @@ describe("ssh command", () => {
99 }),98 }),
100 );99 );
101100
102 it.effect("resolves the remote t3 package spec from the desktop release channel", () =>
103 Effect.sync(() => {
104 assert.equal(
105 resolveRemoteT3CliPackageSpec({
106 appVersion: "0.0.17",
107 updateChannel: "latest",
108 }),
109 "t3@0.0.17",
110 );
111 assert.equal(
112 resolveRemoteT3CliPackageSpec({
113 appVersion: "0.0.17-nightly.20260415.44",
114 updateChannel: "nightly",
115 }),
116 "t3@0.0.17-nightly.20260415.44",
117 );
118 assert.equal(
119 resolveRemoteT3CliPackageSpec({
120 appVersion: "0.0.0-dev",
121 updateChannel: "nightly",
122 isDevelopment: true,
123 }),
124 "t3@nightly",
125 );
126 assert.equal(
127 resolveRemoteT3CliPackageSpec({
128 appVersion: "0.0.0-dev",
129 updateChannel: "latest",
130 isDevelopment: true,
131 }),
132 "t3@nightly",
133 );
134 }),
135 );
136
137 it.effect("reads the last non-empty ssh output line", () =>101 it.effect("reads the last non-empty ssh output line", () =>
138 Effect.sync(() => {102 Effect.sync(() => {
139 assert.equal(103 assert.equal(
packages/ssh/src/command.ts+1-19
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as NodeCrypto from "node:crypto";1import * as NodeCrypto from "node:crypto";
22
3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";3import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Duration from "effect/Duration";5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
...@@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
14import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";14import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
15import { SshCommandError, SshInvalidTargetError } from "./errors.ts";15import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
1616
17const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
18const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;17const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
19const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;18const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
2019
...@@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi...@@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi
363 ),362 ),
364 );363 );
365});364});
366
367export function resolveRemoteT3CliPackageSpec(input: {
368 readonly appVersion: string;
369 readonly updateChannel: DesktopUpdateChannel;
370 readonly isDevelopment?: boolean;
371}): string {
372 const appVersion = input.appVersion.trim();
373 if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) {
374 return `t3@${appVersion}`;
375 }
376
377 if (input.isDevelopment) {
378 return "t3@nightly";
379 }
380
381 return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest";
382}
packages/ssh/src/runnerProcess.test.ts+81-258
...@@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started));...@@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started));
23describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(23describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
24 "remote runner process ownership",24 "remote runner process ownership",
25 () => {25 () => {
26 it.live.each(["npx", "npm"] as const)(26 it.live("keeps the server PID and graceful shutdown through the node-script runner", () =>
27 "keeps the server PID and graceful shutdown through the %s fallback",27 Effect.gen(function* () {
28 (packageManager) =>28 const fs = yield* FileSystem.FileSystem;
29 Effect.gen(function* () {29 const path = yield* Path.Path;
30 const fs = yield* FileSystem.FileSystem;30 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
31 const path = yield* Path.Path;31 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" });
32 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;32 const bin = path.join(fixture, "bin");
33 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" });33 const cliPath = path.join(fixture, "installed cli.mjs");
34 const bin = path.join(fixture, "bin");34 yield* fs.makeDirectory(bin);
35 const cliPath = path.join(fixture, "installed cli.mjs");35 yield* fs.symlink(process.execPath, path.join(bin, "node"));
36 const callsPath = path.join(fixture, "package-manager-calls.jsonl");36 yield* fs.writeFileString(
37 const packageSpec = "t3@0.0.35";37 cliPath,
38 yield* fs.makeDirectory(bin);38 `#!/usr/bin/env node
39 yield* fs.symlink(process.execPath, path.join(bin, "node"));
40 yield* fs.writeFileString(
41 cliPath,
42 `#!/usr/bin/env node
43import * as net from "node:net";39import * as net from "node:net";
44const server = net.createServer((socket) => {40const server = net.createServer((socket) => {
45 socket.end();41 socket.end();
...@@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => {...@@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => {
56 }) + "\\n");52 }) + "\\n");
57});53});
58`,54`,
59 );55 );
60 yield* fs.chmod(cliPath, 0o700);56 yield* fs.chmod(cliPath, 0o700);
61 yield* fs.writeFileString(
62 path.join(bin, packageManager),
63 `#!/usr/bin/env node
64const fs = require("node:fs");
65const childProcess = require("node:child_process");
66const args = process.argv.slice(2);
67fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n");
68if (args.includes("--package")) {
69 process.stdout.write(process.env.T3_TEST_CLI + "\\n");
70} else {
71 const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" });
72 child.once("exit", (code) => { process.exitCode = code ?? 1; });
73}
74`,
75 );
76 yield* fs.chmod(path.join(bin, packageManager), 0o700);
7757
78 const runServer = (port = 0) =>58 const runServer = (port = 0) =>
79 Effect.gen(function* () {59 Effect.gen(function* () {
80 const child = yield* spawner.spawn(60 const child = yield* spawner.spawn(
81 ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], {61 ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], {
82 cwd: fixture,62 cwd: fixture,
83 env: {63 env: {
84 PATH: bin,64 PATH: bin,
85 T3_TEST_CLI: cliPath,65 T3_TEST_PORT: String(port),
86 T3_TEST_CALLS: callsPath,66 },
87 T3_TEST_PORT: String(port),67 detached: false,
88 },68 stdin: Stream.make(
89 detached: false,69 new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })),
90 stdin: Stream.make(
91 new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })),
92 ),
93 }),
94 );
95 const ready = yield* Deferred.make<typeof Started.Type>();
96 const stdout: string[] = [];
97 const output = yield* child.stdout.pipe(
98 Stream.decodeText(),
99 Stream.splitLines,
100 Stream.runForEach((line) =>
101 Effect.gen(function* () {
102 stdout.push(line);
103 if (stdout.length === 1) {
104 yield* Deferred.succeed(ready, decodeStarted(line));
105 }
106 }),
107 ),
108 Effect.forkScoped,
109 );
110 const stderr = yield* child.stderr.pipe(
111 Stream.decodeText(),
112 Stream.mkString,
113 Effect.forkScoped,
114 );
115 const receipt = yield* Effect.raceFirst(
116 Deferred.await(ready),
117 Fiber.join(output).pipe(
118 Effect.flatMap(() => Fiber.join(stderr)),
119 Effect.flatMap((message) =>
120 Effect.die(new Error(`Runner exited before listening: ${message}`)),
121 ),
122 ),70 ),
123 );71 }),
124 // A failed PID assertion must still close the owned fixture server, including an npm child.72 );
125 yield* Effect.addFinalizer(() =>73 const ready = yield* Deferred.make<typeof Started.Type>();
74 const stdout: string[] = [];
75 const output = yield* child.stdout.pipe(
76 Stream.decodeText(),
77 Stream.splitLines,
78 Stream.runForEach((line) =>
126 Effect.gen(function* () {79 Effect.gen(function* () {
127 if (yield* child.isRunning) {80 stdout.push(line);
128 yield* Effect.callback<void>((resume) => {81 if (stdout.length === 1) {
129 const connection = NodeNet.connect(receipt.port, "127.0.0.1");82 yield* Deferred.succeed(ready, decodeStarted(line));
130 connection.on("error", () => undefined);
131 connection.once("close", () => resume(Effect.void));
132 return Effect.sync(() => connection.destroy());
133 });
134 yield* child.exitCode;
135 }83 }
136 }).pipe(Effect.orDie),84 }),
137 );85 ),
138 assert.equal(receipt.pid, child.pid);86 Effect.forkScoped,
139 assert.deepEqual(receipt.args, ["serve", "a path with spaces"]);87 );
140 yield* child.kill({ killSignal: "SIGTERM" });88 const stderr = yield* child.stderr.pipe(
141 assert.equal(yield* child.exitCode, 0);89 Stream.decodeText(),
142 yield* Fiber.join(output);90 Stream.mkString,
143 assert.include(stdout, "graceful shutdown");91 Effect.forkScoped,
144 return receipt.port;92 );
145 }).pipe(Effect.scoped);93 const receipt = yield* Effect.raceFirst(
94 Deferred.await(ready),
95 Fiber.join(output).pipe(
96 Effect.flatMap(() => Fiber.join(stderr)),
97 Effect.flatMap((message) =>
98 Effect.die(new Error(`Runner exited before listening: ${message}`)),
99 ),
100 ),
101 );
102 // A failed PID assertion must still close the owned fixture server.
103 yield* Effect.addFinalizer(() =>
104 Effect.gen(function* () {
105 if (yield* child.isRunning) {
106 yield* Effect.callback<void>((resume) => {
107 const connection = NodeNet.connect(receipt.port, "127.0.0.1");
108 connection.on("error", () => undefined);
109 connection.once("close", () => resume(Effect.void));
110 return Effect.sync(() => connection.destroy());
111 });
112 yield* child.exitCode;
113 }
114 }).pipe(Effect.orDie),
115 );
116 assert.equal(receipt.pid, child.pid);
117 assert.deepEqual(receipt.args, ["serve", "a path with spaces"]);
118 yield* child.kill({ killSignal: "SIGTERM" });
119 assert.equal(yield* child.exitCode, 0);
120 yield* Fiber.join(output);
121 assert.include(stdout, "graceful shutdown");
122 return receipt.port;
123 }).pipe(Effect.scoped);
146124
147 const port = yield* runServer();125 const port = yield* runServer();
148 assert.equal(yield* runServer(port), port);126 assert.equal(yield* runServer(port), port);
149 const calls = (yield* fs.readFileString(callsPath))127 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
150 .trim()
151 .split("\n")
152 .map((line) => JSON.parse(line));
153 const expectedCall = [
154 ...(packageManager === "npm" ? ["exec"] : []),
155 "--yes",
156 "--package",
157 packageSpec,
158 "--",
159 "sh",
160 "-c",
161 "command -v t3",
162 ];
163 assert.deepEqual(calls, [expectedCall, expectedCall]);
164 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
165 );128 );
166 },129 },
167);130);
...@@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => {...@@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => {
288 );251 );
289 },252 },
290);253);
291
292describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
293 "remote runner install diagnostics",
294 () => {
295 const decodeArguments = Schema.decodeUnknownSync(
296 Schema.fromJsonString(Schema.Array(Schema.String)),
297 );
298 const cases = (["npx", "npm"] as const).flatMap((packageManager) =>
299 (
300 [
301 "etarget",
302 "network",
303 "empty-success",
304 "success",
305 "failed-with-path",
306 "existing-cli",
307 "node-override",
308 ] as const
309 ).map((mode) => ({ packageManager, mode })),
310 );
311
312 it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) =>
313 Effect.gen(function* () {
314 const fs = yield* FileSystem.FileSystem;
315 const path = yield* Path.Path;
316 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
317 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" });
318 const bin = path.join(fixture, "bin");
319 const cliPath = path.join(fixture, "installed cli.mjs");
320 const callsPath = path.join(fixture, "installer-calls.jsonl");
321 const packageSpec = "t3@0.0.39-nightly.20260905.1286";
322 const args = ["serve", "a path with spaces"];
323 yield* fs.makeDirectory(bin);
324 yield* fs.symlink(process.execPath, path.join(bin, "node"));
325 yield* fs.writeFileString(
326 cliPath,
327 `#!/usr/bin/env node
328process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n");
329`,
330 );
331 yield* fs.chmod(cliPath, 0o700);
332 yield* fs.writeFileString(callsPath, "");
333 yield* fs.writeFileString(
334 path.join(bin, packageManager),
335 `#!/usr/bin/env node
336const fs = require("node:fs");
337fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n");
338const mode = process.env.T3_TEST_MODE;
339if (mode === "success" || mode === "failed-with-path") {
340 process.stdout.write(process.env.T3_TEST_CLI + "\\n");
341}
342if (mode === "etarget" || mode === "failed-with-path") {
343 process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n");
344 process.exitCode = 42;
345} else if (mode === "network") {
346 process.stderr.write("npm error code ENETUNREACH\\n");
347 process.exitCode = 43;
348}
349`,
350 );
351 yield* fs.chmod(path.join(bin, packageManager), 0o700);
352 if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3"));
353
354 const child = yield* spawner.spawn(
355 ChildProcess.make("/bin/sh", ["-s", "--", ...args], {
356 cwd: fixture,
357 extendEnv: false,
358 env: {
359 PATH: bin,
360 T3_TEST_MODE: mode,
361 T3_TEST_CLI: cliPath,
362 T3_TEST_CALLS: callsPath,
363 },
364 stdin: Stream.make(
365 new TextEncoder().encode(
366 buildRemoteT3RunnerScript({
367 packageSpec,
368 ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}),
369 }),
370 ),
371 ),
372 }),
373 );
374 const { stdout, stderr, exitCode } = yield* Effect.all(
375 {
376 stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString),
377 stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString),
378 exitCode: child.exitCode,
379 },
380 { concurrency: "unbounded" },
381 );
382 const installFailed =
383 mode === "etarget" || mode === "network" || mode === "failed-with-path";
384 const missingExecutable = mode === "empty-success";
385 assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0);
386 if (installFailed || missingExecutable) {
387 assert.equal(stdout, "");
388 } else {
389 assert.deepEqual(decodeArguments(stdout), args);
390 }
391 if (installFailed) {
392 const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET";
393 assert.include(stderr, `npm error code ${npmError}\n`);
394 assert.include(stderr, `Remote host could not install ${packageSpec}.`);
395 assert.notInclude(stderr, "Remote host installed");
396 assert.notInclude(stderr, "Install a C toolchain");
397 } else if (missingExecutable) {
398 assert.include(stderr, `Remote host installed ${packageSpec}`);
399 assert.include(stderr, "npm produced no t3 executable");
400 assert.include(stderr, "Install a C toolchain");
401 } else {
402 assert.equal(stderr, "");
403 }
404 const expectedCall = [
405 ...(packageManager === "npm" ? ["exec"] : []),
406 "--yes",
407 "--package",
408 packageSpec,
409 "--",
410 "sh",
411 "-c",
412 "command -v t3",
413 ];
414 const usesInstaller = mode !== "existing-cli" && mode !== "node-override";
415 const calls = yield* fs.readFileString(callsPath);
416 if (usesInstaller) {
417 assert.deepEqual(
418 calls
419 .trim()
420 .split("\n")
421 .map((line) => decodeArguments(line)),
422 [expectedCall],
423 );
424 } else {
425 assert.equal(calls, "");
426 }
427 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
428 );
429 },
430);
packages/ssh/src/tunnel.test.ts+104-104
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 buildRemoteStopScript,23 buildRemoteStopScript,
24 buildRemoteT3RunnerScript,24 buildRemoteT3RunnerScript,
25 SshInvalidArchiveVersionError,25 SshInvalidArchiveVersionError,
26 SshMissingRunnerError,
26 describeReadinessCause,27 describeReadinessCause,
27 issueRemotePairingToken,28 issueRemotePairingToken,
28 launchOrReuseRemoteServer,29 launchOrReuseRemoteServer,
...@@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {...@@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {
104 return command._tag === "StandardCommand" ? command.args : [];105 return command._tag === "StandardCommand" ? command.args : [];
105}106}
106107
107describe("ssh tunnel scripts", () => {108const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const;
108 it("builds the remote t3 runner with npx and npm fallbacks", () => {109const NODE_SCRIPT = {
109 const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE });110 nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs",
110111} as const;
111 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
112 assert.include(script, 'exec t3 "$@"');
113 assert.include(script, 'exec "$T3_CLI_PATH" "$@"');
114 assert.include(script, "could not install 't3@latest'");
115 assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'");
116 assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'");
117 assert.include(script, "npm produced no t3 executable");
118 assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"');
119 assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`);
120 assert.include(script, "remote_node_satisfies_engine()");
121 assert.include(script, "function satisfiesSemverRange");
122 assert.include(script, "satisfiesSemverRange(rawVersion, range)");
123 assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"');
124 assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"');
125 assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"');
126 assert.include(script, 'eval "$(fnm env --shell bash)"');
127 assert.include(script, "fnm use --silent-if-unchanged");
128 assert.include(script, "fnm use default");
129 assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"');
130 assert.include(script, 'NVM_DIR="$HOME/.nvm"');
131 assert.include(script, "nvm use --silent default");
132 assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin');
133 assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available");
134 });
135112
136 it("installs and runs the release archive when an archive version is set", () => {113describe("ssh tunnel scripts", () => {
137 const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" });114 it("installs and runs the release archive without Node, npm, or npx", () => {
115 const script = buildRemoteT3RunnerScript(ARCHIVE);
138116
139 assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");117 assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
118 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
140 assert.include(119 assert.include(
141 script,120 script,
142 "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'",121 "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'",
...@@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => {...@@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => {
145 assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"');124 assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"');
146 assert.include(script, "SHA256SUMS");125 assert.include(script, "SHA256SUMS");
147 assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"');126 assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"');
127 assert.notInclude(script, "npx");
128 assert.notInclude(script, "npm exec");
129 assert.notInclude(script, "t3@latest");
130 assert.notInclude(script, 'exec t3 "$@"');
148 // Concurrent launches serialize on a per-version mkdir lock and recheck131 // Concurrent launches serialize on a per-version mkdir lock and recheck
149 // the completion marker after acquiring it.132 // the completion marker after acquiring it.
150 assert.include(133 assert.include(
...@@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => {...@@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => {
167 script.indexOf('"$T3_STAGING/t3" --version'),150 script.indexOf('"$T3_STAGING/t3" --version'),
168 script.indexOf('> "$T3_STAGING/.install-complete"'),151 script.indexOf('> "$T3_STAGING/.install-complete"'),
169 );152 );
170 // The archive branch execs before any of the Node discovery runs.153 // Node discovery is defined for the dev path but only ever invoked inside
154 // the node-script branch, which the archive path skips entirely.
155 assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1);
156 assert.isBelow(
157 script.indexOf("ensure_remote_node_path || true"),
158 script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'),
159 );
171 assert.isBelow(160 assert.isBelow(
172 script.indexOf('exec "$T3_RUNTIME_DIR/t3"'),161 script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'),
173 script.indexOf("prepend_path_if_dir()"),162 script.indexOf("T3_ARCHIVE_VERSION="),
174 );163 );
175164
176 const launch = buildRemoteLaunchScript({165 const launch = buildRemoteLaunchScript({
177 archiveVersion: "1.2.3-preview.20260911.4",166 ...ARCHIVE,
178 releaseBaseUrl: "https://mirror.example/t3/",167 releaseBaseUrl: "https://mirror.example/t3/",
179 });168 });
180 assert.include(launch, "T3_ARCHIVE_MODE=1");169 assert.include(launch, "T3_ARCHIVE_MODE=1");
...@@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => {...@@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => {
182 assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"');171 assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"');
183 assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"');172 assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"');
184 assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"');173 assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"');
185 assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0");174 assert.include(buildRemoteLaunchScript(NODE_SCRIPT), "T3_ARCHIVE_MODE=0");
186 });175 });
187176
188 it("rejects archive versions that are not a single exact version segment", () => {177 it("rejects archive versions that are not a single exact version segment", () => {
...@@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => {...@@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => {
202 );191 );
203 }192 }
204 assert.include(193 assert.include(
205 buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }),194 buildRemoteT3RunnerScript(ARCHIVE),
206 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",195 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
207 );196 );
208 });197 });
209198
199 it("refuses to build a runner with neither an archive version nor a node script", () => {
200 for (const input of [undefined, {}, { archiveVersion: " " }, { nodeScriptPath: null }]) {
201 assert.throws(() => buildRemoteT3RunnerScript(input), SshMissingRunnerError);
202 }
203 assert.throws(() => buildRemoteLaunchScript(), SshMissingRunnerError);
204 });
205
210 it("does not hard-code a remote node engine range", () => {206 it("does not hard-code a remote node engine range", () => {
211 const script = buildRemoteT3RunnerScript();207 const script = buildRemoteT3RunnerScript(NODE_SCRIPT);
212208
213 assert.include(script, "T3_NODE_ENGINE_RANGE=''");209 assert.include(script, "T3_NODE_ENGINE_RANGE=''");
214 assert.notInclude(script, TEST_NODE_ENGINE_RANGE);210 assert.notInclude(script, TEST_NODE_ENGINE_RANGE);
215 });211 });
216212
217 it("shell-quotes package specs in the remote t3 runner", () => {
218 const script = buildRemoteT3RunnerScript({
219 packageSpec: "t3@nightly; touch /tmp/t3-owned",
220 });
221
222 assert.include(
223 script,
224 "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'",
225 );
226 assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned");
227 });
228
229 it("builds the remote t3 runner with a node script override", () => {213 it("builds the remote t3 runner with a node script override", () => {
230 const script = buildRemoteT3RunnerScript({214 const script = buildRemoteT3RunnerScript({
231 nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs",215 ...NODE_SCRIPT,
216 nodeEngineRange: TEST_NODE_ENGINE_RANGE,
232 });217 });
233218
234 assert.include(219 assert.include(
...@@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => {...@@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => {
236 "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'",221 "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'",
237 );222 );
238 assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"');223 assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"');
224 assert.include(script, "T3_ARCHIVE_VERSION=''");
225 assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"');
226 assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`);
227 assert.include(script, "remote_node_satisfies_engine()");
228 assert.include(script, "function satisfiesSemverRange");
229 assert.include(script, "satisfiesSemverRange(rawVersion, range)");
230 assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"');
231 assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"');
232 assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"');
233 assert.include(script, 'eval "$(fnm env --shell bash)"');
234 assert.include(script, "fnm use --silent-if-unchanged");
235 assert.include(script, "fnm use default");
236 assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"');
237 assert.include(script, 'NVM_DIR="$HOME/.nvm"');
238 assert.include(script, "nvm use --silent default");
239 assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin');
240 assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available");
241 assert.notInclude(script, "npx");
239 });242 });
240243
241 it("uses the remote t3 runner for launch and pairing scripts", () => {244 it("uses the remote t3 runner for launch and pairing scripts", () => {
...@@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => {...@@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => {
245 username: "julius",248 username: "julius",
246 port: 2222,249 port: 2222,
247 } as const;250 } as const;
251 const launch = buildRemoteLaunchScript(ARCHIVE);
252 const devLaunch = buildRemoteLaunchScript({
253 ...NODE_SCRIPT,
254 nodeEngineRange: TEST_NODE_ENGINE_RANGE,
255 });
248256
249 assert.include(257 assert.include(
250 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),258 launch,
251 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',259 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',
252 );260 );
253 assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1");261 assert.include(launch, "RUNNER_CHANGED=1");
254 assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()");262 assert.include(launch, "ensure_remote_node_path()");
255 assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then");263 assert.include(launch, "if ! ensure_remote_node_path; then");
264 assert.include(devLaunch, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`);
265 assert.include(devLaunch, "does not satisfy required range ");
266 assert.include(launch, 'kill "$REMOTE_PID" 2>/dev/null || true');
267 assert.include(launch, "wait_ready");
268 assert.include(launch, '"$RUNNER_FILE" serve --host 127.0.0.1');
269 assert.include(launch, '--base-dir "$DEFAULT_SERVER_HOME"');
270 assert.notInclude(launch, "server-home");
271 assert.include(launch, "Remote T3 server did not become ready");
272 assert.include(launch, 'wait_ready "60000"');
273 assert.include(launch, 'if [ -s "$LOG_FILE" ]; then');
274 assert.include(launch, "It wrote nothing to %s");
275 assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
256 assert.include(276 assert.include(
257 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),277 buildRemotePairingScript(target, ARCHIVE),
258 `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`,278 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
259 );279 );
260 assert.include(280 assert.include(
261 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),281 buildRemotePairingScript(target, ARCHIVE),
262 "does not satisfy required range ",282 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"',
263 );283 );
264 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');284 assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home");
265 assert.include(buildRemoteLaunchScript(), "wait_ready");
266 assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1');
267 assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"');
268 assert.notInclude(buildRemoteLaunchScript(), "server-home");
269 assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready");
270 assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"');
271 assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then');
272 assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s");
273 assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly");
274 assert.include(285 assert.include(
275 buildRemotePairingScript(target),286 buildRemotePairingScript(target, ARCHIVE),
276 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',287 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
277 );288 );
278 assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"');
279 assert.notInclude(buildRemotePairingScript(target), "server-home");
280 assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly");
281 assert.include(289 assert.include(
282 buildRemoteStopScript(target),290 buildRemoteStopScript(target),
283 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',291 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',
...@@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => {...@@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => {
285 assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true');293 assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true');
286 assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"');294 assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"');
287 assert.include(295 assert.include(
288 buildRemoteLaunchScript(),296 launch,
289 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',297 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',
290 );298 );
291 assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()");299 assert.include(launch, "resolve_default_runtime_port()");
292 assert.include(300 assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port');
293 buildRemoteLaunchScript(),301 assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))");
294 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port',302 assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"');
295 );303 assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"');
296 assert.include(304 assert.include(launch, 'rm -f "$PID_FILE"');
297 buildRemoteLaunchScript(),305 assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\"");
298 "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))",306 assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then');
299 );
300 assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"');
301 assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"');
302 assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"');
303 assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\"");
304 assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then');
305 assert.isBelow(307 assert.isBelow(
306 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),308 launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
307 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),309 launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
308 );310 );
309 assert.isBelow(311 assert.isBelow(
310 buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),312 launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),
311 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),313 launch.indexOf('elif [ -n "$REMOTE_PID" ]'),
312 );314 );
313 });315 });
314316
...@@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => {...@@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => {
330 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);332 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
331333
332 return Effect.gen(function* () {334 return Effect.gen(function* () {
333 const result = yield* launchOrReuseRemoteServer(target);335 const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE);
334 assert.equal(result.remotePort, 3774);336 assert.equal(result.remotePort, 3774);
335 assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]);337 assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]);
336 }).pipe(Effect.provide(processLayer));338 }).pipe(Effect.provide(processLayer));
...@@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => {...@@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => {
350 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());352 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
351353
352 return Effect.gen(function* () {354 return Effect.gen(function* () {
353 const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target));355 const fiber = yield* Effect.forkChild(
356 launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT),
357 );
354 yield* Effect.yieldNow;358 yield* Effect.yieldNow;
355 yield* TestClock.adjust(Duration.seconds(75));359 yield* TestClock.adjust(Duration.seconds(75));
356360
...@@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => {...@@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => {
359 }).pipe(Effect.provide(processLayer));363 }).pipe(Effect.provide(processLayer));
360 });364 });
361365
362 it.effect("gives cold archive launches a larger budget than npm launches", () => {366 it.effect("gives cold archive launches a larger budget than node-script launches", () => {
363 const target = {367 const target = {
364 alias: "devbox",368 alias: "devbox",
365 hostname: "devbox.example.com",369 hostname: "devbox.example.com",
...@@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => {...@@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => {
373 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());377 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
374378
375 return Effect.gen(function* () {379 return Effect.gen(function* () {
376 const fiber = yield* Effect.forkChild(380 const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE));
377 launchOrReuseRemoteServer(target, undefined, {
378 archiveVersion: "1.2.3-preview.20260911.4",
379 }),
380 );
381 yield* Effect.yieldNow;381 yield* Effect.yieldNow;
382 yield* TestClock.adjust(Duration.seconds(800));382 yield* TestClock.adjust(Duration.seconds(800));
383383
...@@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => {...@@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => {
457 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);457 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
458 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);458 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
459 return Effect.gen(function* () {459 return Effect.gen(function* () {
460 const result = yield* issueRemotePairingToken(target);460 const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE);
461 assert.equal(result.credential, "LCL4R2TPHDKQ");461 assert.equal(result.credential, "LCL4R2TPHDKQ");
462 }).pipe(Effect.provide(processLayer));462 }).pipe(Effect.provide(processLayer));
463 });463 });
...@@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => {...@@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => {
485 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);485 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
486 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);486 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
487 return Effect.gen(function* () {487 return Effect.gen(function* () {
488 const result = yield* issueRemotePairingToken(target);488 const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE);
489 assert.equal(result.credential, "LCL4R2TPHDKQ");489 assert.equal(result.credential, "LCL4R2TPHDKQ");
490 }).pipe(Effect.provide(processLayer));490 }).pipe(Effect.provide(processLayer));
491 });491 });
...@@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => {...@@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => {
530 Layer.succeed(HttpClient.HttpClient, testHttpClient),530 Layer.succeed(HttpClient.HttpClient, testHttpClient),
531 Layer.succeed(NetService.NetService, testNetService),531 Layer.succeed(NetService.NetService, testNetService),
532 SshPasswordPrompt.disabledLayer,532 SshPasswordPrompt.disabledLayer,
533 SshEnvironmentManager.layer(),533 SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
534 );534 );
535 const target = {535 const target = {
536 alias: "devbox",536 alias: "devbox",
...@@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => {...@@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => {
655 Layer.succeed(HttpClient.HttpClient, testHttpClient),655 Layer.succeed(HttpClient.HttpClient, testHttpClient),
656 Layer.succeed(NetService.NetService, testNetService),656 Layer.succeed(NetService.NetService, testNetService),
657 SshPasswordPrompt.disabledLayer,657 SshPasswordPrompt.disabledLayer,
658 SshEnvironmentManager.layer(),658 SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
659 );659 );
660 yield* Effect.gen(function* () {660 yield* Effect.gen(function* () {
661 const manager = yield* SshEnvironmentManager;661 const manager = yield* SshEnvironmentManager;
packages/ssh/src/tunnel.ts+129-147
...@@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000;...@@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000;
70const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;70const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
7171
72export interface RemoteT3RunnerOptions {72export interface RemoteT3RunnerOptions {
73 readonly packageSpec?: string;73 /**
74 * Dev mode: run `node <path>` on the remote instead of a release archive.
75 * The only mode that needs Node on the remote.
76 */
74 readonly nodeScriptPath?: string | null;77 readonly nodeScriptPath?: string | null;
75 readonly nodeEngineRange?: string | null;78 readonly nodeEngineRange?: string | null;
76 /**79 /**
77 * Exact version whose release archive the remote installs and runs. Takes80 * Exact version whose self-contained release archive the remote installs
78 * precedence over `packageSpec`; the remote then needs neither Node nor npm.81 * and runs. Required unless `nodeScriptPath` is set; the remote then needs
82 * neither Node nor npm.
79 */83 */
80 readonly archiveVersion?: string | null;84 readonly archiveVersion?: string | null;
81 readonly releaseBaseUrl?: string | null;85 readonly releaseBaseUrl?: string | null;
82}86}
8387
84export interface SshEnvironmentManagerOptions {88export interface SshEnvironmentManagerOptions {
85 readonly resolveCliPackageSpec?: () => string;
86 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;89 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
87}90}
8891
...@@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {...@@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
124 };127 };
125}128}
126129
130function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean {
131 return Boolean(runner?.nodeScriptPath?.trim());
132}
133
127function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {134function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {
128 if (runner?.archiveVersion?.trim()) {
129 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
130 }
131 if (runner?.nodeScriptPath?.trim()) {135 if (runner?.nodeScriptPath?.trim()) {
132 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };136 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };
133 }137 }
134 if (runner?.packageSpec?.trim()) {138 if (runner?.archiveVersion?.trim()) {
135 return { runner: "package", packageSpec: runner.packageSpec.trim() };139 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
136 }140 }
137 return { runner: "default" };141 return { runner: "archive" };
138}142}
139143
140interface SshAuthOperationInput<T> {144interface SshAuthOperationInput<T> {
...@@ -424,144 +428,117 @@ ensure_remote_node_path() {...@@ -424,144 +428,117 @@ ensure_remote_node_path() {
424428
425const REMOTE_RUNNER_SCRIPT = `#!/bin/sh429const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
426set -eu430set -eu
427T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@
428if [ -n "$T3_ARCHIVE_VERSION" ]; then
429 # Self-contained release archive: no Node, npm, or compiler on the remote.
430 # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it.
431 T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@
432 T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"
433 t3_runtime_ready() {
434 [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ]
435 }
436 if ! t3_runtime_ready; then
437 mkdir -p "$HOME/.t3/runtime/versions"
438 # Concurrent launches (two clients, a retry racing a slow first run) must
439 # not both install: mkdir is the atomic lock and the ready check repeats
440 # under it.
441 T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"
442 # mkdir is the only portable atomic exclusive create (mv would silently
443 # nest a candidate inside an existing lock). The owner publishes its pid
444 # right after, so a lock with a live owner is never reclaimed however
445 # slow its download is, and a lock whose owner is dead is reclaimed at
446 # once. A lock with no pid at all is a crash between mkdir and the pid
447 # write; it is reclaimed after a short grace so a live owner has time to
448 # publish.
449 T3_LOCK_WAITED=0
450 T3_LOCK_UNOWNED=0
451 while ! mkdir "$T3_LOCK" 2>/dev/null; do
452 T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)"
453 if [ -n "$T3_LOCK_OWNER" ]; then
454 T3_LOCK_UNOWNED=0
455 if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then
456 rm -rf "$T3_LOCK"
457 continue
458 fi
459 else
460 T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1))
461 if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then
462 rm -rf "$T3_LOCK"
463 continue
464 fi
465 fi
466 if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then
467 printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2
468 exit 1
469 fi
470 sleep 1
471 T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1))
472 done
473 printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"
474 trap 'rm -rf "$T3_LOCK"' EXIT
475 fi
476 if ! t3_runtime_ready; then
477 case "$(uname -s)" in
478 Darwin) T3_PLATFORM="darwin" ;;
479 Linux) T3_PLATFORM="linux" ;;
480 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;;
481 esac
482 case "$(uname -m)" in
483 arm64 | aarch64) T3_ARCH="arm64" ;;
484 x86_64 | amd64) T3_ARCH="x64" ;;
485 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;;
486 esac
487 T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"
488 T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")"
489 trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT
490 t3_fetch() {
491 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2"
492 elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2"
493 else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1
494 fi
495 }
496 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@
497 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@
498 T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)"
499 if command -v sha256sum >/dev/null 2>&1; then
500 T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
501 else
502 T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
503 fi
504 if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
505 printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
506 fi
507 tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
508 rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
509 # Prove the binary runs here (libc, arch) before marking it ready, or every
510 # later launch would exec a broken install instead of retrying.
511 if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then
512 printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1
513 fi
514 printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete"
515 rm -rf "$T3_RUNTIME_DIR"
516 mv "$T3_STAGING" "$T3_RUNTIME_DIR"
517 fi
518 if [ -n "\${T3_LOCK:-}" ]; then
519 rm -rf "$T3_LOCK"
520 trap - EXIT
521 fi
522 exec "$T3_RUNTIME_DIR/t3" "$@"
523fi
524@@T3_NODE_ENV_SCRIPT@@431@@T3_NODE_ENV_SCRIPT@@
525ensure_remote_node_path || true
526T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@432T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
527if [ -n "$T3_NODE_SCRIPT_PATH" ]; then433if [ -n "$T3_NODE_SCRIPT_PATH" ]; then
434 # Dev mode: a source checkout on the remote. This is the only path that
435 # needs Node, so Node discovery runs here and nowhere else.
436 ensure_remote_node_path || true
528 if ! command -v node >/dev/null 2>&1; then437 if ! command -v node >/dev/null 2>&1; then
529 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2438 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
530 exit 1439 exit 1
531 fi440 fi
532 exec node "$T3_NODE_SCRIPT_PATH" "$@"441 exec node "$T3_NODE_SCRIPT_PATH" "$@"
533fi442fi
534if command -v t3 >/dev/null 2>&1; then443T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@
535 exec t3 "$@"444if [ -z "$T3_ARCHIVE_VERSION" ]; then
445 printf 'No t3 release version was provided for the remote runtime.\\n' >&2
446 exit 1
447fi
448# Self-contained release archive: no Node, npm, or compiler on the remote.
449# Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it.
450T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@
451T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"
452t3_runtime_ready() {
453 [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ]
454}
455if ! t3_runtime_ready; then
456 mkdir -p "$HOME/.t3/runtime/versions"
457 # Concurrent launches (two clients, a retry racing a slow first run) must
458 # not both install: mkdir is the atomic lock and the ready check repeats
459 # under it.
460 T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"
461 # mkdir is the only portable atomic exclusive create (mv would silently
462 # nest a candidate inside an existing lock). The owner publishes its pid
463 # right after, so a lock with a live owner is never reclaimed however
464 # slow its download is, and a lock whose owner is dead is reclaimed at
465 # once. A lock with no pid at all is a crash between mkdir and the pid
466 # write; it is reclaimed after a short grace so a live owner has time to
467 # publish.
468 T3_LOCK_WAITED=0
469 T3_LOCK_UNOWNED=0
470 while ! mkdir "$T3_LOCK" 2>/dev/null; do
471 T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)"
472 if [ -n "$T3_LOCK_OWNER" ]; then
473 T3_LOCK_UNOWNED=0
474 if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then
475 rm -rf "$T3_LOCK"
476 continue
477 fi
478 else
479 T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1))
480 if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then
481 rm -rf "$T3_LOCK"
482 continue
483 fi
484 fi
485 if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then
486 printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2
487 exit 1
488 fi
489 sleep 1
490 T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1))
491 done
492 printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"
493 trap 'rm -rf "$T3_LOCK"' EXIT
536fi494fi
537# npm extracts a package before it runs the native builds of its dependencies,495if ! t3_runtime_ready; then
538# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves496 case "$(uname -s)" in
539# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without497 Darwin) T3_PLATFORM="darwin" ;;
540# running anything at all, which the caller only ever sees as a server that498 Linux) T3_PLATFORM="linux" ;;
541# never becomes ready. Resolve the CLI once up front so that install failure is499 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;;
542# reported here, with npm's own output on stderr.500 esac
543require_installed_t3_cli() {501 case "$(uname -m)" in
544 if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then502 arm64 | aarch64) T3_ARCH="arm64" ;;
545 printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2503 x86_64 | amd64) T3_ARCH="x64" ;;
546 return 1504 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;;
505 esac
506 T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"
507 T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")"
508 trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT
509 t3_fetch() {
510 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2"
511 elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2"
512 else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1
513 fi
514 }
515 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@
516 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@
517 T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)"
518 if command -v sha256sum >/dev/null 2>&1; then
519 T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
520 else
521 T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
547 fi522 fi
548 if [ -n "$T3_CLI_PATH" ]; then523 if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
549 return 0524 printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
550 fi525 fi
551 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@@ >&2526 tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
552 return 1527 rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
553}528 # Prove the binary runs here (libc, arch) before marking it ready, or every
554# The launcher records this PID, so exec the CLI without an npm wrapper process.529 # later launch would exec a broken install instead of retrying.
555if command -v npx >/dev/null 2>&1; then530 if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then
556 require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1531 printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1
557 exec "$T3_CLI_PATH" "$@"532 fi
533 printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete"
534 rm -rf "$T3_RUNTIME_DIR"
535 mv "$T3_STAGING" "$T3_RUNTIME_DIR"
558fi536fi
559if command -v npm >/dev/null 2>&1; then537if [ -n "\${T3_LOCK:-}" ]; then
560 require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1538 rm -rf "$T3_LOCK"
561 exec "$T3_CLI_PATH" "$@"539 trap - EXIT
562fi540fi
563printf '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' >&2541exec "$T3_RUNTIME_DIR/t3" "$@"
564exit 1
565`;542`;
566543
567const REMOTE_LAUNCH_SCRIPT = `set -eu544const REMOTE_LAUNCH_SCRIPT = `set -eu
...@@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalid...@@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalid
804const EXACT_ARCHIVE_VERSION =781const EXACT_ARCHIVE_VERSION =
805 /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;782 /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
806783
784export class SshMissingRunnerError extends Schema.TaggedError<SshMissingRunnerError>()(
785 "SshMissingRunnerError",
786 {},
787) {
788 override get message(): string {
789 return "A remote t3 runner needs an archive version or a node script path.";
790 }
791}
792
807export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {793export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {
808 const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest");
809 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";794 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";
810 const archiveVersion = input?.archiveVersion?.trim() || "";795 const archiveVersion = input?.archiveVersion?.trim() || "";
796 if (nodeScriptPath === "" && archiveVersion === "") {
797 throw new SshMissingRunnerError();
798 }
811 if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) {799 if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) {
812 throw new SshInvalidArchiveVersionError({ archiveVersion });800 throw new SshInvalidArchiveVersionError({ archiveVersion });
813 }801 }
...@@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string...@@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string
818 );806 );
819 return stripTrailingNewlines(807 return stripTrailingNewlines(
820 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {808 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
821 T3_PACKAGE_SPEC: packageSpec,
822 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),809 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
823 T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion),810 T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion),
824 T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl),811 T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl),
...@@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string...@@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string
841828
842export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {829export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
843 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {830 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
844 T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0",831 T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1",
845 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),832 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
846 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),833 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
847 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),834 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
...@@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo...@@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
894 const result = yield* runSshCommand(target, {881 const result = yield* runSshCommand(target, {
895 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],882 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],
896 stdin: buildRemoteLaunchScript(runner),883 stdin: buildRemoteLaunchScript(runner),
897 timeoutMs: runner?.archiveVersion?.trim()884 timeoutMs: isNodeScriptRunner(runner)
898 ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS885 ? REMOTE_LAUNCH_TIMEOUT_MS
899 : REMOTE_LAUNCH_TIMEOUT_MS,886 : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS,
900 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),887 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
901 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),888 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
902 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),889 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
...@@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT...@@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
956 stdin: buildRemotePairingScript(target, runner),943 stdin: buildRemotePairingScript(target, runner),
957 // Pairing may be the first command on a cold remote, so it can install944 // Pairing may be the first command on a cold remote, so it can install
958 // the archive on the way.945 // the archive on the way.
959 ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}),946 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),
960 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),947 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
961 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),948 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
962 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),949 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
...@@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma...@@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1687 ...sshTargetLogFields(resolvedTarget),1674 ...sshTargetLogFields(resolvedTarget),
1688 key,1675 key,
1689 });1676 });
1690 const packageSpec = options.resolveCliPackageSpec?.();
1691 const runner =1677 const runner =
1692 options.resolveCliRunner === undefined1678 options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner;
1693 ? packageSpec === undefined
1694 ? undefined
1695 : { packageSpec }
1696 : yield* options.resolveCliRunner;
1697 yield* Effect.logDebug("ssh.environment.runner.resolved", {1679 yield* Effect.logDebug("ssh.environment.runner.resolved", {
1698 ...sshTargetLogFields(resolvedTarget),1680 ...sshTargetLogFields(resolvedTarget),
1699 ...sshRunnerLogFields(runner),1681 ...sshRunnerLogFields(runner),
scripts/install.ps1 created+120
...@@ -0,0 +1,120 @@
1# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs
2# only PowerShell 5.1+; no Node, npm, or compiler.
3#
4# irm https://t3.codes/install.ps1 | iex
5#
6# Environment:
7# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
8# (default: stable; preview is a maintainers' test train)
9# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
10# T3CODE_HOME T3 home directory (default: ~\.t3)
11# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
12# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
13#
14# The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the
15# same layout `t3 service install` uses, so the service reuses this download.
16$ErrorActionPreference = "Stop"
17[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
18
19$repo = "pingdotgg/t3code"
20$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
21$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" }
22$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" }
23
24function Fail([string] $message) {
25 Write-Error "t3 install: $message"
26 exit 1
27}
28
29# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell
30# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts
31# may lack.
32$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
33$arch = switch ($rawArch) {
34 "AMD64" { "x64" }
35 "ARM64" { "arm64" }
36 default { Fail "unsupported architecture $rawArch" }
37}
38
39$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
40$version = $env:T3CODE_VERSION
41if (-not $version) {
42 # Tags are v<semver>; the channel is the prerelease identifier, or none for
43 # stable. Only tags of the requested train are considered, so a stable
44 # install can never pick up a nightly or preview build by accident.
45 $tagPattern = switch ($channel) {
46 "stable" { '^v\d+\.\d+\.\d+$' }
47 "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' }
48 "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' }
49 default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" }
50 }
51 $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" }
52 $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name
53 if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" }
54 $version = $tag.Substring(1)
55}
56if ($version -match '-preview\.') {
57 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."
58 if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) {
59 Fail "refusing a preview build that was not explicitly requested"
60 }
61}
62
63$stem = "t3-$version-win32-$arch"
64$archive = "$stem.zip"
65$versionsDir = Join-Path $t3Home "runtime\versions"
66$targetDir = Join-Path $versionsDir $version
67$marker = Join-Path $targetDir ".install-complete"
68
69if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
70 Write-Host "t3 $version is already installed at $targetDir"
71} else {
72 New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null
73 $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName())
74 New-Item -ItemType Directory -Path $staging | Out-Null
75 try {
76 Write-Host "Downloading $archive..."
77 try {
78 Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing
79 } catch {
80 $status = $_.Exception.Response.StatusCode.value__
81 if ($status -eq 404) {
82 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'"
83 }
84 throw
85 }
86 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing
87
88 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
89 if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" }
90 $expected = ($expected -split "\s+")[0].ToLowerInvariant()
91 $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant()
92 if ($actual -ne $expected) { Fail "checksum mismatch for $archive" }
93
94 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force
95 # The archive wraps everything in one directory named after its stem.
96 Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging
97 Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force
98
99 & (Join-Path $staging "t3.exe") --version | Out-Null
100 if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" }
101 Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline
102
103 if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force }
104 Move-Item $staging $targetDir
105 } catch {
106 if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
107 throw
108 }
109}
110
111New-Item -ItemType Directory -Force -Path $binDir | Out-Null
112$shim = Join-Path $binDir "t3.cmd"
113# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt
114# non-ASCII characters in the user's home path.
115[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false))
116Write-Host "Installed t3 $version"
117Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')"
118if (($env:PATH -split ";") -notcontains $binDir) {
119 Write-Host "Add $binDir to your PATH to run t3."
120}
scripts/install.sh created+140
...@@ -0,0 +1,140 @@
1#!/bin/sh
2# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar,
3# sha256sum or shasum, and curl or wget; no Node, npm, or compiler.
4#
5# curl -fsSL https://t3.codes/install.sh | sh
6#
7# Environment:
8# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
9# (default: stable; preview is a maintainers' test train)
10# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
11# T3CODE_HOME T3 home directory (default: ~/.t3)
12# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)
13# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
14#
15# The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the
16# same layout `t3 service install` uses, so the service reuses this download
17# instead of fetching the release again.
18set -eu
19
20repo="pingdotgg/t3code"
21base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}"
22t3_home="${T3CODE_HOME:-$HOME/.t3}"
23bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
24
25fail() {
26 printf 't3 install: %s\n' "$1" >&2
27 exit 1
28}
29
30# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
31fetch() {
32 if command -v curl >/dev/null 2>&1; then
33 status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1
34 case "$status" in
35 2??) return 0 ;;
36 404) return 44 ;;
37 *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;;
38 esac
39 elif command -v wget >/dev/null 2>&1; then
40 wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0
41 if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi
42 cat "$2.headers" >&2; rm -f "$2.headers"; return 1
43 else
44 fail "curl or wget is required"
45 fi
46}
47
48case "$(uname -s)" in
49 Darwin) platform="darwin" ;;
50 Linux) platform="linux" ;;
51 *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;;
52esac
53case "$(uname -m)" in
54 arm64 | aarch64) arch="arm64" ;;
55 x86_64 | amd64) arch="x64" ;;
56 *) fail "unsupported architecture $(uname -m)" ;;
57esac
58command -v tar >/dev/null 2>&1 || fail "tar is required"
59if command -v sha256sum >/dev/null 2>&1; then
60 checksum() { sha256sum "$1" | cut -d' ' -f1; }
61elif command -v shasum >/dev/null 2>&1; then
62 checksum() { shasum -a 256 "$1" | cut -d' ' -f1; }
63else
64 fail "sha256sum or shasum is required"
65fi
66
67channel="${T3CODE_CHANNEL:-stable}"
68version="${T3CODE_VERSION:-}"
69if [ -z "$version" ]; then
70 # Tags are v<semver>; the channel is the prerelease identifier, or none for
71 # stable. Only tags of the requested train are considered, so a stable
72 # install can never pick up a nightly or preview build by accident.
73 case "$channel" in
74 stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;;
75 nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;;
76 *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;;
77 esac
78 tmp_index="$(mktemp)"
79 fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index"
80 version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
81 rm -f "$tmp_index"
82 [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION"
83fi
84case "$version" in
85 *-preview.*)
86 printf '%s\n' \
87 "t3 ${version} is a preview build." \
88 " Preview builds are cut by maintainers from unreleased branches to exercise the release" \
89 " pipeline. They can be broken, receive no fixes, and are never offered as updates." \
90 " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2
91 if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then
92 fail "refusing a preview build that was not explicitly requested"
93 fi
94 ;;
95esac
96
97stem="t3-${version}-${platform}-${arch}"
98archive="${stem}.tar.gz"
99versions_dir="${t3_home}/runtime/versions"
100target_dir="${versions_dir}/${version}"
101
102if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then
103 printf 't3 %s is already installed at %s\n' "$version" "$target_dir"
104else
105 mkdir -p "$versions_dir"
106 staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")"
107 trap 'rm -rf "$staging"' EXIT
108
109 printf 'Downloading %s...\n' "$archive"
110 fetch_status=0
111 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$?
112 if [ "$fetch_status" -eq 44 ]; then
113 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}\`"
114 elif [ "$fetch_status" -ne 0 ]; then
115 fail "could not download the release checksums"
116 fi
117 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"
118
119 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
120 [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS"
121 actual="$(checksum "${staging}/${archive}")"
122 [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}"
123
124 tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1
125 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS"
126 "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run"
127 printf '%s\n' "$version" > "${staging}/.install-complete"
128
129 rm -rf "$target_dir"
130 mv "$staging" "$target_dir"
131 trap - EXIT
132fi
133
134mkdir -p "$bin_dir"
135ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
136printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3"
137case ":${PATH}:" in
138 *":${bin_dir}:"*) ;;
139 *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;;
140esac