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

feat(server): install preview runtimes from release archives (#11318)

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

25 files changed, 1016 insertions(+), 122 deletions(-)

apps/marketing/public/install.ps1 created+95
......@@ -0,0 +1,95 @@
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_VERSION exact version to install (default: latest preview release)
8# T3CODE_HOME T3 home directory (default: ~\.t3)
9# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
10# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
11#
12# The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the
13# same layout `t3 service install` uses, so the service reuses this download.
14$ErrorActionPreference = "Stop"
15[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
16
17$repo = "pingdotgg/t3code"
18$baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" }
19$t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" }
20$binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" }
21
22function Fail([string] $message) {
23 Write-Error "t3 install: $message"
24 exit 1
25}
26
27# PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell
28# runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts
29# may lack.
30$rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
31$arch = switch ($rawArch) {
32 "AMD64" { "x64" }
33 "ARM64" { "arm64" }
34 default { Fail "unsupported architecture $rawArch" }
35}
36
37$version = $env:T3CODE_VERSION
38if (-not $version) {
39 # Preview is the only train shipping archives while they are being dogfooded.
40 $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" }
41 $tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name
42 if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" }
43 $version = $tag.Substring(1)
44}
45
46$stem = "t3-$version-win32-$arch"
47$archive = "$stem.zip"
48$versionsDir = Join-Path $t3Home "runtime\versions"
49$targetDir = Join-Path $versionsDir $version
50$marker = Join-Path $targetDir ".install-complete"
51
52if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
53 Write-Host "t3 $version is already installed at $targetDir"
54} else {
55 New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null
56 $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName())
57 New-Item -ItemType Directory -Path $staging | Out-Null
58 try {
59 Write-Host "Downloading $archive..."
60 Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing
61 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing
62
63 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
64 if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" }
65 $expected = ($expected -split "\s+")[0].ToLowerInvariant()
66 $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant()
67 if ($actual -ne $expected) { Fail "checksum mismatch for $archive" }
68
69 Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force
70 # The archive wraps everything in one directory named after its stem.
71 Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging
72 Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force
73
74 & (Join-Path $staging "t3.exe") --version | Out-Null
75 if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" }
76 Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline
77
78 if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force }
79 Move-Item $staging $targetDir
80 } catch {
81 if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
82 throw
83 }
84}
85
86New-Item -ItemType Directory -Force -Path $binDir | Out-Null
87$shim = Join-Path $binDir "t3.cmd"
88# UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt
89# non-ASCII characters in the user's home path.
90[System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false))
91Write-Host "Installed t3 $version"
92Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')"
93if (($env:PATH -split ";") -notcontains $binDir) {
94 Write-Host "Add $binDir to your PATH to run t3."
95}
apps/marketing/public/install.sh created+104
......@@ -0,0 +1,104 @@
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_VERSION exact version to install (default: latest preview release)
9# T3CODE_HOME T3 home directory (default: ~/.t3)
10# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)
11# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
12#
13# The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the
14# same layout `t3 service install` uses, so the service reuses this download
15# instead of fetching the release again.
16set -eu
17
18repo="pingdotgg/t3code"
19base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}"
20t3_home="${T3CODE_HOME:-$HOME/.t3}"
21bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}"
22
23fail() {
24 printf 't3 install: %s\n' "$1" >&2
25 exit 1
26}
27
28fetch() {
29 if command -v curl >/dev/null 2>&1; then
30 curl -fsSL "$1" -o "$2"
31 elif command -v wget >/dev/null 2>&1; then
32 wget -q "$1" -O "$2"
33 else
34 fail "curl or wget is required"
35 fi
36}
37
38case "$(uname -s)" in
39 Darwin) platform="darwin" ;;
40 Linux) platform="linux" ;;
41 *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;;
42esac
43case "$(uname -m)" in
44 arm64 | aarch64) arch="arm64" ;;
45 x86_64 | amd64) arch="x64" ;;
46 *) fail "unsupported architecture $(uname -m)" ;;
47esac
48command -v tar >/dev/null 2>&1 || fail "tar is required"
49if command -v sha256sum >/dev/null 2>&1; then
50 checksum() { sha256sum "$1" | cut -d' ' -f1; }
51elif command -v shasum >/dev/null 2>&1; then
52 checksum() { shasum -a 256 "$1" | cut -d' ' -f1; }
53else
54 fail "sha256sum or shasum is required"
55fi
56
57version="${T3CODE_VERSION:-}"
58if [ -z "$version" ]; then
59 # Preview is the only train shipping archives while they are being dogfooded.
60 tmp_index="$(mktemp)"
61 fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index"
62 version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)"
63 rm -f "$tmp_index"
64 [ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION"
65fi
66
67stem="t3-${version}-${platform}-${arch}"
68archive="${stem}.tar.gz"
69versions_dir="${t3_home}/runtime/versions"
70target_dir="${versions_dir}/${version}"
71
72if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then
73 printf 't3 %s is already installed at %s\n' "$version" "$target_dir"
74else
75 mkdir -p "$versions_dir"
76 staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")"
77 trap 'rm -rf "$staging"' EXIT
78
79 printf 'Downloading %s...\n' "$archive"
80 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS"
81 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"
82
83 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
84 [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS"
85 actual="$(checksum "${staging}/${archive}")"
86 [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}"
87
88 tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1
89 rm -f "${staging}/${archive}" "${staging}/SHA256SUMS"
90 "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run"
91 printf '%s\n' "$version" > "${staging}/.install-complete"
92
93 rm -rf "$target_dir"
94 mv "$staging" "$target_dir"
95 trap - EXIT
96fi
97
98mkdir -p "$bin_dir"
99ln -sfn "${target_dir}/t3" "${bin_dir}/t3"
100printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3"
101case ":${PATH}:" in
102 *":${bin_dir}:"*) ;;
103 *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;;
104esac
apps/marketing/src/pages/download.astro+9
......@@ -132,6 +132,9 @@ const imageProps = {
132132 <h2 class="platform-name">Terminal</h2>
133133 </div>
134134 <code class="cli-line">npx t3@nightly</code>
135 <p class="cli-note">No Node.js? The preview build installs as a single download:</p>
136 <code class="cli-line">curl -fsSL https://t3.codes/install.sh | sh</code>
137 <code class="cli-line">irm https://t3.codes/install.ps1 | iex</code>
135138 </section>
136139 </div>
137140
......@@ -432,6 +435,12 @@ const imageProps = {
432435 letter-spacing: -0.01em;
433436 }
434437
438 .cli-note {
439 color: var(--fg-muted);
440 font-size: 0.85rem;
441 margin-top: 0.5rem;
442 }
443
435444 .cli-line {
436445 align-self: flex-start;
437446 font-family: var(--font-mono);
apps/marketing/vercel.ts+18
......@@ -7,6 +7,24 @@ export const config: VercelConfig = {
77 installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'",
88 buildCommand: "vp run --filter @t3tools/marketing build",
99 outputDirectory: "dist",
10 // `curl … | sh` needs the scripts served as plain text, uncompressed by
11 // content negotiation, and never cached past a deploy.
12 headers: [
13 {
14 source: "/install.sh",
15 headers: [
16 { key: "Content-Type", value: "text/x-shellscript; charset=utf-8" },
17 { key: "Cache-Control", value: "public, max-age=300" },
18 ],
19 },
20 {
21 source: "/install.ps1",
22 headers: [
23 { key: "Content-Type", value: "text/plain; charset=utf-8" },
24 { key: "Cache-Control", value: "public, max-age=300" },
25 ],
26 },
27 ],
1028 redirects: [
1129 {
1230 source: "/app",
apps/server/src/bin.ts+4
......@@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts";
1717import { projectCommand } from "./cli/project.ts";
1818import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
1919import { serviceCommand } from "./cli/service.ts";
20import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
21import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
2022import { servicePreflightCommand } from "./cli/servicePreflight.ts";
2123import { themeCommand } from "./cli/theme.ts";
2224import { triageCommand } from "./cli/triage.ts";
......@@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
5961 authCommand,
6062 projectCommand,
6163 serviceCommand,
64 serviceLauncherCommand,
65 claudeHistoryCommand,
6266 servicePreflightCommand,
6367 themeCommand,
6468 triageCommand,
apps/server/src/claude-history-worker.ts created+7
......@@ -0,0 +1,7 @@
1// Standalone entry for npm-distributed runtimes: `node claudeHistoryWorker.mjs
2// <method> <session-id> [options]`. The executable reaches the same worker
3// through the `__claude-history` subcommand.
4import { runClaudeHistoryWorker } from "./claudeHistoryWorker.ts";
5
6const [method, sessionId, rawOptions] = process.argv.slice(2);
7await runClaudeHistoryWorker(method, sessionId, rawOptions);
apps/server/src/claudeHistoryWorker.ts+26-14
......@@ -2,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"
22import * as Schema from "effect/Schema";
33
44// A separate process gives SDK history helpers the provider's environment without
5// mutating the server's environment. This entry is bundled alongside the server.
6const [method, sessionId, rawOptions] = process.argv.slice(2);
7const options = Schema.decodeSync(
5// mutating the server's environment. `claude-history-worker.ts` is the
6// standalone entry bundled beside the server for npm installs; the
7// single-executable hosts the same function as its `__claude-history`
8// subcommand, which has no Node to run a sibling script. Nothing here may run
9// on import: inside the executable `import.meta.main` is true for the whole
10// bundle.
11const decodeHistoryOptions = Schema.decodeSync(
812 Schema.fromJsonString(
913 Schema.Struct({
1014 dir: Schema.optionalKey(Schema.String),
......@@ -12,14 +16,22 @@ const options = Schema.decodeSync(
1216 upToMessageId: Schema.optionalKey(Schema.String),
1317 }),
1418 ),
15)(rawOptions ?? "{}");
16if (!sessionId) throw new Error("Claude history session id is required.");
17const result =
18 method === "getSessionMessages"
19 ? await getSessionMessages(sessionId, options)
20 : method === "forkSession"
21 ? await forkSession(sessionId, options)
22 : (() => {
23 throw new Error("Unknown Claude history operation.");
24 })();
25process.stdout.write(JSON.stringify(result));
19);
20
21export async function runClaudeHistoryWorker(
22 method: string | undefined,
23 sessionId: string | undefined,
24 rawOptions: string | undefined,
25): Promise<void> {
26 const options = decodeHistoryOptions(rawOptions ?? "{}");
27 if (!sessionId) throw new Error("Claude history session id is required.");
28 const result =
29 method === "getSessionMessages"
30 ? await getSessionMessages(sessionId, options)
31 : method === "forkSession"
32 ? await forkSession(sessionId, options)
33 : (() => {
34 throw new Error("Unknown Claude history operation.");
35 })();
36 process.stdout.write(JSON.stringify(result));
37}
apps/server/src/cli/claudeHistory.ts created+27
......@@ -0,0 +1,27 @@
1import * as Effect from "effect/Effect";
2import { Argument, Command } from "effect/unstable/cli";
3
4import { runClaudeHistoryWorker } from "../claudeHistoryWorker.ts";
5
6/**
7 * Hosts the Claude history worker inside the CLI executable. The npm bundle
8 * runs it as a sibling `claudeHistoryWorker.mjs` under the host Node; the
9 * single-executable has no Node to run a script with, so the adapter invokes
10 * this hidden subcommand on its own executable instead.
11 */
12export const claudeHistoryCommand = Command.make("__claude-history", {
13 method: Argument.string("method"),
14 sessionId: Argument.string("session-id"),
15 options: Argument.string("options").pipe(Argument.optional),
16}).pipe(
17 Command.unlisted,
18 Command.withHandler(({ method, sessionId, options }) =>
19 Effect.promise(() =>
20 runClaudeHistoryWorker(
21 method,
22 sessionId,
23 options._tag === "Some" ? options.value : undefined,
24 ),
25 ),
26 ),
27);
apps/server/src/cli/service.test.ts+1-1
......@@ -105,7 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) {
105105 Effect.sync(() => {
106106 installOptions.push(options);
107107 return {
108 nodePath: "/test/node",
108 program: ["/test/node", "/test/service-launcher.mjs"],
109109 launcherPath: "/test/service-launcher.mjs",
110110 baseDir: "/test/t3",
111111 unitPath: serviceStatus.unitPath,
apps/server/src/cli/service.ts+6-1
......@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Terminal from "effect/Terminal";
66import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
7import { FetchHttpClient } from "effect/unstable/http";
78
89import packageJson from "../../package.json" with { type: "json" };
910import * as BootService from "../cloud/bootService.ts";
......@@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) =
1718 baseDir: config.baseDir,
1819 logsDir: config.logsDir,
1920 cliVersion: packageJson.version,
20 }).pipe(Layer.provide(ProcessRunner.layer));
21 }).pipe(
22 Layer.provide(ProcessRunner.layer),
23 // Archive-distributed versions download the release archive here.
24 Layer.provide(FetchHttpClient.layer),
25 );
2126
2227export type ServiceReconcileResult =
2328 | {
apps/server/src/cli/serviceLauncher.ts created+28
......@@ -0,0 +1,28 @@
1import * as Effect from "effect/Effect";
2import { Command } from "effect/unstable/cli";
3
4import { main as runServiceLauncher } from "../serviceLauncher.ts";
5
6/**
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.
11 *
12 * The launcher owns SIGTERM handling and the process lifetime: it must finish
13 * stopping its child before the process exits, so it runs detached from the
14 * CLI's fiber rather than under `runMain`, whose signal handler would
15 * interrupt the fiber and exit while the child is still being terminated.
16 */
17export const serviceLauncherCommand = Command.make("__service-launcher").pipe(
18 Command.unlisted,
19 Command.withHandler(() =>
20 Effect.sync(() => {
21 runServiceLauncher().catch((cause: unknown) => {
22 const error = cause instanceof Error ? cause : new Error(String(cause));
23 process.stderr.write(`[service-launcher] ${error.message}\n`);
24 process.exitCode = 1;
25 });
26 }),
27 ),
28);
apps/server/src/cloud/bootService.test.ts+19-4
......@@ -25,7 +25,7 @@ import {
2525
2626it("keeps systemd pinned to the stable launcher rather than a versioned server", () => {
2727 const unit = BootService.renderBootServiceUnit({
28 nodePath: "/usr/bin/node",
28 program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"],
2929 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
3030 baseDir: "/home/theo/.t3",
3131 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",
3737 expect(unit).not.toContain("versions/1.2.3");
3838});
3939
40it("runs archive-distributed runtimes as their own executable", () => {
41 const unit = BootService.renderBootServiceUnit({
42 program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"],
43 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
44 baseDir: "/home/theo/.t3",
45 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
46 unitPath: "/home/theo/.config/systemd/user/t3code.service",
47 });
48
49 expect(unit).toContain(
50 "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher",
51 );
52 expect(unit).not.toContain("node");
53});
54
4055it("survives the kernel OOM-killing a greedy agent child", () => {
4156 const unit = BootService.renderBootServiceUnit({
42 nodePath: "/usr/bin/node",
57 program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"],
4358 launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs",
4459 baseDir: "/home/theo/.t3",
4560 logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
......@@ -50,7 +65,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => {
5065});
5166
5267const macPlan = {
53 nodePath: "/opt/homebrew/bin/node",
68 program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"],
5469 launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs",
5570 baseDir: "/Users/theo/.t3",
5671 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
......@@ -116,7 +131,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
116131 const sourceLauncher = path.join(home, "service-launcher.mjs");
117132 const statePath = path.join(baseDir, "runtime", "service-state.json");
118133 yield* fs.writeFileString(sourceLauncher, "export {};\n");
119 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3");
134 const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
120135 yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
121136 yield* fs.writeFileString(runtime.entryPath, "export {};\n");
122137 yield* fs.writeFileString(
apps/server/src/cloud/bootService.ts+44-13
......@@ -1,4 +1,5 @@
11import {
2 HostProcessArchitecture,
23 HostProcessExecutablePath,
34 HostProcessPlatform,
45 HostProcessUserId,
......@@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem";
1213import * as Layer from "effect/Layer";
1314import * as Option from "effect/Option";
1415import * as Path from "effect/Path";
16import { HttpClient } from "effect/unstable/http";
1517import * as Schema from "effect/Schema";
1618
19import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";
20
1721import * as ProcessRunner from "../processRunner.ts";
1822import {
1923 ensurePinnedRuntimeInstalled,
24 pinnedRuntimeCommand,
2025 pinnedRuntimePaths,
2126 PinnedRuntimeInstallError,
2227} from "./pinnedRuntime.ts";
......@@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string {
5257}
5358
5459export interface BootServicePlan {
55 readonly nodePath: string;
60 /**
61 * What the service manager executes. npm-distributed runtimes run the
62 * standalone launcher script with the installing Node; archive-distributed
63 * runtimes run their own executable, which hosts the launcher as a hidden
64 * subcommand so the machine never needs Node.
65 */
66 readonly program: ReadonlyArray<string>;
5667 readonly launcherPath: string;
5768 readonly baseDir: string;
5869 readonly logPath: string;
......@@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
7384 "WorkingDirectory=%h",
7485 `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
7586 `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`,
76 `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`,
87 `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`,
7788 // Let the launcher mark an explicit stop before it signals the server.
7889 // systemd still SIGKILLs the whole cgroup if graceful shutdown times out.
7990 "KillMode=mixed",
......@@ -124,8 +135,7 @@ export function renderBootServicePlist(
124135 ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`,
125136 ` <key>ProgramArguments</key>`,
126137 ` <array>`,
127 ` <string>${escapeXmlText(plan.nodePath)}</string>`,
128 ` <string>${escapeXmlText(plan.launcherPath)}</string>`,
138 ...plan.program.map((argument) => ` <string>${escapeXmlText(argument)}</string>`),
129139 ` </array>`,
130140 ` <key>EnvironmentVariables</key>`,
131141 ` <dict>`,
......@@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
505515}) {
506516 const hostExecPath = yield* HostProcessExecutablePath;
507517 const platform = yield* HostProcessPlatform;
518 const arch = yield* HostProcessArchitecture;
508519 const uid = yield* HostProcessUserId;
520 // Archive-distributed versions download from GitHub Releases; npm versions
521 // never touch HTTP, so callers without a client still work.
522 const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient));
523 const releaseBaseUrl = Option.getOrUndefined(
524 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
525 );
509526 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
510527 const installerPath = yield* Config.string("PATH").pipe(Config.withDefault(""));
511528 const fs = yield* FileSystem.FileSystem;
......@@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
544561 const logPath = path.join(input.logsDir, "boot-service.log");
545562 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
546563 const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
547 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion);
564 const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform);
548565 const launcherSourcePath =
549566 host.launcherSourcePath ??
550567 path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE);
......@@ -568,7 +585,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
568585 }),
569586 ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
570587 const plan: BootServicePlan = {
571 nodePath: host.execPath,
588 program:
589 runtimePaths.layout === "archive"
590 ? [runtimePaths.entryPath, "__service-launcher"]
591 : [host.execPath, launcherPath],
572592 launcherPath,
573593 baseDir: input.baseDir,
574594 logPath,
......@@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
708728 fs,
709729 path,
710730 runner,
731 httpClient,
732 platform,
733 arch,
734 releaseBaseUrl,
711735 validate: (runtime) =>
712736 runner
713737 .run({
714 command: host.execPath,
715 args: [runtime.entryPath, "--version"],
738 command: pinnedRuntimeCommand(runtime, host.execPath).command,
739 args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"],
716740 timeout: Duration.seconds(30),
717741 })
718742 .pipe(
......@@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
750774 : new BootServiceInstallError({ cause: error }),
751775 ),
752776 );
753 const launcherSource = yield* fs
754 .readFileString(launcherSourcePath)
755 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
777 // Archive runtimes host the launcher in the executable itself; there is
778 // no standalone script to copy.
779 const launcherSource =
780 runtimePaths.layout === "archive"
781 ? undefined
782 : yield* fs
783 .readFileString(launcherSourcePath)
784 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
756785
757786 const installed = yield* fs
758787 .exists(unitPath)
......@@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
786815 yield* fs
787816 .makeDirectory(path.dirname(unitPath), { recursive: true })
788817 .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
789 yield* writeDurably(launcherPath, launcherSource);
818 if (launcherSource !== undefined) {
819 yield* writeDurably(launcherPath, launcherSource);
820 }
790821 yield* writeDurably(
791822 statePath,
792823 // @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: {
836867 const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] =
837868 yield* Effect.all([
838869 fs.readFileString(unitPath),
839 fs.exists(launcherPath),
870 runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath),
840871 fs.exists(runtimePaths.entryPath),
841872 fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
842873 fs.readFileString(statePath).pipe(Effect.option),
apps/server/src/cloud/pinnedRuntime.test.ts+139-4
......@@ -6,11 +6,13 @@ import * as FileSystem from "effect/FileSystem";
66import * as Fiber from "effect/Fiber";
77import * as Path from "effect/Path";
88import * as PlatformError from "effect/PlatformError";
9import { HttpClient, HttpClientResponse } from "effect/unstable/http";
910import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1011
1112import * as ProcessRunner from "../processRunner.ts";
1213import {
1314 ensurePinnedRuntimeInstalled,
15 pinnedRuntimeCommand,
1416 pinnedRuntimePaths,
1517 PinnedRuntimeInstallError,
1618} from "./pinnedRuntime.ts";
......@@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
5153 version: "1.2.3",
5254 fs,
5355 path,
56 platform: "linux",
57 arch: "x64",
5458 runner: ProcessRunner.ProcessRunner.of({
5559 run: (input) => {
5660 commands.push(input);
......@@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
9599 version: "1.2.3",
96100 fs,
97101 path,
102 platform: "linux",
103 arch: "x64",
98104 runner: ProcessRunner.ProcessRunner.of({
99105 run: (input) => {
100106 commands.push(input.command);
......@@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
122128 const fs = yield* FileSystem.FileSystem;
123129 const path = yield* Path.Path;
124130 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
125 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
131 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
126132 let validatedDirectory = "";
127133
128134 const installed = yield* ensurePinnedRuntimeInstalled({
......@@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
130136 version: "1.2.3",
131137 fs,
132138 path,
139 platform: "linux",
140 arch: "x64",
133141 runner: successfulRunner(fs, path),
134142 validate: (staging) =>
135143 Effect.gen(function* () {
......@@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
151159 const fs = yield* FileSystem.FileSystem;
152160 const path = yield* Path.Path;
153161 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" });
154 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
162 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
155163
156164 yield* ensurePinnedRuntimeInstalled({
157165 baseDir,
158166 version: "1.2.3",
159167 fs,
160168 path,
169 platform: "linux",
170 arch: "x64",
161171 runner: successfulRunner(fs, path),
162172 validate: () =>
163173 Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })),
......@@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
178188 const fs = yield* FileSystem.FileSystem;
179189 const path = yield* Path.Path;
180190 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
181 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
191 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
182192 yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true });
183193 yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n");
184194
......@@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
187197 version: "1.2.3",
188198 fs,
189199 path,
200 platform: "linux",
201 arch: "x64",
190202 runner: successfulRunner(fs, path),
191203 validate: () => Effect.void,
192204 });
......@@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
201213 const fs = yield* FileSystem.FileSystem;
202214 const path = yield* Path.Path;
203215 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" });
204 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3");
216 const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux");
205217 yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true });
206218 yield* fs.writeFileString(finalPaths.entryPath, "broken\n");
207219 yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n");
......@@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
212224 version: "1.2.3",
213225 fs,
214226 path,
227 platform: "linux",
228 arch: "x64",
215229 runner: successfulRunner(fs, path),
216230 validate: (paths) =>
217231 Effect.gen(function* () {
......@@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
242256 version: "1.2.3",
243257 fs,
244258 path,
259 platform: "linux",
260 arch: "x64",
245261 runner,
246262 validate: () => Effect.void,
247263 }).pipe(Effect.forkScoped);
......@@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => {
252268 assert.deepEqual(yield* fs.readDirectory(versionsDir), []);
253269 }),
254270 );
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 });
255390});
apps/server/src/cloud/pinnedRuntime.ts+224-50
......@@ -1,44 +1,91 @@
11import * as Duration from "effect/Duration";
22import * as Effect from "effect/Effect";
3import * as Encoding from "effect/Encoding";
34import * as FileSystem from "effect/FileSystem";
45import * as Path from "effect/Path";
56import * as PlatformError from "effect/PlatformError";
67import * as Schema from "effect/Schema";
78import * as Option from "effect/Option";
89import * as Semaphore from "effect/Semaphore";
10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
11
12import {
13 CLI_RELEASE_CHECKSUMS_FILE,
14 cliArchiveFileName,
15 cliArchivePlatformKey,
16 cliArchiveTarCommand,
17 cliReleaseDownloadBaseUrl,
18 isArchiveDistributedVersion,
19 parseChecksums,
20} from "@t3tools/shared/cliRelease";
921
1022import * as ProcessRunner from "../processRunner.ts";
1123
1224/**
13 * A pinned runtime is an exact `t3@<version>` npm-installed into
25 * A pinned runtime is an exact `t3@<version>` installed into
1426 * <baseDir>/runtime/versions/<version>. The boot service points its unit or
1527 * launch agent here, and server self-update installs the target version here before
1628 * switching over, never `npx t3`, whose cache is ephemeral and whose
1729 * 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.
1836 */
1937
2038const PINNED_RUNTIME_DIR = "runtime";
2139const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
40const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive";
2241// Boot-service setup and remote update can construct separate layers. Serialize
2342// the complete install transaction across every caller in this process.
2443const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1);
2544
45export type PinnedRuntimeLayout = "npm" | "archive";
46
2647export interface PinnedRuntimePaths {
48 readonly layout: PinnedRuntimeLayout;
2749 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 */
2854 readonly entryPath: string;
2955 readonly sentinelPath: string;
3056}
3157
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] };
66}
67
3268export function pinnedRuntimePaths(
3369 path: Path.Path,
3470 baseDir: string,
3571 version: string,
72 platform: NodeJS.Platform,
3673): PinnedRuntimePaths {
3774 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 }
3884 return {
85 layout: "npm",
3986 versionDir,
4087 entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
41 sentinelPath: path.join(versionDir, ".install-complete"),
88 sentinelPath,
4289 };
4390}
4491
......@@ -74,9 +121,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne
74121/**
75122 * Installs `t3@<version>` into the pinned runtime directory unless a complete
76123 * install is already there, and returns its paths. The sentinel is written
77 * only after npm exits 0; checking the entry file alone is not enough. npm
78 * extracts files before running native builds (node-pty), so a killed
79 * install leaves a plausible-looking but broken tree behind.
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.
80128 */
81129interface PinnedRuntimeInstallInput {
82130 readonly baseDir: string;
......@@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput {
87135 readonly validate: (
88136 paths: PinnedRuntimePaths,
89137 ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>;
138 readonly platform: NodeJS.Platform;
139 readonly arch: string;
140 /** Archive-distributed versions download from here; npm versions never need it. */
141 readonly httpClient?: HttpClient.HttpClient | undefined;
142 readonly releaseBaseUrl?: string | undefined;
90143}
91144
145const installFromNpm = Effect.fn("cloud.pinned_runtime.install_npm")(function* (
146 input: PinnedRuntimeInstallInput,
147 stagingDir: string,
148) {
149 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
150 const installArgs = [
151 "install",
152 "--prefix",
153 stagingDir,
154 "--no-fund",
155 "--no-audit",
156 `t3@${input.version}`,
157 ];
158 yield* input.runner
159 .run({
160 command: "npm",
161 args: installArgs,
162 // Native dependencies may compile from source on slower machines.
163 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
164 })
165 .pipe(
166 Effect.catchTags({
167 ProcessSpawnError: (error) =>
168 error.cause instanceof PlatformError.PlatformError &&
169 error.cause.reason._tag === "NotFound"
170 ? // pnpm-managed Node installations do not include npm. Keep npm
171 // installation semantics for the pinned runtime and native builds.
172 input.runner.run({
173 command: "pnpm",
174 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
175 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
176 })
177 : Effect.fail(error),
178 }),
179 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
180 Effect.filterOrFail(
181 (result) => result.code === 0,
182 (result) =>
183 new PinnedRuntimeInstallError({
184 step: installStep,
185 exitCode: Number(result.code),
186 stdoutLength: result.stdout.length,
187 stderrLength: result.stderr.length,
188 }),
189 ),
190 );
191});
192
193const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* (
194 httpClient: HttpClient.HttpClient,
195 url: string,
196 step: string,
197) {
198 // The install lock is held for the whole transaction, so a stalled download
199 // must fail rather than block every other caller.
200 return yield* httpClient.execute(HttpClientRequest.get(url)).pipe(
201 Effect.flatMap(HttpClientResponse.filterStatusOk),
202 Effect.flatMap((response) => response.arrayBuffer),
203 Effect.map((buffer) => new Uint8Array(buffer)),
204 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step, cause })),
205 Effect.timeoutOrElse({
206 duration: PINNED_RUNTIME_INSTALL_TIMEOUT,
207 orElse: () => Effect.fail(new PinnedRuntimeInstallError({ step: `${step} (timed out)` })),
208 }),
209 );
210});
211
212/**
213 * Downloads the release archive for this platform, verifies it against the
214 * release's checksum file, and unpacks it so the executable sits directly in
215 * the staging directory. Only `tar` is required on the host; every supported
216 * OS ships one that reads gzip and zip.
217 */
218const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(function* (
219 input: PinnedRuntimeInstallInput,
220 stagingDir: string,
221) {
222 const { fs, path } = input;
223 const platformKey = cliArchivePlatformKey(input.platform, input.arch);
224 if (platformKey === undefined) {
225 return yield* new PinnedRuntimeInstallError({
226 step: `selecting a t3 release archive for ${input.platform}-${input.arch}`,
227 });
228 }
229 const httpClient = input.httpClient;
230 if (httpClient === undefined) {
231 return yield* new PinnedRuntimeInstallError({
232 step: "downloading the t3 release archive (no HTTP client available)",
233 });
234 }
235 const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl);
236 const fileName = cliArchiveFileName(input.version, platformKey);
237
238 const checksums = parseChecksums(
239 new TextDecoder().decode(
240 yield* fetchReleaseAsset(
241 httpClient,
242 `${baseUrl}/${CLI_RELEASE_CHECKSUMS_FILE}`,
243 "downloading the t3 release checksums",
244 ),
245 ),
246 );
247 const expected = checksums.get(fileName);
248 if (expected === undefined) {
249 return yield* new PinnedRuntimeInstallError({
250 step: `finding ${fileName} in the t3 release checksums`,
251 });
252 }
253 const archive = yield* fetchReleaseAsset(
254 httpClient,
255 `${baseUrl}/${fileName}`,
256 "downloading the t3 release archive",
257 );
258 const digest = yield* Effect.tryPromise({
259 try: () => crypto.subtle.digest("SHA-256", archive),
260 catch: (cause) =>
261 new PinnedRuntimeInstallError({ step: "verifying the t3 release archive", cause }),
262 });
263 if (Encoding.encodeHex(new Uint8Array(digest)) !== expected) {
264 return yield* new PinnedRuntimeInstallError({
265 step: "verifying the t3 release archive checksum",
266 });
267 }
268
269 const archivePath = path.join(stagingDir, PINNED_RUNTIME_ARCHIVE_FILE);
270 yield* fs
271 .writeFile(archivePath, archive)
272 .pipe(
273 Effect.mapError(
274 (cause) => new PinnedRuntimeInstallError({ step: "writing the t3 release archive", cause }),
275 ),
276 );
277 const extractStep = "extracting the t3 release archive";
278 // The archive wraps everything in one directory named after its stem;
279 // strip it so the executable lands at <versionDir>/t3.
280 yield* input.runner
281 .run({
282 command: cliArchiveTarCommand(input.platform, process.env),
283 args: ["-xf", archivePath, "-C", stagingDir, "--strip-components=1"],
284 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
285 })
286 .pipe(
287 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: extractStep, cause })),
288 Effect.filterOrFail(
289 (result) => result.code === 0,
290 (result) =>
291 new PinnedRuntimeInstallError({
292 step: extractStep,
293 exitCode: Number(result.code),
294 stdoutLength: result.stdout.length,
295 stderrLength: result.stderr.length,
296 }),
297 ),
298 );
299 yield* fs.remove(archivePath, { force: true }).pipe(Effect.ignore);
300});
301
92302const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* (
93303 input: PinnedRuntimeInstallInput,
94304) {
95 const { fs, runner } = input;
96 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version);
305 const { fs } = input;
306 const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform);
97307 const [versionDirExists, entryExists, sentinel] = yield* Effect.all([
98308 fs.exists(paths.versionDir),
99309 fs.exists(paths.entryPath),
......@@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(
146356 ),
147357 );
148358 const stagingPaths: PinnedRuntimePaths = {
359 layout: paths.layout,
149360 versionDir: stagingDir,
150 entryPath: input.path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"),
361 entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)),
151362 sentinelPath: input.path.join(stagingDir, ".install-complete"),
152363 };
153364
154365 return yield* Effect.gen(function* () {
155 const installStep = "installing the pinned t3 runtime (this can take a few minutes)";
156 const installArgs = [
157 "install",
158 "--prefix",
159 stagingDir,
160 "--no-fund",
161 "--no-audit",
162 `t3@${input.version}`,
163 ];
164 yield* runner
165 .run({
166 command: "npm",
167 args: installArgs,
168 // Native dependencies may compile from source on slower machines.
169 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
170 })
171 .pipe(
172 Effect.catchTags({
173 ProcessSpawnError: (error) =>
174 error.cause instanceof PlatformError.PlatformError &&
175 error.cause.reason._tag === "NotFound"
176 ? // pnpm-managed Node installations do not include npm. Keep npm
177 // installation semantics for the pinned runtime and native builds.
178 runner.run({
179 command: "pnpm",
180 args: ["--package=npm@11", "dlx", "npm", ...installArgs],
181 timeout: PINNED_RUNTIME_INSTALL_TIMEOUT,
182 })
183 : Effect.fail(error),
184 }),
185 Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })),
186 Effect.filterOrFail(
187 (result) => result.code === 0,
188 (result) =>
189 new PinnedRuntimeInstallError({
190 step: installStep,
191 exitCode: Number(result.code),
192 stdoutLength: result.stdout.length,
193 stderrLength: result.stderr.length,
194 }),
195 ),
196 );
366 if (paths.layout === "archive") {
367 yield* installFromArchive(input, stagingDir);
368 } else {
369 yield* installFromNpm(input, stagingDir);
370 }
197371
198372 yield* input.validate(stagingPaths);
199373 yield* fs
apps/server/src/cloud/selfUpdate.ts+25-3
......@@ -6,22 +6,32 @@ import {
66 type ServerSelfUpdateResult,
77 type ThreadId,
88} from "@t3tools/contracts";
9import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess";
9import {
10 HostProcessArchitecture,
11 HostProcessExecutablePath,
12 HostProcessPlatform,
13} from "@t3tools/shared/hostProcess";
1014import * as Cause from "effect/Cause";
15import * as Config from "effect/Config";
1116import * as Context from "effect/Context";
1217import * as Duration from "effect/Duration";
1318import * as Effect from "effect/Effect";
1419import * as HashSet from "effect/HashSet";
20import * as Option from "effect/Option";
1521import * as Ref from "effect/Ref";
1622import * as FileSystem from "effect/FileSystem";
1723import * as Layer from "effect/Layer";
1824import * as Path from "effect/Path";
25import { HttpClient } from "effect/unstable/http";
26
27import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";
1928
2029import * as ServerConfig from "../config.ts";
2130import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts";
2231import * as ProcessRunner from "../processRunner.ts";
2332import {
2433 ensurePinnedRuntimeInstalled,
34 pinnedRuntimeCommand,
2535 PinnedRuntimeInstallError,
2636 PinnedRuntimePreflightBlockedError,
2737} from "./pinnedRuntime.ts";
......@@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
171181 const fs = yield* FileSystem.FileSystem;
172182 const path = yield* Path.Path;
173183 const execPath = yield* HostProcessExecutablePath;
184 const platform = yield* HostProcessPlatform;
185 const arch = yield* HostProcessArchitecture;
186 // Archive-distributed targets download from GitHub Releases. The client is
187 // optional so callers without one (tests, npm-only hosts) still construct.
188 const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient));
189 const releaseBaseUrl = Option.getOrUndefined(
190 yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option),
191 );
174192 const inFlight = yield* Ref.make(false);
175193
176194 const capability: ServerSelfUpdateCapability | null =
......@@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () {
216234 fs,
217235 path,
218236 runner,
237 httpClient,
238 platform,
239 arch,
240 releaseBaseUrl,
219241 validate: (runtime) =>
220242 runner
221243 .run({
222 command: execPath,
244 command: pinnedRuntimeCommand(runtime, execPath).command,
223245 args: [
224 runtime.entryPath,
246 ...pinnedRuntimeCommand(runtime, execPath).args,
225247 "__service-preflight",
226248 "--database-path",
227249 serverConfig.dbPath,
apps/server/src/provider/Layers/ClaudeAdapter.ts+20-11
......@@ -1,3 +1,4 @@
1// @effect-diagnostics nodeBuiltinImport:off
12/**
23 * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter.
34 *
......@@ -6,6 +7,8 @@
67 *
78 * @module ClaudeAdapterLive
89 */
10import * as NodeSea from "node:sea";
11
912import {
1013 type CanUseTool,
1114 query,
......@@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51285131 detail: "Claude session id is unavailable.",
51295132 });
51305133 }
5131 const historyWorkerPath = yield* path
5132 .fromFileUrl(
5133 new URL(
5134 import.meta.url.endsWith(".ts")
5135 ? "../../claudeHistoryWorker.ts"
5136 : "./claudeHistoryWorker.mjs",
5137 import.meta.url,
5138 ),
5139 )
5140 .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause)));
5134 // The single-executable has no sibling script and no Node to run one
5135 // with, so it hosts the worker as a hidden subcommand of itself.
5136 const historyWorkerArguments = NodeSea.isSea()
5137 ? ["__claude-history"]
5138 : [
5139 yield* path
5140 .fromFileUrl(
5141 new URL(
5142 import.meta.url.endsWith(".ts")
5143 ? "../../claude-history-worker.ts"
5144 : "./claude-history-worker.mjs",
5145 import.meta.url,
5146 ),
5147 )
5148 .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))),
5149 ];
51415150 const runScopedHistoryCommand = async (
51425151 method: "getSessionMessages" | "forkSession",
51435152 args: object,
......@@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
51505159 process.execPath,
51515160 ChildProcess.make(
51525161 process.execPath,
5153 [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)],
5162 [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)],
51545163 { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } },
51555164 ),
51565165 ).pipe(
apps/server/src/service-launcher.ts+20-1
......@@ -1 +1,20 @@
1import "./serviceLauncher.ts";
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.ts+34-18
......@@ -7,6 +7,7 @@ import * as NodeCrypto from "node:crypto";
77import * as NodeFS from "node:fs";
88import * as NodeFSP from "node:fs/promises";
99import * as NodePath from "node:path";
10import * as NodeSea from "node:sea";
1011
1112import type {
1213 PendingServiceUpdate,
......@@ -26,7 +27,6 @@ import {
2627 SERVICE_STATE_FILE,
2728 SERVICE_STOP_MARKER_FILE,
2829} from "./cloud/serviceProtocol.ts";
29import { isEntrypoint } from "./entrypoint.ts";
3030
3131const HANDOFF_DELAY_MS = 2_000;
3232const PREPARED_TIMEOUT_MS = 120_000;
......@@ -41,15 +41,38 @@ interface ManagedChild {
4141 readonly process: NodeChildProcess.ChildProcess;
4242}
4343
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
47// built-ins only.
4448const runtimePaths = (baseDir: string, version: string) => {
4549 const versionDir = NodePath.join(baseDir, "runtime", "versions", version);
50 const archive = /-preview\.\d{8}\.\d+$/.test(version);
51 // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime.
52 const executableName = process.platform === "win32" ? "t3.exe" : "t3";
4653 return {
4754 versionDir,
48 entryPath: NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
55 entryPath: archive
56 ? NodePath.join(versionDir, executableName)
57 : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"),
4958 sentinelPath: NodePath.join(versionDir, ".install-complete"),
59 executable: archive,
5060 };
5161};
5262
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;
75
5376/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */
5477const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const;
5578const RESTORE_MARKER = ".restore-pending";
......@@ -402,7 +425,8 @@ export class Launcher {
402425 childVersion: version,
403426 ...(update === undefined ? {} : { update }),
404427 };
405 const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], {
428 const spawnArguments = runtimeSpawnArguments(paths);
429 const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, {
406430 env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) },
407431 stdio: ["inherit", "inherit", "inherit", "ipc"],
408432 });
......@@ -481,6 +505,12 @@ export class Launcher {
481505 await reject("The requested database path is not absolute.");
482506 return;
483507 }
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 }
484514 if (!(await runtimeExists(this.#baseDir, message.targetVersion))) {
485515 await reject("The requested target runtime is missing or incomplete.");
486516 return;
......@@ -602,7 +632,7 @@ export class Launcher {
602632 }
603633}
604634
605async function main(): Promise<void> {
635export async function main(): Promise<void> {
606636 const baseDir = process.env.T3CODE_HOME?.trim();
607637 if (baseDir === undefined || baseDir === "") {
608638 throw new Error("T3CODE_HOME is required by the T3 Code service launcher.");
......@@ -611,17 +641,3 @@ async function main(): Promise<void> {
611641 const state = await readServiceState(statePath);
612642 await new Launcher(baseDir, state).run();
613643}
614
615if (
616 isEntrypoint({
617 moduleUrl: import.meta.url,
618 entryPath: process.argv[1],
619 runtimeMain: import.meta.main,
620 })
621) {
622 main().catch((cause: unknown) => {
623 const error = cause instanceof Error ? cause : new Error(String(cause));
624 process.stderr.write(`[service-launcher] ${error.message}\n`);
625 process.exitCode = 1;
626 });
627}
apps/server/vite.config.ts+1-1
......@@ -77,7 +77,7 @@ export default mergeConfig(
7777 pack: {
7878 // The executable embeds one entry; the history worker becomes a hidden
7979 // subcommand there instead of a sibling script.
80 entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claudeHistoryWorker.ts"],
80 entry: packExecutable ? ["src/bin.ts"] : ["src/bin.ts", "src/claude-history-worker.ts"],
8181 outDir: packExecutable ? "dist-exe" : "dist",
8282 sourcemap: !packExecutable,
8383 clean: true,
docs/user/background-service.md+15
......@@ -25,6 +25,21 @@ Updating restarts the server. Finish active work first, and wait for any remote
2525update already in progress. To match a remote client's version, follow
2626[Updating T3 Code](./updating.md).
2727
28Preview builds (`t3@preview`) install as a self-contained download from the
29T3 Code GitHub release instead of through npm, so the machine running the
30service does not need Node.js or npm once the CLI is on it. To get the CLI
31onto a machine without Node, run the install script:
32
33```sh
34curl -fsSL https://t3.codes/install.sh | sh
35```
36
37On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead.
38
39It places `t3` in `~/.local/bin` and reuses the same download when you later
40run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or
41`T3CODE_RELEASE_BASE_URL` to download from a mirror.
42
2843## Platform support
2944
3045Linux needs systemd user services. Setup enables lingering so T3 Code starts at
knip.jsonc+1-1
......@@ -21,7 +21,7 @@
2121 "entry": [
2222 "src/bin.ts!",
2323 "src/service-launcher.ts!",
24 "src/claudeHistoryWorker.ts!",
24 "src/claude-history-worker.ts!",
2525 "scripts/cli.ts",
2626 "src/provider/testFixtures/*.mjs",
2727 ],
packages/shared/package.json+4
......@@ -322,6 +322,10 @@
322322 "./dateTime": {
323323 "types": "./src/dateTime.ts",
324324 "import": "./src/dateTime.ts"
325 },
326 "./cliRelease": {
327 "types": "./src/cliRelease.ts",
328 "import": "./src/cliRelease.ts"
325329 }
326330 },
327331 "scripts": {
packages/shared/src/cliRelease.test.ts created+69
......@@ -0,0 +1,69 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 cliArchiveFileName,
5 cliArchivePlatformKey,
6 cliArchiveTarCommand,
7 cliReleaseDownloadBaseUrl,
8 isArchiveDistributedVersion,
9 parseChecksums,
10} from "./cliRelease.ts";
11
12describe("cliRelease", () => {
13 it("names archives by version and platform, zip only on Windows", () => {
14 expect(cliArchiveFileName("1.2.3-preview.20260911.4", "linux-x64")).toBe(
15 "t3-1.2.3-preview.20260911.4-linux-x64.tar.gz",
16 );
17 expect(cliArchiveFileName("1.2.3", "win32-x64")).toBe("t3-1.2.3-win32-x64.zip");
18 });
19
20 it("only maps platforms and architectures that have a release archive", () => {
21 expect(cliArchivePlatformKey("darwin", "arm64")).toBe("darwin-arm64");
22 expect(cliArchivePlatformKey("linux", "x64")).toBe("linux-x64");
23 expect(cliArchivePlatformKey("win32", "x64")).toBe("win32-x64");
24 // Built but not published (macOS x64 segfaults under Rosetta when
25 // cross-injected; the arm64 Linux and Windows runners do not exist yet).
26 expect(cliArchivePlatformKey("darwin", "x64")).toBeUndefined();
27 expect(cliArchivePlatformKey("linux", "arm64")).toBeUndefined();
28 expect(cliArchivePlatformKey("win32", "arm64")).toBeUndefined();
29 expect(cliArchivePlatformKey("freebsd", "x64")).toBeUndefined();
30 expect(cliArchivePlatformKey("linux", "ia32")).toBeUndefined();
31 });
32
33 it("resolves download URLs under the tagged release, honoring a mirror", () => {
34 expect(cliReleaseDownloadBaseUrl("1.2.3")).toBe(
35 "https://github.com/pingdotgg/t3code/releases/download/v1.2.3",
36 );
37 expect(cliReleaseDownloadBaseUrl("1.2.3", "https://mirror.example/t3/")).toBe(
38 "https://mirror.example/t3/v1.2.3",
39 );
40 });
41
42 it("parses sha256sum output including binary-mode markers", () => {
43 const checksums = parseChecksums(
44 [
45 `${"a".repeat(64)} t3-1.2.3-linux-x64.tar.gz`,
46 `${"B".repeat(64)} *t3-1.2.3-win32-x64.zip`,
47 "not a checksum line",
48 "",
49 ].join("\n"),
50 );
51 expect(checksums.get("t3-1.2.3-linux-x64.tar.gz")).toBe("a".repeat(64));
52 expect(checksums.get("t3-1.2.3-win32-x64.zip")).toBe("b".repeat(64));
53 expect(checksums.size).toBe(2);
54 });
55
56 it("treats only preview builds as archive-distributed", () => {
57 expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true);
58 expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false);
59 expect(isArchiveDistributedVersion("1.2.3")).toBe(false);
60 });
61
62 it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => {
63 expect(cliArchiveTarCommand("linux", {})).toBe("tar");
64 expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe(
65 "D:\\Win\\System32\\tar.exe",
66 );
67 expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe");
68 });
69});
packages/shared/src/cliRelease.ts created+76
......@@ -0,0 +1,76 @@
1/**
2 * Naming shared by the release workflow, the runtime installers, and
3 * install scripts for the per-platform CLI archives attached to GitHub
4 * Releases. Every consumer derives the same file names from a version and a
5 * platform key, so a rename here is a release-breaking change.
6 */
7
8const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code";
9export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS";
10/** Overrides the download origin for mirrors and air-gapped installs. */
11export const CLI_RELEASE_BASE_URL_ENV = "T3CODE_RELEASE_BASE_URL";
12
13/**
14 * The archives a release actually attaches. Kept in step with the
15 * `cli_archive` matrix flags in .github/workflows/release.yml: a key here
16 * without a build there produces download URLs that 404, and a build there
17 * without a key here is unreachable from every installer.
18 */
19const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const;
20export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number];
21
22export function cliArchivePlatformKey(
23 platform: NodeJS.Platform,
24 arch: string,
25): CliArchivePlatformKey | undefined {
26 const key = `${platform}-${arch}`;
27 return CLI_ARCHIVE_PLATFORM_KEYS.find((candidate) => candidate === key);
28}
29
30/**
31 * The tar to extract a release archive with. Windows ships bsdtar in
32 * System32, which reads both formats; a Git-for-Windows GNU tar earlier on
33 * PATH cannot open the zip, so the system copy is named by absolute path.
34 */
35export function cliArchiveTarCommand(
36 platform: NodeJS.Platform,
37 env: Readonly<Record<string, string | undefined>>,
38): string {
39 if (platform !== "win32") return "tar";
40 const systemRoot = env["SystemRoot"] ?? env["windir"] ?? "C:\\Windows";
41 return `${systemRoot}\\System32\\tar.exe`;
42}
43
44export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string {
45 return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`;
46}
47
48const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`;
49
50/** Directory that `releases/download/<tag>/<asset>` lives under. */
51export function cliReleaseDownloadBaseUrl(
52 version: string,
53 baseUrl: string | undefined = CLI_RELEASE_DEFAULT_BASE_URL,
54): string {
55 return `${(baseUrl?.trim() || CLI_RELEASE_DEFAULT_BASE_URL).replace(/\/+$/, "")}/v${version}`;
56}
57
58/**
59 * Parses the `sha256sum` style checksum file attached to each release.
60 * Lines are `<hex> <file>`; a leading `*` marks binary mode and is ignored.
61 */
62export function parseChecksums(text: string): ReadonlyMap<string, string> {
63 const checksums = new Map<string, string>();
64 for (const line of text.split(/\r?\n/)) {
65 const match = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(line.trim());
66 if (match?.[1] !== undefined && match[2] !== undefined) {
67 checksums.set(match[2], match[1].toLowerCase());
68 }
69 }
70 return checksums;
71}
72
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}