| author | |
| committer | |
| log | 8f90b380f676735f1e3026b5173c45c22d265583 |
| tree | a7d01b5a304e63f5d8d6bf921fadb3665355e82d |
| parent | eb8f6f42a5cad1a5d0bc51eaa16cff13a9c69f45 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ||
| 22 | function 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 | |
| 38 | if (-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 | ||
| 52 | if ((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 | ||
| 86 | New-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)) | |
| 91 | Write-Host "Installed t3 $version" | |
| 92 | Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" | |
| 93 | if (($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. | |
| 16 | set -eu | |
| 17 | ||
| 18 | repo="pingdotgg/t3code" | |
| 19 | base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" | |
| 20 | t3_home="${T3CODE_HOME:-$HOME/.t3}" | |
| 21 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" | |
| 22 | ||
| 23 | fail() { | |
| 24 | printf 't3 install: %s\n' "$1" >&2 | |
| 25 | exit 1 | |
| 26 | } | |
| 27 | ||
| 28 | fetch() { | |
| 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 | ||
| 38 | case "$(uname -s)" in | |
| 39 | Darwin) platform="darwin" ;; | |
| 40 | Linux) platform="linux" ;; | |
| 41 | *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; | |
| 42 | esac | |
| 43 | case "$(uname -m)" in | |
| 44 | arm64 | aarch64) arch="arm64" ;; | |
| 45 | x86_64 | amd64) arch="x64" ;; | |
| 46 | *) fail "unsupported architecture $(uname -m)" ;; | |
| 47 | esac | |
| 48 | command -v tar >/dev/null 2>&1 || fail "tar is required" | |
| 49 | if command -v sha256sum >/dev/null 2>&1; then | |
| 50 | checksum() { sha256sum "$1" | cut -d' ' -f1; } | |
| 51 | elif command -v shasum >/dev/null 2>&1; then | |
| 52 | checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } | |
| 53 | else | |
| 54 | fail "sha256sum or shasum is required" | |
| 55 | fi | |
| 56 | ||
| 57 | version="${T3CODE_VERSION:-}" | |
| 58 | if [ -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" | |
| 65 | fi | |
| 66 | ||
| 67 | stem="t3-${version}-${platform}-${arch}" | |
| 68 | archive="${stem}.tar.gz" | |
| 69 | versions_dir="${t3_home}/runtime/versions" | |
| 70 | target_dir="${versions_dir}/${version}" | |
| 71 | ||
| 72 | if [ -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" | |
| 74 | else | |
| 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 | |
| 96 | fi | |
| 97 | ||
| 98 | mkdir -p "$bin_dir" | |
| 99 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" | |
| 100 | printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" | |
| 101 | case ":${PATH}:" in | |
| 102 | *":${bin_dir}:"*) ;; | |
| 103 | *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; | |
| 104 | esac |
apps/marketing/src/pages/download.astro+9| ... | ... | @@ -132,6 +132,9 @@ const imageProps = { |
| 132 | 132 | <h2 class="platform-name">Terminal</h2> |
| 133 | 133 | </div> |
| 134 | 134 | <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> | |
| 135 | 138 | </section> |
| 136 | 139 | </div> |
| 137 | 140 | |
| ... | ... | @@ -432,6 +435,12 @@ const imageProps = { |
| 432 | 435 | letter-spacing: -0.01em; |
| 433 | 436 | } |
| 434 | 437 | |
| 438 | .cli-note { | |
| 439 | color: var(--fg-muted); | |
| 440 | font-size: 0.85rem; | |
| 441 | margin-top: 0.5rem; | |
| 442 | } | |
| 443 | ||
| 435 | 444 | .cli-line { |
| 436 | 445 | align-self: flex-start; |
| 437 | 446 | font-family: var(--font-mono); |
apps/marketing/vercel.ts+18| ... | ... | @@ -7,6 +7,24 @@ export const config: VercelConfig = { |
| 7 | 7 | installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", |
| 8 | 8 | buildCommand: "vp run --filter @t3tools/marketing build", |
| 9 | 9 | 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 | ], | |
| 10 | 28 | redirects: [ |
| 11 | 29 | { |
| 12 | 30 | source: "/app", |
apps/server/src/bin.ts+4| ... | ... | @@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts"; |
| 17 | 17 | import { projectCommand } from "./cli/project.ts"; |
| 18 | 18 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 19 | 19 | import { serviceCommand } from "./cli/service.ts"; |
| 20 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; | |
| 21 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; | |
| 20 | 22 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 21 | 23 | import { themeCommand } from "./cli/theme.ts"; |
| 22 | 24 | import { triageCommand } from "./cli/triage.ts"; |
| ... | ... | @@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 59 | 61 | authCommand, |
| 60 | 62 | projectCommand, |
| 61 | 63 | serviceCommand, |
| 64 | serviceLauncherCommand, | |
| 65 | claudeHistoryCommand, | |
| 62 | 66 | servicePreflightCommand, |
| 63 | 67 | themeCommand, |
| 64 | 68 | 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. | |
| 4 | import { runClaudeHistoryWorker } from "./claudeHistoryWorker.ts"; | |
| 5 | ||
| 6 | const [method, sessionId, rawOptions] = process.argv.slice(2); | |
| 7 | await runClaudeHistoryWorker(method, sessionId, rawOptions); |
apps/server/src/claudeHistoryWorker.ts+26-14| ... | ... | @@ -2,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk" |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | // 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. | |
| 6 | const [method, sessionId, rawOptions] = process.argv.slice(2); | |
| 7 | const 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. | |
| 11 | const decodeHistoryOptions = Schema.decodeSync( | |
| 8 | 12 | Schema.fromJsonString( |
| 9 | 13 | Schema.Struct({ |
| 10 | 14 | dir: Schema.optionalKey(Schema.String), |
| ... | ... | @@ -12,14 +16,22 @@ const options = Schema.decodeSync( |
| 12 | 16 | upToMessageId: Schema.optionalKey(Schema.String), |
| 13 | 17 | }), |
| 14 | 18 | ), |
| 15 | )(rawOptions ?? "{}"); | |
| 16 | if (!sessionId) throw new Error("Claude history session id is required."); | |
| 17 | const 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 | })(); | |
| 25 | process.stdout.write(JSON.stringify(result)); | |
| 19 | ); | |
| 20 | ||
| 21 | export 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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { Argument, Command } from "effect/unstable/cli"; | |
| 3 | ||
| 4 | import { 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 | */ | |
| 12 | export 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) { |
| 105 | 105 | Effect.sync(() => { |
| 106 | 106 | installOptions.push(options); |
| 107 | 107 | return { |
| 108 | nodePath: "/test/node", | |
| 108 | program: ["/test/node", "/test/service-launcher.mjs"], | |
| 109 | 109 | launcherPath: "/test/service-launcher.mjs", |
| 110 | 110 | baseDir: "/test/t3", |
| 111 | 111 | unitPath: serviceStatus.unitPath, |
apps/server/src/cli/service.ts+6-1| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Terminal from "effect/Terminal"; |
| 6 | 6 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 7 | import { FetchHttpClient } from "effect/unstable/http"; | |
| 7 | 8 | |
| 8 | 9 | import packageJson from "../../package.json" with { type: "json" }; |
| 9 | 10 | import * as BootService from "../cloud/bootService.ts"; |
| ... | ... | @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = |
| 17 | 18 | baseDir: config.baseDir, |
| 18 | 19 | logsDir: config.logsDir, |
| 19 | 20 | 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 | ); | |
| 21 | 26 | |
| 22 | 27 | export type ServiceReconcileResult = |
| 23 | 28 | | { |
apps/server/src/cli/serviceLauncher.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { Command } from "effect/unstable/cli"; | |
| 3 | ||
| 4 | import { 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 | */ | |
| 17 | export 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 { |
| 25 | 25 | |
| 26 | 26 | it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { |
| 27 | 27 | const unit = BootService.renderBootServiceUnit({ |
| 28 | nodePath: "/usr/bin/node", | |
| 28 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], | |
| 29 | 29 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", |
| 30 | 30 | baseDir: "/home/theo/.t3", |
| 31 | 31 | 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", |
| 37 | 37 | expect(unit).not.toContain("versions/1.2.3"); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | it("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 | ||
| 40 | 55 | it("survives the kernel OOM-killing a greedy agent child", () => { |
| 41 | 56 | const unit = BootService.renderBootServiceUnit({ |
| 42 | nodePath: "/usr/bin/node", | |
| 57 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], | |
| 43 | 58 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", |
| 44 | 59 | baseDir: "/home/theo/.t3", |
| 45 | 60 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", |
| ... | ... | @@ -50,7 +65,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => { |
| 50 | 65 | }); |
| 51 | 66 | |
| 52 | 67 | const macPlan = { |
| 53 | nodePath: "/opt/homebrew/bin/node", | |
| 68 | program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], | |
| 54 | 69 | launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", |
| 55 | 70 | baseDir: "/Users/theo/.t3", |
| 56 | 71 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", |
| ... | ... | @@ -116,7 +131,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 116 | 131 | const sourceLauncher = path.join(home, "service-launcher.mjs"); |
| 117 | 132 | const statePath = path.join(baseDir, "runtime", "service-state.json"); |
| 118 | 133 | 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"); | |
| 120 | 135 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); |
| 121 | 136 | yield* fs.writeFileString(runtime.entryPath, "export {};\n"); |
| 122 | 137 | yield* fs.writeFileString( |
apps/server/src/cloud/bootService.ts+44-13| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { |
| 2 | HostProcessArchitecture, | |
| 2 | 3 | HostProcessExecutablePath, |
| 3 | 4 | HostProcessPlatform, |
| 4 | 5 | HostProcessUserId, |
| ... | ... | @@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | 13 | import * as Layer from "effect/Layer"; |
| 13 | 14 | import * as Option from "effect/Option"; |
| 14 | 15 | import * as Path from "effect/Path"; |
| 16 | import { HttpClient } from "effect/unstable/http"; | |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | |
| 19 | import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; | |
| 20 | ||
| 17 | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 18 | 22 | import { |
| 19 | 23 | ensurePinnedRuntimeInstalled, |
| 24 | pinnedRuntimeCommand, | |
| 20 | 25 | pinnedRuntimePaths, |
| 21 | 26 | PinnedRuntimeInstallError, |
| 22 | 27 | } from "./pinnedRuntime.ts"; |
| ... | ... | @@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string { |
| 52 | 57 | } |
| 53 | 58 | |
| 54 | 59 | export 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>; | |
| 56 | 67 | readonly launcherPath: string; |
| 57 | 68 | readonly baseDir: string; |
| 58 | 69 | readonly logPath: string; |
| ... | ... | @@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 73 | 84 | "WorkingDirectory=%h", |
| 74 | 85 | `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, |
| 75 | 86 | `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, |
| 76 | `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.launcherPath)}`, | |
| 87 | `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, | |
| 77 | 88 | // Let the launcher mark an explicit stop before it signals the server. |
| 78 | 89 | // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. |
| 79 | 90 | "KillMode=mixed", |
| ... | ... | @@ -124,8 +135,7 @@ export function renderBootServicePlist( |
| 124 | 135 | ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, |
| 125 | 136 | ` <key>ProgramArguments</key>`, |
| 126 | 137 | ` <array>`, |
| 127 | ` <string>${escapeXmlText(plan.nodePath)}</string>`, | |
| 128 | ` <string>${escapeXmlText(plan.launcherPath)}</string>`, | |
| 138 | ...plan.program.map((argument) => ` <string>${escapeXmlText(argument)}</string>`), | |
| 129 | 139 | ` </array>`, |
| 130 | 140 | ` <key>EnvironmentVariables</key>`, |
| 131 | 141 | ` <dict>`, |
| ... | ... | @@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 505 | 515 | }) { |
| 506 | 516 | const hostExecPath = yield* HostProcessExecutablePath; |
| 507 | 517 | const platform = yield* HostProcessPlatform; |
| 518 | const arch = yield* HostProcessArchitecture; | |
| 508 | 519 | 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 | ); | |
| 509 | 526 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); |
| 510 | 527 | const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); |
| 511 | 528 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 544 | 561 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 545 | 562 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); |
| 546 | 563 | 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); | |
| 548 | 565 | const launcherSourcePath = |
| 549 | 566 | host.launcherSourcePath ?? |
| 550 | 567 | path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); |
| ... | ... | @@ -568,7 +585,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 568 | 585 | }), |
| 569 | 586 | ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 570 | 587 | const plan: BootServicePlan = { |
| 571 | nodePath: host.execPath, | |
| 588 | program: | |
| 589 | runtimePaths.layout === "archive" | |
| 590 | ? [runtimePaths.entryPath, "__service-launcher"] | |
| 591 | : [host.execPath, launcherPath], | |
| 572 | 592 | launcherPath, |
| 573 | 593 | baseDir: input.baseDir, |
| 574 | 594 | logPath, |
| ... | ... | @@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 708 | 728 | fs, |
| 709 | 729 | path, |
| 710 | 730 | runner, |
| 731 | httpClient, | |
| 732 | platform, | |
| 733 | arch, | |
| 734 | releaseBaseUrl, | |
| 711 | 735 | validate: (runtime) => |
| 712 | 736 | runner |
| 713 | 737 | .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"], | |
| 716 | 740 | timeout: Duration.seconds(30), |
| 717 | 741 | }) |
| 718 | 742 | .pipe( |
| ... | ... | @@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 750 | 774 | : new BootServiceInstallError({ cause: error }), |
| 751 | 775 | ), |
| 752 | 776 | ); |
| 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 }))); | |
| 756 | 785 | |
| 757 | 786 | const installed = yield* fs |
| 758 | 787 | .exists(unitPath) |
| ... | ... | @@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 786 | 815 | yield* fs |
| 787 | 816 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 788 | 817 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 789 | yield* writeDurably(launcherPath, launcherSource); | |
| 818 | if (launcherSource !== undefined) { | |
| 819 | yield* writeDurably(launcherPath, launcherSource); | |
| 820 | } | |
| 790 | 821 | yield* writeDurably( |
| 791 | 822 | statePath, |
| 792 | 823 | // @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: { |
| 836 | 867 | const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = |
| 837 | 868 | yield* Effect.all([ |
| 838 | 869 | fs.readFileString(unitPath), |
| 839 | fs.exists(launcherPath), | |
| 870 | runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), | |
| 840 | 871 | fs.exists(runtimePaths.entryPath), |
| 841 | 872 | fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), |
| 842 | 873 | 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"; |
| 6 | 6 | import * as Fiber from "effect/Fiber"; |
| 7 | 7 | import * as Path from "effect/Path"; |
| 8 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | |
| 9 | 10 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 10 | 11 | |
| 11 | 12 | import * as ProcessRunner from "../processRunner.ts"; |
| 12 | 13 | import { |
| 13 | 14 | ensurePinnedRuntimeInstalled, |
| 15 | pinnedRuntimeCommand, | |
| 14 | 16 | pinnedRuntimePaths, |
| 15 | 17 | PinnedRuntimeInstallError, |
| 16 | 18 | } from "./pinnedRuntime.ts"; |
| ... | ... | @@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 51 | 53 | version: "1.2.3", |
| 52 | 54 | fs, |
| 53 | 55 | path, |
| 56 | platform: "linux", | |
| 57 | arch: "x64", | |
| 54 | 58 | runner: ProcessRunner.ProcessRunner.of({ |
| 55 | 59 | run: (input) => { |
| 56 | 60 | commands.push(input); |
| ... | ... | @@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 95 | 99 | version: "1.2.3", |
| 96 | 100 | fs, |
| 97 | 101 | path, |
| 102 | platform: "linux", | |
| 103 | arch: "x64", | |
| 98 | 104 | runner: ProcessRunner.ProcessRunner.of({ |
| 99 | 105 | run: (input) => { |
| 100 | 106 | commands.push(input.command); |
| ... | ... | @@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 122 | 128 | const fs = yield* FileSystem.FileSystem; |
| 123 | 129 | const path = yield* Path.Path; |
| 124 | 130 | 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"); | |
| 126 | 132 | let validatedDirectory = ""; |
| 127 | 133 | |
| 128 | 134 | const installed = yield* ensurePinnedRuntimeInstalled({ |
| ... | ... | @@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 130 | 136 | version: "1.2.3", |
| 131 | 137 | fs, |
| 132 | 138 | path, |
| 139 | platform: "linux", | |
| 140 | arch: "x64", | |
| 133 | 141 | runner: successfulRunner(fs, path), |
| 134 | 142 | validate: (staging) => |
| 135 | 143 | Effect.gen(function* () { |
| ... | ... | @@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 151 | 159 | const fs = yield* FileSystem.FileSystem; |
| 152 | 160 | const path = yield* Path.Path; |
| 153 | 161 | 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"); | |
| 155 | 163 | |
| 156 | 164 | yield* ensurePinnedRuntimeInstalled({ |
| 157 | 165 | baseDir, |
| 158 | 166 | version: "1.2.3", |
| 159 | 167 | fs, |
| 160 | 168 | path, |
| 169 | platform: "linux", | |
| 170 | arch: "x64", | |
| 161 | 171 | runner: successfulRunner(fs, path), |
| 162 | 172 | validate: () => |
| 163 | 173 | Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), |
| ... | ... | @@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 178 | 188 | const fs = yield* FileSystem.FileSystem; |
| 179 | 189 | const path = yield* Path.Path; |
| 180 | 190 | 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"); | |
| 182 | 192 | yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); |
| 183 | 193 | yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); |
| 184 | 194 | |
| ... | ... | @@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 187 | 197 | version: "1.2.3", |
| 188 | 198 | fs, |
| 189 | 199 | path, |
| 200 | platform: "linux", | |
| 201 | arch: "x64", | |
| 190 | 202 | runner: successfulRunner(fs, path), |
| 191 | 203 | validate: () => Effect.void, |
| 192 | 204 | }); |
| ... | ... | @@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 201 | 213 | const fs = yield* FileSystem.FileSystem; |
| 202 | 214 | const path = yield* Path.Path; |
| 203 | 215 | 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"); | |
| 205 | 217 | yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); |
| 206 | 218 | yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); |
| 207 | 219 | yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); |
| ... | ... | @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 212 | 224 | version: "1.2.3", |
| 213 | 225 | fs, |
| 214 | 226 | path, |
| 227 | platform: "linux", | |
| 228 | arch: "x64", | |
| 215 | 229 | runner: successfulRunner(fs, path), |
| 216 | 230 | validate: (paths) => |
| 217 | 231 | Effect.gen(function* () { |
| ... | ... | @@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 242 | 256 | version: "1.2.3", |
| 243 | 257 | fs, |
| 244 | 258 | path, |
| 259 | platform: "linux", | |
| 260 | arch: "x64", | |
| 245 | 261 | runner, |
| 246 | 262 | validate: () => Effect.void, |
| 247 | 263 | }).pipe(Effect.forkScoped); |
| ... | ... | @@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 252 | 268 | assert.deepEqual(yield* fs.readDirectory(versionsDir), []); |
| 253 | 269 | }), |
| 254 | 270 | ); |
| 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 | }); | |
| 255 | 390 | }); |
apps/server/src/cloud/pinnedRuntime.ts+224-50| ... | ... | @@ -1,44 +1,91 @@ |
| 1 | 1 | import * as Duration from "effect/Duration"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Encoding from "effect/Encoding"; | |
| 3 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 5 | import * as Path from "effect/Path"; |
| 5 | 6 | import * as PlatformError from "effect/PlatformError"; |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | import * as Option from "effect/Option"; |
| 8 | 9 | import * as Semaphore from "effect/Semaphore"; |
| 10 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | |
| 11 | ||
| 12 | import { | |
| 13 | CLI_RELEASE_CHECKSUMS_FILE, | |
| 14 | cliArchiveFileName, | |
| 15 | cliArchivePlatformKey, | |
| 16 | cliArchiveTarCommand, | |
| 17 | cliReleaseDownloadBaseUrl, | |
| 18 | isArchiveDistributedVersion, | |
| 19 | parseChecksums, | |
| 20 | } from "@t3tools/shared/cliRelease"; | |
| 9 | 21 | |
| 10 | 22 | import * as ProcessRunner from "../processRunner.ts"; |
| 11 | 23 | |
| 12 | 24 | /** |
| 13 | * A pinned runtime is an exact `t3@<version>` npm-installed into | |
| 25 | * A pinned runtime is an exact `t3@<version>` installed into | |
| 14 | 26 | * <baseDir>/runtime/versions/<version>. The boot service points its unit or |
| 15 | 27 | * launch agent here, and server self-update installs the target version here before |
| 16 | 28 | * switching over, never `npx t3`, whose cache is ephemeral and whose |
| 17 | 29 | * registry fetch at boot would make startup depend on the network. |
| 30 | * | |
| 31 | * Two layouts exist. npm-distributed versions are `npm install`ed and run as | |
| 32 | * `<node> node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the | |
| 33 | * self-contained release archive unpacked in place and run as `./t3`, which | |
| 34 | * needs neither Node nor npm on the machine. The layout is decided by the | |
| 35 | * version string alone so every consumer agrees without probing the disk. | |
| 18 | 36 | */ |
| 19 | 37 | |
| 20 | 38 | const PINNED_RUNTIME_DIR = "runtime"; |
| 21 | 39 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); |
| 40 | const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; | |
| 22 | 41 | // Boot-service setup and remote update can construct separate layers. Serialize |
| 23 | 42 | // the complete install transaction across every caller in this process. |
| 24 | 43 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); |
| 25 | 44 | |
| 45 | export type PinnedRuntimeLayout = "npm" | "archive"; | |
| 46 | ||
| 26 | 47 | export interface PinnedRuntimePaths { |
| 48 | readonly layout: PinnedRuntimeLayout; | |
| 27 | 49 | 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 | */ | |
| 28 | 54 | readonly entryPath: string; |
| 29 | 55 | readonly sentinelPath: string; |
| 30 | 56 | } |
| 31 | 57 | |
| 58 | /** The exact command that runs a pinned runtime, given the Node hosting the caller. */ | |
| 59 | export 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 | ||
| 32 | 68 | export function pinnedRuntimePaths( |
| 33 | 69 | path: Path.Path, |
| 34 | 70 | baseDir: string, |
| 35 | 71 | version: string, |
| 72 | platform: NodeJS.Platform, | |
| 36 | 73 | ): PinnedRuntimePaths { |
| 37 | 74 | 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 | } | |
| 38 | 84 | return { |
| 85 | layout: "npm", | |
| 39 | 86 | versionDir, |
| 40 | 87 | entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), |
| 41 | sentinelPath: path.join(versionDir, ".install-complete"), | |
| 88 | sentinelPath, | |
| 42 | 89 | }; |
| 43 | 90 | } |
| 44 | 91 | |
| ... | ... | @@ -74,9 +121,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne |
| 74 | 121 | /** |
| 75 | 122 | * Installs `t3@<version>` into the pinned runtime directory unless a complete |
| 76 | 123 | * 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. | |
| 80 | 128 | */ |
| 81 | 129 | interface PinnedRuntimeInstallInput { |
| 82 | 130 | readonly baseDir: string; |
| ... | ... | @@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput { |
| 87 | 135 | readonly validate: ( |
| 88 | 136 | paths: PinnedRuntimePaths, |
| 89 | 137 | ) => 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; | |
| 90 | 143 | } |
| 91 | 144 | |
| 145 | const 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 | ||
| 193 | const 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 | */ | |
| 218 | const 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 | ||
| 92 | 302 | const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( |
| 93 | 303 | input: PinnedRuntimeInstallInput, |
| 94 | 304 | ) { |
| 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); | |
| 97 | 307 | const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ |
| 98 | 308 | fs.exists(paths.versionDir), |
| 99 | 309 | fs.exists(paths.entryPath), |
| ... | ... | @@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 146 | 356 | ), |
| 147 | 357 | ); |
| 148 | 358 | const stagingPaths: PinnedRuntimePaths = { |
| 359 | layout: paths.layout, | |
| 149 | 360 | 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)), | |
| 151 | 362 | sentinelPath: input.path.join(stagingDir, ".install-complete"), |
| 152 | 363 | }; |
| 153 | 364 | |
| 154 | 365 | 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 | } | |
| 197 | 371 | |
| 198 | 372 | yield* input.validate(stagingPaths); |
| 199 | 373 | yield* fs |
apps/server/src/cloud/selfUpdate.ts+25-3| ... | ... | @@ -6,22 +6,32 @@ import { |
| 6 | 6 | type ServerSelfUpdateResult, |
| 7 | 7 | type ThreadId, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; | |
| 9 | import { | |
| 10 | HostProcessArchitecture, | |
| 11 | HostProcessExecutablePath, | |
| 12 | HostProcessPlatform, | |
| 13 | } from "@t3tools/shared/hostProcess"; | |
| 10 | 14 | import * as Cause from "effect/Cause"; |
| 15 | import * as Config from "effect/Config"; | |
| 11 | 16 | import * as Context from "effect/Context"; |
| 12 | 17 | import * as Duration from "effect/Duration"; |
| 13 | 18 | import * as Effect from "effect/Effect"; |
| 14 | 19 | import * as HashSet from "effect/HashSet"; |
| 20 | import * as Option from "effect/Option"; | |
| 15 | 21 | import * as Ref from "effect/Ref"; |
| 16 | 22 | import * as FileSystem from "effect/FileSystem"; |
| 17 | 23 | import * as Layer from "effect/Layer"; |
| 18 | 24 | import * as Path from "effect/Path"; |
| 25 | import { HttpClient } from "effect/unstable/http"; | |
| 26 | ||
| 27 | import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; | |
| 19 | 28 | |
| 20 | 29 | import * as ServerConfig from "../config.ts"; |
| 21 | 30 | import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; |
| 22 | 31 | import * as ProcessRunner from "../processRunner.ts"; |
| 23 | 32 | import { |
| 24 | 33 | ensurePinnedRuntimeInstalled, |
| 34 | pinnedRuntimeCommand, | |
| 25 | 35 | PinnedRuntimeInstallError, |
| 26 | 36 | PinnedRuntimePreflightBlockedError, |
| 27 | 37 | } from "./pinnedRuntime.ts"; |
| ... | ... | @@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 171 | 181 | const fs = yield* FileSystem.FileSystem; |
| 172 | 182 | const path = yield* Path.Path; |
| 173 | 183 | 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 | ); | |
| 174 | 192 | const inFlight = yield* Ref.make(false); |
| 175 | 193 | |
| 176 | 194 | const capability: ServerSelfUpdateCapability | null = |
| ... | ... | @@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 216 | 234 | fs, |
| 217 | 235 | path, |
| 218 | 236 | runner, |
| 237 | httpClient, | |
| 238 | platform, | |
| 239 | arch, | |
| 240 | releaseBaseUrl, | |
| 219 | 241 | validate: (runtime) => |
| 220 | 242 | runner |
| 221 | 243 | .run({ |
| 222 | command: execPath, | |
| 244 | command: pinnedRuntimeCommand(runtime, execPath).command, | |
| 223 | 245 | args: [ |
| 224 | runtime.entryPath, | |
| 246 | ...pinnedRuntimeCommand(runtime, execPath).args, | |
| 225 | 247 | "__service-preflight", |
| 226 | 248 | "--database-path", |
| 227 | 249 | serverConfig.dbPath, |
apps/server/src/provider/Layers/ClaudeAdapter.ts+20-11| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 1 | 2 | /** |
| 2 | 3 | * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. |
| 3 | 4 | * |
| ... | ... | @@ -6,6 +7,8 @@ |
| 6 | 7 | * |
| 7 | 8 | * @module ClaudeAdapterLive |
| 8 | 9 | */ |
| 10 | import * as NodeSea from "node:sea"; | |
| 11 | ||
| 9 | 12 | import { |
| 10 | 13 | type CanUseTool, |
| 11 | 14 | query, |
| ... | ... | @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5128 | 5131 | detail: "Claude session id is unavailable.", |
| 5129 | 5132 | }); |
| 5130 | 5133 | } |
| 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 | ]; | |
| 5141 | 5150 | const runScopedHistoryCommand = async ( |
| 5142 | 5151 | method: "getSessionMessages" | "forkSession", |
| 5143 | 5152 | args: object, |
| ... | ... | @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5150 | 5159 | process.execPath, |
| 5151 | 5160 | ChildProcess.make( |
| 5152 | 5161 | process.execPath, |
| 5153 | [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], | |
| 5162 | [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)], | |
| 5154 | 5163 | { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, |
| 5155 | 5164 | ), |
| 5156 | 5165 | ).pipe( |
apps/server/src/service-launcher.ts+20-1| ... | ... | @@ -1 +1,20 @@ |
| 1 | import "./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. | |
| 5 | import { isEntrypoint } from "./entrypoint.ts"; | |
| 6 | import { main } from "./serviceLauncher.ts"; | |
| 7 | ||
| 8 | if ( | |
| 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"; |
| 7 | 7 | import * as NodeFS from "node:fs"; |
| 8 | 8 | import * as NodeFSP from "node:fs/promises"; |
| 9 | 9 | import * as NodePath from "node:path"; |
| 10 | import * as NodeSea from "node:sea"; | |
| 10 | 11 | |
| 11 | 12 | import type { |
| 12 | 13 | PendingServiceUpdate, |
| ... | ... | @@ -26,7 +27,6 @@ import { |
| 26 | 27 | SERVICE_STATE_FILE, |
| 27 | 28 | SERVICE_STOP_MARKER_FILE, |
| 28 | 29 | } from "./cloud/serviceProtocol.ts"; |
| 29 | import { isEntrypoint } from "./entrypoint.ts"; | |
| 30 | 30 | |
| 31 | 31 | const HANDOFF_DELAY_MS = 2_000; |
| 32 | 32 | const PREPARED_TIMEOUT_MS = 120_000; |
| ... | ... | @@ -41,15 +41,38 @@ interface ManagedChild { |
| 41 | 41 | readonly process: NodeChildProcess.ChildProcess; |
| 42 | 42 | } |
| 43 | 43 | |
| 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. | |
| 44 | 48 | const runtimePaths = (baseDir: string, version: string) => { |
| 45 | 49 | const versionDir = NodePath.join(baseDir, "runtime", "versions", version); |
| 50 | const archive = /-preview\.\d{8}\.\d+$/.test(version); | |
| 51 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. | |
| 52 | const executableName = process.platform === "win32" ? "t3.exe" : "t3"; | |
| 46 | 53 | return { |
| 47 | 54 | 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"), | |
| 49 | 58 | sentinelPath: NodePath.join(versionDir, ".install-complete"), |
| 59 | executable: archive, | |
| 50 | 60 | }; |
| 51 | 61 | }; |
| 52 | 62 | |
| 63 | const 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. | |
| 71 | const launcherIsExecutable = NodeSea.isSea(); | |
| 72 | ||
| 73 | const canLaunchRuntime = (paths: ReturnType<typeof runtimePaths>) => | |
| 74 | paths.executable || !launcherIsExecutable; | |
| 75 | ||
| 53 | 76 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ |
| 54 | 77 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; |
| 55 | 78 | const RESTORE_MARKER = ".restore-pending"; |
| ... | ... | @@ -402,7 +425,8 @@ export class Launcher { |
| 402 | 425 | childVersion: version, |
| 403 | 426 | ...(update === undefined ? {} : { update }), |
| 404 | 427 | }; |
| 405 | const child = NodeChildProcess.spawn(process.execPath, [paths.entryPath, "serve"], { | |
| 428 | const spawnArguments = runtimeSpawnArguments(paths); | |
| 429 | const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { | |
| 406 | 430 | env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, |
| 407 | 431 | stdio: ["inherit", "inherit", "inherit", "ipc"], |
| 408 | 432 | }); |
| ... | ... | @@ -481,6 +505,12 @@ export class Launcher { |
| 481 | 505 | await reject("The requested database path is not absolute."); |
| 482 | 506 | return; |
| 483 | 507 | } |
| 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 | } | |
| 484 | 514 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { |
| 485 | 515 | await reject("The requested target runtime is missing or incomplete."); |
| 486 | 516 | return; |
| ... | ... | @@ -602,7 +632,7 @@ export class Launcher { |
| 602 | 632 | } |
| 603 | 633 | } |
| 604 | 634 | |
| 605 | async function main(): Promise<void> { | |
| 635 | export async function main(): Promise<void> { | |
| 606 | 636 | const baseDir = process.env.T3CODE_HOME?.trim(); |
| 607 | 637 | if (baseDir === undefined || baseDir === "") { |
| 608 | 638 | throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); |
| ... | ... | @@ -611,17 +641,3 @@ async function main(): Promise<void> { |
| 611 | 641 | const state = await readServiceState(statePath); |
| 612 | 642 | await new Launcher(baseDir, state).run(); |
| 613 | 643 | } |
| 614 | ||
| 615 | if ( | |
| 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( |
| 77 | 77 | pack: { |
| 78 | 78 | // The executable embeds one entry; the history worker becomes a hidden |
| 79 | 79 | // 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"], | |
| 81 | 81 | outDir: packExecutable ? "dist-exe" : "dist", |
| 82 | 82 | sourcemap: !packExecutable, |
| 83 | 83 | 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 |
| 25 | 25 | update already in progress. To match a remote client's version, follow |
| 26 | 26 | [Updating T3 Code](./updating.md). |
| 27 | 27 | |
| 28 | Preview builds (`t3@preview`) install as a self-contained download from the | |
| 29 | T3 Code GitHub release instead of through npm, so the machine running the | |
| 30 | service does not need Node.js or npm once the CLI is on it. To get the CLI | |
| 31 | onto a machine without Node, run the install script: | |
| 32 | ||
| 33 | ```sh | |
| 34 | curl -fsSL https://t3.codes/install.sh | sh | |
| 35 | ``` | |
| 36 | ||
| 37 | On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. | |
| 38 | ||
| 39 | It places `t3` in `~/.local/bin` and reuses the same download when you later | |
| 40 | run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or | |
| 41 | `T3CODE_RELEASE_BASE_URL` to download from a mirror. | |
| 42 | ||
| 28 | 43 | ## Platform support |
| 29 | 44 | |
| 30 | 45 | Linux needs systemd user services. Setup enables lingering so T3 Code starts at |
knip.jsonc+1-1| ... | ... | @@ -21,7 +21,7 @@ |
| 21 | 21 | "entry": [ |
| 22 | 22 | "src/bin.ts!", |
| 23 | 23 | "src/service-launcher.ts!", |
| 24 | "src/claudeHistoryWorker.ts!", | |
| 24 | "src/claude-history-worker.ts!", | |
| 25 | 25 | "scripts/cli.ts", |
| 26 | 26 | "src/provider/testFixtures/*.mjs", |
| 27 | 27 | ], |
packages/shared/package.json+4| ... | ... | @@ -322,6 +322,10 @@ |
| 322 | 322 | "./dateTime": { |
| 323 | 323 | "types": "./src/dateTime.ts", |
| 324 | 324 | "import": "./src/dateTime.ts" |
| 325 | }, | |
| 326 | "./cliRelease": { | |
| 327 | "types": "./src/cliRelease.ts", | |
| 328 | "import": "./src/cliRelease.ts" | |
| 325 | 329 | } |
| 326 | 330 | }, |
| 327 | 331 | "scripts": { |
packages/shared/src/cliRelease.test.ts created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | cliArchiveFileName, | |
| 5 | cliArchivePlatformKey, | |
| 6 | cliArchiveTarCommand, | |
| 7 | cliReleaseDownloadBaseUrl, | |
| 8 | isArchiveDistributedVersion, | |
| 9 | parseChecksums, | |
| 10 | } from "./cliRelease.ts"; | |
| 11 | ||
| 12 | describe("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 | ||
| 8 | const CLI_RELEASE_REPOSITORY = "pingdotgg/t3code"; | |
| 9 | export const CLI_RELEASE_CHECKSUMS_FILE = "SHA256SUMS"; | |
| 10 | /** Overrides the download origin for mirrors and air-gapped installs. */ | |
| 11 | export 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 | */ | |
| 19 | const CLI_ARCHIVE_PLATFORM_KEYS = ["darwin-arm64", "linux-x64", "win32-x64"] as const; | |
| 20 | export type CliArchivePlatformKey = (typeof CLI_ARCHIVE_PLATFORM_KEYS)[number]; | |
| 21 | ||
| 22 | export 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 | */ | |
| 35 | export 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 | ||
| 44 | export function cliArchiveFileName(version: string, platformKey: CliArchivePlatformKey): string { | |
| 45 | return `t3-${version}-${platformKey}.${platformKey.startsWith("win32") ? "zip" : "tar.gz"}`; | |
| 46 | } | |
| 47 | ||
| 48 | const CLI_RELEASE_DEFAULT_BASE_URL = `https://github.com/${CLI_RELEASE_REPOSITORY}/releases/download`; | |
| 49 | ||
| 50 | /** Directory that `releases/download/<tag>/<asset>` lives under. */ | |
| 51 | export 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 | */ | |
| 62 | export 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. */ | |
| 74 | export function isArchiveDistributedVersion(version: string): boolean { | |
| 75 | return /-preview\.\d{8}\.\d+$/.test(version); | |
| 76 | } |