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";
1717
1818import * as NetService from "@t3tools/shared/Net";
1919import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
20import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease";
21import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
2220import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
2321import serverPackageJson from "../../server/package.json" with { type: "json" };
2422
......@@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap(
8785 }),
8886);
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.
9091const resolveDesktopSshCliRunner = (
9192 environment: DesktopEnvironment.DesktopEnvironment["Service"],
92 settings: DesktopAppSettings.DesktopSettings,
9393): RemoteT3RunnerOptions => {
9494 const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath);
9595 if (environment.isDevelopment && devRemoteEntryPath !== undefined) {
......@@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = (
9898 nodeEngineRange: serverPackageJson.engines.node,
9999 };
100100 }
101 // Preview builds ship as self-contained archives, so the remote runs the
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 };
101 return { archiveVersion: environment.appVersion };
114102};
115103
116104const desktopSshEnvironmentLayer = Layer.unwrap(
117105 Effect.gen(function* () {
118106 const environment = yield* DesktopEnvironment.DesktopEnvironment;
119 const settings = yield* DesktopAppSettings.DesktopAppSettings;
120107 return DesktopSshEnvironment.layer({
121 resolveCliRunner: settings.get.pipe(
122 Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)),
123 ),
108 resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)),
124109 });
125110 }),
126111);
apps/desktop/src/ssh/DesktopSshEnvironment.ts+4-8
......@@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service<
6969>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}
7070
7171export interface DesktopSshEnvironmentLayerOptions {
72 readonly resolveCliPackageSpec?: () => string;
7372 readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;
7473}
7574
......@@ -168,13 +167,10 @@ export const make = Effect.gen(function* () {
168167export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
169168 Layer.effect(DesktopSshEnvironment, make).pipe(
170169 Layer.provide(
171 SshTunnel.SshEnvironmentManager.layer({
172 ...(options.resolveCliPackageSpec === undefined
170 SshTunnel.SshEnvironmentManager.layer(
171 options.resolveCliRunner === undefined
173172 ? {}
174 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
175 ...(options.resolveCliRunner === undefined
176 ? {}
177 : { resolveCliRunner: options.resolveCliRunner }),
178 }),
173 : { resolveCliRunner: options.resolveCliRunner },
174 ),
179175 ),
180176 );
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 @@
44 "private": true,
55 "type": "module",
66 "scripts": {
7 "dev": "astro dev",
8 "build": "astro build",
7 "stage:install-scripts": "node scripts/stage-install-scripts.mjs",
8 "dev": "node scripts/stage-install-scripts.mjs && astro dev",
9 "build": "node scripts/stage-install-scripts.mjs && astro build",
910 "preview": "astro preview",
1011 "typecheck": "astro check"
1112 },
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 @@
1616 "type": "module",
1717 "scripts": {
1818 "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",
2020 "build:exe": "node scripts/cli.ts build-exe",
2121 "start": "node dist/bin.mjs",
2222 "typecheck": "tsc --noEmit",
apps/server/scripts/cli.ts+1-5
......@@ -284,11 +284,7 @@ const publishCmd = Command.make(
284284 const packageJsonPath = path.join(serverDir, "package.json");
285285
286286 // Assert build assets exist
287 for (const relPath of [
288 "dist/bin.mjs",
289 "dist/service-launcher.mjs",
290 "dist/client/index.html",
291 ]) {
287 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
292288 const abs = path.join(serverDir, relPath);
293289 if (!(yield* fs.exists(abs))) {
294290 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
apps/server/src/cli/service.test.ts+1-2
......@@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {
105105 Effect.sync(() => {
106106 installOptions.push(options);
107107 return {
108 program: ["/test/node", "/test/service-launcher.mjs"],
109 launcherPath: "/test/service-launcher.mjs",
108 program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"],
110109 baseDir: "/test/t3",
111110 unitPath: serviceStatus.unitPath,
112111 logPath: serviceStatus.logPath,
apps/server/src/cli/serviceLauncher.ts+3-4
......@@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli";
44import { main as runServiceLauncher } from "../serviceLauncher.ts";
55
66/**
7 * Hosts the service launcher inside the CLI executable. Archive-distributed
8 * runtimes have no Node on the machine to run `service-launcher.mjs`, so the
9 * service manager runs `t3 __service-launcher` and the launcher spawns the
10 * server from the same executable.
7 * Hosts the service launcher inside the CLI executable. The service manager
8 * runs `t3 __service-launcher` and the launcher spawns the server from the
9 * same executable, so the machine needs no Node to run either.
1110 *
1211 * The launcher owns SIGTERM handling and the process lifetime: it must finish
1312 * 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 @@
11import {
22 HostProcessArchitecture,
33 HostProcessEnvironment,
4 HostProcessExecutablePath,
54 HostProcessInvokedAs,
65 HostProcessIsExecutable,
76 HostProcessPlatform,
......@@ -12,7 +11,6 @@ import {
1211 CLI_RELEASE_CHANNELS,
1312 cliReleaseIndexPageUrl,
1413 cliReleaseChannelOf,
15 isArchiveDistributedVersion,
1614 newestCliReleaseVersion,
1715 type CliReleaseChannel,
1816} from "@t3tools/shared/cliRelease";
......@@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun
320318 Effect.map((result) => (result.code === 0 ? result.stdout : "")),
321319 Effect.orElseSucceed(() => ""),
322320 );
323 return /__service-launcher|service-launcher\.mjs/.test(command);
321 return /__service-launcher/.test(command);
324322 }
325323 return false;
326324});
......@@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
338336 const runner = yield* ProcessRunner.ProcessRunner;
339337 const platform = yield* HostProcessPlatform;
340338 const arch = yield* HostProcessArchitecture;
341 const execPath = yield* HostProcessExecutablePath;
342339 const environment = yield* HostProcessEnvironment;
343340 const httpClient = yield* HttpClient.HttpClient;
344341 const service = yield* BootService.BootService;
......@@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
410407 ? serviceVersion
411408 : 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 }
421410 if (executableCurrent && serviceCurrent) {
422411 yield* Console.log(
423412 serviceVersion !== undefined
......@@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: {
480469 validate: (paths) =>
481470 runner
482471 .run({
483 command: pinnedRuntimeCommand(paths, execPath).command,
484 args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"],
472 command: pinnedRuntimeCommand(paths).command,
473 args: [...pinnedRuntimeCommand(paths).args, "--version"],
485474 timeout: Duration.seconds(30),
486475 })
487476 .pipe(
apps/server/src/cloud/bootService.test.ts+45-82
......@@ -1,7 +1,6 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
33import {
4 HostProcessArguments,
54 HostProcessExecutablePath,
65 HostProcessPlatform,
76 HostProcessUserId,
......@@ -12,6 +11,7 @@ import * as Effect from "effect/Effect";
1211import * as FileSystem from "effect/FileSystem";
1312import * as Layer from "effect/Layer";
1413import * as Path from "effect/Path";
14import { HttpClient } from "effect/unstable/http";
1515import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1616
1717import * as ProcessRunner from "../processRunner.ts";
......@@ -23,24 +23,25 @@ import {
2323 serviceStateHasPendingUpdate,
2424} from "./serviceProtocol.ts";
2525
26it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
27 const unit = BootService.renderBootServiceUnit({
28 program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"],
29 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
30 baseDir: "/home/theo/.t3",
31 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
32 unitPath: "/home/theo/.config/systemd/user/t3code.service",
33 });
26const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3";
27const linuxPlan = {
28 program: [linuxRuntime, "__service-launcher"],
29 baseDir: "/home/theo/.t3",
30 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
31 unitPath: "/home/theo/.config/systemd/user/t3code.service",
32};
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`);
3638 expect(unit).toContain("KillMode=mixed");
37 expect(unit).not.toContain("versions/1.2.3");
39 expect(unit).not.toContain("node");
3840});
3941
4042it("reads the served T3 home back out of a rendered unit or plist", () => {
4143 const plan = (baseDir: string) => ({
42 program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`],
43 launcherPath: `${baseDir}/runtime/service-launcher.mjs`,
44 program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"],
4445 baseDir,
4546 logPath: `${baseDir}/userdata/logs/boot-service.log`,
4647 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", () => {
6667 expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined();
6768});
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
8470it("survives the kernel OOM-killing a greedy agent child", () => {
85 const unit = BootService.renderBootServiceUnit({
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 });
71 const unit = BootService.renderBootServiceUnit(linuxPlan);
9272
9373 expect(unit).toContain("OOMPolicy=continue");
9474});
9575
76const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3";
9677const macPlan = {
97 program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"],
98 launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs",
78 program: [macRuntime, "__service-launcher"],
9979 baseDir: "/Users/theo/.t3",
10080 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
10181 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
......@@ -104,12 +84,13 @@ const macInstallerPath =
10484 "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin";
10585const 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", () => {
10888 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
10989
110 expect(plist).toContain("<string>/opt/homebrew/bin/node</string>");
111 expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>");
112 expect(plist).not.toContain("versions/1.2.3");
90 expect(plist).toContain(
91 ` <array>\n <string>${macRuntime}</string>\n <string>__service-launcher</string>\n </array>`,
92 );
93 expect(plist).not.toContain("node</string>");
11394});
11495
11596it("preserves the installer's provider search path in the launch agent", () => {
......@@ -150,23 +131,18 @@ it("escapes XML in host paths", () => {
150131
151132const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
152133 platform: NodeJS.Platform = "linux",
153 usePinnedLauncher = false,
154134 installerPath = macInstallerPath,
155135) {
156136 const fs = yield* FileSystem.FileSystem;
157137 const path = yield* Path.Path;
158138 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
159139 const baseDir = path.join(home, ".t3");
160 const sourceLauncher = path.join(home, "service-launcher.mjs");
161140 const statePath = path.join(baseDir, "runtime", "service-state.json");
162 yield* fs.writeFileString(sourceLauncher, "export {};\n");
163 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
141 // A complete pinned runtime is already present, so install only validates
142 // it and never downloads a release archive.
143 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform);
164144 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
165 yield* fs.writeFileString(runtime.entryPath, "export {};\n");
166 yield* fs.writeFileString(
167 path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"),
168 "export const source = 'pinned runtime';\n",
169 );
145 yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n");
170146 yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");
171147
172148 const commands: string[] = [];
......@@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
204180 }
205181 return {
206182 stdout:
207 input.args[1] === "--version"
183 input.args[0] === "--version"
208184 ? "t3 v1.2.3\n"
209185 : input.command === "loginctl" && input.args[0] === "show-user"
210186 ? `${control.linger}\n`
......@@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
230206 baseDir,
231207 logsDir: path.join(baseDir, "userdata", "logs"),
232208 cliVersion: "1.2.3",
233 host: {
234 execPath: "/usr/bin/node",
235 ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
236 },
209 host: { execPath: "/usr/bin/t3" },
237210 }).pipe(
238211 Effect.provideService(ProcessRunner.ProcessRunner, runner),
239212 Effect.provide(
240213 Layer.mergeAll(
241214 Layer.succeed(HostProcessPlatform, platform),
242215 Layer.succeed(HostProcessUserId, 501),
243 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
244 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
216 Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"),
217 Layer.succeed(
218 HttpClient.HttpClient,
219 HttpClient.make(() => Effect.die("no release download expected")),
220 ),
245221 ConfigProvider.layer(
246222 ConfigProvider.fromEnv({
247223 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },
......@@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
275251 expect(error.message).toContain("last login session ends");
276252 expect(yield* fs.exists(before.unitPath)).toBe(false);
277253 expect(yield* fs.exists(statePath)).toBe(false);
278 expect(
279 commands.some((command) => command.startsWith("npm ") || command.includes("--version")),
280 ).toBe(false);
254 expect(commands.some((command) => command.includes("--version"))).toBe(false);
281255 expect(
282256 commands.some(
283257 (command) => command.includes("daemon-reload") || command.includes("restart"),
......@@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
350324
351325 it.effect("installs, reports current state, and uninstalls", () =>
352326 Effect.gen(function* () {
353 const { service, fs, statePath, commands, timeouts } = yield* makeHarness();
327 const { service, fs, statePath, timeouts, runtime } = yield* makeHarness();
354328 const plan = yield* service.install();
355329
356330 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
357331 protocol: SERVICE_LAUNCHER_PROTOCOL,
358332 activeVersion: "1.2.3",
359333 });
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 );
361338 expect(yield* service.status).toMatchObject({
362339 current: true,
363340 installedVersion: "1.2.3",
......@@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
378355 expect((yield* service.status).current).toBe(false);
379356 expect(yield* service.uninstall).toBe(true);
380357 expect((yield* service.status).installed).toBe(false);
381 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
382358 // The stop can block up to systemd's 90s TimeoutStopSec; the runner's
383359 // 60s default would cancel it mid-shutdown.
384360 expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual(
......@@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
432408 Effect.gen(function* () {
433409 const { service, fs, statePath, commands, control } = yield* makeHarness(platform);
434410 const plan = yield* service.install();
435 const launcher = yield* fs.readFileString(plan.launcherPath);
436411 const unit = yield* fs.readFileString(plan.unitPath);
437412 control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`;
438413 commands.length = 0;
......@@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
445420 targetVersion: "1.2.3",
446421 });
447422 expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop);
448 expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher);
449423 expect(yield* fs.readFileString(plan.unitPath)).toBe(unit);
450424 expect(
451425 commands.filter(
......@@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
495469 }),
496470 );
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
509472 it.effect("restarts an installed service when repair fails", () =>
510473 Effect.gen(function* () {
511474 const { service, commands, control } = yield* makeHarness();
......@@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
572535
573536 it.effect("installs, reports current state, and uninstalls on macOS", () =>
574537 Effect.gen(function* () {
575 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");
538 const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin");
576539 const path = yield* Path.Path;
577540 const plan = yield* service.install();
578541
......@@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
588551 protocol: SERVICE_LAUNCHER_PROTOCOL,
589552 activeVersion: "1.2.3",
590553 });
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 );
592557 expect(yield* service.status).toMatchObject({
593558 current: true,
594559 installedVersion: "1.2.3",
595560 });
596561 expect(yield* service.uninstall).toBe(true);
597562 expect((yield* service.status).installed).toBe(false);
598 expect(commands.some((command) => command.startsWith("npm "))).toBe(false);
599563 expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
600564 // A bootout can block up to the plist's 90s ExitTimeOut; the runner's
601565 // 60s default would cancel it and let bootstrap race a loaded job.
......@@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
626590
627591 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
628592 Effect.gen(function* () {
629 const { service, fs } = yield* makeHarness("darwin", false, "");
593 const { service, fs } = yield* makeHarness("darwin", "");
630594 const plan = yield* service.install();
631595
632596 expect(yield* fs.readFileString(plan.unitPath)).toContain(
......@@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
638602
639603 it.effect("adds missing provider directories to a minimal installer PATH", () =>
640604 Effect.gen(function* () {
641 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");
605 const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin");
642606 const plan = yield* service.install();
643607
644608 expect(yield* fs.readFileString(plan.unitPath)).toContain(
......@@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
662626 Effect.gen(function* () {
663627 const { service, fs } = yield* makeHarness(
664628 "darwin",
665 false,
666629 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
667630 );
668631 const plan = yield* service.install();
apps/server/src/cloud/bootService.ts+12-38
......@@ -26,7 +26,6 @@ import {
2626 PinnedRuntimeInstallError,
2727} from "./pinnedRuntime.ts";
2828import {
29 SERVICE_LAUNCHER_FILE,
3029 SERVICE_LAUNCHER_PROTOCOL,
3130 SERVICE_STATE_FILE,
3231 compareExactServiceVersions,
......@@ -86,7 +85,6 @@ export interface BootServicePlan {
8685 * subcommand so the machine never needs Node.
8786 */
8887 readonly program: ReadonlyArray<string>;
89 readonly launcherPath: string;
9088 readonly baseDir: string;
9189 readonly logPath: string;
9290 readonly unitPath: string;
......@@ -533,7 +531,6 @@ export class BootService extends Context.Service<
533531
534532export interface BootServiceHost {
535533 readonly execPath: string;
536 readonly launcherSourcePath?: string;
537534}
538535
539536export 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: {
546543 const platform = yield* HostProcessPlatform;
547544 const arch = yield* HostProcessArchitecture;
548545 const uid = yield* HostProcessUserId;
549 // Archive-distributed versions download from GitHub Releases; npm versions
550 // never touch HTTP, so callers without a client still work.
551 const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient));
546 const httpClient = yield* HttpClient.HttpClient;
552547 const releaseBaseUrl = Option.getOrUndefined(
553548 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
554549 );
......@@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
588583 });
589584 const unitPath = detectedManager?.unitPath ?? "";
590585 const logPath = path.join(input.logsDir, "boot-service.log");
591 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
592586 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
593587 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);
597588 const writeDurably = (filePath: string, contents: string) =>
598589 Effect.scoped(
599590 Effect.gen(function* () {
......@@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
613604 );
614605 }),
615606 ).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.
616609 const plan: BootServicePlan = {
617 program:
618 runtimePaths.layout === "archive"
619 ? [runtimePaths.entryPath, "__service-launcher"]
620 : [host.execPath, launcherPath],
621 launcherPath,
610 program: [runtimePaths.entryPath, "__service-launcher"],
622611 baseDir: input.baseDir,
623612 logPath,
624613 unitPath,
......@@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
764753 validate: (runtime) =>
765754 runner
766755 .run({
767 command: pinnedRuntimeCommand(runtime, host.execPath).command,
768 args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"],
756 command: pinnedRuntimeCommand(runtime).command,
757 args: [...pinnedRuntimeCommand(runtime).args, "--version"],
769758 timeout: Duration.seconds(30),
770759 })
771760 .pipe(
......@@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
803792 : new BootServiceInstallError({ cause: error }),
804793 ),
805794 );
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
815795 const installed = yield* fs
816796 .exists(unitPath)
817797 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
......@@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
844824 yield* fs
845825 .makeDirectory(path.dirname(unitPath), { recursive: true })
846826 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
847 if (launcherSource !== undefined) {
848 yield* writeDurably(launcherPath, launcherSource);
849 }
850827 yield* writeDurably(
851828 statePath,
852829 // @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: {
893870 if (!(yield* fs.exists(unitPath))) {
894871 return { supported: true, installed: false, current: false, unitPath, logPath };
895872 }
896 const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] =
897 yield* Effect.all([
898 fs.readFileString(unitPath),
899 runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath),
900 fs.exists(runtimePaths.entryPath),
901 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
902 fs.readFileString(statePath).pipe(Effect.option),
903 ]);
873 const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([
874 fs.readFileString(unitPath),
875 fs.exists(runtimePaths.entryPath),
876 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
877 fs.readFileString(statePath).pipe(Effect.option),
878 ]);
904879 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
905880 const installedVersion = Option.isSome(stateText)
906881 ? serviceStateActiveVersion(stateText.value)
......@@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
920895 current:
921896 problems.length === 0 &&
922897 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
923 launcherExists &&
924898 runtimeEntryExists &&
925899 Option.isSome(runtimeSentinel) &&
926900 runtimeSentinel.value.trim() === input.cliVersion &&
apps/server/src/cloud/pinnedRuntime.test.ts+79-193
......@@ -5,7 +5,6 @@ import * as Effect from "effect/Effect";
55import * as FileSystem from "effect/FileSystem";
66import * as Fiber from "effect/Fiber";
77import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
98import { HttpClient, HttpClientResponse } from "effect/unstable/http";
109import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1110
......@@ -17,16 +16,38 @@ import {
1716 PinnedRuntimeInstallError,
1817} 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[] = []) =>
2141 ProcessRunner.ProcessRunner.of({
2242 run: (input) =>
2343 Effect.gen(function* () {
24 const prefixIndex = input.args.indexOf("--prefix");
25 const stagingDir = input.args[prefixIndex + 1];
26 if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix");
27 const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs");
28 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
29 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
44 commands.push(input.command);
45 const targetIndex = input.args.indexOf("-C");
46 const stagingDir = input.args[targetIndex + 1];
47 if (input.command !== "tar" || stagingDir === undefined) {
48 return yield* Effect.die(`unexpected command ${input.command}`);
49 }
50 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
3051 return {
3152 stdout: "",
3253 stderr: "",
......@@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) =>
4162 });
4263
4364it.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", () =>
4566 Effect.gen(function* () {
4667 const fs = yield* FileSystem.FileSystem;
4768 const path = yield* Path.Path;
48 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" });
49 const commands: Array<ProcessRunner.ProcessRunInput> = [];
50 const install = successfulRunner(fs, path);
69 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" });
70 const requests: string[] = [];
71 const commands: string[] = [];
5172 const paths = yield* ensurePinnedRuntimeInstalled({
5273 baseDir,
53 version: "1.2.3",
74 version,
5475 fs,
5576 path,
5677 platform: "linux",
5778 arch: "x64",
58 runner: ProcessRunner.ProcessRunner.of({
59 run: (input) => {
60 commands.push(input);
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 }),
79 httpClient: releaseHttpClient(yield* validChecksums, requests),
80 releaseBaseUrl: "https://releases.example/download",
81 runner: extractingRunner(fs, path, commands),
7682 validate: (staging) =>
7783 fs.exists(staging.entryPath).pipe(
7884 Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))),
7985 Effect.orDie,
8086 ),
8187 });
82 assert.deepEqual(
83 commands.map((command) => command.command),
84 ["npm", "pnpm"],
85 );
86 assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]);
87 assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n");
88 assert.equal(paths.entryPath, path.join(paths.versionDir, "t3"));
89 assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] });
90 assert.deepEqual(requests, [
91 `https://releases.example/download/v${version}/SHA256SUMS`,
92 `https://releases.example/download/v${version}/${archiveName}`,
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")));
8897 }),
8998 );
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", () =>
92101 Effect.gen(function* () {
93102 const fs = yield* FileSystem.FileSystem;
94103 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-" });
96105 const commands: string[] = [];
97 yield* ensurePinnedRuntimeInstalled({
106 const error = yield* ensurePinnedRuntimeInstalled({
98107 baseDir,
99 version: "1.2.3",
108 version,
100109 fs,
101110 path,
102111 platform: "linux",
103112 arch: "x64",
104 runner: ProcessRunner.ProcessRunner.of({
105 run: (input) => {
106 commands.push(input.command);
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"),
113 httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`),
114 runner: extractingRunner(fs, path, commands),
115 validate: () => Effect.die("must not validate an unverified archive"),
121116 }).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")), []);
123121 }),
124122 );
125123
......@@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
128126 const fs = yield* FileSystem.FileSystem;
129127 const path = yield* Path.Path;
130128 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");
132130 let validatedDirectory = "";
133131
134132 const installed = yield* ensurePinnedRuntimeInstalled({
135133 baseDir,
136 version: "1.2.3",
134 version,
137135 fs,
138136 path,
139137 platform: "linux",
140138 arch: "x64",
141 runner: successfulRunner(fs, path),
139 httpClient: releaseHttpClient(yield* validChecksums),
140 runner: extractingRunner(fs, path),
142141 validate: (staging) =>
143142 Effect.gen(function* () {
144143 validatedDirectory = staging.versionDir;
......@@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
150149 assert.notEqual(validatedDirectory, finalPaths.versionDir);
151150 assert.deepEqual(installed, finalPaths);
152151 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`);
154153 }),
155154 );
156155
......@@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
159158 const fs = yield* FileSystem.FileSystem;
160159 const path = yield* Path.Path;
161160 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
164163 yield* ensurePinnedRuntimeInstalled({
165164 baseDir,
166 version: "1.2.3",
165 version,
167166 fs,
168167 path,
169168 platform: "linux",
170169 arch: "x64",
171 runner: successfulRunner(fs, path),
170 httpClient: releaseHttpClient(yield* validChecksums),
171 runner: extractingRunner(fs, path),
172172 validate: () =>
173173 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),
174174 }).pipe(Effect.flip);
......@@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
188188 const fs = yield* FileSystem.FileSystem;
189189 const path = yield* Path.Path;
190190 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");
192192 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });
193193 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");
194194
195195 yield* ensurePinnedRuntimeInstalled({
196196 baseDir,
197 version: "1.2.3",
197 version,
198198 fs,
199199 path,
200200 platform: "linux",
201201 arch: "x64",
202 runner: successfulRunner(fs, path),
202 httpClient: releaseHttpClient(yield* validChecksums),
203 runner: extractingRunner(fs, path),
203204 validate: () => Effect.void,
204205 });
205206
......@@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
213214 const fs = yield* FileSystem.FileSystem;
214215 const path = yield* Path.Path;
215216 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");
217218 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });
218219 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
221222 let validations = 0;
223 const requests: string[] = [];
222224 yield* ensurePinnedRuntimeInstalled({
223225 baseDir,
224 version: "1.2.3",
226 version,
225227 fs,
226228 path,
227229 platform: "linux",
228230 arch: "x64",
229 runner: successfulRunner(fs, path),
231 httpClient: releaseHttpClient(yield* validChecksums, requests),
232 runner: extractingRunner(fs, path),
230233 validate: (paths) =>
231234 Effect.gen(function* () {
232235 validations += 1;
......@@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
238241 }).pipe(Effect.flip);
239242
240243 assert.equal(validations, 1);
244 assert.deepEqual(requests, []);
241245 assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n");
242246 }),
243247 );
......@@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
253257 });
254258 const install = yield* ensurePinnedRuntimeInstalled({
255259 baseDir,
256 version: "1.2.3",
260 version,
257261 fs,
258262 path,
259263 platform: "linux",
260264 arch: "x64",
265 httpClient: releaseHttpClient(yield* validChecksums),
261266 runner,
262267 validate: () => Effect.void,
263268 }).pipe(Effect.forkScoped);
......@@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
268273 assert.deepEqual(yield* fs.readDirectory(versionsDir), []);
269274 }),
270275 );
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 });
390276});
apps/server/src/cloud/pinnedRuntime.ts+25-108
......@@ -3,7 +3,6 @@ import * as Effect from "effect/Effect";
33import * as Encoding from "effect/Encoding";
44import * as FileSystem from "effect/FileSystem";
55import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
76import * as Schema from "effect/Schema";
87import * as Option from "effect/Option";
98import * as Semaphore from "effect/Semaphore";
......@@ -15,26 +14,21 @@ import {
1514 cliArchivePlatformKey,
1615 cliArchiveTarCommand,
1716 cliReleaseDownloadBaseUrl,
18 isArchiveDistributedVersion,
1917 parseChecksums,
2018} from "@t3tools/shared/cliRelease";
2119
2220import * as ProcessRunner from "../processRunner.ts";
2321
2422/**
25 * A pinned runtime is an exact `t3@<version>` installed into
26 * <baseDir>/runtime/versions/<version>. The boot service points its unit or
27 * launch agent here, and server self-update installs the target version here before
28 * switching over, never `npx t3`, whose cache is ephemeral and whose
29 * registry fetch at boot would make startup depend on the network.
30 *
31 * Two layouts exist. npm-distributed versions are `npm install`ed and run as
32 * `<node> node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the
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.
23 * A pinned runtime is an exact t3 release archive unpacked into
24 * <baseDir>/runtime/versions/<version>: the self-contained executable, the
25 * web client, and the native packages beside it. The boot service points its
26 * unit or launch agent at the executable, and server self-update installs the
27 * target version here before switching over. The runtime never depends on a
28 * Node or npm on the machine; the only npm involvement in T3 Code is the `t3`
29 * package for people who prefer `npx t3` or `npm install -g t3`, and even a
30 * CLI installed that way pins an archive when it sets up the service.
3631 */
37
3832const PINNED_RUNTIME_DIR = "runtime";
3933const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
4034const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
......@@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
4236// the complete install transaction across every caller in this process.
4337const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
4438
45export type PinnedRuntimeLayout = "npm" | "archive";
46
4739export interface PinnedRuntimePaths {
48 readonly layout: PinnedRuntimeLayout;
4940 readonly versionDir: string;
50 /**
51 * `bin.mjs` for npm layouts, the executable itself for archives. Existence
52 * of this file is what marks a runtime as present.
53 */
41 /** The executable. Its existence is what marks a runtime as present. */
5442 readonly entryPath: string;
5543 readonly sentinelPath: string;
5644}
5745
58/** The exact command that runs a pinned runtime, given the Node hosting the caller. */
59export function pinnedRuntimeCommand(
60 paths: PinnedRuntimePaths,
61 nodePath: string,
62): { readonly command: string; readonly args: ReadonlyArray<string> } {
63 return paths.layout === "archive"
64 ? { command: paths.entryPath, args: [] }
65 : { command: nodePath, args: [paths.entryPath] };
46/** The exact command that runs a pinned runtime. */
47export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): {
48 readonly command: string;
49 readonly args: ReadonlyArray<string>;
50} {
51 return { command: paths.entryPath, args: [] };
6652}
6753
6854export function pinnedRuntimePaths(
......@@ -72,20 +58,10 @@ export function pinnedRuntimePaths(
7258 platform: NodeJS.Platform,
7359): PinnedRuntimePaths {
7460 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 }
8461 return {
85 layout: "npm",
8662 versionDir,
87 entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
88 sentinelPath,
63 entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"),
64 sentinelPath: path.join(versionDir, ".install-complete"),
8965 };
9066}
9167
......@@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne
11995}
12096
12197/**
122 * Installs `t3@<version>` into the pinned runtime directory unless a complete
123 * install is already there, and returns its paths. The sentinel is written
124 * only after the install step exits 0; checking the entry file alone is not
125 * enough. npm extracts files before running native builds (node-pty), and tar
126 * writes the executable before the last native package, so a killed install
127 * leaves a plausible-looking but broken tree behind.
98 * Installs the t3 release archive for `version` into the pinned runtime
99 * directory unless a complete install is already there, and returns its
100 * paths. The sentinel is written only after extraction and validation
101 * succeed; checking the entry file alone is not enough, since tar writes the
102 * executable before the last native package and a killed install leaves a
103 * plausible-looking but broken tree behind.
128104 */
129105interface PinnedRuntimeInstallInput {
130106 readonly baseDir: string;
......@@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput {
137113 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
138114 readonly platform: NodeJS.Platform;
139115 readonly arch: string;
140 /** Archive-distributed versions download from here; npm versions never need it. */
141 readonly httpClient?: HttpClient.HttpClient | undefined;
116 readonly httpClient: HttpClient.HttpClient;
142117 readonly releaseBaseUrl?: string | undefined;
143118}
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
193120const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (
194121 httpClient: HttpClient.HttpClient,
195122 url: string,
......@@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun
227154 });
228155 }
229156 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 }
235157 const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl);
236158 const fileName = cliArchiveFileName(input.version, platformKey);
237159
......@@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
356278 ),
357279 );
358280 const stagingPaths: PinnedRuntimePaths = {
359 layout: paths.layout,
360281 versionDir: stagingDir,
361282 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),
362283 sentinelPath: input.path.join(stagingDir, ".install-complete"),
363284 };
364285
365286 return yield* Effect.gen(function* () {
366 if (paths.layout === "archive") {
367 yield* installFromArchive(input, stagingDir);
368 } else {
369 yield* installFromNpm(input, stagingDir);
370 }
287 yield* installFromArchive(input, stagingDir);
371288
372289 yield* input.validate(stagingPaths);
373290 yield* fs
apps/server/src/cloud/selfUpdate.test.ts+33-10
......@@ -1,13 +1,14 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "@effect/vitest";
33import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts";
4import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
4import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
55import * as Cause from "effect/Cause";
66import * as Deferred from "effect/Deferred";
77import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
99import * as Fiber from "effect/Fiber";
1010import * as Path from "effect/Path";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1112import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1213
1314import * as ServerConfig from "../config.ts";
......@@ -25,6 +26,28 @@ interface HarnessOptions {
2526 readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"];
2627}
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
2851const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
2952 options: HarnessOptions = {},
3053) {
......@@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
3558 const runner = ProcessRunner.ProcessRunner.of({
3659 run: (input) =>
3760 Effect.gen(function* () {
38 if (input.command === "npm") {
39 order.push("install");
40 const prefix = input.args[input.args.indexOf("--prefix") + 1];
41 if (prefix === undefined) return yield* Effect.die("missing npm prefix");
42 const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs");
43 yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie);
44 yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie);
61 if (input.command === "tar") {
62 order.push("extract");
63 const stagingDir = input.args[input.args.indexOf("-C") + 1];
64 if (stagingDir === undefined) return yield* Effect.die("missing tar target");
65 yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie);
4566 return {
4667 stdout: "",
4768 stderr: "",
......@@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* (
99120 run: () => Effect.die("unexpected desktop app update run"),
100121 },
101122 ),
102 Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"),
123 Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)),
124 Effect.provideService(HostProcessPlatform, "linux"),
125 Effect.provideService(HostProcessArchitecture, "x64"),
103126 Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })),
104127 );
105128 return { selfUpdate, order };
......@@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => {
329352 method: "boot-service",
330353 updateId: "launcher-id",
331354 });
332 expect(order).toEqual(["install", "preflight", "accept"]);
355 expect(order).toEqual(["download", "extract", "preflight", "accept"]);
333356 }),
334357 );
335358
apps/server/src/cloud/selfUpdate.ts+4-9
......@@ -6,11 +6,7 @@ import {
66 type ServerSelfUpdateResult,
77 type ThreadId,
88} from "@t3tools/contracts";
9import {
10 HostProcessArchitecture,
11 HostProcessExecutablePath,
12 HostProcessPlatform,
13} from "@t3tools/shared/hostProcess";
9import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
1410import * as Cause from "effect/Cause";
1511import * as Config from "effect/Config";
1612import * as Context from "effect/Context";
......@@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
180176 const runner = yield* ProcessRunner.ProcessRunner;
181177 const fs = yield* FileSystem.FileSystem;
182178 const path = yield* Path.Path;
183 const execPath = yield* HostProcessExecutablePath;
184179 const platform = yield* HostProcessPlatform;
185180 const arch = yield* HostProcessArchitecture;
186181 // Archive-distributed targets download from GitHub Releases. The client is
187182 // 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;
189184 const releaseBaseUrl = Option.getOrUndefined(
190185 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
191186 );
......@@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
241236 validate: (runtime) =>
242237 runner
243238 .run({
244 command: pinnedRuntimeCommand(runtime, execPath).command,
239 command: pinnedRuntimeCommand(runtime).command,
245240 args: [
246 ...pinnedRuntimeCommand(runtime, execPath).args,
241 ...pinnedRuntimeCommand(runtime).args,
247242 "__service-preflight",
248243 "--database-path",
249244 serverConfig.dbPath,
apps/server/src/cloud/serviceProtocol.ts-1
......@@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts";
33/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */
44export const SERVICE_LAUNCHER_PROTOCOL = 2 as const;
55export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT";
6export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs";
76export const SERVICE_STATE_FILE = "service-state.json";
87/** Written by the launcher just before an explicit stop kills its child, so
98 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", () => {
7575 );
7676});
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
7899it.layer(NodeServices.layer)("service state persistence", (it) => {
79100 it.effect("durably replaces and strictly reads one state document", () =>
80101 Effect.gen(function* () {
......@@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
98119 const path = yield* Path.Path;
99120 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" });
100121 const statePath = path.join(root, "runtime", "service-state.json");
101 const versionDir = path.join(root, "runtime", "versions", "1.0.0");
102 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
103 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
104 yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n");
105 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n");
122 yield* writeFakeRuntime(
123 fs,
124 path,
125 path.join(root, "runtime", "versions", "1.0.0"),
126 "setInterval(() => {}, 1_000);\n",
127 );
106128 yield* Effect.promise(() =>
107129 writeServiceState(statePath, {
108130 protocol: SERVICE_LAUNCHER_PROTOCOL,
......@@ -148,11 +170,12 @@ if (context.update?.status === "pending") {
148170}
149171`;
150172 for (const version of ["1.0.0", "1.1.0"]) {
151 const versionDir = path.join(root, "runtime", "versions", version);
152 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
153 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
154 yield* fs.writeFileString(entryPath, childSource);
155 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
173 yield* writeFakeRuntime(
174 fs,
175 path,
176 path.join(root, "runtime", "versions", version),
177 childSource,
178 );
156179 }
157180 yield* Effect.promise(() =>
158181 writeServiceState(statePath, {
......@@ -198,11 +221,12 @@ if (context.update?.status === "pending") {
198221}
199222`;
200223 for (const version of ["1.0.0", "1.1.0"]) {
201 const versionDir = path.join(root, "runtime", "versions", version);
202 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
203 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
204 yield* fs.writeFileString(entryPath, childSource);
205 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
224 yield* writeFakeRuntime(
225 fs,
226 path,
227 path.join(root, "runtime", "versions", version),
228 childSource,
229 );
206230 }
207231 yield* Effect.promise(() =>
208232 writeServiceState(statePath, {
......@@ -257,11 +281,12 @@ if (context.update?.status === "pending") {
257281}
258282`;
259283 for (const version of ["1.0.0", "1.1.0"]) {
260 const versionDir = path.join(root, "runtime", "versions", version);
261 const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs");
262 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
263 yield* fs.writeFileString(entryPath, childSource);
264 yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`);
284 yield* writeFakeRuntime(
285 fs,
286 path,
287 path.join(root, "runtime", "versions", version),
288 childSource,
289 );
265290 }
266291 yield* Effect.promise(() =>
267292 writeServiceState(statePath, {
apps/server/src/serviceLauncher.ts+11-29
......@@ -1,13 +1,14 @@
11// @effect-diagnostics nodeBuiltinImport:off
22// @effect-diagnostics globalTimers:off
3// This file is shipped as a standalone bundle and copied to a stable path by
4// `t3 service update`. Keep runtime imports limited to Node built-ins.
3// The launcher supervises the server child for the boot service and must keep
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.
57import * as NodeChildProcess from "node:child_process";
68import * as NodeCrypto from "node:crypto";
79import * as NodeFS from "node:fs";
810import * as NodeFSP from "node:fs/promises";
911import * as NodePath from "node:path";
10import * as NodeSea from "node:sea";
1112
1213import type {
1314 PendingServiceUpdate,
......@@ -41,37 +42,24 @@ interface ManagedChild {
4142 readonly process: NodeChildProcess.ChildProcess;
4243}
4344
44// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked
45// release archives whose executable runs on its own, npm versions are a
46// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node
45// Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose
46// executable runs on its own. Kept inline so this file stays on Node
4747// built-ins only.
4848const runtimePaths = (baseDir: string, version: string) => {
4949 const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
50 const archive = /-preview\.\d{8}\.\d+$/.test(version);
5150 // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime.
5251 const executableName = process.platform === "win32" ? "t3.exe" : "t3";
5352 return {
5453 versionDir,
55 entryPath: archive
56 ? NodePath.join(versionDir, executableName)
57 : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
54 entryPath: NodePath.join(versionDir, executableName),
5855 sentinelPath: NodePath.join(versionDir, ".install-complete"),
59 executable: archive,
6056 };
6157};
6258
63const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) =>
64 paths.executable
65 ? { command: paths.entryPath, args: ["serve"] }
66 : { command: process.execPath, args: [paths.entryPath, "serve"] };
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;
59const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) => ({
60 command: paths.entryPath,
61 args: ["serve"],
62});
7563
7664/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */
7765const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const;
......@@ -505,12 +493,6 @@ export class Launcher {
505493 await reject("The requested database path is not absolute.");
506494 return;
507495 }
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 }
514496 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {
515497 await reject("The requested target runtime is missing or incomplete.");
516498 return;
docs/operations/release.md+15-13
......@@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop
3434 - Nightly runs are always GitHub prereleases and never marked latest.
3535 - 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.
3636- 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.
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`.
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 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`.
3939 - 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`.
4040 - 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.
4141 - 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
251251dependency closure in `resources/server.asar`. Native modules and helper
252252executables declared as unpacked by that archive must be present at the matching
253253paths below `resources/server.asar.unpacked`. The Windows-native backend reads
254the archive in place through Electron. Packaged Windows builds also ship a
255Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies
256and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside
257the selected distro, then reuses it for later launches of the same update. The
258Windows-side `wsl-server-tree/<version>` extraction remains a fallback and is
259removed after the distro-local runtime passes preflight.
254the archive in place through Electron. Packaged Windows builds also ship
255`resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive
256(`t3-<version>-linux-x64.tar.gz`) built by the `build_linux_cli` job and handed
257to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs
258the exact bytes a Linux user downloads. WSL verifies and extracts that archive
259into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside the selected distro,
260then reuses it for later launches of the same update.
260261
261262Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only
262263native libraries and helper executables. Avoid enabling whole-package smart
......@@ -272,11 +273,12 @@ break:
272273- On same-architecture Windows builds, the packaged primary cannot load the fff
273274 native library from inside `server.asar` through its `.unpacked` sibling.
274275- 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-256
276 sidecar, the sidecar digest does not match the emitted archive, or required
277 Linux runtime members are absent.
278- The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY,
279 pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings.
276- A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256
277 sidecar, or the sidecar digest does not match the emitted archive.
278- The emitted WSL archive is not a Linux CLI release archive: it must unpack to
279 a single `t3-<version>-linux-<arch>` directory holding `t3`, `client/`, and
280 `node_modules/` with the Linux node-pty binary, and must not carry a loose
281 server bundle (`bin.mjs`).
280282- The external Windows resource monitor is absent.
281283- 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.
3939It places `t3` in `~/.local/bin` and reuses the same download when you later
4040run `t3 service install`. It follows the stable train by default; set
4141`T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact
42version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that
43were only published to npm are refused with the `npm install` to run instead.
42version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror.
4443
4544`preview` is a third train that maintainers cut from unreleased branches to
4645exercise 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
5857command tells you it is still on the old version so you can restart it
5958yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to
6059pin 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 cannot
62be installed this way; the command says so and names the `npm install` to run
63instead.
60`--allow-downgrade` to move backwards.
6461
6562## Platform support
6663
knip.jsonc-1
......@@ -20,7 +20,6 @@
2020 // Vite+ pack entries and the launcher used by installed background services.
2121 "entry": [
2222 "src/bin.ts!",
23 "src/service-launcher.ts!",
2423 "src/claude-history-worker.ts!",
2524 "scripts/cli.ts",
2625 "src/provider/testFixtures/*.mjs",
packages/shared/src/cliRelease.test.ts-7
......@@ -7,7 +7,6 @@ import {
77 cliReleaseDownloadBaseUrl,
88 cliReleaseChannelOf,
99 cliReleaseIndexPageUrl,
10 isArchiveDistributedVersion,
1110 newestCliReleaseVersion,
1211 parseChecksums,
1312} from "./cliRelease.ts";
......@@ -56,12 +55,6 @@ describe("cliRelease", () => {
5655 expect(checksums.size).toBe(2);
5756 });
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
6558 it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => {
6659 expect(cliArchiveTarCommand("linux", {})).toBe("tar");
6760 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> {
7070 return checksums;
7171}
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
7873export type CliReleaseChannel = "stable" | "nightly" | "preview";
7974export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [
8075 "stable",
packages/ssh/src/command.test.ts-36
......@@ -14,7 +14,6 @@ import {
1414 baseSshArgs,
1515 getLastNonEmptyOutputLine,
1616 parseSshResolveOutput,
17 resolveRemoteT3CliPackageSpec,
1817 runSshCommand,
1918} from "./command.ts";
2019import { SshCommandError } from "./errors.ts";
......@@ -99,41 +98,6 @@ describe("ssh command", () => {
9998 }),
10099 );
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
137101 it.effect("reads the last non-empty ssh output line", () =>
138102 Effect.sync(() => {
139103 assert.equal(
packages/ssh/src/command.ts+1-19
......@@ -1,6 +1,6 @@
11import * as NodeCrypto from "node:crypto";
22
3import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts";
3import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts";
44import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
55import * as Duration from "effect/Duration";
66import * as Effect from "effect/Effect";
......@@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1414import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts";
1515import { SshCommandError, SshInvalidTargetError } from "./errors.ts";
1616
17const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
1817const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000;
1918const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000;
2019
......@@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi
363362 ),
364363 );
365364});
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));
2323describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
2424 "remote runner process ownership",
2525 () => {
26 it.live.each(["npx", "npm"] as const)(
27 "keeps the server PID and graceful shutdown through the %s fallback",
28 (packageManager) =>
29 Effect.gen(function* () {
30 const fs = yield* FileSystem.FileSystem;
31 const path = yield* Path.Path;
32 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" });
34 const bin = path.join(fixture, "bin");
35 const cliPath = path.join(fixture, "installed cli.mjs");
36 const callsPath = path.join(fixture, "package-manager-calls.jsonl");
37 const packageSpec = "t3@0.0.35";
38 yield* fs.makeDirectory(bin);
39 yield* fs.symlink(process.execPath, path.join(bin, "node"));
40 yield* fs.writeFileString(
41 cliPath,
42 `#!/usr/bin/env node
26 it.live("keeps the server PID and graceful shutdown through the node-script runner", () =>
27 Effect.gen(function* () {
28 const fs = yield* FileSystem.FileSystem;
29 const path = yield* Path.Path;
30 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
31 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" });
32 const bin = path.join(fixture, "bin");
33 const cliPath = path.join(fixture, "installed cli.mjs");
34 yield* fs.makeDirectory(bin);
35 yield* fs.symlink(process.execPath, path.join(bin, "node"));
36 yield* fs.writeFileString(
37 cliPath,
38 `#!/usr/bin/env node
4339import * as net from "node:net";
4440const server = net.createServer((socket) => {
4541 socket.end();
......@@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => {
5652 }) + "\\n");
5753});
5854`,
59 );
60 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);
55 );
56 yield* fs.chmod(cliPath, 0o700);
7757
78 const runServer = (port = 0) =>
79 Effect.gen(function* () {
80 const child = yield* spawner.spawn(
81 ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], {
82 cwd: fixture,
83 env: {
84 PATH: bin,
85 T3_TEST_CLI: cliPath,
86 T3_TEST_CALLS: callsPath,
87 T3_TEST_PORT: String(port),
88 },
89 detached: false,
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 ),
58 const runServer = (port = 0) =>
59 Effect.gen(function* () {
60 const child = yield* spawner.spawn(
61 ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], {
62 cwd: fixture,
63 env: {
64 PATH: bin,
65 T3_TEST_PORT: String(port),
66 },
67 detached: false,
68 stdin: Stream.make(
69 new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })),
12270 ),
123 );
124 // A failed PID assertion must still close the owned fixture server, including an npm child.
125 yield* Effect.addFinalizer(() =>
71 }),
72 );
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) =>
12679 Effect.gen(function* () {
127 if (yield* child.isRunning) {
128 yield* Effect.callback<void>((resume) => {
129 const connection = NodeNet.connect(receipt.port, "127.0.0.1");
130 connection.on("error", () => undefined);
131 connection.once("close", () => resume(Effect.void));
132 return Effect.sync(() => connection.destroy());
133 });
134 yield* child.exitCode;
80 stdout.push(line);
81 if (stdout.length === 1) {
82 yield* Deferred.succeed(ready, decodeStarted(line));
13583 }
136 }).pipe(Effect.orDie),
137 );
138 assert.equal(receipt.pid, child.pid);
139 assert.deepEqual(receipt.args, ["serve", "a path with spaces"]);
140 yield* child.kill({ killSignal: "SIGTERM" });
141 assert.equal(yield* child.exitCode, 0);
142 yield* Fiber.join(output);
143 assert.include(stdout, "graceful shutdown");
144 return receipt.port;
145 }).pipe(Effect.scoped);
84 }),
85 ),
86 Effect.forkScoped,
87 );
88 const stderr = yield* child.stderr.pipe(
89 Stream.decodeText(),
90 Stream.mkString,
91 Effect.forkScoped,
92 );
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();
148 assert.equal(yield* runServer(port), port);
149 const calls = (yield* fs.readFileString(callsPath))
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),
125 const port = yield* runServer();
126 assert.equal(yield* runServer(port), port);
127 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
165128 );
166129 },
167130);
......@@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => {
288251 );
289252 },
290253);
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 {
2323 buildRemoteStopScript,
2424 buildRemoteT3RunnerScript,
2525 SshInvalidArchiveVersionError,
26 SshMissingRunnerError,
2627 describeReadinessCause,
2728 issueRemotePairingToken,
2829 launchOrReuseRemoteServer,
......@@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> {
104105 return command._tag === "StandardCommand" ? command.args : [];
105106}
106107
107describe("ssh tunnel scripts", () => {
108 it("builds the remote t3 runner with npx and npm fallbacks", () => {
109 const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE });
110
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 });
108const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const;
109const NODE_SCRIPT = {
110 nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs",
111} as const;
135112
136 it("installs and runs the release archive when an archive version is set", () => {
137 const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" });
113describe("ssh tunnel scripts", () => {
114 it("installs and runs the release archive without Node, npm, or npx", () => {
115 const script = buildRemoteT3RunnerScript(ARCHIVE);
138116
139117 assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'");
118 assert.include(script, "T3_NODE_SCRIPT_PATH=''");
140119 assert.include(
141120 script,
142121 "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'",
......@@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => {
145124 assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"');
146125 assert.include(script, "SHA256SUMS");
147126 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 "$@"');
148131 // Concurrent launches serialize on a per-version mkdir lock and recheck
149132 // the completion marker after acquiring it.
150133 assert.include(
......@@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => {
167150 script.indexOf('"$T3_STAGING/t3" --version'),
168151 script.indexOf('> "$T3_STAGING/.install-complete"'),
169152 );
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 );
171160 assert.isBelow(
172 script.indexOf('exec "$T3_RUNTIME_DIR/t3"'),
173 script.indexOf("prepend_path_if_dir()"),
161 script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'),
162 script.indexOf("T3_ARCHIVE_VERSION="),
174163 );
175164
176165 const launch = buildRemoteLaunchScript({
177 archiveVersion: "1.2.3-preview.20260911.4",
166 ...ARCHIVE,
178167 releaseBaseUrl: "https://mirror.example/t3/",
179168 });
180169 assert.include(launch, "T3_ARCHIVE_MODE=1");
......@@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => {
182171 assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"');
183172 assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"');
184173 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");
186175 });
187176
188177 it("rejects archive versions that are not a single exact version segment", () => {
......@@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => {
202191 );
203192 }
204193 assert.include(
205 buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }),
194 buildRemoteT3RunnerScript(ARCHIVE),
206195 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
207196 );
208197 });
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
210206 it("does not hard-code a remote node engine range", () => {
211 const script = buildRemoteT3RunnerScript();
207 const script = buildRemoteT3RunnerScript(NODE_SCRIPT);
212208
213209 assert.include(script, "T3_NODE_ENGINE_RANGE=''");
214210 assert.notInclude(script, TEST_NODE_ENGINE_RANGE);
215211 });
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
229213 it("builds the remote t3 runner with a node script override", () => {
230214 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,
232217 });
233218
234219 assert.include(
......@@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => {
236221 "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'",
237222 );
238223 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");
239242 });
240243
241244 it("uses the remote t3 runner for launch and pairing scripts", () => {
......@@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => {
245248 username: "julius",
246249 port: 2222,
247250 } as const;
251 const launch = buildRemoteLaunchScript(ARCHIVE);
252 const devLaunch = buildRemoteLaunchScript({
253 ...NODE_SCRIPT,
254 nodeEngineRange: TEST_NODE_ENGINE_RANGE,
255 });
248256
249257 assert.include(
250 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
258 launch,
251259 '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null',
252260 );
253 assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1");
254 assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()");
255 assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then");
261 assert.include(launch, "RUNNER_CHANGED=1");
262 assert.include(launch, "ensure_remote_node_path()");
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'");
256276 assert.include(
257 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
258 `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`,
277 buildRemotePairingScript(target, ARCHIVE),
278 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
259279 );
260280 assert.include(
261 buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }),
262 "does not satisfy required range ",
281 buildRemotePairingScript(target, ARCHIVE),
282 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"',
263283 );
264 assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true');
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");
284 assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home");
274285 assert.include(
275 buildRemotePairingScript(target),
276 '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json',
286 buildRemotePairingScript(target, ARCHIVE),
287 "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'",
277288 );
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");
281289 assert.include(
282290 buildRemoteStopScript(target),
283291 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]',
......@@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => {
285293 assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true');
286294 assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"');
287295 assert.include(
288 buildRemoteLaunchScript(),
296 launch,
289297 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"',
290298 );
291 assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()");
292 assert.include(
293 buildRemoteLaunchScript(),
294 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port',
295 );
296 assert.include(
297 buildRemoteLaunchScript(),
298 "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))",
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');
299 assert.include(launch, "resolve_default_runtime_port()");
300 assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port');
301 assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))");
302 assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"');
303 assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"');
304 assert.include(launch, 'rm -f "$PID_FILE"');
305 assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\"");
306 assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then');
305307 assert.isBelow(
306 buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
307 buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
308 launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'),
309 launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""),
308310 );
309311 assert.isBelow(
310 buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),
311 buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'),
312 launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'),
313 launch.indexOf('elif [ -n "$REMOTE_PID" ]'),
312314 );
313315 });
314316
......@@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => {
330332 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
331333
332334 return Effect.gen(function* () {
333 const result = yield* launchOrReuseRemoteServer(target);
335 const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE);
334336 assert.equal(result.remotePort, 3774);
335337 assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]);
336338 }).pipe(Effect.provide(processLayer));
......@@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => {
350352 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
351353
352354 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 );
354358 yield* Effect.yieldNow;
355359 yield* TestClock.adjust(Duration.seconds(75));
356360
......@@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => {
359363 }).pipe(Effect.provide(processLayer));
360364 });
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", () => {
363367 const target = {
364368 alias: "devbox",
365369 hostname: "devbox.example.com",
......@@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => {
373377 const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer());
374378
375379 return Effect.gen(function* () {
376 const fiber = yield* Effect.forkChild(
377 launchOrReuseRemoteServer(target, undefined, {
378 archiveVersion: "1.2.3-preview.20260911.4",
379 }),
380 );
380 const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE));
381381 yield* Effect.yieldNow;
382382 yield* TestClock.adjust(Duration.seconds(800));
383383
......@@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => {
457457 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
458458 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
459459 return Effect.gen(function* () {
460 const result = yield* issueRemotePairingToken(target);
460 const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE);
461461 assert.equal(result.credential, "LCL4R2TPHDKQ");
462462 }).pipe(Effect.provide(processLayer));
463463 });
......@@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => {
485485 const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner);
486486 const processLayer = Layer.merge(NodeServices.layer, spawnerLayer);
487487 return Effect.gen(function* () {
488 const result = yield* issueRemotePairingToken(target);
488 const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE);
489489 assert.equal(result.credential, "LCL4R2TPHDKQ");
490490 }).pipe(Effect.provide(processLayer));
491491 });
......@@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => {
530530 Layer.succeed(HttpClient.HttpClient, testHttpClient),
531531 Layer.succeed(NetService.NetService, testNetService),
532532 SshPasswordPrompt.disabledLayer,
533 SshEnvironmentManager.layer(),
533 SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
534534 );
535535 const target = {
536536 alias: "devbox",
......@@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => {
655655 Layer.succeed(HttpClient.HttpClient, testHttpClient),
656656 Layer.succeed(NetService.NetService, testNetService),
657657 SshPasswordPrompt.disabledLayer,
658 SshEnvironmentManager.layer(),
658 SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
659659 );
660660 yield* Effect.gen(function* () {
661661 const manager = yield* SshEnvironmentManager;
packages/ssh/src/tunnel.ts+129-147
......@@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000;
7070const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
7171
7272export 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 */
7477 readonly nodeScriptPath?: string | null;
7578 readonly nodeEngineRange?: string | null;
7679 /**
77 * Exact version whose release archive the remote installs and runs. Takes
78 * precedence over `packageSpec`; the remote then needs neither Node nor npm.
80 * Exact version whose self-contained release archive the remote installs
81 * and runs. Required unless `nodeScriptPath` is set; the remote then needs
82 * neither Node nor npm.
7983 */
8084 readonly archiveVersion?: string | null;
8185 readonly releaseBaseUrl?: string | null;
8286}
8387
8488export interface SshEnvironmentManagerOptions {
85 readonly resolveCliPackageSpec?: () => string;
8689 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
8790}
8891
......@@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
124127 };
125128}
126129
130function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean {
131 return Boolean(runner?.nodeScriptPath?.trim());
132}
133
127134function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {
128 if (runner?.archiveVersion?.trim()) {
129 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
130 }
131135 if (runner?.nodeScriptPath?.trim()) {
132136 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };
133137 }
134 if (runner?.packageSpec?.trim()) {
135 return { runner: "package", packageSpec: runner.packageSpec.trim() };
138 if (runner?.archiveVersion?.trim()) {
139 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
136140 }
137 return { runner: "default" };
141 return { runner: "archive" };
138142}
139143
140144interface SshAuthOperationInput<T> {
......@@ -424,144 +428,117 @@ ensure_remote_node_path() {
424428
425429const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
426430set -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
524431@@T3_NODE_ENV_SCRIPT@@
525ensure_remote_node_path || true
526432T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
527433if [ -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
528437 if ! command -v node >/dev/null 2>&1; then
529438 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
530439 exit 1
531440 fi
532441 exec node "$T3_NODE_SCRIPT_PATH" "$@"
533442fi
534if command -v t3 >/dev/null 2>&1; then
535 exec t3 "$@"
443T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@
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
536494fi
537# npm extracts a package before it runs the native builds of its dependencies,
538# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves
539# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without
540# running anything at all, which the caller only ever sees as a server that
541# never becomes ready. Resolve the CLI once up front so that install failure is
542# reported here, with npm's own output on stderr.
543require_installed_t3_cli() {
544 if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then
545 printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2
546 return 1
495if ! t3_runtime_ready; then
496 case "$(uname -s)" in
497 Darwin) T3_PLATFORM="darwin" ;;
498 Linux) T3_PLATFORM="linux" ;;
499 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;;
500 esac
501 case "$(uname -m)" in
502 arm64 | aarch64) T3_ARCH="arm64" ;;
503 x86_64 | amd64) T3_ARCH="x64" ;;
504 *) 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)"
547522 fi
548 if [ -n "$T3_CLI_PATH" ]; then
549 return 0
523 if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
524 printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
550525 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@@ >&2
552 return 1
553}
554# The launcher records this PID, so exec the CLI without an npm wrapper process.
555if command -v npx >/dev/null 2>&1; then
556 require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1
557 exec "$T3_CLI_PATH" "$@"
526 tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
527 rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
528 # Prove the binary runs here (libc, arch) before marking it ready, or every
529 # later launch would exec a broken install instead of retrying.
530 if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then
531 printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1
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"
558536fi
559if command -v npm >/dev/null 2>&1; then
560 require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1
561 exec "$T3_CLI_PATH" "$@"
537if [ -n "\${T3_LOCK:-}" ]; then
538 rm -rf "$T3_LOCK"
539 trap - EXIT
562540fi
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' >&2
564exit 1
541exec "$T3_RUNTIME_DIR/t3" "$@"
565542`;
566543
567544const REMOTE_LAUNCH_SCRIPT = `set -eu
......@@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalid
804781const EXACT_ARCHIVE_VERSION =
805782 /^(?: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
807793export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {
808 const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest");
809794 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";
810795 const archiveVersion = input?.archiveVersion?.trim() || "";
796 if (nodeScriptPath === "" && archiveVersion === "") {
797 throw new SshMissingRunnerError();
798 }
811799 if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) {
812800 throw new SshInvalidArchiveVersionError({ archiveVersion });
813801 }
......@@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string
818806 );
819807 return stripTrailingNewlines(
820808 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
821 T3_PACKAGE_SPEC: packageSpec,
822809 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
823810 T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion),
824811 T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl),
......@@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string
841828
842829export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
843830 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
844 T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0",
831 T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1",
845832 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
846833 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
847834 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
......@@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
894881 const result = yield* runSshCommand(target, {
895882 remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)],
896883 stdin: buildRemoteLaunchScript(runner),
897 timeoutMs: runner?.archiveVersion?.trim()
898 ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS
899 : REMOTE_LAUNCH_TIMEOUT_MS,
884 timeoutMs: isNodeScriptRunner(runner)
885 ? REMOTE_LAUNCH_TIMEOUT_MS
886 : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS,
900887 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
901888 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
902889 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
......@@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT
956943 stdin: buildRemotePairingScript(target, runner),
957944 // Pairing may be the first command on a cold remote, so it can install
958945 // the archive on the way.
959 ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}),
946 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),
960947 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
961948 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
962949 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
......@@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
16871674 ...sshTargetLogFields(resolvedTarget),
16881675 key,
16891676 });
1690 const packageSpec = options.resolveCliPackageSpec?.();
16911677 const runner =
1692 options.resolveCliRunner === undefined
1693 ? packageSpec === undefined
1694 ? undefined
1695 : { packageSpec }
1696 : yield* options.resolveCliRunner;
1678 options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner;
16971679 yield* Effect.logDebug("ssh.environment.runner.resolved", {
16981680 ...sshTargetLogFields(resolvedTarget),
16991681 ...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