| 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,6 +132,9 @@ const imageProps = { |
| 132 | <h2 class="platform-name">Terminal</h2> | 132 | <h2 class="platform-name">Terminal</h2> |
| 133 | </div> | 133 | </div> |
| 134 | <code class="cli-line">npx t3@nightly</code> | 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 | </section> | 138 | </section> |
| 136 | </div> | 139 | </div> |
| 137 | 140 | ||
| ... | @@ -432,6 +435,12 @@ const imageProps = { | ... | @@ -432,6 +435,12 @@ const imageProps = { |
| 432 | letter-spacing: -0.01em; | 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 | .cli-line { | 444 | .cli-line { |
| 436 | align-self: flex-start; | 445 | align-self: flex-start; |
| 437 | font-family: var(--font-mono); | 446 | font-family: var(--font-mono); |
apps/marketing/vercel.ts+18| ... | @@ -7,6 +7,24 @@ export const config: VercelConfig = { | ... | @@ -7,6 +7,24 @@ export const config: VercelConfig = { |
| 7 | installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", | 7 | installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", |
| 8 | buildCommand: "vp run --filter @t3tools/marketing build", | 8 | buildCommand: "vp run --filter @t3tools/marketing build", |
| 9 | outputDirectory: "dist", | 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 | redirects: [ | 28 | redirects: [ |
| 11 | { | 29 | { |
| 12 | source: "/app", | 30 | source: "/app", |
apps/server/src/bin.ts+4| ... | @@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts"; | ... | @@ -17,6 +17,8 @@ import { isEntrypoint } from "./entrypoint.ts"; |
| 17 | import { projectCommand } from "./cli/project.ts"; | 17 | import { projectCommand } from "./cli/project.ts"; |
| 18 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; | 18 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; |
| 19 | import { serviceCommand } from "./cli/service.ts"; | 19 | import { serviceCommand } from "./cli/service.ts"; |
| 20 | import { claudeHistoryCommand } from "./cli/claudeHistory.ts"; | ||
| 21 | import { serviceLauncherCommand } from "./cli/serviceLauncher.ts"; | ||
| 20 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; | 22 | import { servicePreflightCommand } from "./cli/servicePreflight.ts"; |
| 21 | import { themeCommand } from "./cli/theme.ts"; | 23 | import { themeCommand } from "./cli/theme.ts"; |
| 22 | import { triageCommand } from "./cli/triage.ts"; | 24 | import { triageCommand } from "./cli/triage.ts"; |
| ... | @@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => | ... | @@ -59,6 +61,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => |
| 59 | authCommand, | 61 | authCommand, |
| 60 | projectCommand, | 62 | projectCommand, |
| 61 | serviceCommand, | 63 | serviceCommand, |
| 64 | serviceLauncherCommand, | ||
| 65 | claudeHistoryCommand, | ||
| 62 | servicePreflightCommand, | 66 | servicePreflightCommand, |
| 63 | themeCommand, | 67 | themeCommand, |
| 64 | triageCommand, | 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,9 +2,13 @@ import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk" |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | // A separate process gives SDK history helpers the provider's environment without | 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. | 5 | // mutating the server's environment. `claude-history-worker.ts` is the |
| 6 | const [method, sessionId, rawOptions] = process.argv.slice(2); | 6 | // standalone entry bundled beside the server for npm installs; the |
| 7 | const options = Schema.decodeSync( | 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 | Schema.fromJsonString( | 12 | Schema.fromJsonString( |
| 9 | Schema.Struct({ | 13 | Schema.Struct({ |
| 10 | dir: Schema.optionalKey(Schema.String), | 14 | dir: Schema.optionalKey(Schema.String), |
| ... | @@ -12,14 +16,22 @@ const options = Schema.decodeSync( | ... | @@ -12,14 +16,22 @@ const options = Schema.decodeSync( |
| 12 | upToMessageId: Schema.optionalKey(Schema.String), | 16 | upToMessageId: Schema.optionalKey(Schema.String), |
| 13 | }), | 17 | }), |
| 14 | ), | 18 | ), |
| 15 | )(rawOptions ?? "{}"); | 19 | ); |
| 16 | if (!sessionId) throw new Error("Claude history session id is required."); | 20 | |
| 17 | const result = | 21 | export async function runClaudeHistoryWorker( |
| 18 | method === "getSessionMessages" | 22 | method: string | undefined, |
| 19 | ? await getSessionMessages(sessionId, options) | 23 | sessionId: string | undefined, |
| 20 | : method === "forkSession" | 24 | rawOptions: string | undefined, |
| 21 | ? await forkSession(sessionId, options) | 25 | ): Promise<void> { |
| 22 | : (() => { | 26 | const options = decodeHistoryOptions(rawOptions ?? "{}"); |
| 23 | throw new Error("Unknown Claude history operation."); | 27 | if (!sessionId) throw new Error("Claude history session id is required."); |
| 24 | })(); | 28 | const result = |
| 25 | process.stdout.write(JSON.stringify(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,7 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { |
| 105 | Effect.sync(() => { | 105 | Effect.sync(() => { |
| 106 | installOptions.push(options); | 106 | installOptions.push(options); |
| 107 | return { | 107 | return { |
| 108 | nodePath: "/test/node", | 108 | program: ["/test/node", "/test/service-launcher.mjs"], |
| 109 | launcherPath: "/test/service-launcher.mjs", | 109 | launcherPath: "/test/service-launcher.mjs", |
| 110 | baseDir: "/test/t3", | 110 | baseDir: "/test/t3", |
| 111 | unitPath: serviceStatus.unitPath, | 111 | unitPath: serviceStatus.unitPath, |
apps/server/src/cli/service.ts+6-1| ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Terminal from "effect/Terminal"; | 5 | import * as Terminal from "effect/Terminal"; |
| 6 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; | 6 | import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; |
| 7 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 7 | 8 | ||
| 8 | import packageJson from "../../package.json" with { type: "json" }; | 9 | import packageJson from "../../package.json" with { type: "json" }; |
| 9 | import * as BootService from "../cloud/bootService.ts"; | 10 | import * as BootService from "../cloud/bootService.ts"; |
| ... | @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = | ... | @@ -17,7 +18,11 @@ export const bootServiceLayer = (config: ServerConfig.ServerConfig["Service"]) = |
| 17 | baseDir: config.baseDir, | 18 | baseDir: config.baseDir, |
| 18 | logsDir: config.logsDir, | 19 | logsDir: config.logsDir, |
| 19 | cliVersion: packageJson.version, | 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 | export type ServiceReconcileResult = | 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,7 +25,7 @@ import { |
| 25 | 25 | ||
| 26 | it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { | 26 | it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { |
| 27 | const unit = BootService.renderBootServiceUnit({ | 27 | const unit = BootService.renderBootServiceUnit({ |
| 28 | nodePath: "/usr/bin/node", | 28 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], |
| 29 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", | 29 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", |
| 30 | baseDir: "/home/theo/.t3", | 30 | baseDir: "/home/theo/.t3", |
| 31 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | 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,9 +37,24 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server", |
| 37 | expect(unit).not.toContain("versions/1.2.3"); | 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 | it("survives the kernel OOM-killing a greedy agent child", () => { | 55 | it("survives the kernel OOM-killing a greedy agent child", () => { |
| 41 | const unit = BootService.renderBootServiceUnit({ | 56 | const unit = BootService.renderBootServiceUnit({ |
| 42 | nodePath: "/usr/bin/node", | 57 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], |
| 43 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", | 58 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", |
| 44 | baseDir: "/home/theo/.t3", | 59 | baseDir: "/home/theo/.t3", |
| 45 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | 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,7 +65,7 @@ it("survives the kernel OOM-killing a greedy agent child", () => { |
| 50 | }); | 65 | }); |
| 51 | 66 | ||
| 52 | const macPlan = { | 67 | const macPlan = { |
| 53 | nodePath: "/opt/homebrew/bin/node", | 68 | program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], |
| 54 | launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", | 69 | launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", |
| 55 | baseDir: "/Users/theo/.t3", | 70 | baseDir: "/Users/theo/.t3", |
| 56 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", | 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,7 +131,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 116 | const sourceLauncher = path.join(home, "service-launcher.mjs"); | 131 | const sourceLauncher = path.join(home, "service-launcher.mjs"); |
| 117 | const statePath = path.join(baseDir, "runtime", "service-state.json"); | 132 | const statePath = path.join(baseDir, "runtime", "service-state.json"); |
| 118 | yield* fs.writeFileString(sourceLauncher, "export {};\n"); | 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 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); | 135 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); |
| 121 | yield* fs.writeFileString(runtime.entryPath, "export {};\n"); | 136 | yield* fs.writeFileString(runtime.entryPath, "export {};\n"); |
| 122 | yield* fs.writeFileString( | 137 | yield* fs.writeFileString( |
apps/server/src/cloud/bootService.ts+44-13| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | HostProcessArchitecture, | ||
| 2 | HostProcessExecutablePath, | 3 | HostProcessExecutablePath, |
| 3 | HostProcessPlatform, | 4 | HostProcessPlatform, |
| 4 | HostProcessUserId, | 5 | HostProcessUserId, |
| ... | @@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -12,11 +13,15 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 14 | import * as Option from "effect/Option"; |
| 14 | import * as Path from "effect/Path"; | 15 | import * as Path from "effect/Path"; |
| 16 | import { HttpClient } from "effect/unstable/http"; | ||
| 15 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | ||
| 19 | import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; | ||
| 20 | |||
| 17 | import * as ProcessRunner from "../processRunner.ts"; | 21 | import * as ProcessRunner from "../processRunner.ts"; |
| 18 | import { | 22 | import { |
| 19 | ensurePinnedRuntimeInstalled, | 23 | ensurePinnedRuntimeInstalled, |
| 24 | pinnedRuntimeCommand, | ||
| 20 | pinnedRuntimePaths, | 25 | pinnedRuntimePaths, |
| 21 | PinnedRuntimeInstallError, | 26 | PinnedRuntimeInstallError, |
| 22 | } from "./pinnedRuntime.ts"; | 27 | } from "./pinnedRuntime.ts"; |
| ... | @@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string { | ... | @@ -52,7 +57,13 @@ function quoteSystemdValue(value: string): string { |
| 52 | } | 57 | } |
| 53 | 58 | ||
| 54 | export interface BootServicePlan { | 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 | readonly launcherPath: string; | 67 | readonly launcherPath: string; |
| 57 | readonly baseDir: string; | 68 | readonly baseDir: string; |
| 58 | readonly logPath: string; | 69 | readonly logPath: string; |
| ... | @@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { | ... | @@ -73,7 +84,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { |
| 73 | "WorkingDirectory=%h", | 84 | "WorkingDirectory=%h", |
| 74 | `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, | 85 | `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, |
| 75 | `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, | 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 | // Let the launcher mark an explicit stop before it signals the server. | 88 | // Let the launcher mark an explicit stop before it signals the server. |
| 78 | // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. | 89 | // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. |
| 79 | "KillMode=mixed", | 90 | "KillMode=mixed", |
| ... | @@ -124,8 +135,7 @@ export function renderBootServicePlist( | ... | @@ -124,8 +135,7 @@ export function renderBootServicePlist( |
| 124 | ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, | 135 | ` <string>${BOOT_SERVICE_LAUNCHD_LABEL}</string>`, |
| 125 | ` <key>ProgramArguments</key>`, | 136 | ` <key>ProgramArguments</key>`, |
| 126 | ` <array>`, | 137 | ` <array>`, |
| 127 | ` <string>${escapeXmlText(plan.nodePath)}</string>`, | 138 | ...plan.program.map((argument) => ` <string>${escapeXmlText(argument)}</string>`), |
| 128 | ` <string>${escapeXmlText(plan.launcherPath)}</string>`, | ||
| 129 | ` </array>`, | 139 | ` </array>`, |
| 130 | ` <key>EnvironmentVariables</key>`, | 140 | ` <key>EnvironmentVariables</key>`, |
| 131 | ` <dict>`, | 141 | ` <dict>`, |
| ... | @@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -505,7 +515,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 505 | }) { | 515 | }) { |
| 506 | const hostExecPath = yield* HostProcessExecutablePath; | 516 | const hostExecPath = yield* HostProcessExecutablePath; |
| 507 | const platform = yield* HostProcessPlatform; | 517 | const platform = yield* HostProcessPlatform; |
| 518 | const arch = yield* HostProcessArchitecture; | ||
| 508 | const uid = yield* HostProcessUserId; | 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 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); | 526 | const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); |
| 510 | const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); | 527 | const installerPath = yield* Config.string("PATH").pipe(Config.withDefault("")); |
| 511 | const fs = yield* FileSystem.FileSystem; | 528 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -544,7 +561,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 544 | const logPath = path.join(input.logsDir, "boot-service.log"); | 561 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 545 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); | 562 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); |
| 546 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); | 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 | const launcherSourcePath = | 565 | const launcherSourcePath = |
| 549 | host.launcherSourcePath ?? | 566 | host.launcherSourcePath ?? |
| 550 | path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); | 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,7 +585,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 568 | }), | 585 | }), |
| 569 | ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 586 | ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 570 | const plan: BootServicePlan = { | 587 | const plan: BootServicePlan = { |
| 571 | nodePath: host.execPath, | 588 | program: |
| 589 | runtimePaths.layout === "archive" | ||
| 590 | ? [runtimePaths.entryPath, "__service-launcher"] | ||
| 591 | : [host.execPath, launcherPath], | ||
| 572 | launcherPath, | 592 | launcherPath, |
| 573 | baseDir: input.baseDir, | 593 | baseDir: input.baseDir, |
| 574 | logPath, | 594 | logPath, |
| ... | @@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -708,11 +728,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 708 | fs, | 728 | fs, |
| 709 | path, | 729 | path, |
| 710 | runner, | 730 | runner, |
| 731 | httpClient, | ||
| 732 | platform, | ||
| 733 | arch, | ||
| 734 | releaseBaseUrl, | ||
| 711 | validate: (runtime) => | 735 | validate: (runtime) => |
| 712 | runner | 736 | runner |
| 713 | .run({ | 737 | .run({ |
| 714 | command: host.execPath, | 738 | command: pinnedRuntimeCommand(runtime, host.execPath).command, |
| 715 | args: [runtime.entryPath, "--version"], | 739 | args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], |
| 716 | timeout: Duration.seconds(30), | 740 | timeout: Duration.seconds(30), |
| 717 | }) | 741 | }) |
| 718 | .pipe( | 742 | .pipe( |
| ... | @@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -750,9 +774,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 750 | : new BootServiceInstallError({ cause: error }), | 774 | : new BootServiceInstallError({ cause: error }), |
| 751 | ), | 775 | ), |
| 752 | ); | 776 | ); |
| 753 | const launcherSource = yield* fs | 777 | // Archive runtimes host the launcher in the executable itself; there is |
| 754 | .readFileString(launcherSourcePath) | 778 | // no standalone script to copy. |
| 755 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 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 | const installed = yield* fs | 786 | const installed = yield* fs |
| 758 | .exists(unitPath) | 787 | .exists(unitPath) |
| ... | @@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -786,7 +815,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 786 | yield* fs | 815 | yield* fs |
| 787 | .makeDirectory(path.dirname(unitPath), { recursive: true }) | 816 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 788 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 817 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 789 | yield* writeDurably(launcherPath, launcherSource); | 818 | if (launcherSource !== undefined) { |
| 819 | yield* writeDurably(launcherPath, launcherSource); | ||
| 820 | } | ||
| 790 | yield* writeDurably( | 821 | yield* writeDurably( |
| 791 | statePath, | 822 | statePath, |
| 792 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. | 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,7 +867,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 836 | const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = | 867 | const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = |
| 837 | yield* Effect.all([ | 868 | yield* Effect.all([ |
| 838 | fs.readFileString(unitPath), | 869 | fs.readFileString(unitPath), |
| 839 | fs.exists(launcherPath), | 870 | runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), |
| 840 | fs.exists(runtimePaths.entryPath), | 871 | fs.exists(runtimePaths.entryPath), |
| 841 | fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), | 872 | fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), |
| 842 | fs.readFileString(statePath).pipe(Effect.option), | 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,11 +6,13 @@ import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Path from "effect/Path"; | 7 | import * as Path from "effect/Path"; |
| 8 | import * as PlatformError from "effect/PlatformError"; | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 9 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 10 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 10 | 11 | ||
| 11 | import * as ProcessRunner from "../processRunner.ts"; | 12 | import * as ProcessRunner from "../processRunner.ts"; |
| 12 | import { | 13 | import { |
| 13 | ensurePinnedRuntimeInstalled, | 14 | ensurePinnedRuntimeInstalled, |
| 15 | pinnedRuntimeCommand, | ||
| 14 | pinnedRuntimePaths, | 16 | pinnedRuntimePaths, |
| 15 | PinnedRuntimeInstallError, | 17 | PinnedRuntimeInstallError, |
| 16 | } from "./pinnedRuntime.ts"; | 18 | } from "./pinnedRuntime.ts"; |
| ... | @@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -51,6 +53,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 51 | version: "1.2.3", | 53 | version: "1.2.3", |
| 52 | fs, | 54 | fs, |
| 53 | path, | 55 | path, |
| 56 | platform: "linux", | ||
| 57 | arch: "x64", | ||
| 54 | runner: ProcessRunner.ProcessRunner.of({ | 58 | runner: ProcessRunner.ProcessRunner.of({ |
| 55 | run: (input) => { | 59 | run: (input) => { |
| 56 | commands.push(input); | 60 | commands.push(input); |
| ... | @@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -95,6 +99,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 95 | version: "1.2.3", | 99 | version: "1.2.3", |
| 96 | fs, | 100 | fs, |
| 97 | path, | 101 | path, |
| 102 | platform: "linux", | ||
| 103 | arch: "x64", | ||
| 98 | runner: ProcessRunner.ProcessRunner.of({ | 104 | runner: ProcessRunner.ProcessRunner.of({ |
| 99 | run: (input) => { | 105 | run: (input) => { |
| 100 | commands.push(input.command); | 106 | commands.push(input.command); |
| ... | @@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -122,7 +128,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 122 | const fs = yield* FileSystem.FileSystem; | 128 | const fs = yield* FileSystem.FileSystem; |
| 123 | const path = yield* Path.Path; | 129 | const path = yield* Path.Path; |
| 124 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); | 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 | let validatedDirectory = ""; | 132 | let validatedDirectory = ""; |
| 127 | 133 | ||
| 128 | const installed = yield* ensurePinnedRuntimeInstalled({ | 134 | const installed = yield* ensurePinnedRuntimeInstalled({ |
| ... | @@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -130,6 +136,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 130 | version: "1.2.3", | 136 | version: "1.2.3", |
| 131 | fs, | 137 | fs, |
| 132 | path, | 138 | path, |
| 139 | platform: "linux", | ||
| 140 | arch: "x64", | ||
| 133 | runner: successfulRunner(fs, path), | 141 | runner: successfulRunner(fs, path), |
| 134 | validate: (staging) => | 142 | validate: (staging) => |
| 135 | Effect.gen(function* () { | 143 | Effect.gen(function* () { |
| ... | @@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -151,13 +159,15 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 151 | const fs = yield* FileSystem.FileSystem; | 159 | const fs = yield* FileSystem.FileSystem; |
| 152 | const path = yield* Path.Path; | 160 | const path = yield* Path.Path; |
| 153 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); | 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 | yield* ensurePinnedRuntimeInstalled({ | 164 | yield* ensurePinnedRuntimeInstalled({ |
| 157 | baseDir, | 165 | baseDir, |
| 158 | version: "1.2.3", | 166 | version: "1.2.3", |
| 159 | fs, | 167 | fs, |
| 160 | path, | 168 | path, |
| 169 | platform: "linux", | ||
| 170 | arch: "x64", | ||
| 161 | runner: successfulRunner(fs, path), | 171 | runner: successfulRunner(fs, path), |
| 162 | validate: () => | 172 | validate: () => |
| 163 | Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), | 173 | Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), |
| ... | @@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -178,7 +188,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 178 | const fs = yield* FileSystem.FileSystem; | 188 | const fs = yield* FileSystem.FileSystem; |
| 179 | const path = yield* Path.Path; | 189 | const path = yield* Path.Path; |
| 180 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); | 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 | yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); | 192 | yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); |
| 183 | yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); | 193 | yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); |
| 184 | 194 | ||
| ... | @@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -187,6 +197,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 187 | version: "1.2.3", | 197 | version: "1.2.3", |
| 188 | fs, | 198 | fs, |
| 189 | path, | 199 | path, |
| 200 | platform: "linux", | ||
| 201 | arch: "x64", | ||
| 190 | runner: successfulRunner(fs, path), | 202 | runner: successfulRunner(fs, path), |
| 191 | validate: () => Effect.void, | 203 | validate: () => Effect.void, |
| 192 | }); | 204 | }); |
| ... | @@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -201,7 +213,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 201 | const fs = yield* FileSystem.FileSystem; | 213 | const fs = yield* FileSystem.FileSystem; |
| 202 | const path = yield* Path.Path; | 214 | const path = yield* Path.Path; |
| 203 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); | 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 | yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); | 217 | yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); |
| 206 | yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); | 218 | yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); |
| 207 | yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); | 219 | yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); |
| ... | @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -212,6 +224,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 212 | version: "1.2.3", | 224 | version: "1.2.3", |
| 213 | fs, | 225 | fs, |
| 214 | path, | 226 | path, |
| 227 | platform: "linux", | ||
| 228 | arch: "x64", | ||
| 215 | runner: successfulRunner(fs, path), | 229 | runner: successfulRunner(fs, path), |
| 216 | validate: (paths) => | 230 | validate: (paths) => |
| 217 | Effect.gen(function* () { | 231 | Effect.gen(function* () { |
| ... | @@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -242,6 +256,8 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 242 | version: "1.2.3", | 256 | version: "1.2.3", |
| 243 | fs, | 257 | fs, |
| 244 | path, | 258 | path, |
| 259 | platform: "linux", | ||
| 260 | arch: "x64", | ||
| 245 | runner, | 261 | runner, |
| 246 | validate: () => Effect.void, | 262 | validate: () => Effect.void, |
| 247 | }).pipe(Effect.forkScoped); | 263 | }).pipe(Effect.forkScoped); |
| ... | @@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -252,4 +268,123 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 252 | assert.deepEqual(yield* fs.readDirectory(versionsDir), []); | 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,44 +1,91 @@ |
| 1 | import * as Duration from "effect/Duration"; | 1 | import * as Duration from "effect/Duration"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Encoding from "effect/Encoding"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; | 5 | import * as Path from "effect/Path"; |
| 5 | import * as PlatformError from "effect/PlatformError"; | 6 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 7 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 8 | import * as Semaphore from "effect/Semaphore"; | 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 | import * as ProcessRunner from "../processRunner.ts"; | 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 | * <baseDir>/runtime/versions/<version>. The boot service points its unit or | 26 | * <baseDir>/runtime/versions/<version>. The boot service points its unit or |
| 15 | * launch agent here, and server self-update installs the target version here before | 27 | * launch agent here, and server self-update installs the target version here before |
| 16 | * switching over, never `npx t3`, whose cache is ephemeral and whose | 28 | * switching over, never `npx t3`, whose cache is ephemeral and whose |
| 17 | * registry fetch at boot would make startup depend on the network. | 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 | const PINNED_RUNTIME_DIR = "runtime"; | 38 | const PINNED_RUNTIME_DIR = "runtime"; |
| 21 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); | 39 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); |
| 40 | const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; | ||
| 22 | // Boot-service setup and remote update can construct separate layers. Serialize | 41 | // Boot-service setup and remote update can construct separate layers. Serialize |
| 23 | // the complete install transaction across every caller in this process. | 42 | // the complete install transaction across every caller in this process. |
| 24 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); | 43 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); |
| 25 | 44 | ||
| 45 | export type PinnedRuntimeLayout = "npm" | "archive"; | ||
| 46 | |||
| 26 | export interface PinnedRuntimePaths { | 47 | export interface PinnedRuntimePaths { |
| 48 | readonly layout: PinnedRuntimeLayout; | ||
| 27 | readonly versionDir: string; | 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 | readonly entryPath: string; | 54 | readonly entryPath: string; |
| 29 | readonly sentinelPath: string; | 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 | export function pinnedRuntimePaths( | 68 | export function pinnedRuntimePaths( |
| 33 | path: Path.Path, | 69 | path: Path.Path, |
| 34 | baseDir: string, | 70 | baseDir: string, |
| 35 | version: string, | 71 | version: string, |
| 72 | platform: NodeJS.Platform, | ||
| 36 | ): PinnedRuntimePaths { | 73 | ): PinnedRuntimePaths { |
| 37 | const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); | 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 | return { | 84 | return { |
| 85 | layout: "npm", | ||
| 39 | versionDir, | 86 | versionDir, |
| 40 | entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), | 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,9 +121,10 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne |
| 74 | /** | 121 | /** |
| 75 | * Installs `t3@<version>` into the pinned runtime directory unless a complete | 122 | * Installs `t3@<version>` into the pinned runtime directory unless a complete |
| 76 | * install is already there, and returns its paths. The sentinel is written | 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 | 124 | * only after the install step exits 0; checking the entry file alone is not |
| 78 | * extracts files before running native builds (node-pty), so a killed | 125 | * enough. npm extracts files before running native builds (node-pty), and tar |
| 79 | * install leaves a plausible-looking but broken tree behind. | 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 | interface PinnedRuntimeInstallInput { | 129 | interface PinnedRuntimeInstallInput { |
| 82 | readonly baseDir: string; | 130 | readonly baseDir: string; |
| ... | @@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput { | ... | @@ -87,13 +135,175 @@ interface PinnedRuntimeInstallInput { |
| 87 | readonly validate: ( | 135 | readonly validate: ( |
| 88 | paths: PinnedRuntimePaths, | 136 | paths: PinnedRuntimePaths, |
| 89 | ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>; | 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 | const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( | 302 | const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")(function* ( |
| 93 | input: PinnedRuntimeInstallInput, | 303 | input: PinnedRuntimeInstallInput, |
| 94 | ) { | 304 | ) { |
| 95 | const { fs, runner } = input; | 305 | const { fs } = input; |
| 96 | const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version); | 306 | const paths = pinnedRuntimePaths(input.path, input.baseDir, input.version, input.platform); |
| 97 | const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ | 307 | const [versionDirExists, entryExists, sentinel] = yield* Effect.all([ |
| 98 | fs.exists(paths.versionDir), | 308 | fs.exists(paths.versionDir), |
| 99 | fs.exists(paths.entryPath), | 309 | fs.exists(paths.entryPath), |
| ... | @@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( | ... | @@ -146,54 +356,18 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 146 | ), | 356 | ), |
| 147 | ); | 357 | ); |
| 148 | const stagingPaths: PinnedRuntimePaths = { | 358 | const stagingPaths: PinnedRuntimePaths = { |
| 359 | layout: paths.layout, | ||
| 149 | versionDir: stagingDir, | 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 | sentinelPath: input.path.join(stagingDir, ".install-complete"), | 362 | sentinelPath: input.path.join(stagingDir, ".install-complete"), |
| 152 | }; | 363 | }; |
| 153 | 364 | ||
| 154 | return yield* Effect.gen(function* () { | 365 | return yield* Effect.gen(function* () { |
| 155 | const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; | 366 | if (paths.layout === "archive") { |
| 156 | const installArgs = [ | 367 | yield* installFromArchive(input, stagingDir); |
| 157 | "install", | 368 | } else { |
| 158 | "--prefix", | 369 | yield* installFromNpm(input, stagingDir); |
| 159 | stagingDir, | 370 | } |
| 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 | ); | ||
| 197 | 371 | ||
| 198 | yield* input.validate(stagingPaths); | 372 | yield* input.validate(stagingPaths); |
| 199 | yield* fs | 373 | yield* fs |
apps/server/src/cloud/selfUpdate.ts+25-3| ... | @@ -6,22 +6,32 @@ import { | ... | @@ -6,22 +6,32 @@ import { |
| 6 | type ServerSelfUpdateResult, | 6 | type ServerSelfUpdateResult, |
| 7 | type ThreadId, | 7 | type ThreadId, |
| 8 | } from "@t3tools/contracts"; | 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 | import * as Cause from "effect/Cause"; | 14 | import * as Cause from "effect/Cause"; |
| 15 | import * as Config from "effect/Config"; | ||
| 11 | import * as Context from "effect/Context"; | 16 | import * as Context from "effect/Context"; |
| 12 | import * as Duration from "effect/Duration"; | 17 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; | 18 | import * as Effect from "effect/Effect"; |
| 14 | import * as HashSet from "effect/HashSet"; | 19 | import * as HashSet from "effect/HashSet"; |
| 20 | import * as Option from "effect/Option"; | ||
| 15 | import * as Ref from "effect/Ref"; | 21 | import * as Ref from "effect/Ref"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | 22 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; | 23 | import * as Layer from "effect/Layer"; |
| 18 | import * as Path from "effect/Path"; | 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 | import * as ServerConfig from "../config.ts"; | 29 | import * as ServerConfig from "../config.ts"; |
| 21 | import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; | 30 | import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; |
| 22 | import * as ProcessRunner from "../processRunner.ts"; | 31 | import * as ProcessRunner from "../processRunner.ts"; |
| 23 | import { | 32 | import { |
| 24 | ensurePinnedRuntimeInstalled, | 33 | ensurePinnedRuntimeInstalled, |
| 34 | pinnedRuntimeCommand, | ||
| 25 | PinnedRuntimeInstallError, | 35 | PinnedRuntimeInstallError, |
| 26 | PinnedRuntimePreflightBlockedError, | 36 | PinnedRuntimePreflightBlockedError, |
| 27 | } from "./pinnedRuntime.ts"; | 37 | } from "./pinnedRuntime.ts"; |
| ... | @@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { | ... | @@ -171,6 +181,14 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 171 | const fs = yield* FileSystem.FileSystem; | 181 | const fs = yield* FileSystem.FileSystem; |
| 172 | const path = yield* Path.Path; | 182 | const path = yield* Path.Path; |
| 173 | const execPath = yield* HostProcessExecutablePath; | 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 | const inFlight = yield* Ref.make(false); | 192 | const inFlight = yield* Ref.make(false); |
| 175 | 193 | ||
| 176 | const capability: ServerSelfUpdateCapability | null = | 194 | const capability: ServerSelfUpdateCapability | null = |
| ... | @@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { | ... | @@ -216,12 +234,16 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 216 | fs, | 234 | fs, |
| 217 | path, | 235 | path, |
| 218 | runner, | 236 | runner, |
| 237 | httpClient, | ||
| 238 | platform, | ||
| 239 | arch, | ||
| 240 | releaseBaseUrl, | ||
| 219 | validate: (runtime) => | 241 | validate: (runtime) => |
| 220 | runner | 242 | runner |
| 221 | .run({ | 243 | .run({ |
| 222 | command: execPath, | 244 | command: pinnedRuntimeCommand(runtime, execPath).command, |
| 223 | args: [ | 245 | args: [ |
| 224 | runtime.entryPath, | 246 | ...pinnedRuntimeCommand(runtime, execPath).args, |
| 225 | "__service-preflight", | 247 | "__service-preflight", |
| 226 | "--database-path", | 248 | "--database-path", |
| 227 | serverConfig.dbPath, | 249 | serverConfig.dbPath, |
apps/server/src/provider/Layers/ClaudeAdapter.ts+20-11| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | ||
| 1 | /** | 2 | /** |
| 2 | * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. | 3 | * ClaudeAdapterLive - Scoped live implementation for the Claude Agent provider adapter. |
| 3 | * | 4 | * |
| ... | @@ -6,6 +7,8 @@ | ... | @@ -6,6 +7,8 @@ |
| 6 | * | 7 | * |
| 7 | * @module ClaudeAdapterLive | 8 | * @module ClaudeAdapterLive |
| 8 | */ | 9 | */ |
| 10 | import * as NodeSea from "node:sea"; | ||
| 11 | |||
| 9 | import { | 12 | import { |
| 10 | type CanUseTool, | 13 | type CanUseTool, |
| 11 | query, | 14 | query, |
| ... | @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -5128,16 +5131,22 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5128 | detail: "Claude session id is unavailable.", | 5131 | detail: "Claude session id is unavailable.", |
| 5129 | }); | 5132 | }); |
| 5130 | } | 5133 | } |
| 5131 | const historyWorkerPath = yield* path | 5134 | // The single-executable has no sibling script and no Node to run one |
| 5132 | .fromFileUrl( | 5135 | // with, so it hosts the worker as a hidden subcommand of itself. |
| 5133 | new URL( | 5136 | const historyWorkerArguments = NodeSea.isSea() |
| 5134 | import.meta.url.endsWith(".ts") | 5137 | ? ["__claude-history"] |
| 5135 | ? "../../claudeHistoryWorker.ts" | 5138 | : [ |
| 5136 | : "./claudeHistoryWorker.mjs", | 5139 | yield* path |
| 5137 | import.meta.url, | 5140 | .fromFileUrl( |
| 5138 | ), | 5141 | new URL( |
| 5139 | ) | 5142 | import.meta.url.endsWith(".ts") |
| 5140 | .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); | 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 | const runScopedHistoryCommand = async ( | 5150 | const runScopedHistoryCommand = async ( |
| 5142 | method: "getSessionMessages" | "forkSession", | 5151 | method: "getSessionMessages" | "forkSession", |
| 5143 | args: object, | 5152 | args: object, |
| ... | @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -5150,7 +5159,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5150 | process.execPath, | 5159 | process.execPath, |
| 5151 | ChildProcess.make( | 5160 | ChildProcess.make( |
| 5152 | process.execPath, | 5161 | process.execPath, |
| 5153 | [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], | 5162 | [...historyWorkerArguments, method, historySessionId, encodeHistoryArgs(args)], |
| 5154 | { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, | 5163 | { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, |
| 5155 | ), | 5164 | ), |
| 5156 | ).pipe( | 5165 | ).pipe( |
apps/server/src/service-launcher.ts+20-1| ... | @@ -1 +1,20 @@ | ... | @@ -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,6 +7,7 @@ import * as NodeCrypto from "node:crypto"; |
| 7 | import * as NodeFS from "node:fs"; | 7 | import * as NodeFS from "node:fs"; |
| 8 | import * as NodeFSP from "node:fs/promises"; | 8 | import * as NodeFSP from "node:fs/promises"; |
| 9 | import * as NodePath from "node:path"; | 9 | import * as NodePath from "node:path"; |
| 10 | import * as NodeSea from "node:sea"; | ||
| 10 | 11 | ||
| 11 | import type { | 12 | import type { |
| 12 | PendingServiceUpdate, | 13 | PendingServiceUpdate, |
| ... | @@ -26,7 +27,6 @@ import { | ... | @@ -26,7 +27,6 @@ import { |
| 26 | SERVICE_STATE_FILE, | 27 | SERVICE_STATE_FILE, |
| 27 | SERVICE_STOP_MARKER_FILE, | 28 | SERVICE_STOP_MARKER_FILE, |
| 28 | } from "./cloud/serviceProtocol.ts"; | 29 | } from "./cloud/serviceProtocol.ts"; |
| 29 | import { isEntrypoint } from "./entrypoint.ts"; | ||
| 30 | 30 | ||
| 31 | const HANDOFF_DELAY_MS = 2_000; | 31 | const HANDOFF_DELAY_MS = 2_000; |
| 32 | const PREPARED_TIMEOUT_MS = 120_000; | 32 | const PREPARED_TIMEOUT_MS = 120_000; |
| ... | @@ -41,15 +41,38 @@ interface ManagedChild { | ... | @@ -41,15 +41,38 @@ interface ManagedChild { |
| 41 | readonly process: NodeChildProcess.ChildProcess; | 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 | const runtimePaths = (baseDir: string, version: string) => { | 48 | const runtimePaths = (baseDir: string, version: string) => { |
| 45 | const versionDir = NodePath.join(baseDir, "runtime", "versions", version); | 49 | const versionDir = NodePath.join(baseDir, "runtime", "versions", version); |
| 50 | const archive = /-preview\.\d{8}\.\d+$/.test(version); | ||
| 51 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. | ||
| 52 | const executableName = process.platform === "win32" ? "t3.exe" : "t3"; | ||
| 46 | return { | 53 | return { |
| 47 | versionDir, | 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 | sentinelPath: NodePath.join(versionDir, ".install-complete"), | 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 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ | 76 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ |
| 54 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; | 77 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; |
| 55 | const RESTORE_MARKER = ".restore-pending"; | 78 | const RESTORE_MARKER = ".restore-pending"; |
| ... | @@ -402,7 +425,8 @@ export class Launcher { | ... | @@ -402,7 +425,8 @@ export class Launcher { |
| 402 | childVersion: version, | 425 | childVersion: version, |
| 403 | ...(update === undefined ? {} : { update }), | 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 | env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, | 430 | env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, |
| 407 | stdio: ["inherit", "inherit", "inherit", "ipc"], | 431 | stdio: ["inherit", "inherit", "inherit", "ipc"], |
| 408 | }); | 432 | }); |
| ... | @@ -481,6 +505,12 @@ export class Launcher { | ... | @@ -481,6 +505,12 @@ export class Launcher { |
| 481 | await reject("The requested database path is not absolute."); | 505 | await reject("The requested database path is not absolute."); |
| 482 | return; | 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 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { | 514 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { |
| 485 | await reject("The requested target runtime is missing or incomplete."); | 515 | await reject("The requested target runtime is missing or incomplete."); |
| 486 | return; | 516 | return; |
| ... | @@ -602,7 +632,7 @@ export class Launcher { | ... | @@ -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 | const baseDir = process.env.T3CODE_HOME?.trim(); | 636 | const baseDir = process.env.T3CODE_HOME?.trim(); |
| 607 | if (baseDir === undefined || baseDir === "") { | 637 | if (baseDir === undefined || baseDir === "") { |
| 608 | throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); | 638 | throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); |
| ... | @@ -611,17 +641,3 @@ async function main(): Promise<void> { | ... | @@ -611,17 +641,3 @@ async function main(): Promise<void> { |
| 611 | const state = await readServiceState(statePath); | 641 | const state = await readServiceState(statePath); |
| 612 | await new Launcher(baseDir, state).run(); | 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,7 +77,7 @@ export default mergeConfig( |
| 77 | pack: { | 77 | pack: { |
| 78 | // The executable embeds one entry; the history worker becomes a hidden | 78 | // The executable embeds one entry; the history worker becomes a hidden |
| 79 | // subcommand there instead of a sibling script. | 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 | outDir: packExecutable ? "dist-exe" : "dist", | 81 | outDir: packExecutable ? "dist-exe" : "dist", |
| 82 | sourcemap: !packExecutable, | 82 | sourcemap: !packExecutable, |
| 83 | clean: true, | 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,6 +25,21 @@ Updating restarts the server. Finish active work first, and wait for any remote |
| 25 | update already in progress. To match a remote client's version, follow | 25 | update already in progress. To match a remote client's version, follow |
| 26 | [Updating T3 Code](./updating.md). | 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 | ## Platform support | 43 | ## Platform support |
| 29 | 44 | ||
| 30 | Linux needs systemd user services. Setup enables lingering so T3 Code starts at | 45 | Linux needs systemd user services. Setup enables lingering so T3 Code starts at |
knip.jsonc+1-1| ... | @@ -21,7 +21,7 @@ | ... | @@ -21,7 +21,7 @@ |
| 21 | "entry": [ | 21 | "entry": [ |
| 22 | "src/bin.ts!", | 22 | "src/bin.ts!", |
| 23 | "src/service-launcher.ts!", | 23 | "src/service-launcher.ts!", |
| 24 | "src/claudeHistoryWorker.ts!", | 24 | "src/claude-history-worker.ts!", |
| 25 | "scripts/cli.ts", | 25 | "scripts/cli.ts", |
| 26 | "src/provider/testFixtures/*.mjs", | 26 | "src/provider/testFixtures/*.mjs", |
| 27 | ], | 27 | ], |
packages/shared/package.json+4| ... | @@ -322,6 +322,10 @@ | ... | @@ -322,6 +322,10 @@ |
| 322 | "./dateTime": { | 322 | "./dateTime": { |
| 323 | "types": "./src/dateTime.ts", | 323 | "types": "./src/dateTime.ts", |
| 324 | "import": "./src/dateTime.ts" | 324 | "import": "./src/dateTime.ts" |
| 325 | }, | ||
| 326 | "./cliRelease": { | ||
| 327 | "types": "./src/cliRelease.ts", | ||
| 328 | "import": "./src/cliRelease.ts" | ||
| 325 | } | 329 | } |
| 326 | }, | 330 | }, |
| 327 | "scripts": { | 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 | } | ||