From 8f90b380f676735f1e3026b5173c45c22d265583 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 21:24:21 -0700 Subject: [PATCH] feat(server): install preview runtimes from release archives (#11318) Co-authored-by: Claude Fable 5 --- apps/marketing/public/install.ps1 | 95 ++++++ apps/marketing/public/install.sh | 104 +++++++ apps/marketing/src/pages/download.astro | 9 + apps/marketing/vercel.ts | 18 ++ apps/server/src/bin.ts | 4 + apps/server/src/claude-history-worker.ts | 7 + apps/server/src/claudeHistoryWorker.ts | 40 ++- apps/server/src/cli/claudeHistory.ts | 27 ++ apps/server/src/cli/service.test.ts | 2 +- apps/server/src/cli/service.ts | 7 +- apps/server/src/cli/serviceLauncher.ts | 28 ++ apps/server/src/cloud/bootService.test.ts | 23 +- apps/server/src/cloud/bootService.ts | 57 +++- apps/server/src/cloud/pinnedRuntime.test.ts | 143 ++++++++- apps/server/src/cloud/pinnedRuntime.ts | 274 ++++++++++++++---- apps/server/src/cloud/selfUpdate.ts | 28 +- .../src/provider/Layers/ClaudeAdapter.ts | 31 +- apps/server/src/service-launcher.ts | 21 +- apps/server/src/serviceLauncher.ts | 52 ++-- apps/server/vite.config.ts | 2 +- docs/user/background-service.md | 15 + knip.jsonc | 2 +- packages/shared/package.json | 4 + packages/shared/src/cliRelease.test.ts | 69 +++++ packages/shared/src/cliRelease.ts | 76 +++++ 25 files changed, 1016 insertions(+), 122 deletions(-) create mode 100644 apps/marketing/public/install.ps1 create mode 100755 apps/marketing/public/install.sh create mode 100644 apps/server/src/claude-history-worker.ts create mode 100644 apps/server/src/cli/claudeHistory.ts create mode 100644 apps/server/src/cli/serviceLauncher.ts create mode 100644 packages/shared/src/cliRelease.test.ts create mode 100644 packages/shared/src/cliRelease.ts diff --git a/apps/marketing/public/install.ps1 b/apps/marketing/public/install.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..0900bd01c1c91dd3069370ba50b14e568bccbd34 --- /dev/null +++ b/apps/marketing/public/install.ps1 @@ -0,0 +1,95 @@ +# Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs +# only PowerShell 5.1+; no Node, npm, or compiler. +# +# irm https://t3.codes/install.ps1 | iex +# +# Environment: +# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_HOME T3 home directory (default: ~\.t3) +# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME\runtime\versions\, the +# same layout `t3 service install` uses, so the service reuses this download. +$ErrorActionPreference = "Stop" +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$repo = "pingdotgg/t3code" +$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } +$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } +$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } + +function Fail([string] $message) { + Write-Error "t3 install: $message" + exit 1 +} + +# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell +# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts +# may lack. +$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$arch = switch ($rawArch) { + "AMD64" { "x64" } + "ARM64" { "arm64" } + default { Fail "unsupported architecture $rawArch" } +} + +$version = $env:T3CODE_VERSION +if (-not $version) { + # Preview is the only train shipping archives while they are being dogfooded. + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name + if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" } + $version = $tag.Substring(1) +} + +$stem = "t3-$version-win32-$arch" +$archive = "$stem.zip" +$versionsDir = Join-Path $t3Home "runtime\versions" +$targetDir = Join-Path $versionsDir $version +$marker = Join-Path $targetDir ".install-complete" + +if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { + Write-Host "t3 $version is already installed at $targetDir" +} else { + New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null + $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $staging | Out-Null + try { + Write-Host "Downloading $archive..." + Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing + Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing + + $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) + if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } + $expected = ($expected -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } + + Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force + # The archive wraps everything in one directory named after its stem. + Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging + Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force + + & (Join-Path $staging "t3.exe") --version | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } + Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline + + if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } + Move-Item $staging $targetDir + } catch { + if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } + throw + } +} + +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +$shim = Join-Path $binDir "t3.cmd" +# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt +# non-ASCII characters in the user's home path. +[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) +Write-Host "Installed t3 $version" +Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" +if (($env:PATH -split ";") -notcontains $binDir) { + Write-Host "Add $binDir to your PATH to run t3." +} diff --git a/apps/marketing/public/install.sh b/apps/marketing/public/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..d7ddc0d1de468e5e99d802218591e1accae42278 --- /dev/null +++ b/apps/marketing/public/install.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, +# sha256sum or shasum, and curl or wget; no Node, npm, or compiler. +# +# curl -fsSL https://t3.codes/install.sh | sh +# +# Environment: +# T3CODE_VERSION exact version to install (default: latest preview release) +# T3CODE_HOME T3 home directory (default: ~/.t3) +# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) +# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) +# +# The archive is unpacked into $T3CODE_HOME/runtime/versions/, the +# same layout `t3 service install` uses, so the service reuses this download +# instead of fetching the release again. +set -eu + +repo="pingdotgg/t3code" +base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" +t3_home="${T3CODE_HOME:-$HOME/.t3}" +bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" + +fail() { + printf 't3 install: %s\n' "$1" >&2 + exit 1 +} + +fetch() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -q "$1" -O "$2" + else + fail "curl or wget is required" + fi +} + +case "$(uname -s)" in + Darwin) platform="darwin" ;; + Linux) platform="linux" ;; + *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; +esac +case "$(uname -m)" in + arm64 | aarch64) arch="arm64" ;; + x86_64 | amd64) arch="x64" ;; + *) fail "unsupported architecture $(uname -m)" ;; +esac +command -v tar >/dev/null 2>&1 || fail "tar is required" +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + fail "sha256sum or shasum is required" +fi + +version="${T3CODE_VERSION:-}" +if [ -z "$version" ]; then + # Preview is the only train shipping archives while they are being dogfooded. + tmp_index="$(mktemp)" + fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index" + version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)" + rm -f "$tmp_index" + [ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION" +fi + +stem="t3-${version}-${platform}-${arch}" +archive="${stem}.tar.gz" +versions_dir="${t3_home}/runtime/versions" +target_dir="${versions_dir}/${version}" + +if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then + printf 't3 %s is already installed at %s\n' "$version" "$target_dir" +else + mkdir -p "$versions_dir" + staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" + trap 'rm -rf "$staging"' EXIT + + printf 'Downloading %s...\n' "$archive" + fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" + fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" + + expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" + [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" + actual="$(checksum "${staging}/${archive}")" + [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" + + tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 + rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" + "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" + printf '%s\n' "$version" > "${staging}/.install-complete" + + rm -rf "$target_dir" + mv "$staging" "$target_dir" + trap - EXIT +fi + +mkdir -p "$bin_dir" +ln -sfn "${target_dir}/t3" "${bin_dir}/t3" +printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" +case ":${PATH}:" in + *":${bin_dir}:"*) ;; + *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; +esac diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 08a1c008453e1cad0eee2b6a7f5532232ad2f5f4..95c6f513f665bd40e531e8d63fcc45079c341db1 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -132,6 +132,9 @@ const imageProps = {

Terminal

npx t3@nightly +

No Node.js? The preview build installs as a single download:

+ curl -fsSL https://t3.codes/install.sh | sh + irm https://t3.codes/install.ps1 | iex @@ -432,6 +435,12 @@ const imageProps = { letter-spacing: -0.01em; } + .cli-note { + color: var(--fg-muted); + font-size: 0.85rem; + margin-top: 0.5rem; + } + .cli-line { align-self: flex-start; font-family: var(--font-mono); diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index e37be215f1a0d2ce7125dd10a0581ef7bdfebd8a..1ec71b1c7115b1be88342549c8ef3b77deac69ff 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -7,6 +7,24 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", + // `curl … | sh` needs the scripts served as plain text, uncompressed by + // content negotiation, and never cached past a deploy. + headers: [ + { + source: "/install.sh", + headers: [ + { key: "Content-Type", value: "text/x-shellscript; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + { + source: "/install.ps1", + headers: [ + { key: "Content-Type", value: "text/plain; charset=utf-8" }, + { key: "Cache-Control", value: "public, max-age=300" }, + ], + }, + ], redirects: [ { source: "/app", diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 52cc363ed04ff2e1113cde9aca5f724bebe52c9e..9f9d4645c9d068059796d20cf9d43461b5d1a182 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; +import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; import { themeCommand } from "./cli/theme.ts"; import { triageCommand } from "./cli/triage.ts"; @@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + serviceLauncherCommand, + claudeHistoryCommand, servicePreflightCommand, themeCommand, triageCommand, diff --git a/apps/server/src/claude-history-worker.ts b/apps/server/src/claude-history-worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..2554401dbd64a20c7997be5ea51751b98a3baf39 --- /dev/null +++ b/apps/server/src/claude-history-worker.ts @@ -0,0 +1,7 @@ +// Standalone entry for npm-distributed runtimes: `node claudeHistoryWorker.mjs +// [options]`. The executable reaches the same worker +// through the `__claude-history` subcommand. +import { runClaudeHistoryWorker } from "./claudeHistoryWorker.ts"; + +const [method, sessionId, rawOptions] = process.argv.slice(2); +await runClaudeHistoryWorker(method, sessionId, rawOptions); diff --git a/apps/server/src/claudeHistoryWorker.ts b/apps/server/src/claudeHistoryWorker.ts index d00282bb77f081f4533779a7fe30f6c675a81433..f09e0dd784358b6ba04e0e8b20016f2d9b7c7994 100644 --- a/apps/server/src/claudeHistoryWorker.ts +++ b/apps/server/src/claudeHistoryWorker.ts @@ -2,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk" import * as Schema from "effect/Schema"; // A separate process gives SDK history helpers the provider's environment without -// mutating the server's environment. This entry is bundled alongside the server. -const [method, sessionId, rawOptions] = process.argv.slice(2); -const options = Schema.decodeSync( +// mutating the server's environment. `claude-history-worker.ts` is the +// standalone entry bundled beside the server for npm installs; the +// single-executable hosts the same function as its `__claude-history` +// subcommand, which has no Node to run a sibling script. Nothing here may run +// on import: inside the executable `import.meta.main` is true for the whole +// bundle. +const decodeHistoryOptions = Schema.decodeSync( Schema.fromJsonString( Schema.Struct({ dir: Schema.optionalKey(Schema.String), @@ -12,14 +16,22 @@ const options = Schema.decodeSync( upToMessageId: Schema.optionalKey(Schema.String), }), ), -)(rawOptions ?? "{}"); -if (!sessionId) throw new Error("Claude history session id is required."); -const result = - method === "getSessionMessages" - ? await getSessionMessages(sessionId, options) - : method === "forkSession" - ? await forkSession(sessionId, options) - : (() => { - throw new Error("Unknown Claude history operation."); - })(); -process.stdout.write(JSON.stringify(result)); +); + +export async function runClaudeHistoryWorker( + method: string | undefined, + sessionId: string | undefined, + rawOptions: string | undefined, +): Promise { + const options = decodeHistoryOptions(rawOptions ?? "{}"); + if (!sessionId) throw new Error("Claude history session id is required."); + const result = + method === "getSessionMessages" + ? await getSessionMessages(sessionId, options) + : method === "forkSession" + ? await forkSession(sessionId, options) + : (() => { + throw new Error("Unknown Claude history operation."); + })(); + process.stdout.write(JSON.stringify(result)); +} diff --git a/apps/server/src/cli/claudeHistory.ts b/apps/server/src/cli/claudeHistory.ts new file mode 100644 index 0000000000000000000000000000000000000000..81bef135970347831476405b42e61f992ad92758 --- /dev/null +++ b/apps/server/src/cli/claudeHistory.ts @@ -0,0 +1,27 @@ +import * as Effect from "effect/Effect"; +import { Argument, Command } from "effect/unstable/cli"; + +import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts"; + +/** + * Hosts the Claude history worker inside the CLI executable. The npm bundle + * runs it as a sibling `claudeHistoryWorker.mjs` under the host Node; the + * single-executable has no Node to run a script with, so the adapter invokes + * this hidden subcommand on its own executable instead. + */ +export const claudeHistoryCommand = Command.make("__claude-history", { + method: Argument.string("method"), + sessionId: Argument.string("session-id"), + options: Argument.string("options").pipe(Argument.optional), +}).pipe( + Command.unlisted, + Command.withHandler(({ method, sessionId, options }) => + Effect.promise(() => + runClaudeHistoryWorker( + method, + sessionId, + options._tag === "Some" ? options.value : undefined, + ), + ), + ), +); diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 38732e42987ae3ea10c95464584c14492fe5ea24..b682c4ce44ce526af8e98e510e535c539662817e 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -105,7 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { Effect.sync(() => { installOptions.push(options); return { - nodePath: "/test/node", + program: ["/test/node", "/test/service-launcher.mjs"], launcherPath: "/test/service-launcher.mjs", baseDir: "/test/t3", unitPath: serviceStatus.unitPath, diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 0cea18ff497761dfa9adc16e98961aebaa1f634a..5b53e81769b81f928ffa6045e7bfc67df640afc9 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = baseDir: config.baseDir, logsDir: config.logsDir, cliVersion: packageJson.version, - }).pipe(Layer.provide(ProcessRunner.layer)); + }).pipe( + Layer.provide(ProcessRunner.layer), + // Archive-distributed versions download the release archive here. + Layer.provide(FetchHttpClient.layer), + ); export type ServiceReconcileResult = | { diff --git a/apps/server/src/cli/serviceLauncher.ts b/apps/server/src/cli/serviceLauncher.ts new file mode 100644 index 0000000000000000000000000000000000000000..696fc55c45f011db10a54cc893fdc852e9bdbea3 --- /dev/null +++ b/apps/server/src/cli/serviceLauncher.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import { Command } from "effect/unstable/cli"; + +import { main as runServiceLauncher } from "../serviceLauncher.ts"; + +/** + * Hosts the service launcher inside the CLI executable. Archive-distributed + * runtimes have no Node on the machine to run `service-launcher.mjs`, so the + * service manager runs `t3 __service-launcher` and the launcher spawns the + * server from the same executable. + * + * The launcher owns SIGTERM handling and the process lifetime: it must finish + * stopping its child before the process exits, so it runs detached from the + * CLI's fiber rather than under `runMain`, whose signal handler would + * interrupt the fiber and exit while the child is still being terminated. + */ +export const serviceLauncherCommand = Command.make("__service-launcher").pipe( + Command.unlisted, + Command.withHandler(() => + Effect.sync(() => { + runServiceLauncher().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); + }), + ), +); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 688617440500b88edbeba8be523000390953598f..d1e5011f2a3a2d8af2296a16f14fd114fc4af1d5 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -25,7 +25,7 @@ import { it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", + program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", @@ -37,9 +37,24 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", expect(unit).not.toContain("versions/1.2.3"); }); +it("runs archive-distributed runtimes as their own executable", () => { + const unit = BootService.renderBootServiceUnit({ + program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], + launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", + baseDir: "/home/theo/.t3", + logPath: "/home/theo/.t3/userdata/logs/boot-service.log", + unitPath: "/home/theo/.config/systemd/user/t3code.service", + }); + + expect(unit).toContain( + "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", + ); + expect(unit).not.toContain("node"); +}); + it("survives the kernel OOM-killing a greedy agent child", () => { const unit = BootService.renderBootServiceUnit({ - nodePath: "/usr/bin/node", + program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", baseDir: "/home/theo/.t3", logPath: "/home/theo/.t3/userdata/logs/boot-service.log", @@ -50,7 +65,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => { }); const macPlan = { - nodePath: "/opt/homebrew/bin/node", + program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", baseDir: "/Users/theo/.t3", logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", @@ -116,7 +131,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const sourceLauncher = path.join(home, "service-launcher.mjs"); const statePath = path.join(baseDir, "runtime", "service-state.json"); yield* fs.writeFileString(sourceLauncher, "export {};\n"); - const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); yield* fs.writeFileString(runtime.entryPath, "export {};\n"); yield* fs.writeFileString( diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 22f383701e8ed46b2595f4a282755406a3d708f9..805ddb8ec320cb07112033334a54a0543bb05a08 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,4 +1,5 @@ import { + HostProcessArchitecture, HostProcessExecutablePath, HostProcessPlatform, HostProcessUserId, @@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; import * as Schema from "effect/Schema"; +import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; + import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; @@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string { } export interface BootServicePlan { - readonly nodePath: string; + /** + * What the service manager executes. npm-distributed runtimes run the + * standalone launcher script with the installing Node; archive-distributed + * runtimes run their own executable, which hosts the launcher as a hidden + * subcommand so the machine never needs Node. + */ + readonly program: ReadonlyArray; readonly launcherPath: string; readonly baseDir: string; readonly logPath: string; @@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, - `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, + `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. "KillMode=mixed", @@ -124,8 +135,7 @@ export function renderBootServicePlist( ` ${BOOT_SERVICE_LAUNCHD_LABEL}`, ` ProgramArguments`, ` `, - ` ${escapeXmlText(plan.nodePath)}`, - ` ${escapeXmlText(plan.launcherPath)}`, + ...plan.program.map((argument) => ` ${escapeXmlText(argument)}`), ` `, ` EnvironmentVariables`, ` `, @@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }) { const hostExecPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; const uid = yield* HostProcessUserId; + // Archive-distributed versions download from GitHub Releases; npm versions + // never touch HTTP, so callers without a client still work. + const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; @@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); - const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); + const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); const launcherSourcePath = host.launcherSourcePath ?? path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); @@ -568,7 +585,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { }), ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const plan: BootServicePlan = { - nodePath: host.execPath, + program: + runtimePaths.layout === "archive" + ? [runtimePaths.entryPath, "__service-launcher"] + : [host.execPath, launcherPath], launcherPath, baseDir: input.baseDir, logPath, @@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: host.execPath, - args: [runtime.entryPath, "--version"], + command: pinnedRuntimeCommand(runtime, host.execPath).command, + args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], timeout: Duration.seconds(30), }) .pipe( @@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { : new BootServiceInstallError({ cause: error }), ), ); - const launcherSource = yield* fs - .readFileString(launcherSourcePath) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // Archive runtimes host the launcher in the executable itself; there is + // no standalone script to copy. + const launcherSource = + runtimePaths.layout === "archive" + ? undefined + : yield* fs + .readFileString(launcherSourcePath) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); const installed = yield* fs .exists(unitPath) @@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* fs .makeDirectory(path.dirname(unitPath), { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - yield* writeDurably(launcherPath, launcherSource); + if (launcherSource !== undefined) { + yield* writeDurably(launcherPath, launcherSource); + } yield* writeDurably( statePath, // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. @@ -836,7 +867,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ fs.readFileString(unitPath), - fs.exists(launcherPath), + runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), fs.exists(runtimePaths.entryPath), fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), fs.readFileString(statePath).pipe(Effect.option), diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index a0ca9e5f0fa07c7e5e303af591b33f9cc86fb8b0..b542907b4d01f92831aaf81712b67062df600ab4 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -6,11 +6,13 @@ import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, pinnedRuntimePaths, PinnedRuntimeInstallError, } from "./pinnedRuntime.ts"; @@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: ProcessRunner.ProcessRunner.of({ run: (input) => { commands.push(input); @@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: ProcessRunner.ProcessRunner.of({ run: (input) => { commands.push(input.command); @@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); let validatedDirectory = ""; const installed = yield* ensurePinnedRuntimeInstalled({ @@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: (staging) => Effect.gen(function* () { @@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* ensurePinnedRuntimeInstalled({ baseDir, version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: () => Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), @@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); @@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: () => Effect.void, }); @@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); - const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3"); + const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner: successfulRunner(fs, path), validate: (paths) => Effect.gen(function* () { @@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { version: "1.2.3", fs, path, + platform: "linux", + arch: "x64", runner, validate: () => Effect.void, }).pipe(Effect.forkScoped); @@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { assert.deepEqual(yield* fs.readDirectory(versionsDir), []); }), ); + + // Archive-distributed versions never touch npm: the release archive is + // fetched, checked against SHA256SUMS, and unpacked with tar. + const archiveVersion = "1.3.0-preview.20260911.7"; + const archiveName = `t3-${archiveVersion}-linux-x64.tar.gz`; + const archiveBytes = new TextEncoder().encode("not really a tarball"); + const archiveHex = (bytes: Uint8Array) => + Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( + Effect.map((digest) => + Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), + ), + ); + const releaseHttpClient = (checksums: string, requests: string[]) => + HttpClient.make((request) => { + requests.push(request.url); + const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); + }); + const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[]) => + ProcessRunner.ProcessRunner.of({ + run: (input) => + Effect.gen(function* () { + commands.push(input.command); + const targetIndex = input.args.indexOf("-C"); + const stagingDir = input.args[targetIndex + 1]; + if (input.command !== "tar" || stagingDir === undefined) { + return yield* Effect.die(`unexpected command ${input.command}`); + } + yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); + return { + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), + }); + + it.effect("installs archive-distributed versions from the verified release archive", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); + const requests: string[] = []; + const commands: string[] = []; + const checksums = `${yield* archiveHex(archiveBytes)} ${archiveName}\n`; + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: archiveVersion, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(checksums, requests), + releaseBaseUrl: "https://releases.example/download", + runner: extractingRunner(fs, path, commands), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.equal(paths.layout, "archive"); + assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); + assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { + command: paths.entryPath, + args: [], + }); + assert.deepEqual(requests, [ + `https://releases.example/download/v${archiveVersion}/SHA256SUMS`, + `https://releases.example/download/v${archiveVersion}/${archiveName}`, + ]); + assert.deepEqual(commands, ["tar"]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), `${archiveVersion}\n`); + assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); + }), + ); + + it.effect("refuses an archive whose checksum does not match the release", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); + const commands: string[] = []; + const error = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: archiveVersion, + fs, + path, + platform: "linux", + arch: "x64", + httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`, []), + runner: extractingRunner(fs, path, commands), + validate: () => Effect.die("must not validate an unverified archive"), + }).pipe(Effect.flip); + assert.instanceOf(error, PinnedRuntimeInstallError); + assert.equal(error.step, "verifying the t3 release archive checksum"); + assert.deepEqual(commands, []); + assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); + }), + ); + + it("runs npm layouts through the host Node", () => { + const paths = pinnedRuntimePaths( + { join: (...parts: string[]) => parts.join("/") } as Path.Path, + "/home/theo/.t3", + "1.2.3", + "linux", + ); + assert.equal(paths.layout, "npm"); + assert.deepEqual(pinnedRuntimeCommand(paths, "/usr/bin/node"), { + command: "/usr/bin/node", + args: ["/home/theo/.t3/runtime/versions/1.2.3/node_modules/t3/dist/bin.mjs"], + }); + }); }); diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 534ed917218f9087f199eacd2894b3de6120ef1e..c4e02e41ddf7f02c027d8e258b8402aca7d3d085 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -1,44 +1,91 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + CLI_RELEASE_CHECKSUMS_FILE, + cliArchiveFileName, + cliArchivePlatformKey, + cliArchiveTarCommand, + cliReleaseDownloadBaseUrl, + isArchiveDistributedVersion, + parseChecksums, +} from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; /** - * A pinned runtime is an exact `t3@` npm-installed into + * A pinned runtime is an exact `t3@` installed into * /runtime/versions/. The boot service points its unit or * launch agent here, and server self-update installs the target version here before * switching over, never `npx t3`, whose cache is ephemeral and whose * registry fetch at boot would make startup depend on the network. + * + * Two layouts exist. npm-distributed versions are `npm install`ed and run as + * ` node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the + * self-contained release archive unpacked in place and run as `./t3`, which + * needs neither Node nor npm on the machine. The layout is decided by the + * version string alone so every consumer agrees without probing the disk. */ const PINNED_RUNTIME_DIR = "runtime"; const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); +const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; // Boot-service setup and remote update can construct separate layers. Serialize // the complete install transaction across every caller in this process. const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); +export type PinnedRuntimeLayout = "npm" | "archive"; + export interface PinnedRuntimePaths { + readonly layout: PinnedRuntimeLayout; readonly versionDir: string; + /** + * `bin.mjs` for npm layouts, the executable itself for archives. Existence + * of this file is what marks a runtime as present. + */ readonly entryPath: string; readonly sentinelPath: string; } +/** The exact command that runs a pinned runtime, given the Node hosting the caller. */ +export function pinnedRuntimeCommand( + paths: PinnedRuntimePaths, + nodePath: string, +): { readonly command: string; readonly args: ReadonlyArray } { + return paths.layout === "archive" + ? { command: paths.entryPath, args: [] } + : { command: nodePath, args: [paths.entryPath] }; +} + export function pinnedRuntimePaths( path: Path.Path, baseDir: string, version: string, + platform: NodeJS.Platform, ): PinnedRuntimePaths { const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); + const sentinelPath = path.join(versionDir, ".install-complete"); + if (isArchiveDistributedVersion(version)) { + return { + layout: "archive", + versionDir, + entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), + sentinelPath, + }; + } return { + layout: "npm", versionDir, entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), - sentinelPath: path.join(versionDir, ".install-complete"), + sentinelPath, }; } @@ -74,9 +121,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError` into the pinned runtime directory unless a complete * install is already there, and returns its paths. The sentinel is written - * only after npm exits 0; checking the entry file alone is not enough. npm - * extracts files before running native builds (node-pty), so a killed - * install leaves a plausible-looking but broken tree behind. + * only after the install step exits 0; checking the entry file alone is not + * enough. npm extracts files before running native builds (node-pty), and tar + * writes the executable before the last native package, so a killed install + * leaves a plausible-looking but broken tree behind. */ interface PinnedRuntimeInstallInput { readonly baseDir: string; @@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput { readonly validate: ( paths: PinnedRuntimePaths, ) => Effect.Effect; + readonly platform: NodeJS.Platform; + readonly arch: string; + /** Archive-distributed versions download from here; npm versions never need it. */ + readonly httpClient?: HttpClient.HttpClient | undefined; + readonly releaseBaseUrl?: string | undefined; } +const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* ( + input: PinnedRuntimeInstallInput, + stagingDir: string, +) { + const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ]; + yield* input.runner + .run({ + command: "npm", + args: installArgs, + // Native dependencies may compile from source on slower machines. + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + input.runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: installStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); +}); + +const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( + httpClient: HttpClient.HttpClient, + url: string, + step: string, +) { + // The install lock is held for the whole transaction, so a stalled download + // must fail rather than block every other caller. + return yield* httpClient.execute(HttpClientRequest.get(url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })), + Effect.timeoutOrElse({ + duration: PINNED_RUNTIME_INSTALL_TIMEOUT, + orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })), + }), + ); +}); + +/** + * Downloads the release archive for this platform, verifies it against the + * release's checksum file, and unpacks it so the executable sits directly in + * the staging directory. Only `tar` is required on the host; every supported + * OS ships one that reads gzip and zip. + */ +const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* ( + input: PinnedRuntimeInstallInput, + stagingDir: string, +) { + const { fs, path } = input; + const platformKey = cliArchivePlatformKey(input.platform, input.arch); + if (platformKey === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `selecting a t3 release archive for ${input.platform}-${input.arch}`, + }); + } + const httpClient = input.httpClient; + if (httpClient === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: "downloading the t3 release archive (no HTTP client available)", + }); + } + const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); + const fileName = cliArchiveFileName(input.version, platformKey); + + const checksums = parseChecksums( + new TextDecoder().decode( + yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`, + "downloading the t3 release checksums", + ), + ), + ); + const expected = checksums.get(fileName); + if (expected === undefined) { + return yield* new PinnedRuntimeInstallError({ + step: `finding ${fileName} in the t3 release checksums`, + }); + } + const archive = yield* fetchReleaseAsset( + httpClient, + `${baseUrl}/${fileName}`, + "downloading the t3 release archive", + ); + const digest = yield* Effect.tryPromise({ + try: () => crypto.subtle.digest("SHA-256", archive), + catch: (cause) => + new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }), + }); + if (Encoding.encodeHex(new Uint8Array(digest)) !== expected) { + return yield* new PinnedRuntimeInstallError({ + step: "verifying the t3 release archive checksum", + }); + } + + const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE); + yield* fs + .writeFile(archivePath, archive) + .pipe( + Effect.mapError( + (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }), + ), + ); + const extractStep = "extracting the t3 release archive"; + // The archive wraps everything in one directory named after its stem; + // strip it so the executable lands at /t3. + yield* input.runner + .run({ + command: cliArchiveTarCommand(input.platform, process.env), + args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + .pipe( + Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })), + Effect.filterOrFail( + (result) => result.code === 0, + (result) => + new PinnedRuntimeInstallError({ + step: extractStep, + exitCode: Number(result.code), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ), + ); + yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore); +}); + const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( input: PinnedRuntimeInstallInput, ) { - const { fs, runner } = input; - const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); + const { fs } = input; + const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ fs.exists(paths.versionDir), fs.exists(paths.entryPath), @@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( ), ); const stagingPaths: PinnedRuntimePaths = { + layout: paths.layout, versionDir: stagingDir, - entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), sentinelPath: input.path.join(stagingDir, ".install-complete"), }; return yield* Effect.gen(function* () { - const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; - const installArgs = [ - "install", - "--prefix", - stagingDir, - "--no-fund", - "--no-audit", - `t3@${input.version}`, - ]; - yield* runner - .run({ - command: "npm", - args: installArgs, - // Native dependencies may compile from source on slower machines. - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - .pipe( - Effect.catchTags({ - ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? // pnpm-managed Node installations do not include npm. Keep npm - // installation semantics for the pinned runtime and native builds. - runner.run({ - command: "pnpm", - args: ["--package=npm@11", "dlx", "npm", ...installArgs], - timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, - }) - : Effect.fail(error), - }), - Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), - Effect.filterOrFail( - (result) => result.code === 0, - (result) => - new PinnedRuntimeInstallError({ - step: installStep, - exitCode: Number(result.code), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ); + if (paths.layout === "archive") { + yield* installFromArchive(input, stagingDir); + } else { + yield* installFromNpm(input, stagingDir); + } yield* input.validate(stagingPaths); yield* fs diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 3cea30790a4c4005e9f1fd93876a7d77f6a2d1e5..734c8ca732fd42a9a0233204b8acc69867f70d86 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -6,22 +6,32 @@ import { type ServerSelfUpdateResult, type ThreadId, } from "@t3tools/contracts"; -import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; +import { + HostProcessArchitecture, + HostProcessExecutablePath, + HostProcessPlatform, +} from "@t3tools/shared/hostProcess"; import * as Cause from "effect/Cause"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as HashSet from "effect/HashSet"; +import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import { HttpClient } from "effect/unstable/http"; + +import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ServerConfig from "../config.ts"; import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, + pinnedRuntimeCommand, PinnedRuntimeInstallError, PinnedRuntimePreflightBlockedError, } from "./pinnedRuntime.ts"; @@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const execPath = yield* HostProcessExecutablePath; + const platform = yield* HostProcessPlatform; + const arch = yield* HostProcessArchitecture; + // Archive-distributed targets download from GitHub Releases. The client is + // optional so callers without one (tests, npm-only hosts) still construct. + const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); + const releaseBaseUrl = Option.getOrUndefined( + yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), + ); const inFlight = yield* Ref.make(false); const capability: ServerSelfUpdateCapability | null = @@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { fs, path, runner, + httpClient, + platform, + arch, + releaseBaseUrl, validate: (runtime) => runner .run({ - command: execPath, + command: pinnedRuntimeCommand(runtime, execPath).command, args: [ - runtime.entryPath, + ...pinnedRuntimeCommand(runtime, execPath).args, "__service-preflight", "--database-path", serverConfig.dbPath, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index fc52fe38dc682ab0aba01d7f32ff8ef2d6761502..2a7be73838e3d2df71309d69fcb17596df1271c9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off /** * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. * @@ -6,6 +7,8 @@ * * @module ClaudeAdapterLive */ +import * as NodeSea from "node:sea"; + import { type CanUseTool, query, @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( detail: "Claude session id is unavailable.", }); } - const historyWorkerPath = yield* path - .fromFileUrl( - new URL( - import.meta.url.endsWith(".ts") - ? "../../claudeHistoryWorker.ts" - : "./claudeHistoryWorker.mjs", - import.meta.url, - ), - ) - .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); + // The single-executable has no sibling script and no Node to run one + // with, so it hosts the worker as a hidden subcommand of itself. + const historyWorkerArguments = NodeSea.isSea() + ? ["__claude-history"] + : [ + yield* path + .fromFileUrl( + new URL( + import.meta.url.endsWith(".ts") + ? "../../claude-history-worker.ts" + : "./claude-history-worker.mjs", + import.meta.url, + ), + ) + .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))), + ]; const runScopedHistoryCommand = async ( method: "getSessionMessages" | "forkSession", args: object, @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( process.execPath, ChildProcess.make( process.execPath, - [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], + [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)], { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, ), ).pipe( diff --git a/apps/server/src/service-launcher.ts b/apps/server/src/service-launcher.ts index 10521245162986b13147d2dd3ec161094ecb9786..b761998339235b5d267f30a0ef97baffcda1d6ea 100644 --- a/apps/server/src/service-launcher.ts +++ b/apps/server/src/service-launcher.ts @@ -1 +1,20 @@ -import "./serviceLauncher.ts"; +// Standalone launcher entry for npm-distributed runtimes: `node +// service-launcher.mjs`. Archive runtimes reach the same code through the +// `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run +// anything on import. +import { isEntrypoint } from "./entrypoint.ts"; +import { main } from "./serviceLauncher.ts"; + +if ( + isEntrypoint({ + moduleUrl: import.meta.url, + entryPath: process.argv[1], + runtimeMain: import.meta.main, + }) +) { + main().catch((cause: unknown) => { + const error = cause instanceof Error ? cause : new Error(String(cause)); + process.stderr.write(`[service-launcher] ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e912130c8939d11d38f9497ded6e2e85cb8abd55..fc3d8674e68e59bec56140f036be6fcc804eff58 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -7,6 +7,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeSea from "node:sea"; import type { PendingServiceUpdate, @@ -26,7 +27,6 @@ import { SERVICE_STATE_FILE, SERVICE_STOP_MARKER_FILE, } from "./cloud/serviceProtocol.ts"; -import { isEntrypoint } from "./entrypoint.ts"; const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; @@ -41,15 +41,38 @@ interface ManagedChild { readonly process: NodeChildProcess.ChildProcess; } +// Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked +// release archives whose executable runs on its own, npm versions are a +// bin.mjs the launcher's Node runs. Kept inline so this file stays on Node +// built-ins only. const runtimePaths = (baseDir: string, version: string) => { const versionDir = NodePath.join(baseDir, "runtime", "versions", version); + const archive = /-preview\.\d{8}\.\d+$/.test(version); + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. + const executableName = process.platform === "win32" ? "t3.exe" : "t3"; return { versionDir, - entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), + entryPath: archive + ? NodePath.join(versionDir, executableName) + : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), sentinelPath: NodePath.join(versionDir, ".install-complete"), + executable: archive, }; }; +const runtimeSpawnArguments = (paths: ReturnType) => + paths.executable + ? { command: paths.entryPath, args: ["serve"] } + : { command: process.execPath, args: [paths.entryPath, "serve"] }; + +// An npm-layout runtime needs a Node interpreter. When the launcher itself is +// the single-executable, process.execPath is `t3`, which cannot run a +// bin.mjs, so the two layouts cannot be mixed within one service install. +const launcherIsExecutable = NodeSea.isSea(); + +const canLaunchRuntime = (paths: ReturnType) => + paths.executable || !launcherIsExecutable; + /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -402,7 +425,8 @@ export class Launcher { childVersion: version, ...(update === undefined ? {} : { update }), }; - const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { + const spawnArguments = runtimeSpawnArguments(paths); + const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, stdio: ["inherit", "inherit", "inherit", "ipc"], }); @@ -481,6 +505,12 @@ export class Launcher { await reject("The requested database path is not absolute."); return; } + if (!canLaunchRuntime(runtimePaths(this.#baseDir, message.targetVersion))) { + await reject( + "This service runs from a self-contained t3 executable and cannot switch to an npm-installed version. Reinstall the service with the target version instead.", + ); + return; + } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; @@ -602,7 +632,7 @@ export class Launcher { } } -async function main(): Promise { +export async function main(): Promise { const baseDir = process.env.T3CODE_HOME?.trim(); if (baseDir === undefined || baseDir === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); @@ -611,17 +641,3 @@ async function main(): Promise { const state = await readServiceState(statePath); await new Launcher(baseDir, state).run(); } - -if ( - isEntrypoint({ - moduleUrl: import.meta.url, - entryPath: process.argv[1], - runtimeMain: import.meta.main, - }) -) { - main().catch((cause: unknown) => { - const error = cause instanceof Error ? cause : new Error(String(cause)); - process.stderr.write(`[service-launcher] ${error.message}\n`); - process.exitCode = 1; - }); -} diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 2015ad4de8d65acda630c2a53fc9dac81dde862d..32a8be7e6daa9c249e3bc62b8830df4d281759a1 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -77,7 +77,7 @@ export default mergeConfig( pack: { // The executable embeds one entry; the history worker becomes a hidden // subcommand there instead of a sibling script. - entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"], + entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"], outDir: packExecutable ? "dist-exe" : "dist", sourcemap: !packExecutable, clean: true, diff --git a/docs/user/background-service.md b/docs/user/background-service.md index eecd6fa77b3ae2046a7e28757e5987377521385d..4bf3ae45f6a0280042b286522eb3c8e0673a5f12 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -25,6 +25,21 @@ Updating restarts the server. Finish active work first, and wait for any remote update already in progress. To match a remote client's version, follow [Updating T3 Code](./updating.md). +Preview builds (`t3@preview`) install as a self-contained download from the +T3 Code GitHub release instead of through npm, so the machine running the +service does not need Node.js or npm once the CLI is on it. To get the CLI +onto a machine without Node, run the install script: + +```sh +curl -fsSL https://t3.codes/install.sh | sh +``` + +On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. + +It places `t3` in `~/.local/bin` and reuses the same download when you later +run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or +`T3CODE_RELEASE_BASE_URL` to download from a mirror. + ## Platform support Linux needs systemd user services. Setup enables lingering so T3 Code starts at diff --git a/knip.jsonc b/knip.jsonc index 9b8712a078723af0c89f6fe03395b40e331a0d7e..e225298fabc0a464f8c3957107781ef794384930 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -21,7 +21,7 @@ "entry": [ "src/bin.ts!", "src/service-launcher.ts!", - "src/claudeHistoryWorker.ts!", + "src/claude-history-worker.ts!", "scripts/cli.ts", "src/provider/testFixtures/*.mjs", ], diff --git a/packages/shared/package.json b/packages/shared/package.json index faf0d6f2a0f5f879e365e5d9dfdb1796cc54ec85..d30c26c2de7998d3eb859d1347780a04b6dcbcb4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -322,6 +322,10 @@ "./dateTime": { "types": "./src/dateTime.ts", "import": "./src/dateTime.ts" + }, + "./cliRelease": { + "types": "./src/cliRelease.ts", + "import": "./src/cliRelease.ts" } }, "scripts": { diff --git a/packages/shared/src/cliRelease.test.ts b/packages/shared/src/cliRelease.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d9b2446b779f4c23f1c8b53207eea22dfdb455b --- /dev/null +++ b/packages/shared/src/cliRelease.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + cliArchiveFileName, + cliArchivePlatformKey, + cliArchiveTarCommand, + cliReleaseDownloadBaseUrl, + isArchiveDistributedVersion, + parseChecksums, +} from "./cliRelease.ts"; + +describe("cliRelease", () => { + it("names archives by version and platform, zip only on Windows", () => { + expect(cliArchiveFileName("1.2.3-preview.20260911.4", "linux-x64")).toBe( + "t3-1.2.3-preview.20260911.4-linux-x64.tar.gz", + ); + expect(cliArchiveFileName("1.2.3", "win32-x64")).toBe("t3-1.2.3-win32-x64.zip"); + }); + + it("only maps platforms and architectures that have a release archive", () => { + expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64"); + expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64"); + expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64"); + // Built but not published (macOS x64 segfaults under Rosetta when + // cross-injected; the arm64 Linux and Windows runners do not exist yet). + expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined(); + expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined(); + expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined(); + }); + + it("resolves download URLs under the tagged release, honoring a mirror", () => { + expect(cliReleaseDownloadBaseUrl("1.2.3")).toBe( + "https://github.com/pingdotgg/t3code/releases/download/v1.2.3", + ); + expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe( + "https://mirror.example/t3/v1.2.3", + ); + }); + + it("parses sha256sum output including binary-mode markers", () => { + const checksums = parseChecksums( + [ + `${"a".repeat(64)} t3-1.2.3-linux-x64.tar.gz`, + `${"B".repeat(64)} *t3-1.2.3-win32-x64.zip`, + "not a checksum line", + "", + ].join("\n"), + ); + expect(checksums.get("t3-1.2.3-linux-x64.tar.gz")).toBe("a".repeat(64)); + expect(checksums.get("t3-1.2.3-win32-x64.zip")).toBe("b".repeat(64)); + expect(checksums.size).toBe(2); + }); + + it("treats only preview builds as archive-distributed", () => { + expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); + expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); + expect(isArchiveDistributedVersion("1.2.3")).toBe(false); + }); + + it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { + expect(cliArchiveTarCommand("linux", {})).toBe("tar"); + expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( + "D:\\Win\\System32\\tar.exe", + ); + expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe"); + }); +}); diff --git a/packages/shared/src/cliRelease.ts b/packages/shared/src/cliRelease.ts new file mode 100644 index 0000000000000000000000000000000000000000..8863a5c1ebf73c082eb05b9b5a11bca87c363833 --- /dev/null +++ b/packages/shared/src/cliRelease.ts @@ -0,0 +1,76 @@ +/** + * Naming shared by the release workflow, the runtime installers, and + * install scripts for the per-platform CLI archives attached to GitHub + * Releases. Every consumer derives the same file names from a version and a + * platform key, so a rename here is a release-breaking change. + */ + +const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; +export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; +/** Overrides the download origin for mirrors and air-gapped installs. */ +export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL"; + +/** + * The archives a release actually attaches. Kept in step with the + * `cli_archive` matrix flags in .github/workflows/release.yml: a key here + * without a build there produces download URLs that 404, and a build there + * without a key here is unreachable from every installer. + */ +const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const; +export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; + +export function cliArchivePlatformKey( + platform: NodeJS.Platform, + arch: string, +): CliArchivePlatformKey | undefined { + const key = `${platform}-${arch}`; + return CLI_ARCHIVE_PLATFORM_KEYS.find((candidate) => candidate === key); +} + +/** + * The tar to extract a release archive with. Windows ships bsdtar in + * System32, which reads both formats; a Git-for-Windows GNU tar earlier on + * PATH cannot open the zip, so the system copy is named by absolute path. + */ +export function cliArchiveTarCommand( + platform: NodeJS.Platform, + env: Readonly>, +): string { + if (platform !== "win32") return "tar"; + const systemRoot = env["SystemRoot"] ?? env["windir"] ?? "C:\\Windows"; + return `${systemRoot}\\System32\\tar.exe`; +} + +export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string { + return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; +} + +const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; + +/** Directory that `releases/download//` lives under. */ +export function cliReleaseDownloadBaseUrl( + version: string, + baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL, +): string { + return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`; +} + +/** + * Parses the `sha256sum` style checksum file attached to each release. + * Lines are ` `; a leading `*` marks binary mode and is ignored. + */ +export function parseChecksums(text: string): ReadonlyMap { + const checksums = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(line.trim()); + if (match?.[1] !== undefined && match[2] !== undefined) { + checksums.set(match[2], match[1].toLowerCase()); + } + } + return checksums; +} + +/** Whether a version was published from a release train that ships archives. */ +export function isArchiveDistributedVersion(version: string): boolean { + return /-preview\.\d{8}\.\d+$/.test(version); +} -- 2.54.0