| author | |
| committer | |
| log | af6c138a0de297f94ddef3133e2f47d017b46e94 |
| tree | 0ac168ff249cfda0d1d00b0de7b7a5d5cc77b5c2 |
| parent | c7f23c4668ce3ff2e1b2f63fe51759d1bcc159f8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>34 files changed, 884 insertions(+), 1421 deletions(-)
apps/desktop/src/main.ts+5-20| ... | @@ -17,8 +17,6 @@ import * as Electron from "electron"; | ... | @@ -17,8 +17,6 @@ import * as Electron from "electron"; |
| 17 | 17 | ||
| 18 | import * as NetService from "@t3tools/shared/Net"; | 18 | import * as NetService from "@t3tools/shared/Net"; |
| 19 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 19 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 20 | import { isArchiveDistributedVersion } from "@t3tools/shared/cliRelease"; | ||
| 21 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | ||
| 22 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | 20 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 23 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 21 | import serverPackageJson from "../../server/package.json" with { type: "json" }; |
| 24 | 22 | ||
| ... | @@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( | ... | @@ -87,9 +85,11 @@ const desktopEnvironmentLayer = Layer.unwrap( |
| 87 | }), | 85 | }), |
| 88 | ); | 86 | ); |
| 89 | 87 | ||
| 88 | // The remote runs the exact release this app is on, from its self-contained | ||
| 89 | // archive, so it needs neither Node nor npm. Development points the remote at | ||
| 90 | // a source checkout instead so the two sides can be iterated together. | ||
| 90 | const resolveDesktopSshCliRunner = ( | 91 | const resolveDesktopSshCliRunner = ( |
| 91 | environment: DesktopEnvironment.DesktopEnvironment["Service"], | 92 | environment: DesktopEnvironment.DesktopEnvironment["Service"], |
| 92 | settings: DesktopAppSettings.DesktopSettings, | ||
| 93 | ): RemoteT3RunnerOptions => { | 93 | ): RemoteT3RunnerOptions => { |
| 94 | const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); | 94 | const devRemoteEntryPath = Option.getOrUndefined(environment.devRemoteT3ServerEntryPath); |
| 95 | if (environment.isDevelopment && devRemoteEntryPath !== undefined) { | 95 | if (environment.isDevelopment && devRemoteEntryPath !== undefined) { |
| ... | @@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = ( | ... | @@ -98,29 +98,14 @@ const resolveDesktopSshCliRunner = ( |
| 98 | nodeEngineRange: serverPackageJson.engines.node, | 98 | nodeEngineRange: serverPackageJson.engines.node, |
| 99 | }; | 99 | }; |
| 100 | } | 100 | } |
| 101 | // Preview builds ship as self-contained archives, so the remote runs the | 101 | return { archiveVersion: environment.appVersion }; |
| 102 | // same version this app is on without Node or npm. | ||
| 103 | if (!environment.isDevelopment && isArchiveDistributedVersion(environment.appVersion)) { | ||
| 104 | return { archiveVersion: environment.appVersion }; | ||
| 105 | } | ||
| 106 | return { | ||
| 107 | packageSpec: resolveRemoteT3CliPackageSpec({ | ||
| 108 | appVersion: environment.appVersion, | ||
| 109 | updateChannel: settings.updateChannel, | ||
| 110 | isDevelopment: environment.isDevelopment, | ||
| 111 | }), | ||
| 112 | nodeEngineRange: serverPackageJson.engines.node, | ||
| 113 | }; | ||
| 114 | }; | 102 | }; |
| 115 | 103 | ||
| 116 | const desktopSshEnvironmentLayer = Layer.unwrap( | 104 | const desktopSshEnvironmentLayer = Layer.unwrap( |
| 117 | Effect.gen(function* () { | 105 | Effect.gen(function* () { |
| 118 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | 106 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 119 | const settings = yield* DesktopAppSettings.DesktopAppSettings; | ||
| 120 | return DesktopSshEnvironment.layer({ | 107 | return DesktopSshEnvironment.layer({ |
| 121 | resolveCliRunner: settings.get.pipe( | 108 | resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)), |
| 122 | Effect.map((currentSettings) => resolveDesktopSshCliRunner(environment, currentSettings)), | ||
| 123 | ), | ||
| 124 | }); | 109 | }); |
| 125 | }), | 110 | }), |
| 126 | ); | 111 | ); |
apps/desktop/src/ssh/DesktopSshEnvironment.ts+4-8| ... | @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< | ... | @@ -69,7 +69,6 @@ export class DesktopSshEnvironment extends Context.Service< |
| 69 | >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} | 69 | >()("@t3tools/desktop/ssh/DesktopSshEnvironment") {} |
| 70 | 70 | ||
| 71 | export interface DesktopSshEnvironmentLayerOptions { | 71 | export interface DesktopSshEnvironmentLayerOptions { |
| 72 | readonly resolveCliPackageSpec?: () => string; | ||
| 73 | readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>; | 72 | readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>; |
| 74 | } | 73 | } |
| 75 | 74 | ||
| ... | @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { | ... | @@ -168,13 +167,10 @@ export const make = Effect.gen(function* () { |
| 168 | export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => | 167 | export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => |
| 169 | Layer.effect(DesktopSshEnvironment, make).pipe( | 168 | Layer.effect(DesktopSshEnvironment, make).pipe( |
| 170 | Layer.provide( | 169 | Layer.provide( |
| 171 | SshTunnel.SshEnvironmentManager.layer({ | 170 | SshTunnel.SshEnvironmentManager.layer( |
| 172 | ...(options.resolveCliPackageSpec === undefined | 171 | options.resolveCliRunner === undefined |
| 173 | ? {} | 172 | ? {} |
| 174 | : { resolveCliPackageSpec: options.resolveCliPackageSpec }), | 173 | : { resolveCliRunner: options.resolveCliRunner }, |
| 175 | ...(options.resolveCliRunner === undefined | 174 | ), |
| 176 | ? {} | ||
| 177 | : { resolveCliRunner: options.resolveCliRunner }), | ||
| 178 | }), | ||
| 179 | ), | 175 | ), |
| 180 | ); | 176 | ); |
apps/marketing/.gitignore created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | /public/install.sh | ||
| 2 | /public/install.ps1 | ||
apps/marketing/package.json+3-2| ... | @@ -4,8 +4,9 @@ | ... | @@ -4,8 +4,9 @@ |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| 7 | "dev": "astro dev", | 7 | "stage:install-scripts": "node scripts/stage-install-scripts.mjs", |
| 8 | "build": "astro build", | 8 | "dev": "node scripts/stage-install-scripts.mjs && astro dev", |
| 9 | "build": "node scripts/stage-install-scripts.mjs && astro build", | ||
| 9 | "preview": "astro preview", | 10 | "preview": "astro preview", |
| 10 | "typecheck": "astro check" | 11 | "typecheck": "astro check" |
| 11 | }, | 12 | }, |
apps/marketing/public/install.ps1 deleted-120| ... | @@ -1,120 +0,0 @@ | ||
| 1 | # Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs | ||
| 2 | # only PowerShell 5.1+; no Node, npm, or compiler. | ||
| 3 | # | ||
| 4 | # irm https://t3.codes/install.ps1 | iex | ||
| 5 | # | ||
| 6 | # Environment: | ||
| 7 | # T3CODE_CHANNEL release train to follow: stable, nightly, or preview | ||
| 8 | # (default: stable; preview is a maintainers' test train) | ||
| 9 | # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) | ||
| 10 | # T3CODE_HOME T3 home directory (default: ~\.t3) | ||
| 11 | # T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) | ||
| 12 | # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) | ||
| 13 | # | ||
| 14 | # The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the | ||
| 15 | # same layout `t3 service install` uses, so the service reuses this download. | ||
| 16 | $ErrorActionPreference = "Stop" | ||
| 17 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | ||
| 18 | |||
| 19 | $repo = "pingdotgg/t3code" | ||
| 20 | $baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } | ||
| 21 | $t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } | ||
| 22 | $binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } | ||
| 23 | |||
| 24 | function Fail([string] $message) { | ||
| 25 | Write-Error "t3 install: $message" | ||
| 26 | exit 1 | ||
| 27 | } | ||
| 28 | |||
| 29 | # PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell | ||
| 30 | # runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts | ||
| 31 | # may lack. | ||
| 32 | $rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } | ||
| 33 | $arch = switch ($rawArch) { | ||
| 34 | "AMD64" { "x64" } | ||
| 35 | "ARM64" { "arm64" } | ||
| 36 | default { Fail "unsupported architecture $rawArch" } | ||
| 37 | } | ||
| 38 | |||
| 39 | $channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } | ||
| 40 | $version = $env:T3CODE_VERSION | ||
| 41 | if (-not $version) { | ||
| 42 | # Tags are v<semver>; the channel is the prerelease identifier, or none for | ||
| 43 | # stable. Only tags of the requested train are considered, so a stable | ||
| 44 | # install can never pick up a nightly or preview build by accident. | ||
| 45 | $tagPattern = switch ($channel) { | ||
| 46 | "stable" { '^v\d+\.\d+\.\d+$' } | ||
| 47 | "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } | ||
| 48 | "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | ||
| 49 | default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } | ||
| 50 | } | ||
| 51 | $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } | ||
| 52 | $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name | ||
| 53 | if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } | ||
| 54 | $version = $tag.Substring(1) | ||
| 55 | } | ||
| 56 | if ($version -match '-preview\.') { | ||
| 57 | Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." | ||
| 58 | if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { | ||
| 59 | Fail "refusing a preview build that was not explicitly requested" | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | $stem = "t3-$version-win32-$arch" | ||
| 64 | $archive = "$stem.zip" | ||
| 65 | $versionsDir = Join-Path $t3Home "runtime\versions" | ||
| 66 | $targetDir = Join-Path $versionsDir $version | ||
| 67 | $marker = Join-Path $targetDir ".install-complete" | ||
| 68 | |||
| 69 | if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { | ||
| 70 | Write-Host "t3 $version is already installed at $targetDir" | ||
| 71 | } else { | ||
| 72 | New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null | ||
| 73 | $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) | ||
| 74 | New-Item -ItemType Directory -Path $staging | Out-Null | ||
| 75 | try { | ||
| 76 | Write-Host "Downloading $archive..." | ||
| 77 | try { | ||
| 78 | Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing | ||
| 79 | } catch { | ||
| 80 | $status = $_.Exception.Response.StatusCode.value__ | ||
| 81 | if ($status -eq 404) { | ||
| 82 | Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead" | ||
| 83 | } | ||
| 84 | throw | ||
| 85 | } | ||
| 86 | Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing | ||
| 87 | |||
| 88 | $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) | ||
| 89 | if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } | ||
| 90 | $expected = ($expected -split "\s+")[0].ToLowerInvariant() | ||
| 91 | $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() | ||
| 92 | if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } | ||
| 93 | |||
| 94 | Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force | ||
| 95 | # The archive wraps everything in one directory named after its stem. | ||
| 96 | Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging | ||
| 97 | Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force | ||
| 98 | |||
| 99 | & (Join-Path $staging "t3.exe") --version | Out-Null | ||
| 100 | if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } | ||
| 101 | Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline | ||
| 102 | |||
| 103 | if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } | ||
| 104 | Move-Item $staging $targetDir | ||
| 105 | } catch { | ||
| 106 | if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } | ||
| 107 | throw | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | New-Item -ItemType Directory -Force -Path $binDir | Out-Null | ||
| 112 | $shim = Join-Path $binDir "t3.cmd" | ||
| 113 | # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt | ||
| 114 | # non-ASCII characters in the user's home path. | ||
| 115 | [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) | ||
| 116 | Write-Host "Installed t3 $version" | ||
| 117 | Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" | ||
| 118 | if (($env:PATH -split ";") -notcontains $binDir) { | ||
| 119 | Write-Host "Add $binDir to your PATH to run t3." | ||
| 120 | } | ||
apps/marketing/public/install.sh deleted-140| ... | @@ -1,140 +0,0 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | # Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, | ||
| 3 | # sha256sum or shasum, and curl or wget; no Node, npm, or compiler. | ||
| 4 | # | ||
| 5 | # curl -fsSL https://t3.codes/install.sh | sh | ||
| 6 | # | ||
| 7 | # Environment: | ||
| 8 | # T3CODE_CHANNEL release train to follow: stable, nightly, or preview | ||
| 9 | # (default: stable; preview is a maintainers' test train) | ||
| 10 | # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) | ||
| 11 | # T3CODE_HOME T3 home directory (default: ~/.t3) | ||
| 12 | # T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) | ||
| 13 | # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) | ||
| 14 | # | ||
| 15 | # The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the | ||
| 16 | # same layout `t3 service install` uses, so the service reuses this download | ||
| 17 | # instead of fetching the release again. | ||
| 18 | set -eu | ||
| 19 | |||
| 20 | repo="pingdotgg/t3code" | ||
| 21 | base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" | ||
| 22 | t3_home="${T3CODE_HOME:-$HOME/.t3}" | ||
| 23 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" | ||
| 24 | |||
| 25 | fail() { | ||
| 26 | printf 't3 install: %s\n' "$1" >&2 | ||
| 27 | exit 1 | ||
| 28 | } | ||
| 29 | |||
| 30 | # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. | ||
| 31 | fetch() { | ||
| 32 | if command -v curl >/dev/null 2>&1; then | ||
| 33 | status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 | ||
| 34 | case "$status" in | ||
| 35 | 2??) return 0 ;; | ||
| 36 | 404) return 44 ;; | ||
| 37 | *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; | ||
| 38 | esac | ||
| 39 | elif command -v wget >/dev/null 2>&1; then | ||
| 40 | wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 | ||
| 41 | if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi | ||
| 42 | cat "$2.headers" >&2; rm -f "$2.headers"; return 1 | ||
| 43 | else | ||
| 44 | fail "curl or wget is required" | ||
| 45 | fi | ||
| 46 | } | ||
| 47 | |||
| 48 | case "$(uname -s)" in | ||
| 49 | Darwin) platform="darwin" ;; | ||
| 50 | Linux) platform="linux" ;; | ||
| 51 | *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; | ||
| 52 | esac | ||
| 53 | case "$(uname -m)" in | ||
| 54 | arm64 | aarch64) arch="arm64" ;; | ||
| 55 | x86_64 | amd64) arch="x64" ;; | ||
| 56 | *) fail "unsupported architecture $(uname -m)" ;; | ||
| 57 | esac | ||
| 58 | command -v tar >/dev/null 2>&1 || fail "tar is required" | ||
| 59 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 60 | checksum() { sha256sum "$1" | cut -d' ' -f1; } | ||
| 61 | elif command -v shasum >/dev/null 2>&1; then | ||
| 62 | checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } | ||
| 63 | else | ||
| 64 | fail "sha256sum or shasum is required" | ||
| 65 | fi | ||
| 66 | |||
| 67 | channel="${T3CODE_CHANNEL:-stable}" | ||
| 68 | version="${T3CODE_VERSION:-}" | ||
| 69 | if [ -z "$version" ]; then | ||
| 70 | # Tags are v<semver>; the channel is the prerelease identifier, or none for | ||
| 71 | # stable. Only tags of the requested train are considered, so a stable | ||
| 72 | # install can never pick up a nightly or preview build by accident. | ||
| 73 | case "$channel" in | ||
| 74 | stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; | ||
| 75 | nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; | ||
| 76 | *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; | ||
| 77 | esac | ||
| 78 | tmp_index="$(mktemp)" | ||
| 79 | fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" | ||
| 80 | version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" | ||
| 81 | rm -f "$tmp_index" | ||
| 82 | [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" | ||
| 83 | fi | ||
| 84 | case "$version" in | ||
| 85 | *-preview.*) | ||
| 86 | printf '%s\n' \ | ||
| 87 | "t3 ${version} is a preview build." \ | ||
| 88 | " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ | ||
| 89 | " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ | ||
| 90 | " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 | ||
| 91 | if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then | ||
| 92 | fail "refusing a preview build that was not explicitly requested" | ||
| 93 | fi | ||
| 94 | ;; | ||
| 95 | esac | ||
| 96 | |||
| 97 | stem="t3-${version}-${platform}-${arch}" | ||
| 98 | archive="${stem}.tar.gz" | ||
| 99 | versions_dir="${t3_home}/runtime/versions" | ||
| 100 | target_dir="${versions_dir}/${version}" | ||
| 101 | |||
| 102 | if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then | ||
| 103 | printf 't3 %s is already installed at %s\n' "$version" "$target_dir" | ||
| 104 | else | ||
| 105 | mkdir -p "$versions_dir" | ||
| 106 | staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" | ||
| 107 | trap 'rm -rf "$staging"' EXIT | ||
| 108 | |||
| 109 | printf 'Downloading %s...\n' "$archive" | ||
| 110 | fetch_status=0 | ||
| 111 | fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? | ||
| 112 | if [ "$fetch_status" -eq 44 ]; then | ||
| 113 | fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead" | ||
| 114 | elif [ "$fetch_status" -ne 0 ]; then | ||
| 115 | fail "could not download the release checksums" | ||
| 116 | fi | ||
| 117 | fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" | ||
| 118 | |||
| 119 | expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" | ||
| 120 | [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" | ||
| 121 | actual="$(checksum "${staging}/${archive}")" | ||
| 122 | [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" | ||
| 123 | |||
| 124 | tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 | ||
| 125 | rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" | ||
| 126 | "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" | ||
| 127 | printf '%s\n' "$version" > "${staging}/.install-complete" | ||
| 128 | |||
| 129 | rm -rf "$target_dir" | ||
| 130 | mv "$staging" "$target_dir" | ||
| 131 | trap - EXIT | ||
| 132 | fi | ||
| 133 | |||
| 134 | mkdir -p "$bin_dir" | ||
| 135 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" | ||
| 136 | printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" | ||
| 137 | case ":${PATH}:" in | ||
| 138 | *":${bin_dir}:"*) ;; | ||
| 139 | *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; | ||
| 140 | esac | ||
apps/marketing/scripts/stage-install-scripts.mjs created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | // The CLI install scripts live in scripts/ at the repo root with the rest of | ||
| 2 | // the release tooling; the site serves them at /install.sh and /install.ps1. | ||
| 3 | // Copy them into public/ before every Astro build and dev server so the two | ||
| 4 | // never drift. The copies are gitignored. | ||
| 5 | import * as NodeFS from "node:fs"; | ||
| 6 | import * as NodePath from "node:path"; | ||
| 7 | import * as NodeURL from "node:url"; | ||
| 8 | |||
| 9 | const marketingDir = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))); | ||
| 10 | const repoRoot = NodePath.dirname(NodePath.dirname(marketingDir)); | ||
| 11 | const publicDir = NodePath.join(marketingDir, "public"); | ||
| 12 | NodeFS.mkdirSync(publicDir, { recursive: true }); | ||
| 13 | for (const name of ["install.sh", "install.ps1"]) { | ||
| 14 | NodeFS.copyFileSync(NodePath.join(repoRoot, "scripts", name), NodePath.join(publicDir, name)); | ||
| 15 | } | ||
apps/server/package.json+1-1| ... | @@ -16,7 +16,7 @@ | ... | @@ -16,7 +16,7 @@ |
| 16 | "type": "module", | 16 | "type": "module", |
| 17 | "scripts": { | 17 | "scripts": { |
| 18 | "dev": "node --watch src/bin.ts", | 18 | "dev": "node --watch src/bin.ts", |
| 19 | "build:bundle": "vp pack && vp pack src/service-launcher.ts --out-dir dist --no-clean", | 19 | "build:bundle": "vp pack", |
| 20 | "build:exe": "node scripts/cli.ts build-exe", | 20 | "build:exe": "node scripts/cli.ts build-exe", |
| 21 | "start": "node dist/bin.mjs", | 21 | "start": "node dist/bin.mjs", |
| 22 | "typecheck": "tsc --noEmit", | 22 | "typecheck": "tsc --noEmit", |
apps/server/scripts/cli.ts+1-5| ... | @@ -284,11 +284,7 @@ const publishCmd = Command.make( | ... | @@ -284,11 +284,7 @@ const publishCmd = Command.make( |
| 284 | const packageJsonPath = path.join(serverDir, "package.json"); | 284 | const packageJsonPath = path.join(serverDir, "package.json"); |
| 285 | 285 | ||
| 286 | // Assert build assets exist | 286 | // Assert build assets exist |
| 287 | for (const relPath of [ | 287 | for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { |
| 288 | "dist/bin.mjs", | ||
| 289 | "dist/service-launcher.mjs", | ||
| 290 | "dist/client/index.html", | ||
| 291 | ]) { | ||
| 292 | const abs = path.join(serverDir, relPath); | 288 | const abs = path.join(serverDir, relPath); |
| 293 | if (!(yield* fs.exists(abs))) { | 289 | if (!(yield* fs.exists(abs))) { |
| 294 | return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); | 290 | return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); |
apps/server/src/cli/service.test.ts+1-2| ... | @@ -105,8 +105,7 @@ function makeTestService(serviceStatus: BootService.BootServiceStatus) { | ... | @@ -105,8 +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 | program: ["/test/node", "/test/service-launcher.mjs"], | 108 | program: ["/test/t3/runtime/versions/1.0.0/t3", "__service-launcher"], |
| 109 | launcherPath: "/test/service-launcher.mjs", | ||
| 110 | baseDir: "/test/t3", | 109 | baseDir: "/test/t3", |
| 111 | unitPath: serviceStatus.unitPath, | 110 | unitPath: serviceStatus.unitPath, |
| 112 | logPath: serviceStatus.logPath, | 111 | logPath: serviceStatus.logPath, |
apps/server/src/cli/serviceLauncher.ts+3-4| ... | @@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli"; | ... | @@ -4,10 +4,9 @@ import { Command } from "effect/unstable/cli"; |
| 4 | import { main as runServiceLauncher } from "../serviceLauncher.ts"; | 4 | import { main as runServiceLauncher } from "../serviceLauncher.ts"; |
| 5 | 5 | ||
| 6 | /** | 6 | /** |
| 7 | * Hosts the service launcher inside the CLI executable. Archive-distributed | 7 | * Hosts the service launcher inside the CLI executable. The service manager |
| 8 | * runtimes have no Node on the machine to run `service-launcher.mjs`, so the | 8 | * runs `t3 __service-launcher` and the launcher spawns the server from the |
| 9 | * service manager runs `t3 __service-launcher` and the launcher spawns the | 9 | * same executable, so the machine needs no Node to run either. |
| 10 | * server from the same executable. | ||
| 11 | * | 10 | * |
| 12 | * The launcher owns SIGTERM handling and the process lifetime: it must finish | 11 | * 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 | 12 | * stopping its child before the process exits, so it runs detached from the |
apps/server/src/cli/update.ts+3-14| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { | 1 | import { |
| 2 | HostProcessArchitecture, | 2 | HostProcessArchitecture, |
| 3 | HostProcessEnvironment, | 3 | HostProcessEnvironment, |
| 4 | HostProcessExecutablePath, | ||
| 5 | HostProcessInvokedAs, | 4 | HostProcessInvokedAs, |
| 6 | HostProcessIsExecutable, | 5 | HostProcessIsExecutable, |
| 7 | HostProcessPlatform, | 6 | HostProcessPlatform, |
| ... | @@ -12,7 +11,6 @@ import { | ... | @@ -12,7 +11,6 @@ import { |
| 12 | CLI_RELEASE_CHANNELS, | 11 | CLI_RELEASE_CHANNELS, |
| 13 | cliReleaseIndexPageUrl, | 12 | cliReleaseIndexPageUrl, |
| 14 | cliReleaseChannelOf, | 13 | cliReleaseChannelOf, |
| 15 | isArchiveDistributedVersion, | ||
| 16 | newestCliReleaseVersion, | 14 | newestCliReleaseVersion, |
| 17 | type CliReleaseChannel, | 15 | type CliReleaseChannel, |
| 18 | } from "@t3tools/shared/cliRelease"; | 16 | } from "@t3tools/shared/cliRelease"; |
| ... | @@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun | ... | @@ -320,7 +318,7 @@ const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(fun |
| 320 | Effect.map((result) => (result.code === 0 ? result.stdout : "")), | 318 | Effect.map((result) => (result.code === 0 ? result.stdout : "")), |
| 321 | Effect.orElseSucceed(() => ""), | 319 | Effect.orElseSucceed(() => ""), |
| 322 | ); | 320 | ); |
| 323 | return /__service-launcher|service-launcher\.mjs/.test(command); | 321 | return /__service-launcher/.test(command); |
| 324 | } | 322 | } |
| 325 | return false; | 323 | return false; |
| 326 | }); | 324 | }); |
| ... | @@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -338,7 +336,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 338 | const runner = yield* ProcessRunner.ProcessRunner; | 336 | const runner = yield* ProcessRunner.ProcessRunner; |
| 339 | const platform = yield* HostProcessPlatform; | 337 | const platform = yield* HostProcessPlatform; |
| 340 | const arch = yield* HostProcessArchitecture; | 338 | const arch = yield* HostProcessArchitecture; |
| 341 | const execPath = yield* HostProcessExecutablePath; | ||
| 342 | const environment = yield* HostProcessEnvironment; | 339 | const environment = yield* HostProcessEnvironment; |
| 343 | const httpClient = yield* HttpClient.HttpClient; | 340 | const httpClient = yield* HttpClient.HttpClient; |
| 344 | const service = yield* BootService.BootService; | 341 | const service = yield* BootService.BootService; |
| ... | @@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -410,14 +407,6 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 410 | ? serviceVersion | 407 | ? serviceVersion |
| 411 | : currentVersion; | 408 | : currentVersion; |
| 412 | 409 | ||
| 413 | // Only archive-distributed versions install without Node and npm on the | ||
| 414 | // machine. Until nightly and stable ship archives, updating onto them from | ||
| 415 | // here would reintroduce the dependency this command exists to remove. | ||
| 416 | if (!isArchiveDistributedVersion(targetVersion)) { | ||
| 417 | return yield* new CliUpdateError({ | ||
| 418 | reason: `t3@${targetVersion} is published on npm only. Install it with \`npm install -g t3@${targetVersion}\`, or pick a version from the preview channel.`, | ||
| 419 | }); | ||
| 420 | } | ||
| 421 | if (executableCurrent && serviceCurrent) { | 410 | if (executableCurrent && serviceCurrent) { |
| 422 | yield* Console.log( | 411 | yield* Console.log( |
| 423 | serviceVersion !== undefined | 412 | serviceVersion !== undefined |
| ... | @@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { | ... | @@ -480,8 +469,8 @@ const runUpdate = Effect.fn("cli.update.run")(function* (input: { |
| 480 | validate: (paths) => | 469 | validate: (paths) => |
| 481 | runner | 470 | runner |
| 482 | .run({ | 471 | .run({ |
| 483 | command: pinnedRuntimeCommand(paths, execPath).command, | 472 | command: pinnedRuntimeCommand(paths).command, |
| 484 | args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"], | 473 | args: [...pinnedRuntimeCommand(paths).args, "--version"], |
| 485 | timeout: Duration.seconds(30), | 474 | timeout: Duration.seconds(30), |
| 486 | }) | 475 | }) |
| 487 | .pipe( | 476 | .pipe( |
apps/server/src/cloud/bootService.test.ts+45-82| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { | 3 | import { |
| 4 | HostProcessArguments, | ||
| 5 | HostProcessExecutablePath, | 4 | HostProcessExecutablePath, |
| 6 | HostProcessPlatform, | 5 | HostProcessPlatform, |
| 7 | HostProcessUserId, | 6 | HostProcessUserId, |
| ... | @@ -12,6 +11,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -12,6 +11,7 @@ import * as Effect from "effect/Effect"; |
| 12 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 14 | import * as Path from "effect/Path"; | 13 | import * as Path from "effect/Path"; |
| 14 | import { HttpClient } from "effect/unstable/http"; | ||
| 15 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 15 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 16 | 16 | ||
| 17 | import * as ProcessRunner from "../processRunner.ts"; | 17 | import * as ProcessRunner from "../processRunner.ts"; |
| ... | @@ -23,24 +23,25 @@ import { | ... | @@ -23,24 +23,25 @@ import { |
| 23 | serviceStateHasPendingUpdate, | 23 | serviceStateHasPendingUpdate, |
| 24 | } from "./serviceProtocol.ts"; | 24 | } from "./serviceProtocol.ts"; |
| 25 | 25 | ||
| 26 | it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { | 26 | const linuxRuntime = "/home/theo/.t3/runtime/versions/1.2.3/t3"; |
| 27 | const unit = BootService.renderBootServiceUnit({ | 27 | const linuxPlan = { |
| 28 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], | 28 | program: [linuxRuntime, "__service-launcher"], |
| 29 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", | 29 | baseDir: "/home/theo/.t3", |
| 30 | baseDir: "/home/theo/.t3", | 30 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", |
| 31 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | 31 | unitPath: "/home/theo/.config/systemd/user/t3code.service", |
| 32 | unitPath: "/home/theo/.config/systemd/user/t3code.service", | 32 | }; |
| 33 | }); | 33 | |
| 34 | it("runs the pinned runtime's own executable as the systemd launcher", () => { | ||
| 35 | const unit = BootService.renderBootServiceUnit(linuxPlan); | ||
| 34 | 36 | ||
| 35 | expect(unit).toContain("ExecStart=/usr/bin/node /home/theo/.t3/runtime/service-launcher.mjs"); | 37 | expect(unit).toContain(`ExecStart=${linuxRuntime} __service-launcher`); |
| 36 | expect(unit).toContain("KillMode=mixed"); | 38 | expect(unit).toContain("KillMode=mixed"); |
| 37 | expect(unit).not.toContain("versions/1.2.3"); | 39 | expect(unit).not.toContain("node"); |
| 38 | }); | 40 | }); |
| 39 | 41 | ||
| 40 | it("reads the served T3 home back out of a rendered unit or plist", () => { | 42 | it("reads the served T3 home back out of a rendered unit or plist", () => { |
| 41 | const plan = (baseDir: string) => ({ | 43 | const plan = (baseDir: string) => ({ |
| 42 | program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`], | 44 | program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], |
| 43 | launcherPath: `${baseDir}/runtime/service-launcher.mjs`, | ||
| 44 | baseDir, | 45 | baseDir, |
| 45 | logPath: `${baseDir}/userdata/logs/boot-service.log`, | 46 | logPath: `${baseDir}/userdata/logs/boot-service.log`, |
| 46 | unitPath: "/home/theo/.config/systemd/user/t3code.service", | 47 | unitPath: "/home/theo/.config/systemd/user/t3code.service", |
| ... | @@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { | ... | @@ -66,36 +67,15 @@ it("reads the served T3 home back out of a rendered unit or plist", () => { |
| 66 | expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); | 67 | expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined(); |
| 67 | }); | 68 | }); |
| 68 | 69 | ||
| 69 | it("runs archive-distributed runtimes as their own executable", () => { | ||
| 70 | const unit = BootService.renderBootServiceUnit({ | ||
| 71 | program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"], | ||
| 72 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", | ||
| 73 | baseDir: "/home/theo/.t3", | ||
| 74 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | ||
| 75 | unitPath: "/home/theo/.config/systemd/user/t3code.service", | ||
| 76 | }); | ||
| 77 | |||
| 78 | expect(unit).toContain( | ||
| 79 | "ExecStart=/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3 __service-launcher", | ||
| 80 | ); | ||
| 81 | expect(unit).not.toContain("node"); | ||
| 82 | }); | ||
| 83 | |||
| 84 | it("survives the kernel OOM-killing a greedy agent child", () => { | 70 | it("survives the kernel OOM-killing a greedy agent child", () => { |
| 85 | const unit = BootService.renderBootServiceUnit({ | 71 | const unit = BootService.renderBootServiceUnit(linuxPlan); |
| 86 | program: ["/usr/bin/node", "/home/theo/.t3/runtime/service-launcher.mjs"], | ||
| 87 | launcherPath: "/home/theo/.t3/runtime/service-launcher.mjs", | ||
| 88 | baseDir: "/home/theo/.t3", | ||
| 89 | logPath: "/home/theo/.t3/userdata/logs/boot-service.log", | ||
| 90 | unitPath: "/home/theo/.config/systemd/user/t3code.service", | ||
| 91 | }); | ||
| 92 | 72 | ||
| 93 | expect(unit).toContain("OOMPolicy=continue"); | 73 | expect(unit).toContain("OOMPolicy=continue"); |
| 94 | }); | 74 | }); |
| 95 | 75 | ||
| 76 | const macRuntime = "/Users/theo/.t3/runtime/versions/1.2.3/t3"; | ||
| 96 | const macPlan = { | 77 | const macPlan = { |
| 97 | program: ["/opt/homebrew/bin/node", "/Users/theo/.t3/runtime/service-launcher.mjs"], | 78 | program: [macRuntime, "__service-launcher"], |
| 98 | launcherPath: "/Users/theo/.t3/runtime/service-launcher.mjs", | ||
| 99 | baseDir: "/Users/theo/.t3", | 79 | baseDir: "/Users/theo/.t3", |
| 100 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", | 80 | logPath: "/Users/theo/.t3/userdata/logs/boot-service.log", |
| 101 | unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", | 81 | unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist", |
| ... | @@ -104,12 +84,13 @@ const macInstallerPath = | ... | @@ -104,12 +84,13 @@ const macInstallerPath = |
| 104 | "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; | 84 | "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin"; |
| 105 | const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; | 85 | const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath }; |
| 106 | 86 | ||
| 107 | it("keeps launchd pinned to the stable launcher rather than a versioned server", () => { | 87 | it("runs the pinned runtime's own executable as the launch agent", () => { |
| 108 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); | 88 | const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions); |
| 109 | 89 | ||
| 110 | expect(plist).toContain("<string>/opt/homebrew/bin/node</string>"); | 90 | expect(plist).toContain( |
| 111 | expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>"); | 91 | ` <array>\n <string>${macRuntime}</string>\n <string>__service-launcher</string>\n </array>`, |
| 112 | expect(plist).not.toContain("versions/1.2.3"); | 92 | ); |
| 93 | expect(plist).not.toContain("node</string>"); | ||
| 113 | }); | 94 | }); |
| 114 | 95 | ||
| 115 | it("preserves the installer's provider search path in the launch agent", () => { | 96 | it("preserves the installer's provider search path in the launch agent", () => { |
| ... | @@ -150,23 +131,18 @@ it("escapes XML in host paths", () => { | ... | @@ -150,23 +131,18 @@ it("escapes XML in host paths", () => { |
| 150 | 131 | ||
| 151 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | 132 | const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 152 | platform: NodeJS.Platform = "linux", | 133 | platform: NodeJS.Platform = "linux", |
| 153 | usePinnedLauncher = false, | ||
| 154 | installerPath = macInstallerPath, | 134 | installerPath = macInstallerPath, |
| 155 | ) { | 135 | ) { |
| 156 | const fs = yield* FileSystem.FileSystem; | 136 | const fs = yield* FileSystem.FileSystem; |
| 157 | const path = yield* Path.Path; | 137 | const path = yield* Path.Path; |
| 158 | const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); | 138 | const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" }); |
| 159 | const baseDir = path.join(home, ".t3"); | 139 | const baseDir = path.join(home, ".t3"); |
| 160 | const sourceLauncher = path.join(home, "service-launcher.mjs"); | ||
| 161 | const statePath = path.join(baseDir, "runtime", "service-state.json"); | 140 | const statePath = path.join(baseDir, "runtime", "service-state.json"); |
| 162 | yield* fs.writeFileString(sourceLauncher, "export {};\n"); | 141 | // A complete pinned runtime is already present, so install only validates |
| 163 | const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); | 142 | // it and never downloads a release archive. |
| 143 | const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform); | ||
| 164 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); | 144 | yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); |
| 165 | yield* fs.writeFileString(runtime.entryPath, "export {};\n"); | 145 | yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n"); |
| 166 | yield* fs.writeFileString( | ||
| 167 | path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), | ||
| 168 | "export const source = 'pinned runtime';\n", | ||
| 169 | ); | ||
| 170 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); | 146 | yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); |
| 171 | 147 | ||
| 172 | const commands: string[] = []; | 148 | const commands: string[] = []; |
| ... | @@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -204,7 +180,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 204 | } | 180 | } |
| 205 | return { | 181 | return { |
| 206 | stdout: | 182 | stdout: |
| 207 | input.args[1] === "--version" | 183 | input.args[0] === "--version" |
| 208 | ? "t3 v1.2.3\n" | 184 | ? "t3 v1.2.3\n" |
| 209 | : input.command === "loginctl" && input.args[0] === "show-user" | 185 | : input.command === "loginctl" && input.args[0] === "show-user" |
| 210 | ? `${control.linger}\n` | 186 | ? `${control.linger}\n` |
| ... | @@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( | ... | @@ -230,18 +206,18 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( |
| 230 | baseDir, | 206 | baseDir, |
| 231 | logsDir: path.join(baseDir, "userdata", "logs"), | 207 | logsDir: path.join(baseDir, "userdata", "logs"), |
| 232 | cliVersion: "1.2.3", | 208 | cliVersion: "1.2.3", |
| 233 | host: { | 209 | host: { execPath: "/usr/bin/t3" }, |
| 234 | execPath: "/usr/bin/node", | ||
| 235 | ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), | ||
| 236 | }, | ||
| 237 | }).pipe( | 210 | }).pipe( |
| 238 | Effect.provideService(ProcessRunner.ProcessRunner, runner), | 211 | Effect.provideService(ProcessRunner.ProcessRunner, runner), |
| 239 | Effect.provide( | 212 | Effect.provide( |
| 240 | Layer.mergeAll( | 213 | Layer.mergeAll( |
| 241 | Layer.succeed(HostProcessPlatform, platform), | 214 | Layer.succeed(HostProcessPlatform, platform), |
| 242 | Layer.succeed(HostProcessUserId, 501), | 215 | Layer.succeed(HostProcessUserId, 501), |
| 243 | Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"), | 216 | Layer.succeed(HostProcessExecutablePath, "/usr/bin/t3"), |
| 244 | Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]), | 217 | Layer.succeed( |
| 218 | HttpClient.HttpClient, | ||
| 219 | HttpClient.make(() => Effect.die("no release download expected")), | ||
| 220 | ), | ||
| 245 | ConfigProvider.layer( | 221 | ConfigProvider.layer( |
| 246 | ConfigProvider.fromEnv({ | 222 | ConfigProvider.fromEnv({ |
| 247 | env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, | 223 | env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) }, |
| ... | @@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -275,9 +251,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 275 | expect(error.message).toContain("last login session ends"); | 251 | expect(error.message).toContain("last login session ends"); |
| 276 | expect(yield* fs.exists(before.unitPath)).toBe(false); | 252 | expect(yield* fs.exists(before.unitPath)).toBe(false); |
| 277 | expect(yield* fs.exists(statePath)).toBe(false); | 253 | expect(yield* fs.exists(statePath)).toBe(false); |
| 278 | expect( | 254 | expect(commands.some((command) => command.includes("--version"))).toBe(false); |
| 279 | commands.some((command) => command.startsWith("npm ") || command.includes("--version")), | ||
| 280 | ).toBe(false); | ||
| 281 | expect( | 255 | expect( |
| 282 | commands.some( | 256 | commands.some( |
| 283 | (command) => command.includes("daemon-reload") || command.includes("restart"), | 257 | (command) => command.includes("daemon-reload") || command.includes("restart"), |
| ... | @@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -350,14 +324,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 350 | 324 | ||
| 351 | it.effect("installs, reports current state, and uninstalls", () => | 325 | it.effect("installs, reports current state, and uninstalls", () => |
| 352 | Effect.gen(function* () { | 326 | Effect.gen(function* () { |
| 353 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); | 327 | const { service, fs, statePath, timeouts, runtime } = yield* makeHarness(); |
| 354 | const plan = yield* service.install(); | 328 | const plan = yield* service.install(); |
| 355 | 329 | ||
| 356 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ | 330 | expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ |
| 357 | protocol: SERVICE_LAUNCHER_PROTOCOL, | 331 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 358 | activeVersion: "1.2.3", | 332 | activeVersion: "1.2.3", |
| 359 | }); | 333 | }); |
| 360 | expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); | 334 | expect(plan.program).toEqual([runtime.entryPath, "__service-launcher"]); |
| 335 | expect(yield* fs.readFileString(plan.unitPath)).toContain( | ||
| 336 | `ExecStart=${runtime.entryPath} __service-launcher`, | ||
| 337 | ); | ||
| 361 | expect(yield* service.status).toMatchObject({ | 338 | expect(yield* service.status).toMatchObject({ |
| 362 | current: true, | 339 | current: true, |
| 363 | installedVersion: "1.2.3", | 340 | installedVersion: "1.2.3", |
| ... | @@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -378,7 +355,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 378 | expect((yield* service.status).current).toBe(false); | 355 | expect((yield* service.status).current).toBe(false); |
| 379 | expect(yield* service.uninstall).toBe(true); | 356 | expect(yield* service.uninstall).toBe(true); |
| 380 | expect((yield* service.status).installed).toBe(false); | 357 | expect((yield* service.status).installed).toBe(false); |
| 381 | expect(commands.some((command) => command.startsWith("npm "))).toBe(false); | ||
| 382 | // The stop can block up to systemd's 90s TimeoutStopSec; the runner's | 358 | // The stop can block up to systemd's 90s TimeoutStopSec; the runner's |
| 383 | // 60s default would cancel it mid-shutdown. | 359 | // 60s default would cancel it mid-shutdown. |
| 384 | expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( | 360 | expect(timeouts.get("systemctl --user disable --now t3code.service")).toEqual( |
| ... | @@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -432,7 +408,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 432 | Effect.gen(function* () { | 408 | Effect.gen(function* () { |
| 433 | const { service, fs, statePath, commands, control } = yield* makeHarness(platform); | 409 | const { service, fs, statePath, commands, control } = yield* makeHarness(platform); |
| 434 | const plan = yield* service.install(); | 410 | const plan = yield* service.install(); |
| 435 | const launcher = yield* fs.readFileString(plan.launcherPath); | ||
| 436 | const unit = yield* fs.readFileString(plan.unitPath); | 411 | const unit = yield* fs.readFileString(plan.unitPath); |
| 437 | control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; | 412 | control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; |
| 438 | commands.length = 0; | 413 | commands.length = 0; |
| ... | @@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -445,7 +420,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 445 | targetVersion: "1.2.3", | 420 | targetVersion: "1.2.3", |
| 446 | }); | 421 | }); |
| 447 | expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); | 422 | expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); |
| 448 | expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); | ||
| 449 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); | 423 | expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); |
| 450 | expect( | 424 | expect( |
| 451 | commands.filter( | 425 | commands.filter( |
| ... | @@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -495,17 +469,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 495 | }), | 469 | }), |
| 496 | ); | 470 | ); |
| 497 | 471 | ||
| 498 | it.effect("copies the launcher from the prepared pinned runtime", () => | ||
| 499 | Effect.gen(function* () { | ||
| 500 | const { service, fs } = yield* makeHarness("linux", true); | ||
| 501 | const plan = yield* service.install(); | ||
| 502 | |||
| 503 | expect(yield* fs.readFileString(plan.launcherPath)).toBe( | ||
| 504 | "export const source = 'pinned runtime';\n", | ||
| 505 | ); | ||
| 506 | }), | ||
| 507 | ); | ||
| 508 | |||
| 509 | it.effect("restarts an installed service when repair fails", () => | 472 | it.effect("restarts an installed service when repair fails", () => |
| 510 | Effect.gen(function* () { | 473 | Effect.gen(function* () { |
| 511 | const { service, commands, control } = yield* makeHarness(); | 474 | const { service, commands, control } = yield* makeHarness(); |
| ... | @@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -572,7 +535,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 572 | 535 | ||
| 573 | it.effect("installs, reports current state, and uninstalls on macOS", () => | 536 | it.effect("installs, reports current state, and uninstalls on macOS", () => |
| 574 | Effect.gen(function* () { | 537 | Effect.gen(function* () { |
| 575 | const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); | 538 | const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin"); |
| 576 | const path = yield* Path.Path; | 539 | const path = yield* Path.Path; |
| 577 | const plan = yield* service.install(); | 540 | const plan = yield* service.install(); |
| 578 | 541 | ||
| ... | @@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -588,14 +551,15 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 588 | protocol: SERVICE_LAUNCHER_PROTOCOL, | 551 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| 589 | activeVersion: "1.2.3", | 552 | activeVersion: "1.2.3", |
| 590 | }); | 553 | }); |
| 591 | expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); | 554 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| 555 | ` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`, | ||
| 556 | ); | ||
| 592 | expect(yield* service.status).toMatchObject({ | 557 | expect(yield* service.status).toMatchObject({ |
| 593 | current: true, | 558 | current: true, |
| 594 | installedVersion: "1.2.3", | 559 | installedVersion: "1.2.3", |
| 595 | }); | 560 | }); |
| 596 | expect(yield* service.uninstall).toBe(true); | 561 | expect(yield* service.uninstall).toBe(true); |
| 597 | expect((yield* service.status).installed).toBe(false); | 562 | expect((yield* service.status).installed).toBe(false); |
| 598 | expect(commands.some((command) => command.startsWith("npm "))).toBe(false); | ||
| 599 | expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); | 563 | expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false); |
| 600 | // A bootout can block up to the plist's 90s ExitTimeOut; the runner's | 564 | // A bootout can block up to the plist's 90s ExitTimeOut; the runner's |
| 601 | // 60s default would cancel it and let bootstrap race a loaded job. | 565 | // 60s default would cancel it and let bootstrap race a loaded job. |
| ... | @@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -626,7 +590,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 626 | 590 | ||
| 627 | it.effect("reconstructs a launch agent search path when the installer has no PATH", () => | 591 | it.effect("reconstructs a launch agent search path when the installer has no PATH", () => |
| 628 | Effect.gen(function* () { | 592 | Effect.gen(function* () { |
| 629 | const { service, fs } = yield* makeHarness("darwin", false, ""); | 593 | const { service, fs } = yield* makeHarness("darwin", ""); |
| 630 | const plan = yield* service.install(); | 594 | const plan = yield* service.install(); |
| 631 | 595 | ||
| 632 | expect(yield* fs.readFileString(plan.unitPath)).toContain( | 596 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| ... | @@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -638,7 +602,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 638 | 602 | ||
| 639 | it.effect("adds missing provider directories to a minimal installer PATH", () => | 603 | it.effect("adds missing provider directories to a minimal installer PATH", () => |
| 640 | Effect.gen(function* () { | 604 | Effect.gen(function* () { |
| 641 | const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); | 605 | const { service, fs } = yield* makeHarness("darwin", "/usr/bin:/bin"); |
| 642 | const plan = yield* service.install(); | 606 | const plan = yield* service.install(); |
| 643 | 607 | ||
| 644 | expect(yield* fs.readFileString(plan.unitPath)).toContain( | 608 | expect(yield* fs.readFileString(plan.unitPath)).toContain( |
| ... | @@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { | ... | @@ -662,7 +626,6 @@ it.layer(NodeServices.layer)("boot service install", (it) => { |
| 662 | Effect.gen(function* () { | 626 | Effect.gen(function* () { |
| 663 | const { service, fs } = yield* makeHarness( | 627 | const { service, fs } = yield* makeHarness( |
| 664 | "darwin", | 628 | "darwin", |
| 665 | false, | ||
| 666 | "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", | 629 | "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", |
| 667 | ); | 630 | ); |
| 668 | const plan = yield* service.install(); | 631 | const plan = yield* service.install(); |
apps/server/src/cloud/bootService.ts+12-38| ... | @@ -26,7 +26,6 @@ import { | ... | @@ -26,7 +26,6 @@ import { |
| 26 | PinnedRuntimeInstallError, | 26 | PinnedRuntimeInstallError, |
| 27 | } from "./pinnedRuntime.ts"; | 27 | } from "./pinnedRuntime.ts"; |
| 28 | import { | 28 | import { |
| 29 | SERVICE_LAUNCHER_FILE, | ||
| 30 | SERVICE_LAUNCHER_PROTOCOL, | 29 | SERVICE_LAUNCHER_PROTOCOL, |
| 31 | SERVICE_STATE_FILE, | 30 | SERVICE_STATE_FILE, |
| 32 | compareExactServiceVersions, | 31 | compareExactServiceVersions, |
| ... | @@ -86,7 +85,6 @@ export interface BootServicePlan { | ... | @@ -86,7 +85,6 @@ export interface BootServicePlan { |
| 86 | * subcommand so the machine never needs Node. | 85 | * subcommand so the machine never needs Node. |
| 87 | */ | 86 | */ |
| 88 | readonly program: ReadonlyArray<string>; | 87 | readonly program: ReadonlyArray<string>; |
| 89 | readonly launcherPath: string; | ||
| 90 | readonly baseDir: string; | 88 | readonly baseDir: string; |
| 91 | readonly logPath: string; | 89 | readonly logPath: string; |
| 92 | readonly unitPath: string; | 90 | readonly unitPath: string; |
| ... | @@ -533,7 +531,6 @@ export class BootService extends Context.Service< | ... | @@ -533,7 +531,6 @@ export class BootService extends Context.Service< |
| 533 | 531 | ||
| 534 | export interface BootServiceHost { | 532 | export interface BootServiceHost { |
| 535 | readonly execPath: string; | 533 | readonly execPath: string; |
| 536 | readonly launcherSourcePath?: string; | ||
| 537 | } | 534 | } |
| 538 | 535 | ||
| 539 | export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | 536 | export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| ... | @@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -546,9 +543,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 546 | const platform = yield* HostProcessPlatform; | 543 | const platform = yield* HostProcessPlatform; |
| 547 | const arch = yield* HostProcessArchitecture; | 544 | const arch = yield* HostProcessArchitecture; |
| 548 | const uid = yield* HostProcessUserId; | 545 | const uid = yield* HostProcessUserId; |
| 549 | // Archive-distributed versions download from GitHub Releases; npm versions | 546 | const httpClient = yield* HttpClient.HttpClient; |
| 550 | // never touch HTTP, so callers without a client still work. | ||
| 551 | const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); | ||
| 552 | const releaseBaseUrl = Option.getOrUndefined( | 547 | const releaseBaseUrl = Option.getOrUndefined( |
| 553 | yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), | 548 | yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), |
| 554 | ); | 549 | ); |
| ... | @@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -588,12 +583,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 588 | }); | 583 | }); |
| 589 | const unitPath = detectedManager?.unitPath ?? ""; | 584 | const unitPath = detectedManager?.unitPath ?? ""; |
| 590 | const logPath = path.join(input.logsDir, "boot-service.log"); | 585 | const logPath = path.join(input.logsDir, "boot-service.log"); |
| 591 | const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); | ||
| 592 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); | 586 | const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); |
| 593 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); | 587 | const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion, platform); |
| 594 | const launcherSourcePath = | ||
| 595 | host.launcherSourcePath ?? | ||
| 596 | path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); | ||
| 597 | const writeDurably = (filePath: string, contents: string) => | 588 | const writeDurably = (filePath: string, contents: string) => |
| 598 | Effect.scoped( | 589 | Effect.scoped( |
| 599 | Effect.gen(function* () { | 590 | Effect.gen(function* () { |
| ... | @@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -613,12 +604,10 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 613 | ); | 604 | ); |
| 614 | }), | 605 | }), |
| 615 | ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 606 | ).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 607 | // The executable hosts the launcher as a hidden subcommand of itself, so | ||
| 608 | // the unit runs the pinned runtime directly. | ||
| 616 | const plan: BootServicePlan = { | 609 | const plan: BootServicePlan = { |
| 617 | program: | 610 | program: [runtimePaths.entryPath, "__service-launcher"], |
| 618 | runtimePaths.layout === "archive" | ||
| 619 | ? [runtimePaths.entryPath, "__service-launcher"] | ||
| 620 | : [host.execPath, launcherPath], | ||
| 621 | launcherPath, | ||
| 622 | baseDir: input.baseDir, | 611 | baseDir: input.baseDir, |
| 623 | logPath, | 612 | logPath, |
| 624 | unitPath, | 613 | unitPath, |
| ... | @@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -764,8 +753,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 764 | validate: (runtime) => | 753 | validate: (runtime) => |
| 765 | runner | 754 | runner |
| 766 | .run({ | 755 | .run({ |
| 767 | command: pinnedRuntimeCommand(runtime, host.execPath).command, | 756 | command: pinnedRuntimeCommand(runtime).command, |
| 768 | args: [...pinnedRuntimeCommand(runtime, host.execPath).args, "--version"], | 757 | args: [...pinnedRuntimeCommand(runtime).args, "--version"], |
| 769 | timeout: Duration.seconds(30), | 758 | timeout: Duration.seconds(30), |
| 770 | }) | 759 | }) |
| 771 | .pipe( | 760 | .pipe( |
| ... | @@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -803,15 +792,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 803 | : new BootServiceInstallError({ cause: error }), | 792 | : new BootServiceInstallError({ cause: error }), |
| 804 | ), | 793 | ), |
| 805 | ); | 794 | ); |
| 806 | // Archive runtimes host the launcher in the executable itself; there is | ||
| 807 | // no standalone script to copy. | ||
| 808 | const launcherSource = | ||
| 809 | runtimePaths.layout === "archive" | ||
| 810 | ? undefined | ||
| 811 | : yield* fs | ||
| 812 | .readFileString(launcherSourcePath) | ||
| 813 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | ||
| 814 | |||
| 815 | const installed = yield* fs | 795 | const installed = yield* fs |
| 816 | .exists(unitPath) | 796 | .exists(unitPath) |
| 817 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 797 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| ... | @@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -844,9 +824,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 844 | yield* fs | 824 | yield* fs |
| 845 | .makeDirectory(path.dirname(unitPath), { recursive: true }) | 825 | .makeDirectory(path.dirname(unitPath), { recursive: true }) |
| 846 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); | 826 | .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); |
| 847 | if (launcherSource !== undefined) { | ||
| 848 | yield* writeDurably(launcherPath, launcherSource); | ||
| 849 | } | ||
| 850 | yield* writeDurably( | 827 | yield* writeDurably( |
| 851 | statePath, | 828 | statePath, |
| 852 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. | 829 | // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned document. |
| ... | @@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -893,14 +870,12 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 893 | if (!(yield* fs.exists(unitPath))) { | 870 | if (!(yield* fs.exists(unitPath))) { |
| 894 | return { supported: true, installed: false, current: false, unitPath, logPath }; | 871 | return { supported: true, installed: false, current: false, unitPath, logPath }; |
| 895 | } | 872 | } |
| 896 | const [unit, launcherExists, runtimeEntryExists, runtimeSentinel, stateText] = | 873 | const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([ |
| 897 | yield* Effect.all([ | 874 | fs.readFileString(unitPath), |
| 898 | fs.readFileString(unitPath), | 875 | fs.exists(runtimePaths.entryPath), |
| 899 | runtimePaths.layout === "archive" ? Effect.succeed(true) : fs.exists(launcherPath), | 876 | fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), |
| 900 | fs.exists(runtimePaths.entryPath), | 877 | fs.readFileString(statePath).pipe(Effect.option), |
| 901 | fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option), | 878 | ]); |
| 902 | fs.readFileString(statePath).pipe(Effect.option), | ||
| 903 | ]); | ||
| 904 | const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; | 879 | const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; |
| 905 | const installedVersion = Option.isSome(stateText) | 880 | const installedVersion = Option.isSome(stateText) |
| 906 | ? serviceStateActiveVersion(stateText.value) | 881 | ? serviceStateActiveVersion(stateText.value) |
| ... | @@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { | ... | @@ -920,7 +895,6 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { |
| 920 | current: | 895 | current: |
| 921 | problems.length === 0 && | 896 | problems.length === 0 && |
| 922 | normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && | 897 | normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && |
| 923 | launcherExists && | ||
| 924 | runtimeEntryExists && | 898 | runtimeEntryExists && |
| 925 | Option.isSome(runtimeSentinel) && | 899 | Option.isSome(runtimeSentinel) && |
| 926 | runtimeSentinel.value.trim() === input.cliVersion && | 900 | runtimeSentinel.value.trim() === input.cliVersion && |
apps/server/src/cloud/pinnedRuntime.test.ts+79-193| ... | @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,7 +5,6 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | 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"; | ||
| 9 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 8 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 10 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 9 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 11 | 10 | ||
| ... | @@ -17,16 +16,38 @@ import { | ... | @@ -17,16 +16,38 @@ import { |
| 17 | PinnedRuntimeInstallError, | 16 | PinnedRuntimeInstallError, |
| 18 | } from "./pinnedRuntime.ts"; | 17 | } from "./pinnedRuntime.ts"; |
| 19 | 18 | ||
| 20 | const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => | 19 | // Every install fetches the release archive, checks it against SHA256SUMS, |
| 20 | // and unpacks it with tar. The fake client serves both files; the fake runner | ||
| 21 | // stands in for tar and drops the executable where extraction would. | ||
| 22 | const version = "1.2.3"; | ||
| 23 | const archiveName = `t3-${version}-linux-x64.tar.gz`; | ||
| 24 | const archiveBytes = new TextEncoder().encode("not really a tarball"); | ||
| 25 | const archiveHex = (bytes: Uint8Array) => | ||
| 26 | Effect.promise(() => crypto.subtle.digest("SHA-256", bytes)).pipe( | ||
| 27 | Effect.map((digest) => | ||
| 28 | Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""), | ||
| 29 | ), | ||
| 30 | ); | ||
| 31 | const validChecksums = archiveHex(archiveBytes).pipe( | ||
| 32 | Effect.map((hex) => `${hex} ${archiveName}\n`), | ||
| 33 | ); | ||
| 34 | const releaseHttpClient = (checksums: string, requests: string[] = []) => | ||
| 35 | HttpClient.make((request) => { | ||
| 36 | requests.push(request.url); | ||
| 37 | const body = request.url.endsWith("/SHA256SUMS") ? checksums : archiveBytes; | ||
| 38 | return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body))); | ||
| 39 | }); | ||
| 40 | const extractingRunner = (fs: FileSystem.FileSystem, path: Path.Path, commands: string[] = []) => | ||
| 21 | ProcessRunner.ProcessRunner.of({ | 41 | ProcessRunner.ProcessRunner.of({ |
| 22 | run: (input) => | 42 | run: (input) => |
| 23 | Effect.gen(function* () { | 43 | Effect.gen(function* () { |
| 24 | const prefixIndex = input.args.indexOf("--prefix"); | 44 | commands.push(input.command); |
| 25 | const stagingDir = input.args[prefixIndex + 1]; | 45 | const targetIndex = input.args.indexOf("-C"); |
| 26 | if (stagingDir === undefined) return yield* Effect.die("missing npm --prefix"); | 46 | const stagingDir = input.args[targetIndex + 1]; |
| 27 | const entry = path.join(stagingDir, "node_modules", "t3", "dist", "bin.mjs"); | 47 | if (input.command !== "tar" || stagingDir === undefined) { |
| 28 | yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); | 48 | return yield* Effect.die(`unexpected command ${input.command}`); |
| 29 | yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); | 49 | } |
| 50 | yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); | ||
| 30 | return { | 51 | return { |
| 31 | stdout: "", | 52 | stdout: "", |
| 32 | stderr: "", | 53 | stderr: "", |
| ... | @@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => | ... | @@ -41,85 +62,62 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => |
| 41 | }); | 62 | }); |
| 42 | 63 | ||
| 43 | it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | 64 | it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 44 | it.effect("installs through pnpm when its Node runtime has no npm executable", () => | 65 | it.effect("installs the verified release archive as the runtime executable", () => |
| 45 | Effect.gen(function* () { | 66 | Effect.gen(function* () { |
| 46 | const fs = yield* FileSystem.FileSystem; | 67 | const fs = yield* FileSystem.FileSystem; |
| 47 | const path = yield* Path.Path; | 68 | const path = yield* Path.Path; |
| 48 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); | 69 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-" }); |
| 49 | const commands: Array<ProcessRunner.ProcessRunInput> = []; | 70 | const requests: string[] = []; |
| 50 | const install = successfulRunner(fs, path); | 71 | const commands: string[] = []; |
| 51 | const paths = yield* ensurePinnedRuntimeInstalled({ | 72 | const paths = yield* ensurePinnedRuntimeInstalled({ |
| 52 | baseDir, | 73 | baseDir, |
| 53 | version: "1.2.3", | 74 | version, |
| 54 | fs, | 75 | fs, |
| 55 | path, | 76 | path, |
| 56 | platform: "linux", | 77 | platform: "linux", |
| 57 | arch: "x64", | 78 | arch: "x64", |
| 58 | runner: ProcessRunner.ProcessRunner.of({ | 79 | httpClient: releaseHttpClient(yield* validChecksums, requests), |
| 59 | run: (input) => { | 80 | releaseBaseUrl: "https://releases.example/download", |
| 60 | commands.push(input); | 81 | runner: extractingRunner(fs, path, commands), |
| 61 | return input.command === "npm" | ||
| 62 | ? Effect.fail( | ||
| 63 | new ProcessRunner.ProcessSpawnError({ | ||
| 64 | command: "npm", | ||
| 65 | argumentCount: input.args.length, | ||
| 66 | cause: PlatformError.systemError({ | ||
| 67 | _tag: "NotFound", | ||
| 68 | module: "ChildProcess", | ||
| 69 | method: "spawn", | ||
| 70 | }), | ||
| 71 | }), | ||
| 72 | ) | ||
| 73 | : install.run(input); | ||
| 74 | }, | ||
| 75 | }), | ||
| 76 | validate: (staging) => | 82 | validate: (staging) => |
| 77 | fs.exists(staging.entryPath).pipe( | 83 | fs.exists(staging.entryPath).pipe( |
| 78 | Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), | 84 | Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), |
| 79 | Effect.orDie, | 85 | Effect.orDie, |
| 80 | ), | 86 | ), |
| 81 | }); | 87 | }); |
| 82 | assert.deepEqual( | 88 | assert.equal(paths.entryPath, path.join(paths.versionDir, "t3")); |
| 83 | commands.map((command) => command.command), | 89 | assert.deepEqual(pinnedRuntimeCommand(paths), { command: paths.entryPath, args: [] }); |
| 84 | ["npm", "pnpm"], | 90 | assert.deepEqual(requests, [ |
| 85 | ); | 91 | `https://releases.example/download/v${version}/SHA256SUMS`, |
| 86 | assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); | 92 | `https://releases.example/download/v${version}/${archiveName}`, |
| 87 | assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); | 93 | ]); |
| 94 | assert.deepEqual(commands, ["tar"]); | ||
| 95 | assert.equal(yield* fs.readFileString(paths.sentinelPath), `${version}\n`); | ||
| 96 | assert.isFalse(yield* fs.exists(path.join(paths.versionDir, "t3-runtime-archive"))); | ||
| 88 | }), | 97 | }), |
| 89 | ); | 98 | ); |
| 90 | 99 | ||
| 91 | it.effect("does not try a different installer for npm permission failures", () => | 100 | it.effect("refuses an archive whose checksum does not match the release", () => |
| 92 | Effect.gen(function* () { | 101 | Effect.gen(function* () { |
| 93 | const fs = yield* FileSystem.FileSystem; | 102 | const fs = yield* FileSystem.FileSystem; |
| 94 | const path = yield* Path.Path; | 103 | const path = yield* Path.Path; |
| 95 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); | 104 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-archive-bad-" }); |
| 96 | const commands: string[] = []; | 105 | const commands: string[] = []; |
| 97 | yield* ensurePinnedRuntimeInstalled({ | 106 | const error = yield* ensurePinnedRuntimeInstalled({ |
| 98 | baseDir, | 107 | baseDir, |
| 99 | version: "1.2.3", | 108 | version, |
| 100 | fs, | 109 | fs, |
| 101 | path, | 110 | path, |
| 102 | platform: "linux", | 111 | platform: "linux", |
| 103 | arch: "x64", | 112 | arch: "x64", |
| 104 | runner: ProcessRunner.ProcessRunner.of({ | 113 | httpClient: releaseHttpClient(`${"0".repeat(64)} ${archiveName}\n`), |
| 105 | run: (input) => { | 114 | runner: extractingRunner(fs, path, commands), |
| 106 | commands.push(input.command); | 115 | validate: () => Effect.die("must not validate an unverified archive"), |
| 107 | return Effect.fail( | ||
| 108 | new ProcessRunner.ProcessSpawnError({ | ||
| 109 | command: input.command, | ||
| 110 | argumentCount: input.args.length, | ||
| 111 | cause: PlatformError.systemError({ | ||
| 112 | _tag: "PermissionDenied", | ||
| 113 | module: "ChildProcess", | ||
| 114 | method: "spawn", | ||
| 115 | }), | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | }, | ||
| 119 | }), | ||
| 120 | validate: () => Effect.die("must not validate a failed install"), | ||
| 121 | }).pipe(Effect.flip); | 116 | }).pipe(Effect.flip); |
| 122 | assert.deepEqual(commands, ["npm"]); | 117 | assert.instanceOf(error, PinnedRuntimeInstallError); |
| 118 | assert.equal(error.step, "verifying the t3 release archive checksum"); | ||
| 119 | assert.deepEqual(commands, []); | ||
| 120 | assert.deepEqual(yield* fs.readDirectory(path.join(baseDir, "runtime", "versions")), []); | ||
| 123 | }), | 121 | }), |
| 124 | ); | 122 | ); |
| 125 | 123 | ||
| ... | @@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -128,17 +126,18 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 128 | const fs = yield* FileSystem.FileSystem; | 126 | const fs = yield* FileSystem.FileSystem; |
| 129 | const path = yield* Path.Path; | 127 | const path = yield* Path.Path; |
| 130 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); | 128 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); |
| 131 | const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); | 129 | const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); |
| 132 | let validatedDirectory = ""; | 130 | let validatedDirectory = ""; |
| 133 | 131 | ||
| 134 | const installed = yield* ensurePinnedRuntimeInstalled({ | 132 | const installed = yield* ensurePinnedRuntimeInstalled({ |
| 135 | baseDir, | 133 | baseDir, |
| 136 | version: "1.2.3", | 134 | version, |
| 137 | fs, | 135 | fs, |
| 138 | path, | 136 | path, |
| 139 | platform: "linux", | 137 | platform: "linux", |
| 140 | arch: "x64", | 138 | arch: "x64", |
| 141 | runner: successfulRunner(fs, path), | 139 | httpClient: releaseHttpClient(yield* validChecksums), |
| 140 | runner: extractingRunner(fs, path), | ||
| 142 | validate: (staging) => | 141 | validate: (staging) => |
| 143 | Effect.gen(function* () { | 142 | Effect.gen(function* () { |
| 144 | validatedDirectory = staging.versionDir; | 143 | validatedDirectory = staging.versionDir; |
| ... | @@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -150,7 +149,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 150 | assert.notEqual(validatedDirectory, finalPaths.versionDir); | 149 | assert.notEqual(validatedDirectory, finalPaths.versionDir); |
| 151 | assert.deepEqual(installed, finalPaths); | 150 | assert.deepEqual(installed, finalPaths); |
| 152 | assert.isTrue(yield* fs.exists(finalPaths.entryPath)); | 151 | assert.isTrue(yield* fs.exists(finalPaths.entryPath)); |
| 153 | assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), "1.2.3\n"); | 152 | assert.equal(yield* fs.readFileString(finalPaths.sentinelPath), `${version}\n`); |
| 154 | }), | 153 | }), |
| 155 | ); | 154 | ); |
| 156 | 155 | ||
| ... | @@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -159,16 +158,17 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 159 | const fs = yield* FileSystem.FileSystem; | 158 | const fs = yield* FileSystem.FileSystem; |
| 160 | const path = yield* Path.Path; | 159 | const path = yield* Path.Path; |
| 161 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); | 160 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-test-" }); |
| 162 | const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); | 161 | const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); |
| 163 | 162 | ||
| 164 | yield* ensurePinnedRuntimeInstalled({ | 163 | yield* ensurePinnedRuntimeInstalled({ |
| 165 | baseDir, | 164 | baseDir, |
| 166 | version: "1.2.3", | 165 | version, |
| 167 | fs, | 166 | fs, |
| 168 | path, | 167 | path, |
| 169 | platform: "linux", | 168 | platform: "linux", |
| 170 | arch: "x64", | 169 | arch: "x64", |
| 171 | runner: successfulRunner(fs, path), | 170 | httpClient: releaseHttpClient(yield* validChecksums), |
| 171 | runner: extractingRunner(fs, path), | ||
| 172 | validate: () => | 172 | validate: () => |
| 173 | Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), | 173 | Effect.fail(new PinnedRuntimeInstallError({ step: "validating the staged runtime" })), |
| 174 | }).pipe(Effect.flip); | 174 | }).pipe(Effect.flip); |
| ... | @@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -188,18 +188,19 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 188 | const fs = yield* FileSystem.FileSystem; | 188 | const fs = yield* FileSystem.FileSystem; |
| 189 | const path = yield* Path.Path; | 189 | const path = yield* Path.Path; |
| 190 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); | 190 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); |
| 191 | const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); | 191 | const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); |
| 192 | yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); | 192 | yield* fs.makeDirectory(finalPaths.versionDir, { recursive: true }); |
| 193 | yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); | 193 | yield* fs.writeFileString(path.join(finalPaths.versionDir, "partial"), "incomplete\n"); |
| 194 | 194 | ||
| 195 | yield* ensurePinnedRuntimeInstalled({ | 195 | yield* ensurePinnedRuntimeInstalled({ |
| 196 | baseDir, | 196 | baseDir, |
| 197 | version: "1.2.3", | 197 | version, |
| 198 | fs, | 198 | fs, |
| 199 | path, | 199 | path, |
| 200 | platform: "linux", | 200 | platform: "linux", |
| 201 | arch: "x64", | 201 | arch: "x64", |
| 202 | runner: successfulRunner(fs, path), | 202 | httpClient: releaseHttpClient(yield* validChecksums), |
| 203 | runner: extractingRunner(fs, path), | ||
| 203 | validate: () => Effect.void, | 204 | validate: () => Effect.void, |
| 204 | }); | 205 | }); |
| 205 | 206 | ||
| ... | @@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -213,20 +214,22 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 213 | const fs = yield* FileSystem.FileSystem; | 214 | const fs = yield* FileSystem.FileSystem; |
| 214 | const path = yield* Path.Path; | 215 | const path = yield* Path.Path; |
| 215 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); | 216 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-runtime-repair-" }); |
| 216 | const finalPaths = pinnedRuntimePaths(path, baseDir, "1.2.3", "linux"); | 217 | const finalPaths = pinnedRuntimePaths(path, baseDir, version, "linux"); |
| 217 | yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); | 218 | yield* fs.makeDirectory(path.dirname(finalPaths.entryPath), { recursive: true }); |
| 218 | yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); | 219 | yield* fs.writeFileString(finalPaths.entryPath, "broken\n"); |
| 219 | yield* fs.writeFileString(finalPaths.sentinelPath, "1.2.3\n"); | 220 | yield* fs.writeFileString(finalPaths.sentinelPath, `${version}\n`); |
| 220 | 221 | ||
| 221 | let validations = 0; | 222 | let validations = 0; |
| 223 | const requests: string[] = []; | ||
| 222 | yield* ensurePinnedRuntimeInstalled({ | 224 | yield* ensurePinnedRuntimeInstalled({ |
| 223 | baseDir, | 225 | baseDir, |
| 224 | version: "1.2.3", | 226 | version, |
| 225 | fs, | 227 | fs, |
| 226 | path, | 228 | path, |
| 227 | platform: "linux", | 229 | platform: "linux", |
| 228 | arch: "x64", | 230 | arch: "x64", |
| 229 | runner: successfulRunner(fs, path), | 231 | httpClient: releaseHttpClient(yield* validChecksums, requests), |
| 232 | runner: extractingRunner(fs, path), | ||
| 230 | validate: (paths) => | 233 | validate: (paths) => |
| 231 | Effect.gen(function* () { | 234 | Effect.gen(function* () { |
| 232 | validations += 1; | 235 | validations += 1; |
| ... | @@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -238,6 +241,7 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 238 | }).pipe(Effect.flip); | 241 | }).pipe(Effect.flip); |
| 239 | 242 | ||
| 240 | assert.equal(validations, 1); | 243 | assert.equal(validations, 1); |
| 244 | assert.deepEqual(requests, []); | ||
| 241 | assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); | 245 | assert.equal(yield* fs.readFileString(finalPaths.entryPath), "broken\n"); |
| 242 | }), | 246 | }), |
| 243 | ); | 247 | ); |
| ... | @@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -253,11 +257,12 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 253 | }); | 257 | }); |
| 254 | const install = yield* ensurePinnedRuntimeInstalled({ | 258 | const install = yield* ensurePinnedRuntimeInstalled({ |
| 255 | baseDir, | 259 | baseDir, |
| 256 | version: "1.2.3", | 260 | version, |
| 257 | fs, | 261 | fs, |
| 258 | path, | 262 | path, |
| 259 | platform: "linux", | 263 | platform: "linux", |
| 260 | arch: "x64", | 264 | arch: "x64", |
| 265 | httpClient: releaseHttpClient(yield* validChecksums), | ||
| 261 | runner, | 266 | runner, |
| 262 | validate: () => Effect.void, | 267 | validate: () => Effect.void, |
| 263 | }).pipe(Effect.forkScoped); | 268 | }).pipe(Effect.forkScoped); |
| ... | @@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { | ... | @@ -268,123 +273,4 @@ it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { |
| 268 | assert.deepEqual(yield* fs.readDirectory(versionsDir), []); | 273 | assert.deepEqual(yield* fs.readDirectory(versionsDir), []); |
| 269 | }), | 274 | }), |
| 270 | ); | 275 | ); |
| 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 | }); | ||
| 390 | }); | 276 | }); |
apps/server/src/cloud/pinnedRuntime.ts+25-108| ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; | ... | @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; |
| 3 | import * as Encoding from "effect/Encoding"; | 3 | import * as Encoding from "effect/Encoding"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; | 5 | import * as Path from "effect/Path"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | ||
| 7 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 8 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 9 | import * as Semaphore from "effect/Semaphore"; | 8 | import * as Semaphore from "effect/Semaphore"; |
| ... | @@ -15,26 +14,21 @@ import { | ... | @@ -15,26 +14,21 @@ import { |
| 15 | cliArchivePlatformKey, | 14 | cliArchivePlatformKey, |
| 16 | cliArchiveTarCommand, | 15 | cliArchiveTarCommand, |
| 17 | cliReleaseDownloadBaseUrl, | 16 | cliReleaseDownloadBaseUrl, |
| 18 | isArchiveDistributedVersion, | ||
| 19 | parseChecksums, | 17 | parseChecksums, |
| 20 | } from "@t3tools/shared/cliRelease"; | 18 | } from "@t3tools/shared/cliRelease"; |
| 21 | 19 | ||
| 22 | import * as ProcessRunner from "../processRunner.ts"; | 20 | import * as ProcessRunner from "../processRunner.ts"; |
| 23 | 21 | ||
| 24 | /** | 22 | /** |
| 25 | * A pinned runtime is an exact `t3@<version>` installed into | 23 | * A pinned runtime is an exact t3 release archive unpacked into |
| 26 | * <baseDir>/runtime/versions/<version>. The boot service points its unit or | 24 | * <baseDir>/runtime/versions/<version>: the self-contained executable, the |
| 27 | * launch agent here, and server self-update installs the target version here before | 25 | * web client, and the native packages beside it. The boot service points its |
| 28 | * switching over, never `npx t3`, whose cache is ephemeral and whose | 26 | * unit or launch agent at the executable, and server self-update installs the |
| 29 | * registry fetch at boot would make startup depend on the network. | 27 | * target version here before switching over. The runtime never depends on a |
| 30 | * | 28 | * Node or npm on the machine; the only npm involvement in T3 Code is the `t3` |
| 31 | * Two layouts exist. npm-distributed versions are `npm install`ed and run as | 29 | * package for people who prefer `npx t3` or `npm install -g t3`, and even a |
| 32 | * `<node> node_modules/t3/dist/bin.mjs`. Archive-distributed versions are the | 30 | * CLI installed that way pins an archive when it sets up the service. |
| 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. | ||
| 36 | */ | 31 | */ |
| 37 | |||
| 38 | const PINNED_RUNTIME_DIR = "runtime"; | 32 | const PINNED_RUNTIME_DIR = "runtime"; |
| 39 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); | 33 | const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); |
| 40 | const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; | 34 | const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; |
| ... | @@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; | ... | @@ -42,27 +36,19 @@ const PINNED_RUNTIME_ARCHIVE_FILE = "t3-runtime-archive"; |
| 42 | // the complete install transaction across every caller in this process. | 36 | // the complete install transaction across every caller in this process. |
| 43 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); | 37 | const pinnedRuntimeInstallLock = Semaphore.makeUnsafe(1); |
| 44 | 38 | ||
| 45 | export type PinnedRuntimeLayout = "npm" | "archive"; | ||
| 46 | |||
| 47 | export interface PinnedRuntimePaths { | 39 | export interface PinnedRuntimePaths { |
| 48 | readonly layout: PinnedRuntimeLayout; | ||
| 49 | readonly versionDir: string; | 40 | readonly versionDir: string; |
| 50 | /** | 41 | /** The executable. Its existence is what marks a runtime as present. */ |
| 51 | * `bin.mjs` for npm layouts, the executable itself for archives. Existence | ||
| 52 | * of this file is what marks a runtime as present. | ||
| 53 | */ | ||
| 54 | readonly entryPath: string; | 42 | readonly entryPath: string; |
| 55 | readonly sentinelPath: string; | 43 | readonly sentinelPath: string; |
| 56 | } | 44 | } |
| 57 | 45 | ||
| 58 | /** The exact command that runs a pinned runtime, given the Node hosting the caller. */ | 46 | /** The exact command that runs a pinned runtime. */ |
| 59 | export function pinnedRuntimeCommand( | 47 | export function pinnedRuntimeCommand(paths: PinnedRuntimePaths): { |
| 60 | paths: PinnedRuntimePaths, | 48 | readonly command: string; |
| 61 | nodePath: string, | 49 | readonly args: ReadonlyArray<string>; |
| 62 | ): { readonly command: string; readonly args: ReadonlyArray<string> } { | 50 | } { |
| 63 | return paths.layout === "archive" | 51 | return { command: paths.entryPath, args: [] }; |
| 64 | ? { command: paths.entryPath, args: [] } | ||
| 65 | : { command: nodePath, args: [paths.entryPath] }; | ||
| 66 | } | 52 | } |
| 67 | 53 | ||
| 68 | export function pinnedRuntimePaths( | 54 | export function pinnedRuntimePaths( |
| ... | @@ -72,20 +58,10 @@ export function pinnedRuntimePaths( | ... | @@ -72,20 +58,10 @@ export function pinnedRuntimePaths( |
| 72 | platform: NodeJS.Platform, | 58 | platform: NodeJS.Platform, |
| 73 | ): PinnedRuntimePaths { | 59 | ): PinnedRuntimePaths { |
| 74 | const versionDir = path.join(baseDir, PINNED_RUNTIME_DIR, "versions", version); | 60 | 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 | } | ||
| 84 | return { | 61 | return { |
| 85 | layout: "npm", | ||
| 86 | versionDir, | 62 | versionDir, |
| 87 | entryPath: path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), | 63 | entryPath: path.join(versionDir, platform === "win32" ? "t3.exe" : "t3"), |
| 88 | sentinelPath, | 64 | sentinelPath: path.join(versionDir, ".install-complete"), |
| 89 | }; | 65 | }; |
| 90 | } | 66 | } |
| 91 | 67 | ||
| ... | @@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne | ... | @@ -119,12 +95,12 @@ export class PinnedRuntimePreflightBlockedError extends Schema.TaggedError<Pinne |
| 119 | } | 95 | } |
| 120 | 96 | ||
| 121 | /** | 97 | /** |
| 122 | * Installs `t3@<version>` into the pinned runtime directory unless a complete | 98 | * Installs the t3 release archive for `version` into the pinned runtime |
| 123 | * install is already there, and returns its paths. The sentinel is written | 99 | * directory unless a complete install is already there, and returns its |
| 124 | * only after the install step exits 0; checking the entry file alone is not | 100 | * paths. The sentinel is written only after extraction and validation |
| 125 | * enough. npm extracts files before running native builds (node-pty), and tar | 101 | * succeed; checking the entry file alone is not enough, since tar writes the |
| 126 | * writes the executable before the last native package, so a killed install | 102 | * executable before the last native package and a killed install leaves a |
| 127 | * leaves a plausible-looking but broken tree behind. | 103 | * plausible-looking but broken tree behind. |
| 128 | */ | 104 | */ |
| 129 | interface PinnedRuntimeInstallInput { | 105 | interface PinnedRuntimeInstallInput { |
| 130 | readonly baseDir: string; | 106 | readonly baseDir: string; |
| ... | @@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput { | ... | @@ -137,59 +113,10 @@ interface PinnedRuntimeInstallInput { |
| 137 | ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>; | 113 | ) => Effect.Effect<void, PinnedRuntimeInstallError | PinnedRuntimePreflightBlockedError>; |
| 138 | readonly platform: NodeJS.Platform; | 114 | readonly platform: NodeJS.Platform; |
| 139 | readonly arch: string; | 115 | readonly arch: string; |
| 140 | /** Archive-distributed versions download from here; npm versions never need it. */ | 116 | readonly httpClient: HttpClient.HttpClient; |
| 141 | readonly httpClient?: HttpClient.HttpClient | undefined; | ||
| 142 | readonly releaseBaseUrl?: string | undefined; | 117 | readonly releaseBaseUrl?: string | undefined; |
| 143 | } | 118 | } |
| 144 | 119 | ||
| 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* ( | 120 | const fetchReleaseAsset = Effect.fn("cloud.pinned_runtime.fetch_release_asset")(function* ( |
| 194 | httpClient: HttpClient.HttpClient, | 121 | httpClient: HttpClient.HttpClient, |
| 195 | url: string, | 122 | url: string, |
| ... | @@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun | ... | @@ -227,11 +154,6 @@ const installFromArchive = Effect.fn("cloud.pinned_runtime.install_archive")(fun |
| 227 | }); | 154 | }); |
| 228 | } | 155 | } |
| 229 | const httpClient = input.httpClient; | 156 | 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); | 157 | const baseUrl = cliReleaseDownloadBaseUrl(input.version, input.releaseBaseUrl); |
| 236 | const fileName = cliArchiveFileName(input.version, platformKey); | 158 | const fileName = cliArchiveFileName(input.version, platformKey); |
| 237 | 159 | ||
| ... | @@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( | ... | @@ -356,18 +278,13 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( |
| 356 | ), | 278 | ), |
| 357 | ); | 279 | ); |
| 358 | const stagingPaths: PinnedRuntimePaths = { | 280 | const stagingPaths: PinnedRuntimePaths = { |
| 359 | layout: paths.layout, | ||
| 360 | versionDir: stagingDir, | 281 | versionDir: stagingDir, |
| 361 | entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), | 282 | entryPath: input.path.join(stagingDir, input.path.relative(paths.versionDir, paths.entryPath)), |
| 362 | sentinelPath: input.path.join(stagingDir, ".install-complete"), | 283 | sentinelPath: input.path.join(stagingDir, ".install-complete"), |
| 363 | }; | 284 | }; |
| 364 | 285 | ||
| 365 | return yield* Effect.gen(function* () { | 286 | return yield* Effect.gen(function* () { |
| 366 | if (paths.layout === "archive") { | 287 | yield* installFromArchive(input, stagingDir); |
| 367 | yield* installFromArchive(input, stagingDir); | ||
| 368 | } else { | ||
| 369 | yield* installFromNpm(input, stagingDir); | ||
| 370 | } | ||
| 371 | 288 | ||
| 372 | yield* input.validate(stagingPaths); | 289 | yield* input.validate(stagingPaths); |
| 373 | yield* fs | 290 | yield* fs |
apps/server/src/cloud/selfUpdate.test.ts+33-10| ... | @@ -1,13 +1,14 @@ | ... | @@ -1,13 +1,14 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; | 3 | import { ServerSelfUpdateError, ThreadId } from "@t3tools/contracts"; |
| 4 | import { HostProcessExecutablePath } from "@t3tools/shared/hostProcess"; | 4 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 5 | import * as Cause from "effect/Cause"; | 5 | import * as Cause from "effect/Cause"; |
| 6 | import * as Deferred from "effect/Deferred"; | 6 | import * as Deferred from "effect/Deferred"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 11 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 12 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 12 | 13 | ||
| 13 | import * as ServerConfig from "../config.ts"; | 14 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -25,6 +26,28 @@ interface HarnessOptions { | ... | @@ -25,6 +26,28 @@ interface HarnessOptions { |
| 25 | readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; | 26 | readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; |
| 26 | } | 27 | } |
| 27 | 28 | ||
| 29 | // The staged runtime is a release archive: the fake client serves SHA256SUMS | ||
| 30 | // and the tarball, and the fake runner stands in for tar before it answers | ||
| 31 | // the staged preflight. | ||
| 32 | const archiveBytes = new TextEncoder().encode("not really a tarball"); | ||
| 33 | const releaseHttpClient = (order: string[]) => | ||
| 34 | HttpClient.make((request) => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | if (request.url.endsWith("/SHA256SUMS")) { | ||
| 37 | const digest = yield* Effect.promise(() => crypto.subtle.digest("SHA-256", archiveBytes)); | ||
| 38 | const hex = Array.from(new Uint8Array(digest), (byte) => | ||
| 39 | byte.toString(16).padStart(2, "0"), | ||
| 40 | ).join(""); | ||
| 41 | return HttpClientResponse.fromWeb( | ||
| 42 | request, | ||
| 43 | new Response(`${hex} t3-1.1.0-linux-x64.tar.gz\n`), | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | order.push("download"); | ||
| 47 | return HttpClientResponse.fromWeb(request, new Response(archiveBytes)); | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | |||
| 28 | const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( | 51 | const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 29 | options: HarnessOptions = {}, | 52 | options: HarnessOptions = {}, |
| 30 | ) { | 53 | ) { |
| ... | @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( | ... | @@ -35,13 +58,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 35 | const runner = ProcessRunner.ProcessRunner.of({ | 58 | const runner = ProcessRunner.ProcessRunner.of({ |
| 36 | run: (input) => | 59 | run: (input) => |
| 37 | Effect.gen(function* () { | 60 | Effect.gen(function* () { |
| 38 | if (input.command === "npm") { | 61 | if (input.command === "tar") { |
| 39 | order.push("install"); | 62 | order.push("extract"); |
| 40 | const prefix = input.args[input.args.indexOf("--prefix") + 1]; | 63 | const stagingDir = input.args[input.args.indexOf("-C") + 1]; |
| 41 | if (prefix === undefined) return yield* Effect.die("missing npm prefix"); | 64 | if (stagingDir === undefined) return yield* Effect.die("missing tar target"); |
| 42 | const entry = path.join(prefix, "node_modules", "t3", "dist", "bin.mjs"); | 65 | yield* fs.writeFileString(path.join(stagingDir, "t3"), "#!/bin/sh\n").pipe(Effect.orDie); |
| 43 | yield* fs.makeDirectory(path.dirname(entry), { recursive: true }).pipe(Effect.orDie); | ||
| 44 | yield* fs.writeFileString(entry, "export {};\n").pipe(Effect.orDie); | ||
| 45 | return { | 66 | return { |
| 46 | stdout: "", | 67 | stdout: "", |
| 47 | stderr: "", | 68 | stderr: "", |
| ... | @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( | ... | @@ -99,7 +120,9 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( |
| 99 | run: () => Effect.die("unexpected desktop app update run"), | 120 | run: () => Effect.die("unexpected desktop app update run"), |
| 100 | }, | 121 | }, |
| 101 | ), | 122 | ), |
| 102 | Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), | 123 | Effect.provideService(HttpClient.HttpClient, releaseHttpClient(order)), |
| 124 | Effect.provideService(HostProcessPlatform, "linux"), | ||
| 125 | Effect.provideService(HostProcessArchitecture, "x64"), | ||
| 103 | Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), | 126 | Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), |
| 104 | ); | 127 | ); |
| 105 | return { selfUpdate, order }; | 128 | return { selfUpdate, order }; |
| ... | @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { | ... | @@ -329,7 +352,7 @@ it.layer(NodeServices.layer)("server self update", (it) => { |
| 329 | method: "boot-service", | 352 | method: "boot-service", |
| 330 | updateId: "launcher-id", | 353 | updateId: "launcher-id", |
| 331 | }); | 354 | }); |
| 332 | expect(order).toEqual(["install", "preflight", "accept"]); | 355 | expect(order).toEqual(["download", "extract", "preflight", "accept"]); |
| 333 | }), | 356 | }), |
| 334 | ); | 357 | ); |
| 335 | 358 |
apps/server/src/cloud/selfUpdate.ts+4-9| ... | @@ -6,11 +6,7 @@ import { | ... | @@ -6,11 +6,7 @@ import { |
| 6 | type ServerSelfUpdateResult, | 6 | type ServerSelfUpdateResult, |
| 7 | type ThreadId, | 7 | type ThreadId, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { | 9 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 10 | HostProcessArchitecture, | ||
| 11 | HostProcessExecutablePath, | ||
| 12 | HostProcessPlatform, | ||
| 13 | } from "@t3tools/shared/hostProcess"; | ||
| 14 | import * as Cause from "effect/Cause"; | 10 | import * as Cause from "effect/Cause"; |
| 15 | import * as Config from "effect/Config"; | 11 | import * as Config from "effect/Config"; |
| 16 | import * as Context from "effect/Context"; | 12 | import * as Context from "effect/Context"; |
| ... | @@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { | ... | @@ -180,12 +176,11 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 180 | const runner = yield* ProcessRunner.ProcessRunner; | 176 | const runner = yield* ProcessRunner.ProcessRunner; |
| 181 | const fs = yield* FileSystem.FileSystem; | 177 | const fs = yield* FileSystem.FileSystem; |
| 182 | const path = yield* Path.Path; | 178 | const path = yield* Path.Path; |
| 183 | const execPath = yield* HostProcessExecutablePath; | ||
| 184 | const platform = yield* HostProcessPlatform; | 179 | const platform = yield* HostProcessPlatform; |
| 185 | const arch = yield* HostProcessArchitecture; | 180 | const arch = yield* HostProcessArchitecture; |
| 186 | // Archive-distributed targets download from GitHub Releases. The client is | 181 | // Archive-distributed targets download from GitHub Releases. The client is |
| 187 | // optional so callers without one (tests, npm-only hosts) still construct. | 182 | // optional so callers without one (tests, npm-only hosts) still construct. |
| 188 | const httpClient = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)); | 183 | const httpClient = yield* HttpClient.HttpClient; |
| 189 | const releaseBaseUrl = Option.getOrUndefined( | 184 | const releaseBaseUrl = Option.getOrUndefined( |
| 190 | yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), | 185 | yield* Config.string(CLI_RELEASE_BASE_URL_ENV).pipe(Config.option), |
| 191 | ); | 186 | ); |
| ... | @@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { | ... | @@ -241,9 +236,9 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { |
| 241 | validate: (runtime) => | 236 | validate: (runtime) => |
| 242 | runner | 237 | runner |
| 243 | .run({ | 238 | .run({ |
| 244 | command: pinnedRuntimeCommand(runtime, execPath).command, | 239 | command: pinnedRuntimeCommand(runtime).command, |
| 245 | args: [ | 240 | args: [ |
| 246 | ...pinnedRuntimeCommand(runtime, execPath).args, | 241 | ...pinnedRuntimeCommand(runtime).args, |
| 247 | "__service-preflight", | 242 | "__service-preflight", |
| 248 | "--database-path", | 243 | "--database-path", |
| 249 | serverConfig.dbPath, | 244 | serverConfig.dbPath, |
apps/server/src/cloud/serviceProtocol.ts-1| ... | @@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; | ... | @@ -3,7 +3,6 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; |
| 3 | /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ | 3 | /** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ |
| 4 | export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; | 4 | export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; |
| 5 | export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; | 5 | export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; |
| 6 | export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; | ||
| 7 | export const SERVICE_STATE_FILE = "service-state.json"; | 6 | export const SERVICE_STATE_FILE = "service-state.json"; |
| 8 | /** Written by the launcher just before an explicit stop kills its child, so | 7 | /** Written by the launcher just before an explicit stop kills its child, so |
| 9 | the child can tell "the service is going away" from "the launcher is about | 8 | the child can tell "the service is going away" from "the launcher is about |
apps/server/src/service-launcher.ts deleted-20| ... | @@ -1,20 +0,0 @@ | ||
| 1 | // Standalone launcher entry for npm-distributed runtimes: `node | ||
| 2 | // service-launcher.mjs`. Archive runtimes reach the same code through the | ||
| 3 | // `t3 __service-launcher` subcommand, so serviceLauncher.ts itself must not run | ||
| 4 | // anything on import. | ||
| 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.test.ts+45-20| ... | @@ -75,6 +75,27 @@ it("rejects contradictory service state", () => { | ... | @@ -75,6 +75,27 @@ it("rejects contradictory service state", () => { |
| 75 | ); | 75 | ); |
| 76 | }); | 76 | }); |
| 77 | 77 | ||
| 78 | // A pinned runtime is an executable at <versionDir>/t3. The tests stand one up | ||
| 79 | // as a Node shebang script so the launcher spawns it the way it spawns the | ||
| 80 | // real single-executable, IPC channel included. | ||
| 81 | const writeFakeRuntime = ( | ||
| 82 | fs: FileSystem.FileSystem, | ||
| 83 | path: Path.Path, | ||
| 84 | versionDir: string, | ||
| 85 | childSource: string, | ||
| 86 | ) => | ||
| 87 | Effect.gen(function* () { | ||
| 88 | const entryPath = path.join(versionDir, "t3"); | ||
| 89 | yield* fs.makeDirectory(versionDir, { recursive: true }); | ||
| 90 | yield* fs.writeFileString(entryPath, `#!${process.execPath}\n${childSource}`); | ||
| 91 | yield* fs.chmod(entryPath, 0o755); | ||
| 92 | yield* fs.writeFileString( | ||
| 93 | path.join(versionDir, ".install-complete"), | ||
| 94 | `${path.basename(versionDir)}\n`, | ||
| 95 | ); | ||
| 96 | return entryPath; | ||
| 97 | }); | ||
| 98 | |||
| 78 | it.layer(NodeServices.layer)("service state persistence", (it) => { | 99 | it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 79 | it.effect("durably replaces and strictly reads one state document", () => | 100 | it.effect("durably replaces and strictly reads one state document", () => |
| 80 | Effect.gen(function* () { | 101 | Effect.gen(function* () { |
| ... | @@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { | ... | @@ -98,11 +119,12 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { |
| 98 | const path = yield* Path.Path; | 119 | const path = yield* Path.Path; |
| 99 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); | 120 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-stop-" }); |
| 100 | const statePath = path.join(root, "runtime", "service-state.json"); | 121 | const statePath = path.join(root, "runtime", "service-state.json"); |
| 101 | const versionDir = path.join(root, "runtime", "versions", "1.0.0"); | 122 | yield* writeFakeRuntime( |
| 102 | const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); | 123 | fs, |
| 103 | yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); | 124 | path, |
| 104 | yield* fs.writeFileString(entryPath, "setInterval(() => {}, 1_000);\n"); | 125 | path.join(root, "runtime", "versions", "1.0.0"), |
| 105 | yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); | 126 | "setInterval(() => {}, 1_000);\n", |
| 127 | ); | ||
| 106 | yield* Effect.promise(() => | 128 | yield* Effect.promise(() => |
| 107 | writeServiceState(statePath, { | 129 | writeServiceState(statePath, { |
| 108 | protocol: SERVICE_LAUNCHER_PROTOCOL, | 130 | protocol: SERVICE_LAUNCHER_PROTOCOL, |
| ... | @@ -148,11 +170,12 @@ if (context.update?.status === "pending") { | ... | @@ -148,11 +170,12 @@ if (context.update?.status === "pending") { |
| 148 | } | 170 | } |
| 149 | `; | 171 | `; |
| 150 | for (const version of ["1.0.0", "1.1.0"]) { | 172 | for (const version of ["1.0.0", "1.1.0"]) { |
| 151 | const versionDir = path.join(root, "runtime", "versions", version); | 173 | yield* writeFakeRuntime( |
| 152 | const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); | 174 | fs, |
| 153 | yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); | 175 | path, |
| 154 | yield* fs.writeFileString(entryPath, childSource); | 176 | path.join(root, "runtime", "versions", version), |
| 155 | yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); | 177 | childSource, |
| 178 | ); | ||
| 156 | } | 179 | } |
| 157 | yield* Effect.promise(() => | 180 | yield* Effect.promise(() => |
| 158 | writeServiceState(statePath, { | 181 | writeServiceState(statePath, { |
| ... | @@ -198,11 +221,12 @@ if (context.update?.status === "pending") { | ... | @@ -198,11 +221,12 @@ if (context.update?.status === "pending") { |
| 198 | } | 221 | } |
| 199 | `; | 222 | `; |
| 200 | for (const version of ["1.0.0", "1.1.0"]) { | 223 | for (const version of ["1.0.0", "1.1.0"]) { |
| 201 | const versionDir = path.join(root, "runtime", "versions", version); | 224 | yield* writeFakeRuntime( |
| 202 | const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); | 225 | fs, |
| 203 | yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); | 226 | path, |
| 204 | yield* fs.writeFileString(entryPath, childSource); | 227 | path.join(root, "runtime", "versions", version), |
| 205 | yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); | 228 | childSource, |
| 229 | ); | ||
| 206 | } | 230 | } |
| 207 | yield* Effect.promise(() => | 231 | yield* Effect.promise(() => |
| 208 | writeServiceState(statePath, { | 232 | writeServiceState(statePath, { |
| ... | @@ -257,11 +281,12 @@ if (context.update?.status === "pending") { | ... | @@ -257,11 +281,12 @@ if (context.update?.status === "pending") { |
| 257 | } | 281 | } |
| 258 | `; | 282 | `; |
| 259 | for (const version of ["1.0.0", "1.1.0"]) { | 283 | for (const version of ["1.0.0", "1.1.0"]) { |
| 260 | const versionDir = path.join(root, "runtime", "versions", version); | 284 | yield* writeFakeRuntime( |
| 261 | const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); | 285 | fs, |
| 262 | yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); | 286 | path, |
| 263 | yield* fs.writeFileString(entryPath, childSource); | 287 | path.join(root, "runtime", "versions", version), |
| 264 | yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); | 288 | childSource, |
| 289 | ); | ||
| 265 | } | 290 | } |
| 266 | yield* Effect.promise(() => | 291 | yield* Effect.promise(() => |
| 267 | writeServiceState(statePath, { | 292 | writeServiceState(statePath, { |
apps/server/src/serviceLauncher.ts+11-29| ... | @@ -1,13 +1,14 @@ | ... | @@ -1,13 +1,14 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | // @effect-diagnostics globalTimers:off | 2 | // @effect-diagnostics globalTimers:off |
| 3 | // This file is shipped as a standalone bundle and copied to a stable path by | 3 | // The launcher supervises the server child for the boot service and must keep |
| 4 | // `t3 service update`. Keep runtime imports limited to Node built-ins. | 4 | // working across server versions, so it stays on Node built-ins with no Effect |
| 5 | // runtime: it is the one part of the executable that cannot depend on the | ||
| 6 | // rest of it being loadable. | ||
| 5 | import * as NodeChildProcess from "node:child_process"; | 7 | import * as NodeChildProcess from "node:child_process"; |
| 6 | import * as NodeCrypto from "node:crypto"; | 8 | import * as NodeCrypto from "node:crypto"; |
| 7 | import * as NodeFS from "node:fs"; | 9 | import * as NodeFS from "node:fs"; |
| 8 | import * as NodeFSP from "node:fs/promises"; | 10 | import * as NodeFSP from "node:fs/promises"; |
| 9 | import * as NodePath from "node:path"; | 11 | import * as NodePath from "node:path"; |
| 10 | import * as NodeSea from "node:sea"; | ||
| 11 | 12 | ||
| 12 | import type { | 13 | import type { |
| 13 | PendingServiceUpdate, | 14 | PendingServiceUpdate, |
| ... | @@ -41,37 +42,24 @@ interface ManagedChild { | ... | @@ -41,37 +42,24 @@ interface ManagedChild { |
| 41 | readonly process: NodeChildProcess.ChildProcess; | 42 | readonly process: NodeChildProcess.ChildProcess; |
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | // Mirrors pinnedRuntimePaths: archive-distributed versions are unpacked | 45 | // Mirrors pinnedRuntimePaths: a runtime is an unpacked release archive whose |
| 45 | // release archives whose executable runs on its own, npm versions are a | 46 | // executable runs on its own. Kept inline so this file stays on Node |
| 46 | // bin.mjs the launcher's Node runs. Kept inline so this file stays on Node | ||
| 47 | // built-ins only. | 47 | // built-ins only. |
| 48 | const runtimePaths = (baseDir: string, version: string) => { | 48 | const runtimePaths = (baseDir: string, version: string) => { |
| 49 | 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. | 50 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. |
| 52 | const executableName = process.platform === "win32" ? "t3.exe" : "t3"; | 51 | const executableName = process.platform === "win32" ? "t3.exe" : "t3"; |
| 53 | return { | 52 | return { |
| 54 | versionDir, | 53 | versionDir, |
| 55 | entryPath: archive | 54 | entryPath: NodePath.join(versionDir, executableName), |
| 56 | ? NodePath.join(versionDir, executableName) | ||
| 57 | : NodePath.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"), | ||
| 58 | sentinelPath: NodePath.join(versionDir, ".install-complete"), | 55 | sentinelPath: NodePath.join(versionDir, ".install-complete"), |
| 59 | executable: archive, | ||
| 60 | }; | 56 | }; |
| 61 | }; | 57 | }; |
| 62 | 58 | ||
| 63 | const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) => | 59 | const runtimeSpawnArguments = (paths: ReturnType<typeof runtimePaths>) => ({ |
| 64 | paths.executable | 60 | command: paths.entryPath, |
| 65 | ? { command: paths.entryPath, args: ["serve"] } | 61 | args: ["serve"], |
| 66 | : { command: process.execPath, args: [paths.entryPath, "serve"] }; | 62 | }); |
| 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 | 63 | ||
| 76 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ | 64 | /** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ |
| 77 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; | 65 | const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; |
| ... | @@ -505,12 +493,6 @@ export class Launcher { | ... | @@ -505,12 +493,6 @@ export class Launcher { |
| 505 | await reject("The requested database path is not absolute."); | 493 | await reject("The requested database path is not absolute."); |
| 506 | return; | 494 | return; |
| 507 | } | 495 | } |
| 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 | } | ||
| 514 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { | 496 | if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { |
| 515 | await reject("The requested target runtime is missing or incomplete."); | 497 | await reject("The requested target runtime is missing or incomplete."); |
| 516 | return; | 498 | return; |
docs/operations/release.md+15-13| ... | @@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop | ... | @@ -34,8 +34,8 @@ This document covers the unified release workflow for stable and nightly desktop |
| 34 | - Nightly runs are always GitHub prereleases and never marked latest. | 34 | - Nightly runs are always GitHub prereleases and never marked latest. |
| 35 | - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. | 35 | - Automatically generated release notes are pinned to the previous tag in the same channel, so stable compares to the previous stable tag and nightly compares to the previous nightly tag. |
| 36 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. | 36 | - Includes Electron auto-update metadata (for example `latest*.yml`, `nightly*.yml`, and `*.blockmap`) in release assets. |
| 37 | - Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. | 37 | - Builds a self-contained CLI archive per platform (`t3-<version>-<platform>-<arch>.tar.gz`, `.zip` on Windows) on the same runners as the desktop artifacts and attaches them to the GitHub Release with a `SHA256SUMS` file, on every channel. Only native runners build one (macOS arm64, Linux x64, Windows x64); macOS x64 is skipped because a cross-built executable cannot be verified on real x64 hardware in CI. |
| 38 | - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the download every runtime installer will verify against `SHA256SUMS`. | 38 | - The archive holds the server as a Node single-executable (`scripts/build-cli-archive.ts`), so unpacking it needs neither Node, npm, nor a compiler. It is the only form in which T3 Code manages a runtime: the desktop's SSH environments, the boot service, `t3 update`, and the install scripts all download and verify this archive against `SHA256SUMS`. The npm package exists for people who run `npx t3` or `npm install -g t3` themselves; nothing in the product installs from npm. The `curl | sh` installers are `scripts/install.sh` and `scripts/install.ps1`; the marketing site copies them into its `public/` at build time (`apps/marketing/scripts/stage-install-scripts.mjs`) and serves them at `t3.codes/install.sh` and `/install.ps1`. |
| 39 | - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. | 39 | - The executable is built with a Node that supports `--build-sea` (`VP_NODE_VERSION=26.8.2`, kept in step with `SEA_NODE_VERSION` in `apps/server/vite.config.ts`), while the repo stays on `engines.node`. |
| 40 | - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. | 40 | - macOS archives are signed with the Developer ID certificate and notarized when the Apple secrets are present (ad hoc otherwise, which still runs from `curl`/`tar` installs). Windows executables use the same Azure Trusted Signing setup as the installer. Every native addon in the macOS archive is signed too, since the hardened runtime refuses unsigned libraries. |
| 41 | - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. | 41 | - Each archive is extracted and executed on its build runner (`scripts/smoke-cli-archive.ts`) before it is uploaded. |
| ... | @@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native | ... | @@ -251,12 +251,13 @@ Windows packages the bundled server and only its runtime-external/native |
| 251 | dependency closure in `resources/server.asar`. Native modules and helper | 251 | dependency closure in `resources/server.asar`. Native modules and helper |
| 252 | executables declared as unpacked by that archive must be present at the matching | 252 | executables declared as unpacked by that archive must be present at the matching |
| 253 | paths below `resources/server.asar.unpacked`. The Windows-native backend reads | 253 | paths below `resources/server.asar.unpacked`. The Windows-native backend reads |
| 254 | the archive in place through Electron. Packaged Windows builds also ship a | 254 | the archive in place through Electron. Packaged Windows builds also ship |
| 255 | Linux-only `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar. WSL verifies | 255 | `resources/wsl-runtime.tar.gz` plus its SHA-256 sidecar: the Linux CLI archive |
| 256 | and extracts that archive into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside | 256 | (`t3-<version>-linux-x64.tar.gz`) built by the `build_linux_cli` job and handed |
| 257 | the selected distro, then reuses it for later launches of the same update. The | 257 | to the Windows desktop build as `--wsl-runtime`, copied in verbatim so WSL runs |
| 258 | Windows-side `wsl-server-tree/<version>` extraction remains a fallback and is | 258 | the exact bytes a Linux user downloads. WSL verifies and extracts that archive |
| 259 | removed after the distro-local runtime passes preflight. | 259 | into `~/.t3/wsl-runtime/sha256-<archive-digest>` inside the selected distro, |
| 260 | then reuses it for later launches of the same update. | ||
| 260 | 261 | ||
| 261 | Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only | 262 | Windows keeps JavaScript and package metadata inside `app.asar` and unpacks only |
| 262 | native libraries and helper executables. Avoid enabling whole-package smart | 263 | native libraries and helper executables. Avoid enabling whole-package smart |
| ... | @@ -272,11 +273,12 @@ break: | ... | @@ -272,11 +273,12 @@ break: |
| 272 | - On same-architecture Windows builds, the packaged primary cannot load the fff | 273 | - On same-architecture Windows builds, the packaged primary cannot load the fff |
| 273 | native library from inside `server.asar` through its `.unpacked` sibling. | 274 | native library from inside `server.asar` through its `.unpacked` sibling. |
| 274 | - The isolated, extracted sidecar cannot load the server entry with plain Node. | 275 | - The isolated, extracted sidecar cannot load the server entry with plain Node. |
| 275 | - A Windows build with a WSL node-pty prebuild omits the WSL archive or SHA-256 | 276 | - A Windows build given `--wsl-runtime` omits the WSL archive or SHA-256 |
| 276 | sidecar, the sidecar digest does not match the emitted archive, or required | 277 | sidecar, or the sidecar digest does not match the emitted archive. |
| 277 | Linux runtime members are absent. | 278 | - The emitted WSL archive is not a Linux CLI release archive: it must unpack to |
| 278 | - The emitted WSL archive contains Windows/Darwin node-pty payloads, ConPTY, | 279 | a single `t3-<version>-linux-<arch>` directory holding `t3`, `client/`, and |
| 279 | pnpm install metadata, or Windows-only FFF, ffi-rs, or msgpackr bindings. | 280 | `node_modules/` with the Linux node-pty binary, and must not carry a loose |
| 281 | server bundle (`bin.mjs`). | ||
| 280 | - The external Windows resource monitor is absent. | 282 | - The external Windows resource monitor is absent. |
| 281 | - The unpacked Windows application contains more than 80 files. | 283 | - The unpacked Windows application contains more than 80 files. |
| 282 | 284 |
docs/user/background-service.md+2-5| ... | @@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. | ... | @@ -39,8 +39,7 @@ On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead. |
| 39 | It places `t3` in `~/.local/bin` and reuses the same download when you later | 39 | It places `t3` in `~/.local/bin` and reuses the same download when you later |
| 40 | run `t3 service install`. It follows the stable train by default; set | 40 | run `t3 service install`. It follows the stable train by default; set |
| 41 | `T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact | 41 | `T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact |
| 42 | version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that | 42 | version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. |
| 43 | were only published to npm are refused with the `npm install` to run instead. | ||
| 44 | 43 | ||
| 45 | `preview` is a third train that maintainers cut from unreleased branches to | 44 | `preview` is a third train that maintainers cut from unreleased branches to |
| 46 | exercise the release pipeline. Those builds can be broken, receive no fixes, | 45 | exercise the release pipeline. Those builds can be broken, receive no fixes, |
| ... | @@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the | ... | @@ -58,9 +57,7 @@ restart the service. A server you started by hand is never touched; the |
| 58 | command tells you it is still on the old version so you can restart it | 57 | command tells you it is still on the old version so you can restart it |
| 59 | yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to | 58 | yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to |
| 60 | pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or | 59 | pin one, `--channel` to follow a different release train (moving onto preview from stable or nightly asks for confirmation), or |
| 61 | `--allow-downgrade` to move backwards. Versions published only to npm cannot | 60 | `--allow-downgrade` to move backwards. |
| 62 | be installed this way; the command says so and names the `npm install` to run | ||
| 63 | instead. | ||
| 64 | 61 | ||
| 65 | ## Platform support | 62 | ## Platform support |
| 66 | 63 |
knip.jsonc-1| ... | @@ -20,7 +20,6 @@ | ... | @@ -20,7 +20,6 @@ |
| 20 | // Vite+ pack entries and the launcher used by installed background services. | 20 | // Vite+ pack entries and the launcher used by installed background services. |
| 21 | "entry": [ | 21 | "entry": [ |
| 22 | "src/bin.ts!", | 22 | "src/bin.ts!", |
| 23 | "src/service-launcher.ts!", | ||
| 24 | "src/claude-history-worker.ts!", | 23 | "src/claude-history-worker.ts!", |
| 25 | "scripts/cli.ts", | 24 | "scripts/cli.ts", |
| 26 | "src/provider/testFixtures/*.mjs", | 25 | "src/provider/testFixtures/*.mjs", |
packages/shared/src/cliRelease.test.ts-7| ... | @@ -7,7 +7,6 @@ import { | ... | @@ -7,7 +7,6 @@ import { |
| 7 | cliReleaseDownloadBaseUrl, | 7 | cliReleaseDownloadBaseUrl, |
| 8 | cliReleaseChannelOf, | 8 | cliReleaseChannelOf, |
| 9 | cliReleaseIndexPageUrl, | 9 | cliReleaseIndexPageUrl, |
| 10 | isArchiveDistributedVersion, | ||
| 11 | newestCliReleaseVersion, | 10 | newestCliReleaseVersion, |
| 12 | parseChecksums, | 11 | parseChecksums, |
| 13 | } from "./cliRelease.ts"; | 12 | } from "./cliRelease.ts"; |
| ... | @@ -56,12 +55,6 @@ describe("cliRelease", () => { | ... | @@ -56,12 +55,6 @@ describe("cliRelease", () => { |
| 56 | expect(checksums.size).toBe(2); | 55 | expect(checksums.size).toBe(2); |
| 57 | }); | 56 | }); |
| 58 | 57 | ||
| 59 | it("treats only preview builds as archive-distributed", () => { | ||
| 60 | expect(isArchiveDistributedVersion("1.2.3-preview.20260911.4")).toBe(true); | ||
| 61 | expect(isArchiveDistributedVersion("1.2.3-nightly.20260911.4")).toBe(false); | ||
| 62 | expect(isArchiveDistributedVersion("1.2.3")).toBe(false); | ||
| 63 | }); | ||
| 64 | |||
| 65 | it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { | 58 | it("extracts with the System32 bsdtar on Windows and plain tar elsewhere", () => { |
| 66 | expect(cliArchiveTarCommand("linux", {})).toBe("tar"); | 59 | expect(cliArchiveTarCommand("linux", {})).toBe("tar"); |
| 67 | expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( | 60 | expect(cliArchiveTarCommand("win32", { SystemRoot: "D:\\Win" })).toBe( |
packages/shared/src/cliRelease.ts-5| ... | @@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> { | ... | @@ -70,11 +70,6 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> { |
| 70 | return checksums; | 70 | return checksums; |
| 71 | } | 71 | } |
| 72 | 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 | } | ||
| 77 | |||
| 78 | export type CliReleaseChannel = "stable" | "nightly" | "preview"; | 73 | export type CliReleaseChannel = "stable" | "nightly" | "preview"; |
| 79 | export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [ | 74 | export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [ |
| 80 | "stable", | 75 | "stable", |
packages/ssh/src/command.test.ts-36| ... | @@ -14,7 +14,6 @@ import { | ... | @@ -14,7 +14,6 @@ import { |
| 14 | baseSshArgs, | 14 | baseSshArgs, |
| 15 | getLastNonEmptyOutputLine, | 15 | getLastNonEmptyOutputLine, |
| 16 | parseSshResolveOutput, | 16 | parseSshResolveOutput, |
| 17 | resolveRemoteT3CliPackageSpec, | ||
| 18 | runSshCommand, | 17 | runSshCommand, |
| 19 | } from "./command.ts"; | 18 | } from "./command.ts"; |
| 20 | import { SshCommandError } from "./errors.ts"; | 19 | import { SshCommandError } from "./errors.ts"; |
| ... | @@ -99,41 +98,6 @@ describe("ssh command", () => { | ... | @@ -99,41 +98,6 @@ describe("ssh command", () => { |
| 99 | }), | 98 | }), |
| 100 | ); | 99 | ); |
| 101 | 100 | ||
| 102 | it.effect("resolves the remote t3 package spec from the desktop release channel", () => | ||
| 103 | Effect.sync(() => { | ||
| 104 | assert.equal( | ||
| 105 | resolveRemoteT3CliPackageSpec({ | ||
| 106 | appVersion: "0.0.17", | ||
| 107 | updateChannel: "latest", | ||
| 108 | }), | ||
| 109 | "t3@0.0.17", | ||
| 110 | ); | ||
| 111 | assert.equal( | ||
| 112 | resolveRemoteT3CliPackageSpec({ | ||
| 113 | appVersion: "0.0.17-nightly.20260415.44", | ||
| 114 | updateChannel: "nightly", | ||
| 115 | }), | ||
| 116 | "t3@0.0.17-nightly.20260415.44", | ||
| 117 | ); | ||
| 118 | assert.equal( | ||
| 119 | resolveRemoteT3CliPackageSpec({ | ||
| 120 | appVersion: "0.0.0-dev", | ||
| 121 | updateChannel: "nightly", | ||
| 122 | isDevelopment: true, | ||
| 123 | }), | ||
| 124 | "t3@nightly", | ||
| 125 | ); | ||
| 126 | assert.equal( | ||
| 127 | resolveRemoteT3CliPackageSpec({ | ||
| 128 | appVersion: "0.0.0-dev", | ||
| 129 | updateChannel: "latest", | ||
| 130 | isDevelopment: true, | ||
| 131 | }), | ||
| 132 | "t3@nightly", | ||
| 133 | ); | ||
| 134 | }), | ||
| 135 | ); | ||
| 136 | |||
| 137 | it.effect("reads the last non-empty ssh output line", () => | 101 | it.effect("reads the last non-empty ssh output line", () => |
| 138 | Effect.sync(() => { | 102 | Effect.sync(() => { |
| 139 | assert.equal( | 103 | assert.equal( |
packages/ssh/src/command.ts+1-19| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import * as NodeCrypto from "node:crypto"; |
| 2 | 2 | ||
| 3 | import type { DesktopSshEnvironmentTarget, DesktopUpdateChannel } from "@t3tools/contracts"; | 3 | import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 5 | import * as Duration from "effect/Duration"; | 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| ... | @@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ... | @@ -14,7 +14,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 14 | import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; | 14 | import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; |
| 15 | import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; | 15 | import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; |
| 16 | 16 | ||
| 17 | const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; | ||
| 18 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; | 17 | const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; |
| 19 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; | 18 | const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; |
| 20 | 19 | ||
| ... | @@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi | ... | @@ -363,20 +362,3 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi |
| 363 | ), | 362 | ), |
| 364 | ); | 363 | ); |
| 365 | }); | 364 | }); |
| 366 | |||
| 367 | export function resolveRemoteT3CliPackageSpec(input: { | ||
| 368 | readonly appVersion: string; | ||
| 369 | readonly updateChannel: DesktopUpdateChannel; | ||
| 370 | readonly isDevelopment?: boolean; | ||
| 371 | }): string { | ||
| 372 | const appVersion = input.appVersion.trim(); | ||
| 373 | if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { | ||
| 374 | return `t3@${appVersion}`; | ||
| 375 | } | ||
| 376 | |||
| 377 | if (input.isDevelopment) { | ||
| 378 | return "t3@nightly"; | ||
| 379 | } | ||
| 380 | |||
| 381 | return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; | ||
| 382 | } |
packages/ssh/src/runnerProcess.test.ts+81-258| ... | @@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); | ... | @@ -23,23 +23,19 @@ const decodeStarted = Schema.decodeUnknownSync(Schema.fromJsonString(Started)); |
| 23 | describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( | 23 | describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( |
| 24 | "remote runner process ownership", | 24 | "remote runner process ownership", |
| 25 | () => { | 25 | () => { |
| 26 | it.live.each(["npx", "npm"] as const)( | 26 | it.live("keeps the server PID and graceful shutdown through the node-script runner", () => |
| 27 | "keeps the server PID and graceful shutdown through the %s fallback", | 27 | Effect.gen(function* () { |
| 28 | (packageManager) => | 28 | const fs = yield* FileSystem.FileSystem; |
| 29 | Effect.gen(function* () { | 29 | const path = yield* Path.Path; |
| 30 | const fs = yield* FileSystem.FileSystem; | 30 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 31 | const path = yield* Path.Path; | 31 | const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); |
| 32 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 32 | const bin = path.join(fixture, "bin"); |
| 33 | const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-" }); | 33 | const cliPath = path.join(fixture, "installed cli.mjs"); |
| 34 | const bin = path.join(fixture, "bin"); | 34 | yield* fs.makeDirectory(bin); |
| 35 | const cliPath = path.join(fixture, "installed cli.mjs"); | 35 | yield* fs.symlink(process.execPath, path.join(bin, "node")); |
| 36 | const callsPath = path.join(fixture, "package-manager-calls.jsonl"); | 36 | yield* fs.writeFileString( |
| 37 | const packageSpec = "t3@0.0.35"; | 37 | cliPath, |
| 38 | yield* fs.makeDirectory(bin); | 38 | `#!/usr/bin/env node |
| 39 | yield* fs.symlink(process.execPath, path.join(bin, "node")); | ||
| 40 | yield* fs.writeFileString( | ||
| 41 | cliPath, | ||
| 42 | `#!/usr/bin/env node | ||
| 43 | import * as net from "node:net"; | 39 | import * as net from "node:net"; |
| 44 | const server = net.createServer((socket) => { | 40 | const server = net.createServer((socket) => { |
| 45 | socket.end(); | 41 | socket.end(); |
| ... | @@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { | ... | @@ -56,112 +52,79 @@ server.listen(Number(process.env.T3_TEST_PORT ?? 0), "127.0.0.1", () => { |
| 56 | }) + "\\n"); | 52 | }) + "\\n"); |
| 57 | }); | 53 | }); |
| 58 | `, | 54 | `, |
| 59 | ); | 55 | ); |
| 60 | yield* fs.chmod(cliPath, 0o700); | 56 | yield* fs.chmod(cliPath, 0o700); |
| 61 | yield* fs.writeFileString( | ||
| 62 | path.join(bin, packageManager), | ||
| 63 | `#!/usr/bin/env node | ||
| 64 | const fs = require("node:fs"); | ||
| 65 | const childProcess = require("node:child_process"); | ||
| 66 | const args = process.argv.slice(2); | ||
| 67 | fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(args) + "\\n"); | ||
| 68 | if (args.includes("--package")) { | ||
| 69 | process.stdout.write(process.env.T3_TEST_CLI + "\\n"); | ||
| 70 | } else { | ||
| 71 | const child = childProcess.spawn(process.execPath, [process.env.T3_TEST_CLI, ...args], { stdio: "inherit" }); | ||
| 72 | child.once("exit", (code) => { process.exitCode = code ?? 1; }); | ||
| 73 | } | ||
| 74 | `, | ||
| 75 | ); | ||
| 76 | yield* fs.chmod(path.join(bin, packageManager), 0o700); | ||
| 77 | 57 | ||
| 78 | const runServer = (port = 0) => | 58 | const runServer = (port = 0) => |
| 79 | Effect.gen(function* () { | 59 | Effect.gen(function* () { |
| 80 | const child = yield* spawner.spawn( | 60 | const child = yield* spawner.spawn( |
| 81 | ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { | 61 | ChildProcess.make("/bin/sh", ["-s", "--", "serve", "a path with spaces"], { |
| 82 | cwd: fixture, | 62 | cwd: fixture, |
| 83 | env: { | 63 | env: { |
| 84 | PATH: bin, | 64 | PATH: bin, |
| 85 | T3_TEST_CLI: cliPath, | 65 | T3_TEST_PORT: String(port), |
| 86 | T3_TEST_CALLS: callsPath, | 66 | }, |
| 87 | T3_TEST_PORT: String(port), | 67 | detached: false, |
| 88 | }, | 68 | stdin: Stream.make( |
| 89 | detached: false, | 69 | new TextEncoder().encode(buildRemoteT3RunnerScript({ nodeScriptPath: cliPath })), |
| 90 | stdin: Stream.make( | ||
| 91 | new TextEncoder().encode(buildRemoteT3RunnerScript({ packageSpec })), | ||
| 92 | ), | ||
| 93 | }), | ||
| 94 | ); | ||
| 95 | const ready = yield* Deferred.make<typeof Started.Type>(); | ||
| 96 | const stdout: string[] = []; | ||
| 97 | const output = yield* child.stdout.pipe( | ||
| 98 | Stream.decodeText(), | ||
| 99 | Stream.splitLines, | ||
| 100 | Stream.runForEach((line) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | stdout.push(line); | ||
| 103 | if (stdout.length === 1) { | ||
| 104 | yield* Deferred.succeed(ready, decodeStarted(line)); | ||
| 105 | } | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | Effect.forkScoped, | ||
| 109 | ); | ||
| 110 | const stderr = yield* child.stderr.pipe( | ||
| 111 | Stream.decodeText(), | ||
| 112 | Stream.mkString, | ||
| 113 | Effect.forkScoped, | ||
| 114 | ); | ||
| 115 | const receipt = yield* Effect.raceFirst( | ||
| 116 | Deferred.await(ready), | ||
| 117 | Fiber.join(output).pipe( | ||
| 118 | Effect.flatMap(() => Fiber.join(stderr)), | ||
| 119 | Effect.flatMap((message) => | ||
| 120 | Effect.die(new Error(`Runner exited before listening: ${message}`)), | ||
| 121 | ), | ||
| 122 | ), | 70 | ), |
| 123 | ); | 71 | }), |
| 124 | // A failed PID assertion must still close the owned fixture server, including an npm child. | 72 | ); |
| 125 | yield* Effect.addFinalizer(() => | 73 | const ready = yield* Deferred.make<typeof Started.Type>(); |
| 74 | const stdout: string[] = []; | ||
| 75 | const output = yield* child.stdout.pipe( | ||
| 76 | Stream.decodeText(), | ||
| 77 | Stream.splitLines, | ||
| 78 | Stream.runForEach((line) => | ||
| 126 | Effect.gen(function* () { | 79 | Effect.gen(function* () { |
| 127 | if (yield* child.isRunning) { | 80 | stdout.push(line); |
| 128 | yield* Effect.callback<void>((resume) => { | 81 | if (stdout.length === 1) { |
| 129 | const connection = NodeNet.connect(receipt.port, "127.0.0.1"); | 82 | yield* Deferred.succeed(ready, decodeStarted(line)); |
| 130 | connection.on("error", () => undefined); | ||
| 131 | connection.once("close", () => resume(Effect.void)); | ||
| 132 | return Effect.sync(() => connection.destroy()); | ||
| 133 | }); | ||
| 134 | yield* child.exitCode; | ||
| 135 | } | 83 | } |
| 136 | }).pipe(Effect.orDie), | 84 | }), |
| 137 | ); | 85 | ), |
| 138 | assert.equal(receipt.pid, child.pid); | 86 | Effect.forkScoped, |
| 139 | assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); | 87 | ); |
| 140 | yield* child.kill({ killSignal: "SIGTERM" }); | 88 | const stderr = yield* child.stderr.pipe( |
| 141 | assert.equal(yield* child.exitCode, 0); | 89 | Stream.decodeText(), |
| 142 | yield* Fiber.join(output); | 90 | Stream.mkString, |
| 143 | assert.include(stdout, "graceful shutdown"); | 91 | Effect.forkScoped, |
| 144 | return receipt.port; | 92 | ); |
| 145 | }).pipe(Effect.scoped); | 93 | const receipt = yield* Effect.raceFirst( |
| 94 | Deferred.await(ready), | ||
| 95 | Fiber.join(output).pipe( | ||
| 96 | Effect.flatMap(() => Fiber.join(stderr)), | ||
| 97 | Effect.flatMap((message) => | ||
| 98 | Effect.die(new Error(`Runner exited before listening: ${message}`)), | ||
| 99 | ), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | // A failed PID assertion must still close the owned fixture server. | ||
| 103 | yield* Effect.addFinalizer(() => | ||
| 104 | Effect.gen(function* () { | ||
| 105 | if (yield* child.isRunning) { | ||
| 106 | yield* Effect.callback<void>((resume) => { | ||
| 107 | const connection = NodeNet.connect(receipt.port, "127.0.0.1"); | ||
| 108 | connection.on("error", () => undefined); | ||
| 109 | connection.once("close", () => resume(Effect.void)); | ||
| 110 | return Effect.sync(() => connection.destroy()); | ||
| 111 | }); | ||
| 112 | yield* child.exitCode; | ||
| 113 | } | ||
| 114 | }).pipe(Effect.orDie), | ||
| 115 | ); | ||
| 116 | assert.equal(receipt.pid, child.pid); | ||
| 117 | assert.deepEqual(receipt.args, ["serve", "a path with spaces"]); | ||
| 118 | yield* child.kill({ killSignal: "SIGTERM" }); | ||
| 119 | assert.equal(yield* child.exitCode, 0); | ||
| 120 | yield* Fiber.join(output); | ||
| 121 | assert.include(stdout, "graceful shutdown"); | ||
| 122 | return receipt.port; | ||
| 123 | }).pipe(Effect.scoped); | ||
| 146 | 124 | ||
| 147 | const port = yield* runServer(); | 125 | const port = yield* runServer(); |
| 148 | assert.equal(yield* runServer(port), port); | 126 | assert.equal(yield* runServer(port), port); |
| 149 | const calls = (yield* fs.readFileString(callsPath)) | 127 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 150 | .trim() | ||
| 151 | .split("\n") | ||
| 152 | .map((line) => JSON.parse(line)); | ||
| 153 | const expectedCall = [ | ||
| 154 | ...(packageManager === "npm" ? ["exec"] : []), | ||
| 155 | "--yes", | ||
| 156 | "--package", | ||
| 157 | packageSpec, | ||
| 158 | "--", | ||
| 159 | "sh", | ||
| 160 | "-c", | ||
| 161 | "command -v t3", | ||
| 162 | ]; | ||
| 163 | assert.deepEqual(calls, [expectedCall, expectedCall]); | ||
| 164 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 165 | ); | 128 | ); |
| 166 | }, | 129 | }, |
| 167 | ); | 130 | ); |
| ... | @@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => { | ... | @@ -288,143 +251,3 @@ server.listen(0, "127.0.0.1", () => { |
| 288 | ); | 251 | ); |
| 289 | }, | 252 | }, |
| 290 | ); | 253 | ); |
| 291 | |||
| 292 | describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( | ||
| 293 | "remote runner install diagnostics", | ||
| 294 | () => { | ||
| 295 | const decodeArguments = Schema.decodeUnknownSync( | ||
| 296 | Schema.fromJsonString(Schema.Array(Schema.String)), | ||
| 297 | ); | ||
| 298 | const cases = (["npx", "npm"] as const).flatMap((packageManager) => | ||
| 299 | ( | ||
| 300 | [ | ||
| 301 | "etarget", | ||
| 302 | "network", | ||
| 303 | "empty-success", | ||
| 304 | "success", | ||
| 305 | "failed-with-path", | ||
| 306 | "existing-cli", | ||
| 307 | "node-override", | ||
| 308 | ] as const | ||
| 309 | ).map((mode) => ({ packageManager, mode })), | ||
| 310 | ); | ||
| 311 | |||
| 312 | it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | const fs = yield* FileSystem.FileSystem; | ||
| 315 | const path = yield* Path.Path; | ||
| 316 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 317 | const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); | ||
| 318 | const bin = path.join(fixture, "bin"); | ||
| 319 | const cliPath = path.join(fixture, "installed cli.mjs"); | ||
| 320 | const callsPath = path.join(fixture, "installer-calls.jsonl"); | ||
| 321 | const packageSpec = "t3@0.0.39-nightly.20260905.1286"; | ||
| 322 | const args = ["serve", "a path with spaces"]; | ||
| 323 | yield* fs.makeDirectory(bin); | ||
| 324 | yield* fs.symlink(process.execPath, path.join(bin, "node")); | ||
| 325 | yield* fs.writeFileString( | ||
| 326 | cliPath, | ||
| 327 | `#!/usr/bin/env node | ||
| 328 | process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); | ||
| 329 | `, | ||
| 330 | ); | ||
| 331 | yield* fs.chmod(cliPath, 0o700); | ||
| 332 | yield* fs.writeFileString(callsPath, ""); | ||
| 333 | yield* fs.writeFileString( | ||
| 334 | path.join(bin, packageManager), | ||
| 335 | `#!/usr/bin/env node | ||
| 336 | const fs = require("node:fs"); | ||
| 337 | fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); | ||
| 338 | const mode = process.env.T3_TEST_MODE; | ||
| 339 | if (mode === "success" || mode === "failed-with-path") { | ||
| 340 | process.stdout.write(process.env.T3_TEST_CLI + "\\n"); | ||
| 341 | } | ||
| 342 | if (mode === "etarget" || mode === "failed-with-path") { | ||
| 343 | process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); | ||
| 344 | process.exitCode = 42; | ||
| 345 | } else if (mode === "network") { | ||
| 346 | process.stderr.write("npm error code ENETUNREACH\\n"); | ||
| 347 | process.exitCode = 43; | ||
| 348 | } | ||
| 349 | `, | ||
| 350 | ); | ||
| 351 | yield* fs.chmod(path.join(bin, packageManager), 0o700); | ||
| 352 | if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); | ||
| 353 | |||
| 354 | const child = yield* spawner.spawn( | ||
| 355 | ChildProcess.make("/bin/sh", ["-s", "--", ...args], { | ||
| 356 | cwd: fixture, | ||
| 357 | extendEnv: false, | ||
| 358 | env: { | ||
| 359 | PATH: bin, | ||
| 360 | T3_TEST_MODE: mode, | ||
| 361 | T3_TEST_CLI: cliPath, | ||
| 362 | T3_TEST_CALLS: callsPath, | ||
| 363 | }, | ||
| 364 | stdin: Stream.make( | ||
| 365 | new TextEncoder().encode( | ||
| 366 | buildRemoteT3RunnerScript({ | ||
| 367 | packageSpec, | ||
| 368 | ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), | ||
| 369 | }), | ||
| 370 | ), | ||
| 371 | ), | ||
| 372 | }), | ||
| 373 | ); | ||
| 374 | const { stdout, stderr, exitCode } = yield* Effect.all( | ||
| 375 | { | ||
| 376 | stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), | ||
| 377 | stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), | ||
| 378 | exitCode: child.exitCode, | ||
| 379 | }, | ||
| 380 | { concurrency: "unbounded" }, | ||
| 381 | ); | ||
| 382 | const installFailed = | ||
| 383 | mode === "etarget" || mode === "network" || mode === "failed-with-path"; | ||
| 384 | const missingExecutable = mode === "empty-success"; | ||
| 385 | assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); | ||
| 386 | if (installFailed || missingExecutable) { | ||
| 387 | assert.equal(stdout, ""); | ||
| 388 | } else { | ||
| 389 | assert.deepEqual(decodeArguments(stdout), args); | ||
| 390 | } | ||
| 391 | if (installFailed) { | ||
| 392 | const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; | ||
| 393 | assert.include(stderr, `npm error code ${npmError}\n`); | ||
| 394 | assert.include(stderr, `Remote host could not install ${packageSpec}.`); | ||
| 395 | assert.notInclude(stderr, "Remote host installed"); | ||
| 396 | assert.notInclude(stderr, "Install a C toolchain"); | ||
| 397 | } else if (missingExecutable) { | ||
| 398 | assert.include(stderr, `Remote host installed ${packageSpec}`); | ||
| 399 | assert.include(stderr, "npm produced no t3 executable"); | ||
| 400 | assert.include(stderr, "Install a C toolchain"); | ||
| 401 | } else { | ||
| 402 | assert.equal(stderr, ""); | ||
| 403 | } | ||
| 404 | const expectedCall = [ | ||
| 405 | ...(packageManager === "npm" ? ["exec"] : []), | ||
| 406 | "--yes", | ||
| 407 | "--package", | ||
| 408 | packageSpec, | ||
| 409 | "--", | ||
| 410 | "sh", | ||
| 411 | "-c", | ||
| 412 | "command -v t3", | ||
| 413 | ]; | ||
| 414 | const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; | ||
| 415 | const calls = yield* fs.readFileString(callsPath); | ||
| 416 | if (usesInstaller) { | ||
| 417 | assert.deepEqual( | ||
| 418 | calls | ||
| 419 | .trim() | ||
| 420 | .split("\n") | ||
| 421 | .map((line) => decodeArguments(line)), | ||
| 422 | [expectedCall], | ||
| 423 | ); | ||
| 424 | } else { | ||
| 425 | assert.equal(calls, ""); | ||
| 426 | } | ||
| 427 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | ||
| 428 | ); | ||
| 429 | }, | ||
| 430 | ); |
packages/ssh/src/tunnel.test.ts+104-104| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | buildRemoteStopScript, | 23 | buildRemoteStopScript, |
| 24 | buildRemoteT3RunnerScript, | 24 | buildRemoteT3RunnerScript, |
| 25 | SshInvalidArchiveVersionError, | 25 | SshInvalidArchiveVersionError, |
| 26 | SshMissingRunnerError, | ||
| 26 | describeReadinessCause, | 27 | describeReadinessCause, |
| 27 | issueRemotePairingToken, | 28 | issueRemotePairingToken, |
| 28 | launchOrReuseRemoteServer, | 29 | launchOrReuseRemoteServer, |
| ... | @@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> { | ... | @@ -104,39 +105,17 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray<string> { |
| 104 | return command._tag === "StandardCommand" ? command.args : []; | 105 | return command._tag === "StandardCommand" ? command.args : []; |
| 105 | } | 106 | } |
| 106 | 107 | ||
| 107 | describe("ssh tunnel scripts", () => { | 108 | const ARCHIVE = { archiveVersion: "1.2.3-preview.20260911.4" } as const; |
| 108 | it("builds the remote t3 runner with npx and npm fallbacks", () => { | 109 | const NODE_SCRIPT = { |
| 109 | const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); | 110 | nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", |
| 110 | 111 | } as const; | |
| 111 | assert.include(script, "T3_NODE_SCRIPT_PATH=''"); | ||
| 112 | assert.include(script, 'exec t3 "$@"'); | ||
| 113 | assert.include(script, 'exec "$T3_CLI_PATH" "$@"'); | ||
| 114 | assert.include(script, "could not install 't3@latest'"); | ||
| 115 | assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); | ||
| 116 | assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); | ||
| 117 | assert.include(script, "npm produced no t3 executable"); | ||
| 118 | assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); | ||
| 119 | assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); | ||
| 120 | assert.include(script, "remote_node_satisfies_engine()"); | ||
| 121 | assert.include(script, "function satisfiesSemverRange"); | ||
| 122 | assert.include(script, "satisfiesSemverRange(rawVersion, range)"); | ||
| 123 | assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); | ||
| 124 | assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); | ||
| 125 | assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); | ||
| 126 | assert.include(script, 'eval "$(fnm env --shell bash)"'); | ||
| 127 | assert.include(script, "fnm use --silent-if-unchanged"); | ||
| 128 | assert.include(script, "fnm use default"); | ||
| 129 | assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); | ||
| 130 | assert.include(script, 'NVM_DIR="$HOME/.nvm"'); | ||
| 131 | assert.include(script, "nvm use --silent default"); | ||
| 132 | assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); | ||
| 133 | assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); | ||
| 134 | }); | ||
| 135 | 112 | ||
| 136 | it("installs and runs the release archive when an archive version is set", () => { | 113 | describe("ssh tunnel scripts", () => { |
| 137 | const script = buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }); | 114 | it("installs and runs the release archive without Node, npm, or npx", () => { |
| 115 | const script = buildRemoteT3RunnerScript(ARCHIVE); | ||
| 138 | 116 | ||
| 139 | assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); | 117 | assert.include(script, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); |
| 118 | assert.include(script, "T3_NODE_SCRIPT_PATH=''"); | ||
| 140 | assert.include( | 119 | assert.include( |
| 141 | script, | 120 | script, |
| 142 | "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", | 121 | "T3_RELEASE_BASE_URL='https://github.com/pingdotgg/t3code/releases/download'", |
| ... | @@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => { | ... | @@ -145,6 +124,10 @@ describe("ssh tunnel scripts", () => { |
| 145 | assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); | 124 | assert.include(script, 'T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"'); |
| 146 | assert.include(script, "SHA256SUMS"); | 125 | assert.include(script, "SHA256SUMS"); |
| 147 | assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); | 126 | assert.include(script, 'exec "$T3_RUNTIME_DIR/t3" "$@"'); |
| 127 | assert.notInclude(script, "npx"); | ||
| 128 | assert.notInclude(script, "npm exec"); | ||
| 129 | assert.notInclude(script, "t3@latest"); | ||
| 130 | assert.notInclude(script, 'exec t3 "$@"'); | ||
| 148 | // Concurrent launches serialize on a per-version mkdir lock and recheck | 131 | // Concurrent launches serialize on a per-version mkdir lock and recheck |
| 149 | // the completion marker after acquiring it. | 132 | // the completion marker after acquiring it. |
| 150 | assert.include( | 133 | assert.include( |
| ... | @@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => { | ... | @@ -167,14 +150,20 @@ describe("ssh tunnel scripts", () => { |
| 167 | script.indexOf('"$T3_STAGING/t3" --version'), | 150 | script.indexOf('"$T3_STAGING/t3" --version'), |
| 168 | script.indexOf('> "$T3_STAGING/.install-complete"'), | 151 | script.indexOf('> "$T3_STAGING/.install-complete"'), |
| 169 | ); | 152 | ); |
| 170 | // The archive branch execs before any of the Node discovery runs. | 153 | // Node discovery is defined for the dev path but only ever invoked inside |
| 154 | // the node-script branch, which the archive path skips entirely. | ||
| 155 | assert.equal(script.split("ensure_remote_node_path || true").length - 1, 1); | ||
| 156 | assert.isBelow( | ||
| 157 | script.indexOf("ensure_remote_node_path || true"), | ||
| 158 | script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), | ||
| 159 | ); | ||
| 171 | assert.isBelow( | 160 | assert.isBelow( |
| 172 | script.indexOf('exec "$T3_RUNTIME_DIR/t3"'), | 161 | script.indexOf('exec node "$T3_NODE_SCRIPT_PATH" "$@"'), |
| 173 | script.indexOf("prepend_path_if_dir()"), | 162 | script.indexOf("T3_ARCHIVE_VERSION="), |
| 174 | ); | 163 | ); |
| 175 | 164 | ||
| 176 | const launch = buildRemoteLaunchScript({ | 165 | const launch = buildRemoteLaunchScript({ |
| 177 | archiveVersion: "1.2.3-preview.20260911.4", | 166 | ...ARCHIVE, |
| 178 | releaseBaseUrl: "https://mirror.example/t3/", | 167 | releaseBaseUrl: "https://mirror.example/t3/", |
| 179 | }); | 168 | }); |
| 180 | assert.include(launch, "T3_ARCHIVE_MODE=1"); | 169 | assert.include(launch, "T3_ARCHIVE_MODE=1"); |
| ... | @@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -182,7 +171,7 @@ describe("ssh tunnel scripts", () => { |
| 182 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); | 171 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE"'); |
| 183 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); | 172 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT"'); |
| 184 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); | 173 | assert.include(launch, '"$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"'); |
| 185 | assert.include(buildRemoteLaunchScript(), "T3_ARCHIVE_MODE=0"); | 174 | assert.include(buildRemoteLaunchScript(NODE_SCRIPT), "T3_ARCHIVE_MODE=0"); |
| 186 | }); | 175 | }); |
| 187 | 176 | ||
| 188 | it("rejects archive versions that are not a single exact version segment", () => { | 177 | it("rejects archive versions that are not a single exact version segment", () => { |
| ... | @@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => { | ... | @@ -202,33 +191,29 @@ describe("ssh tunnel scripts", () => { |
| 202 | ); | 191 | ); |
| 203 | } | 192 | } |
| 204 | assert.include( | 193 | assert.include( |
| 205 | buildRemoteT3RunnerScript({ archiveVersion: "1.2.3-preview.20260911.4" }), | 194 | buildRemoteT3RunnerScript(ARCHIVE), |
| 206 | "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", | 195 | "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", |
| 207 | ); | 196 | ); |
| 208 | }); | 197 | }); |
| 209 | 198 | ||
| 199 | it("refuses to build a runner with neither an archive version nor a node script", () => { | ||
| 200 | for (const input of [undefined, {}, { archiveVersion: " " }, { nodeScriptPath: null }]) { | ||
| 201 | assert.throws(() => buildRemoteT3RunnerScript(input), SshMissingRunnerError); | ||
| 202 | } | ||
| 203 | assert.throws(() => buildRemoteLaunchScript(), SshMissingRunnerError); | ||
| 204 | }); | ||
| 205 | |||
| 210 | it("does not hard-code a remote node engine range", () => { | 206 | it("does not hard-code a remote node engine range", () => { |
| 211 | const script = buildRemoteT3RunnerScript(); | 207 | const script = buildRemoteT3RunnerScript(NODE_SCRIPT); |
| 212 | 208 | ||
| 213 | assert.include(script, "T3_NODE_ENGINE_RANGE=''"); | 209 | assert.include(script, "T3_NODE_ENGINE_RANGE=''"); |
| 214 | assert.notInclude(script, TEST_NODE_ENGINE_RANGE); | 210 | assert.notInclude(script, TEST_NODE_ENGINE_RANGE); |
| 215 | }); | 211 | }); |
| 216 | 212 | ||
| 217 | it("shell-quotes package specs in the remote t3 runner", () => { | ||
| 218 | const script = buildRemoteT3RunnerScript({ | ||
| 219 | packageSpec: "t3@nightly; touch /tmp/t3-owned", | ||
| 220 | }); | ||
| 221 | |||
| 222 | assert.include( | ||
| 223 | script, | ||
| 224 | "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", | ||
| 225 | ); | ||
| 226 | assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); | ||
| 227 | }); | ||
| 228 | |||
| 229 | it("builds the remote t3 runner with a node script override", () => { | 213 | it("builds the remote t3 runner with a node script override", () => { |
| 230 | const script = buildRemoteT3RunnerScript({ | 214 | const script = buildRemoteT3RunnerScript({ |
| 231 | nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", | 215 | ...NODE_SCRIPT, |
| 216 | nodeEngineRange: TEST_NODE_ENGINE_RANGE, | ||
| 232 | }); | 217 | }); |
| 233 | 218 | ||
| 234 | assert.include( | 219 | assert.include( |
| ... | @@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => { | ... | @@ -236,6 +221,24 @@ describe("ssh tunnel scripts", () => { |
| 236 | "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", | 221 | "T3_NODE_SCRIPT_PATH='/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs'", |
| 237 | ); | 222 | ); |
| 238 | assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); | 223 | assert.include(script, 'exec node "$T3_NODE_SCRIPT_PATH" "$@"'); |
| 224 | assert.include(script, "T3_ARCHIVE_VERSION=''"); | ||
| 225 | assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); | ||
| 226 | assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); | ||
| 227 | assert.include(script, "remote_node_satisfies_engine()"); | ||
| 228 | assert.include(script, "function satisfiesSemverRange"); | ||
| 229 | assert.include(script, "satisfiesSemverRange(rawVersion, range)"); | ||
| 230 | assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); | ||
| 231 | assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); | ||
| 232 | assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); | ||
| 233 | assert.include(script, 'eval "$(fnm env --shell bash)"'); | ||
| 234 | assert.include(script, "fnm use --silent-if-unchanged"); | ||
| 235 | assert.include(script, "fnm use default"); | ||
| 236 | assert.include(script, 'prepend_path_if_dir "$HOME/.nodenv/shims"'); | ||
| 237 | assert.include(script, 'NVM_DIR="$HOME/.nvm"'); | ||
| 238 | assert.include(script, "nvm use --silent default"); | ||
| 239 | assert.include(script, 'for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin'); | ||
| 240 | assert.notInclude(script, "ensure $NVM_DIR/nvm.sh is available"); | ||
| 241 | assert.notInclude(script, "npx"); | ||
| 239 | }); | 242 | }); |
| 240 | 243 | ||
| 241 | it("uses the remote t3 runner for launch and pairing scripts", () => { | 244 | it("uses the remote t3 runner for launch and pairing scripts", () => { |
| ... | @@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => { | ... | @@ -245,39 +248,44 @@ describe("ssh tunnel scripts", () => { |
| 245 | username: "julius", | 248 | username: "julius", |
| 246 | port: 2222, | 249 | port: 2222, |
| 247 | } as const; | 250 | } as const; |
| 251 | const launch = buildRemoteLaunchScript(ARCHIVE); | ||
| 252 | const devLaunch = buildRemoteLaunchScript({ | ||
| 253 | ...NODE_SCRIPT, | ||
| 254 | nodeEngineRange: TEST_NODE_ENGINE_RANGE, | ||
| 255 | }); | ||
| 248 | 256 | ||
| 249 | assert.include( | 257 | assert.include( |
| 250 | buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), | 258 | launch, |
| 251 | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', | 259 | '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', |
| 252 | ); | 260 | ); |
| 253 | assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); | 261 | assert.include(launch, "RUNNER_CHANGED=1"); |
| 254 | assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()"); | 262 | assert.include(launch, "ensure_remote_node_path()"); |
| 255 | assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); | 263 | assert.include(launch, "if ! ensure_remote_node_path; then"); |
| 264 | assert.include(devLaunch, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); | ||
| 265 | assert.include(devLaunch, "does not satisfy required range "); | ||
| 266 | assert.include(launch, 'kill "$REMOTE_PID" 2>/dev/null || true'); | ||
| 267 | assert.include(launch, "wait_ready"); | ||
| 268 | assert.include(launch, '"$RUNNER_FILE" serve --host 127.0.0.1'); | ||
| 269 | assert.include(launch, '--base-dir "$DEFAULT_SERVER_HOME"'); | ||
| 270 | assert.notInclude(launch, "server-home"); | ||
| 271 | assert.include(launch, "Remote T3 server did not become ready"); | ||
| 272 | assert.include(launch, 'wait_ready "60000"'); | ||
| 273 | assert.include(launch, 'if [ -s "$LOG_FILE" ]; then'); | ||
| 274 | assert.include(launch, "It wrote nothing to %s"); | ||
| 275 | assert.include(launch, "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'"); | ||
| 256 | assert.include( | 276 | assert.include( |
| 257 | buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), | 277 | buildRemotePairingScript(target, ARCHIVE), |
| 258 | `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`, | 278 | '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', |
| 259 | ); | 279 | ); |
| 260 | assert.include( | 280 | assert.include( |
| 261 | buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), | 281 | buildRemotePairingScript(target, ARCHIVE), |
| 262 | "does not satisfy required range ", | 282 | 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"', |
| 263 | ); | 283 | ); |
| 264 | assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); | 284 | assert.notInclude(buildRemotePairingScript(target, ARCHIVE), "server-home"); |
| 265 | assert.include(buildRemoteLaunchScript(), "wait_ready"); | ||
| 266 | assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); | ||
| 267 | assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); | ||
| 268 | assert.notInclude(buildRemoteLaunchScript(), "server-home"); | ||
| 269 | assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); | ||
| 270 | assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); | ||
| 271 | assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); | ||
| 272 | assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); | ||
| 273 | assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); | ||
| 274 | assert.include( | 285 | assert.include( |
| 275 | buildRemotePairingScript(target), | 286 | buildRemotePairingScript(target, ARCHIVE), |
| 276 | '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', | 287 | "T3_ARCHIVE_VERSION='1.2.3-preview.20260911.4'", |
| 277 | ); | 288 | ); |
| 278 | assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); | ||
| 279 | assert.notInclude(buildRemotePairingScript(target), "server-home"); | ||
| 280 | assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); | ||
| 281 | assert.include( | 289 | assert.include( |
| 282 | buildRemoteStopScript(target), | 290 | buildRemoteStopScript(target), |
| 283 | 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', | 291 | 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', |
| ... | @@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => { | ... | @@ -285,30 +293,24 @@ describe("ssh tunnel scripts", () => { |
| 285 | assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); | 293 | assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); |
| 286 | assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); | 294 | assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); |
| 287 | assert.include( | 295 | assert.include( |
| 288 | buildRemoteLaunchScript(), | 296 | launch, |
| 289 | 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', | 297 | 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', |
| 290 | ); | 298 | ); |
| 291 | assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); | 299 | assert.include(launch, "resolve_default_runtime_port()"); |
| 292 | assert.include( | 300 | assert.include(launch, 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'); |
| 293 | buildRemoteLaunchScript(), | 301 | assert.include(launch, "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))"); |
| 294 | 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', | 302 | assert.include(launch, 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); |
| 295 | ); | 303 | assert.include(launch, 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); |
| 296 | assert.include( | 304 | assert.include(launch, 'rm -f "$PID_FILE"'); |
| 297 | buildRemoteLaunchScript(), | 305 | assert.include(launch, "printf 'external\\n' >\"$MANAGED_FILE\""); |
| 298 | "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))", | 306 | assert.include(launch, 'if [ -z "$REMOTE_PORT" ]; then'); |
| 299 | ); | ||
| 300 | assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); | ||
| 301 | assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); | ||
| 302 | assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); | ||
| 303 | assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); | ||
| 304 | assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); | ||
| 305 | assert.isBelow( | 307 | assert.isBelow( |
| 306 | buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), | 308 | launch.indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), |
| 307 | buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), | 309 | launch.indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), |
| 308 | ); | 310 | ); |
| 309 | assert.isBelow( | 311 | assert.isBelow( |
| 310 | buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), | 312 | launch.indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), |
| 311 | buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), | 313 | launch.indexOf('elif [ -n "$REMOTE_PID" ]'), |
| 312 | ); | 314 | ); |
| 313 | }); | 315 | }); |
| 314 | 316 | ||
| ... | @@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -330,7 +332,7 @@ describe("ssh tunnel scripts", () => { |
| 330 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); | 332 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); |
| 331 | 333 | ||
| 332 | return Effect.gen(function* () { | 334 | return Effect.gen(function* () { |
| 333 | const result = yield* launchOrReuseRemoteServer(target); | 335 | const result = yield* launchOrReuseRemoteServer(target, undefined, ARCHIVE); |
| 334 | assert.equal(result.remotePort, 3774); | 336 | assert.equal(result.remotePort, 3774); |
| 335 | assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]); | 337 | assert.deepEqual(spawnedCommands[0]?.slice(-5, -1), ["sh", "-l", "-s", "--"]); |
| 336 | }).pipe(Effect.provide(processLayer)); | 338 | }).pipe(Effect.provide(processLayer)); |
| ... | @@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => { | ... | @@ -350,7 +352,9 @@ describe("ssh tunnel scripts", () => { |
| 350 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); | 352 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); |
| 351 | 353 | ||
| 352 | return Effect.gen(function* () { | 354 | return Effect.gen(function* () { |
| 353 | const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); | 355 | const fiber = yield* Effect.forkChild( |
| 356 | launchOrReuseRemoteServer(target, undefined, NODE_SCRIPT), | ||
| 357 | ); | ||
| 354 | yield* Effect.yieldNow; | 358 | yield* Effect.yieldNow; |
| 355 | yield* TestClock.adjust(Duration.seconds(75)); | 359 | yield* TestClock.adjust(Duration.seconds(75)); |
| 356 | 360 | ||
| ... | @@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -359,7 +363,7 @@ describe("ssh tunnel scripts", () => { |
| 359 | }).pipe(Effect.provide(processLayer)); | 363 | }).pipe(Effect.provide(processLayer)); |
| 360 | }); | 364 | }); |
| 361 | 365 | ||
| 362 | it.effect("gives cold archive launches a larger budget than npm launches", () => { | 366 | it.effect("gives cold archive launches a larger budget than node-script launches", () => { |
| 363 | const target = { | 367 | const target = { |
| 364 | alias: "devbox", | 368 | alias: "devbox", |
| 365 | hostname: "devbox.example.com", | 369 | hostname: "devbox.example.com", |
| ... | @@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -373,11 +377,7 @@ describe("ssh tunnel scripts", () => { |
| 373 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); | 377 | const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); |
| 374 | 378 | ||
| 375 | return Effect.gen(function* () { | 379 | return Effect.gen(function* () { |
| 376 | const fiber = yield* Effect.forkChild( | 380 | const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target, undefined, ARCHIVE)); |
| 377 | launchOrReuseRemoteServer(target, undefined, { | ||
| 378 | archiveVersion: "1.2.3-preview.20260911.4", | ||
| 379 | }), | ||
| 380 | ); | ||
| 381 | yield* Effect.yieldNow; | 381 | yield* Effect.yieldNow; |
| 382 | yield* TestClock.adjust(Duration.seconds(800)); | 382 | yield* TestClock.adjust(Duration.seconds(800)); |
| 383 | 383 | ||
| ... | @@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -457,7 +457,7 @@ describe("ssh tunnel scripts", () => { |
| 457 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); | 457 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); |
| 458 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); | 458 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); |
| 459 | return Effect.gen(function* () { | 459 | return Effect.gen(function* () { |
| 460 | const result = yield* issueRemotePairingToken(target); | 460 | const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); |
| 461 | assert.equal(result.credential, "LCL4R2TPHDKQ"); | 461 | assert.equal(result.credential, "LCL4R2TPHDKQ"); |
| 462 | }).pipe(Effect.provide(processLayer)); | 462 | }).pipe(Effect.provide(processLayer)); |
| 463 | }); | 463 | }); |
| ... | @@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -485,7 +485,7 @@ describe("ssh tunnel scripts", () => { |
| 485 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); | 485 | const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); |
| 486 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); | 486 | const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); |
| 487 | return Effect.gen(function* () { | 487 | return Effect.gen(function* () { |
| 488 | const result = yield* issueRemotePairingToken(target); | 488 | const result = yield* issueRemotePairingToken(target, undefined, ARCHIVE); |
| 489 | assert.equal(result.credential, "LCL4R2TPHDKQ"); | 489 | assert.equal(result.credential, "LCL4R2TPHDKQ"); |
| 490 | }).pipe(Effect.provide(processLayer)); | 490 | }).pipe(Effect.provide(processLayer)); |
| 491 | }); | 491 | }); |
| ... | @@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -530,7 +530,7 @@ describe("ssh tunnel scripts", () => { |
| 530 | Layer.succeed(HttpClient.HttpClient, testHttpClient), | 530 | Layer.succeed(HttpClient.HttpClient, testHttpClient), |
| 531 | Layer.succeed(NetService.NetService, testNetService), | 531 | Layer.succeed(NetService.NetService, testNetService), |
| 532 | SshPasswordPrompt.disabledLayer, | 532 | SshPasswordPrompt.disabledLayer, |
| 533 | SshEnvironmentManager.layer(), | 533 | SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), |
| 534 | ); | 534 | ); |
| 535 | const target = { | 535 | const target = { |
| 536 | alias: "devbox", | 536 | alias: "devbox", |
| ... | @@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => { | ... | @@ -655,7 +655,7 @@ describe("ssh tunnel scripts", () => { |
| 655 | Layer.succeed(HttpClient.HttpClient, testHttpClient), | 655 | Layer.succeed(HttpClient.HttpClient, testHttpClient), |
| 656 | Layer.succeed(NetService.NetService, testNetService), | 656 | Layer.succeed(NetService.NetService, testNetService), |
| 657 | SshPasswordPrompt.disabledLayer, | 657 | SshPasswordPrompt.disabledLayer, |
| 658 | SshEnvironmentManager.layer(), | 658 | SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), |
| 659 | ); | 659 | ); |
| 660 | yield* Effect.gen(function* () { | 660 | yield* Effect.gen(function* () { |
| 661 | const manager = yield* SshEnvironmentManager; | 661 | const manager = yield* SshEnvironmentManager; |
packages/ssh/src/tunnel.ts+129-147| ... | @@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; | ... | @@ -70,19 +70,22 @@ const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000; |
| 70 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; | 70 | const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; |
| 71 | 71 | ||
| 72 | export interface RemoteT3RunnerOptions { | 72 | export interface RemoteT3RunnerOptions { |
| 73 | readonly packageSpec?: string; | 73 | /** |
| 74 | * Dev mode: run `node <path>` on the remote instead of a release archive. | ||
| 75 | * The only mode that needs Node on the remote. | ||
| 76 | */ | ||
| 74 | readonly nodeScriptPath?: string | null; | 77 | readonly nodeScriptPath?: string | null; |
| 75 | readonly nodeEngineRange?: string | null; | 78 | readonly nodeEngineRange?: string | null; |
| 76 | /** | 79 | /** |
| 77 | * Exact version whose release archive the remote installs and runs. Takes | 80 | * Exact version whose self-contained release archive the remote installs |
| 78 | * precedence over `packageSpec`; the remote then needs neither Node nor npm. | 81 | * and runs. Required unless `nodeScriptPath` is set; the remote then needs |
| 82 | * neither Node nor npm. | ||
| 79 | */ | 83 | */ |
| 80 | readonly archiveVersion?: string | null; | 84 | readonly archiveVersion?: string | null; |
| 81 | readonly releaseBaseUrl?: string | null; | 85 | readonly releaseBaseUrl?: string | null; |
| 82 | } | 86 | } |
| 83 | 87 | ||
| 84 | export interface SshEnvironmentManagerOptions { | 88 | export interface SshEnvironmentManagerOptions { |
| 85 | readonly resolveCliPackageSpec?: () => string; | ||
| 86 | readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>; | 89 | readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>; |
| 87 | } | 90 | } |
| 88 | 91 | ||
| ... | @@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { | ... | @@ -124,17 +127,18 @@ function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { |
| 124 | }; | 127 | }; |
| 125 | } | 128 | } |
| 126 | 129 | ||
| 130 | function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean { | ||
| 131 | return Boolean(runner?.nodeScriptPath?.trim()); | ||
| 132 | } | ||
| 133 | |||
| 127 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { | 134 | function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) { |
| 128 | if (runner?.archiveVersion?.trim()) { | ||
| 129 | return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; | ||
| 130 | } | ||
| 131 | if (runner?.nodeScriptPath?.trim()) { | 135 | if (runner?.nodeScriptPath?.trim()) { |
| 132 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; | 136 | return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() }; |
| 133 | } | 137 | } |
| 134 | if (runner?.packageSpec?.trim()) { | 138 | if (runner?.archiveVersion?.trim()) { |
| 135 | return { runner: "package", packageSpec: runner.packageSpec.trim() }; | 139 | return { runner: "archive", archiveVersion: runner.archiveVersion.trim() }; |
| 136 | } | 140 | } |
| 137 | return { runner: "default" }; | 141 | return { runner: "archive" }; |
| 138 | } | 142 | } |
| 139 | 143 | ||
| 140 | interface SshAuthOperationInput<T> { | 144 | interface SshAuthOperationInput<T> { |
| ... | @@ -424,144 +428,117 @@ ensure_remote_node_path() { | ... | @@ -424,144 +428,117 @@ ensure_remote_node_path() { |
| 424 | 428 | ||
| 425 | const REMOTE_RUNNER_SCRIPT = `#!/bin/sh | 429 | const REMOTE_RUNNER_SCRIPT = `#!/bin/sh |
| 426 | set -eu | 430 | set -eu |
| 427 | T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ | ||
| 428 | if [ -n "$T3_ARCHIVE_VERSION" ]; then | ||
| 429 | # Self-contained release archive: no Node, npm, or compiler on the remote. | ||
| 430 | # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. | ||
| 431 | T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ | ||
| 432 | T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" | ||
| 433 | t3_runtime_ready() { | ||
| 434 | [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] | ||
| 435 | } | ||
| 436 | if ! t3_runtime_ready; then | ||
| 437 | mkdir -p "$HOME/.t3/runtime/versions" | ||
| 438 | # Concurrent launches (two clients, a retry racing a slow first run) must | ||
| 439 | # not both install: mkdir is the atomic lock and the ready check repeats | ||
| 440 | # under it. | ||
| 441 | T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" | ||
| 442 | # mkdir is the only portable atomic exclusive create (mv would silently | ||
| 443 | # nest a candidate inside an existing lock). The owner publishes its pid | ||
| 444 | # right after, so a lock with a live owner is never reclaimed however | ||
| 445 | # slow its download is, and a lock whose owner is dead is reclaimed at | ||
| 446 | # once. A lock with no pid at all is a crash between mkdir and the pid | ||
| 447 | # write; it is reclaimed after a short grace so a live owner has time to | ||
| 448 | # publish. | ||
| 449 | T3_LOCK_WAITED=0 | ||
| 450 | T3_LOCK_UNOWNED=0 | ||
| 451 | while ! mkdir "$T3_LOCK" 2>/dev/null; do | ||
| 452 | T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" | ||
| 453 | if [ -n "$T3_LOCK_OWNER" ]; then | ||
| 454 | T3_LOCK_UNOWNED=0 | ||
| 455 | if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then | ||
| 456 | rm -rf "$T3_LOCK" | ||
| 457 | continue | ||
| 458 | fi | ||
| 459 | else | ||
| 460 | T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) | ||
| 461 | if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then | ||
| 462 | rm -rf "$T3_LOCK" | ||
| 463 | continue | ||
| 464 | fi | ||
| 465 | fi | ||
| 466 | if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then | ||
| 467 | printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 | ||
| 468 | exit 1 | ||
| 469 | fi | ||
| 470 | sleep 1 | ||
| 471 | T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) | ||
| 472 | done | ||
| 473 | printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" | ||
| 474 | trap 'rm -rf "$T3_LOCK"' EXIT | ||
| 475 | fi | ||
| 476 | if ! t3_runtime_ready; then | ||
| 477 | case "$(uname -s)" in | ||
| 478 | Darwin) T3_PLATFORM="darwin" ;; | ||
| 479 | Linux) T3_PLATFORM="linux" ;; | ||
| 480 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; | ||
| 481 | esac | ||
| 482 | case "$(uname -m)" in | ||
| 483 | arm64 | aarch64) T3_ARCH="arm64" ;; | ||
| 484 | x86_64 | amd64) T3_ARCH="x64" ;; | ||
| 485 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; | ||
| 486 | esac | ||
| 487 | T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" | ||
| 488 | T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" | ||
| 489 | trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT | ||
| 490 | t3_fetch() { | ||
| 491 | if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" | ||
| 492 | elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" | ||
| 493 | else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 | ||
| 494 | fi | ||
| 495 | } | ||
| 496 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ | ||
| 497 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ | ||
| 498 | T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" | ||
| 499 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 500 | T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | ||
| 501 | else | ||
| 502 | T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | ||
| 503 | fi | ||
| 504 | if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then | ||
| 505 | printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 | ||
| 506 | fi | ||
| 507 | tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 | ||
| 508 | rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" | ||
| 509 | # Prove the binary runs here (libc, arch) before marking it ready, or every | ||
| 510 | # later launch would exec a broken install instead of retrying. | ||
| 511 | if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then | ||
| 512 | printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 | ||
| 513 | fi | ||
| 514 | printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" | ||
| 515 | rm -rf "$T3_RUNTIME_DIR" | ||
| 516 | mv "$T3_STAGING" "$T3_RUNTIME_DIR" | ||
| 517 | fi | ||
| 518 | if [ -n "\${T3_LOCK:-}" ]; then | ||
| 519 | rm -rf "$T3_LOCK" | ||
| 520 | trap - EXIT | ||
| 521 | fi | ||
| 522 | exec "$T3_RUNTIME_DIR/t3" "$@" | ||
| 523 | fi | ||
| 524 | @@T3_NODE_ENV_SCRIPT@@ | 431 | @@T3_NODE_ENV_SCRIPT@@ |
| 525 | ensure_remote_node_path || true | ||
| 526 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ | 432 | T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@ |
| 527 | if [ -n "$T3_NODE_SCRIPT_PATH" ]; then | 433 | if [ -n "$T3_NODE_SCRIPT_PATH" ]; then |
| 434 | # Dev mode: a source checkout on the remote. This is the only path that | ||
| 435 | # needs Node, so Node discovery runs here and nowhere else. | ||
| 436 | ensure_remote_node_path || true | ||
| 528 | if ! command -v node >/dev/null 2>&1; then | 437 | if ! command -v node >/dev/null 2>&1; then |
| 529 | printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 | 438 | printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 |
| 530 | exit 1 | 439 | exit 1 |
| 531 | fi | 440 | fi |
| 532 | exec node "$T3_NODE_SCRIPT_PATH" "$@" | 441 | exec node "$T3_NODE_SCRIPT_PATH" "$@" |
| 533 | fi | 442 | fi |
| 534 | if command -v t3 >/dev/null 2>&1; then | 443 | T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@ |
| 535 | exec t3 "$@" | 444 | if [ -z "$T3_ARCHIVE_VERSION" ]; then |
| 445 | printf 'No t3 release version was provided for the remote runtime.\\n' >&2 | ||
| 446 | exit 1 | ||
| 447 | fi | ||
| 448 | # Self-contained release archive: no Node, npm, or compiler on the remote. | ||
| 449 | # Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it. | ||
| 450 | T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@ | ||
| 451 | T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION" | ||
| 452 | t3_runtime_ready() { | ||
| 453 | [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ] | ||
| 454 | } | ||
| 455 | if ! t3_runtime_ready; then | ||
| 456 | mkdir -p "$HOME/.t3/runtime/versions" | ||
| 457 | # Concurrent launches (two clients, a retry racing a slow first run) must | ||
| 458 | # not both install: mkdir is the atomic lock and the ready check repeats | ||
| 459 | # under it. | ||
| 460 | T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock" | ||
| 461 | # mkdir is the only portable atomic exclusive create (mv would silently | ||
| 462 | # nest a candidate inside an existing lock). The owner publishes its pid | ||
| 463 | # right after, so a lock with a live owner is never reclaimed however | ||
| 464 | # slow its download is, and a lock whose owner is dead is reclaimed at | ||
| 465 | # once. A lock with no pid at all is a crash between mkdir and the pid | ||
| 466 | # write; it is reclaimed after a short grace so a live owner has time to | ||
| 467 | # publish. | ||
| 468 | T3_LOCK_WAITED=0 | ||
| 469 | T3_LOCK_UNOWNED=0 | ||
| 470 | while ! mkdir "$T3_LOCK" 2>/dev/null; do | ||
| 471 | T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)" | ||
| 472 | if [ -n "$T3_LOCK_OWNER" ]; then | ||
| 473 | T3_LOCK_UNOWNED=0 | ||
| 474 | if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then | ||
| 475 | rm -rf "$T3_LOCK" | ||
| 476 | continue | ||
| 477 | fi | ||
| 478 | else | ||
| 479 | T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1)) | ||
| 480 | if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then | ||
| 481 | rm -rf "$T3_LOCK" | ||
| 482 | continue | ||
| 483 | fi | ||
| 484 | fi | ||
| 485 | if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then | ||
| 486 | printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2 | ||
| 487 | exit 1 | ||
| 488 | fi | ||
| 489 | sleep 1 | ||
| 490 | T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1)) | ||
| 491 | done | ||
| 492 | printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid" | ||
| 493 | trap 'rm -rf "$T3_LOCK"' EXIT | ||
| 536 | fi | 494 | fi |
| 537 | # npm extracts a package before it runs the native builds of its dependencies, | 495 | if ! t3_runtime_ready; then |
| 538 | # so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves | 496 | case "$(uname -s)" in |
| 539 | # the npx cache without a t3 executable. \`npx --yes\` then exits 0 without | 497 | Darwin) T3_PLATFORM="darwin" ;; |
| 540 | # running anything at all, which the caller only ever sees as a server that | 498 | Linux) T3_PLATFORM="linux" ;; |
| 541 | # never becomes ready. Resolve the CLI once up front so that install failure is | 499 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;; |
| 542 | # reported here, with npm's own output on stderr. | 500 | esac |
| 543 | require_installed_t3_cli() { | 501 | case "$(uname -m)" in |
| 544 | if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then | 502 | arm64 | aarch64) T3_ARCH="arm64" ;; |
| 545 | printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 | 503 | x86_64 | amd64) T3_ARCH="x64" ;; |
| 546 | return 1 | 504 | *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;; |
| 505 | esac | ||
| 506 | T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz" | ||
| 507 | T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")" | ||
| 508 | trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT | ||
| 509 | t3_fetch() { | ||
| 510 | if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2" | ||
| 511 | elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2" | ||
| 512 | else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1 | ||
| 513 | fi | ||
| 514 | } | ||
| 515 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@ | ||
| 516 | t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@ | ||
| 517 | T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)" | ||
| 518 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 519 | T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | ||
| 520 | else | ||
| 521 | T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)" | ||
| 547 | fi | 522 | fi |
| 548 | if [ -n "$T3_CLI_PATH" ]; then | 523 | if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then |
| 549 | return 0 | 524 | printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 |
| 550 | fi | 525 | fi |
| 551 | printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 | 526 | tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 |
| 552 | return 1 | 527 | rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" |
| 553 | } | 528 | # Prove the binary runs here (libc, arch) before marking it ready, or every |
| 554 | # The launcher records this PID, so exec the CLI without an npm wrapper process. | 529 | # later launch would exec a broken install instead of retrying. |
| 555 | if command -v npx >/dev/null 2>&1; then | 530 | if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then |
| 556 | require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 | 531 | printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1 |
| 557 | exec "$T3_CLI_PATH" "$@" | 532 | fi |
| 533 | printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete" | ||
| 534 | rm -rf "$T3_RUNTIME_DIR" | ||
| 535 | mv "$T3_STAGING" "$T3_RUNTIME_DIR" | ||
| 558 | fi | 536 | fi |
| 559 | if command -v npm >/dev/null 2>&1; then | 537 | if [ -n "\${T3_LOCK:-}" ]; then |
| 560 | require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 | 538 | rm -rf "$T3_LOCK" |
| 561 | exec "$T3_CLI_PATH" "$@" | 539 | trap - EXIT |
| 562 | fi | 540 | fi |
| 563 | printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 | 541 | exec "$T3_RUNTIME_DIR/t3" "$@" |
| 564 | exit 1 | ||
| 565 | `; | 542 | `; |
| 566 | 543 | ||
| 567 | const REMOTE_LAUNCH_SCRIPT = `set -eu | 544 | const REMOTE_LAUNCH_SCRIPT = `set -eu |
| ... | @@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalid | ... | @@ -804,10 +781,21 @@ export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalid |
| 804 | const EXACT_ARCHIVE_VERSION = | 781 | const EXACT_ARCHIVE_VERSION = |
| 805 | /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; | 782 | /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; |
| 806 | 783 | ||
| 784 | export class SshMissingRunnerError extends Schema.TaggedError<SshMissingRunnerError>()( | ||
| 785 | "SshMissingRunnerError", | ||
| 786 | {}, | ||
| 787 | ) { | ||
| 788 | override get message(): string { | ||
| 789 | return "A remote t3 runner needs an archive version or a node script path."; | ||
| 790 | } | ||
| 791 | } | ||
| 792 | |||
| 807 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { | 793 | export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string { |
| 808 | const packageSpec = shellSingleQuote(input?.packageSpec?.trim() || "t3@latest"); | ||
| 809 | const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; | 794 | const nodeScriptPath = input?.nodeScriptPath?.trim() || ""; |
| 810 | const archiveVersion = input?.archiveVersion?.trim() || ""; | 795 | const archiveVersion = input?.archiveVersion?.trim() || ""; |
| 796 | if (nodeScriptPath === "" && archiveVersion === "") { | ||
| 797 | throw new SshMissingRunnerError(); | ||
| 798 | } | ||
| 811 | if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { | 799 | if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) { |
| 812 | throw new SshInvalidArchiveVersionError({ archiveVersion }); | 800 | throw new SshInvalidArchiveVersionError({ archiveVersion }); |
| 813 | } | 801 | } |
| ... | @@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string | ... | @@ -818,7 +806,6 @@ export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string |
| 818 | ); | 806 | ); |
| 819 | return stripTrailingNewlines( | 807 | return stripTrailingNewlines( |
| 820 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { | 808 | applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, { |
| 821 | T3_PACKAGE_SPEC: packageSpec, | ||
| 822 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), | 809 | T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath), |
| 823 | T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), | 810 | T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion), |
| 824 | T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), | 811 | T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl), |
| ... | @@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string | ... | @@ -841,7 +828,7 @@ export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string |
| 841 | 828 | ||
| 842 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { | 829 | export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { |
| 843 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { | 830 | return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, { |
| 844 | T3_ARCHIVE_MODE: input?.archiveVersion?.trim() ? "1" : "0", | 831 | T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1", |
| 845 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), | 832 | T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input), |
| 846 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), | 833 | T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), |
| 847 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), | 834 | T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), |
| ... | @@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo | ... | @@ -894,9 +881,9 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo |
| 894 | const result = yield* runSshCommand(target, { | 881 | const result = yield* runSshCommand(target, { |
| 895 | remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], | 882 | remoteCommandArgs: ["sh", "-l", "-s", "--", remoteStateKey(target)], |
| 896 | stdin: buildRemoteLaunchScript(runner), | 883 | stdin: buildRemoteLaunchScript(runner), |
| 897 | timeoutMs: runner?.archiveVersion?.trim() | 884 | timeoutMs: isNodeScriptRunner(runner) |
| 898 | ? REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS | 885 | ? REMOTE_LAUNCH_TIMEOUT_MS |
| 899 | : REMOTE_LAUNCH_TIMEOUT_MS, | 886 | : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS, |
| 900 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | 887 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 901 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | 888 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 902 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | 889 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |
| ... | @@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT | ... | @@ -956,7 +943,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT |
| 956 | stdin: buildRemotePairingScript(target, runner), | 943 | stdin: buildRemotePairingScript(target, runner), |
| 957 | // Pairing may be the first command on a cold remote, so it can install | 944 | // Pairing may be the first command on a cold remote, so it can install |
| 958 | // the archive on the way. | 945 | // the archive on the way. |
| 959 | ...(runner?.archiveVersion?.trim() ? { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS } : {}), | 946 | ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }), |
| 960 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), | 947 | ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), |
| 961 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), | 948 | ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), |
| 962 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), | 949 | ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), |
| ... | @@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma | ... | @@ -1687,13 +1674,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma |
| 1687 | ...sshTargetLogFields(resolvedTarget), | 1674 | ...sshTargetLogFields(resolvedTarget), |
| 1688 | key, | 1675 | key, |
| 1689 | }); | 1676 | }); |
| 1690 | const packageSpec = options.resolveCliPackageSpec?.(); | ||
| 1691 | const runner = | 1677 | const runner = |
| 1692 | options.resolveCliRunner === undefined | 1678 | options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner; |
| 1693 | ? packageSpec === undefined | ||
| 1694 | ? undefined | ||
| 1695 | : { packageSpec } | ||
| 1696 | : yield* options.resolveCliRunner; | ||
| 1697 | yield* Effect.logDebug("ssh.environment.runner.resolved", { | 1679 | yield* Effect.logDebug("ssh.environment.runner.resolved", { |
| 1698 | ...sshTargetLogFields(resolvedTarget), | 1680 | ...sshTargetLogFields(resolvedTarget), |
| 1699 | ...sshRunnerLogFields(runner), | 1681 | ...sshRunnerLogFields(runner), |
scripts/install.ps1 created+120| ... | @@ -0,0 +1,120 @@ | ||
| 1 | # Installs the T3 Code CLI from a GitHub Release archive on Windows. Needs | ||
| 2 | # only PowerShell 5.1+; no Node, npm, or compiler. | ||
| 3 | # | ||
| 4 | # irm https://t3.codes/install.ps1 | iex | ||
| 5 | # | ||
| 6 | # Environment: | ||
| 7 | # T3CODE_CHANNEL release train to follow: stable, nightly, or preview | ||
| 8 | # (default: stable; preview is a maintainers' test train) | ||
| 9 | # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) | ||
| 10 | # T3CODE_HOME T3 home directory (default: ~\.t3) | ||
| 11 | # T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin) | ||
| 12 | # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) | ||
| 13 | # | ||
| 14 | # The archive is unpacked into $T3CODE_HOME\runtime\versions\<version>, the | ||
| 15 | # same layout `t3 service install` uses, so the service reuses this download. | ||
| 16 | $ErrorActionPreference = "Stop" | ||
| 17 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | ||
| 18 | |||
| 19 | $repo = "pingdotgg/t3code" | ||
| 20 | $baseUrl = if ($env:T3CODE_RELEASE_BASE_URL) { $env:T3CODE_RELEASE_BASE_URL.TrimEnd("/") } else { "https://github.com/$repo/releases/download" } | ||
| 21 | $t3Home = if ($env:T3CODE_HOME) { $env:T3CODE_HOME } else { Join-Path $HOME ".t3" } | ||
| 22 | $binDir = if ($env:T3CODE_INSTALL_BIN_DIR) { $env:T3CODE_INSTALL_BIN_DIR } else { Join-Path $HOME ".local\bin" } | ||
| 23 | |||
| 24 | function Fail([string] $message) { | ||
| 25 | Write-Error "t3 install: $message" | ||
| 26 | exit 1 | ||
| 27 | } | ||
| 28 | |||
| 29 | # PROCESSOR_ARCHITEW6432 reports the real machine when a 32-bit PowerShell | ||
| 30 | # runs under WOW64; RuntimeInformation needs .NET 4.7.1+, which 5.1 hosts | ||
| 31 | # may lack. | ||
| 32 | $rawArch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } | ||
| 33 | $arch = switch ($rawArch) { | ||
| 34 | "AMD64" { "x64" } | ||
| 35 | "ARM64" { "arm64" } | ||
| 36 | default { Fail "unsupported architecture $rawArch" } | ||
| 37 | } | ||
| 38 | |||
| 39 | $channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" } | ||
| 40 | $version = $env:T3CODE_VERSION | ||
| 41 | if (-not $version) { | ||
| 42 | # Tags are v<semver>; the channel is the prerelease identifier, or none for | ||
| 43 | # stable. Only tags of the requested train are considered, so a stable | ||
| 44 | # install can never pick up a nightly or preview build by accident. | ||
| 45 | $tagPattern = switch ($channel) { | ||
| 46 | "stable" { '^v\d+\.\d+\.\d+$' } | ||
| 47 | "nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' } | ||
| 48 | "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | ||
| 49 | default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } | ||
| 50 | } | ||
| 51 | $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } | ||
| 52 | $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name | ||
| 53 | if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } | ||
| 54 | $version = $tag.Substring(1) | ||
| 55 | } | ||
| 56 | if ($version -match '-preview\.') { | ||
| 57 | Write-Warning "t3 $version is a preview build. Preview builds are cut by maintainers from unreleased branches to exercise the release pipeline. They can be broken, receive no fixes, and are never offered as updates. Set T3CODE_CHANNEL=stable (the default) for a supported build." | ||
| 58 | if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) { | ||
| 59 | Fail "refusing a preview build that was not explicitly requested" | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | $stem = "t3-$version-win32-$arch" | ||
| 64 | $archive = "$stem.zip" | ||
| 65 | $versionsDir = Join-Path $t3Home "runtime\versions" | ||
| 66 | $targetDir = Join-Path $versionsDir $version | ||
| 67 | $marker = Join-Path $targetDir ".install-complete" | ||
| 68 | |||
| 69 | if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) { | ||
| 70 | Write-Host "t3 $version is already installed at $targetDir" | ||
| 71 | } else { | ||
| 72 | New-Item -ItemType Directory -Force -Path $versionsDir | Out-Null | ||
| 73 | $staging = Join-Path $versionsDir (".staging-" + [System.IO.Path]::GetRandomFileName()) | ||
| 74 | New-Item -ItemType Directory -Path $staging | Out-Null | ||
| 75 | try { | ||
| 76 | Write-Host "Downloading $archive..." | ||
| 77 | try { | ||
| 78 | Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing | ||
| 79 | } catch { | ||
| 80 | $status = $_.Exception.Response.StatusCode.value__ | ||
| 81 | if ($status -eq 404) { | ||
| 82 | Fail "t3 $version has no release archive for win32-$arch; releases before the self-contained CLI can only be installed with 'npm install -g t3@$version'" | ||
| 83 | } | ||
| 84 | throw | ||
| 85 | } | ||
| 86 | Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing | ||
| 87 | |||
| 88 | $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1) | ||
| 89 | if (-not $expected) { Fail "$archive is not listed in SHA256SUMS" } | ||
| 90 | $expected = ($expected -split "\s+")[0].ToLowerInvariant() | ||
| 91 | $actual = (Get-FileHash -Algorithm SHA256 (Join-Path $staging $archive)).Hash.ToLowerInvariant() | ||
| 92 | if ($actual -ne $expected) { Fail "checksum mismatch for $archive" } | ||
| 93 | |||
| 94 | Expand-Archive -Path (Join-Path $staging $archive) -DestinationPath $staging -Force | ||
| 95 | # The archive wraps everything in one directory named after its stem. | ||
| 96 | Get-ChildItem (Join-Path $staging $stem) | Move-Item -Destination $staging | ||
| 97 | Remove-Item (Join-Path $staging $stem), (Join-Path $staging $archive), (Join-Path $staging "SHA256SUMS") -Recurse -Force | ||
| 98 | |||
| 99 | & (Join-Path $staging "t3.exe") --version | Out-Null | ||
| 100 | if ($LASTEXITCODE -ne 0) { Fail "the downloaded executable does not run" } | ||
| 101 | Set-Content -Path (Join-Path $staging ".install-complete") -Value $version -NoNewline | ||
| 102 | |||
| 103 | if (Test-Path $targetDir) { Remove-Item $targetDir -Recurse -Force } | ||
| 104 | Move-Item $staging $targetDir | ||
| 105 | } catch { | ||
| 106 | if (Test-Path $staging) { Remove-Item $staging -Recurse -Force } | ||
| 107 | throw | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | New-Item -ItemType Directory -Force -Path $binDir | Out-Null | ||
| 112 | $shim = Join-Path $binDir "t3.cmd" | ||
| 113 | # UTF-8 without a BOM: cmd.exe reads the shim as-is, and ASCII would corrupt | ||
| 114 | # non-ASCII characters in the user's home path. | ||
| 115 | [System.IO.File]::WriteAllText($shim, "@echo off`r`n`"$(Join-Path $targetDir 't3.exe')`" %*", (New-Object System.Text.UTF8Encoding $false)) | ||
| 116 | Write-Host "Installed t3 $version" | ||
| 117 | Write-Host " $shim -> $(Join-Path $targetDir 't3.exe')" | ||
| 118 | if (($env:PATH -split ";") -notcontains $binDir) { | ||
| 119 | Write-Host "Add $binDir to your PATH to run t3." | ||
| 120 | } | ||
scripts/install.sh created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | #!/bin/sh | ||
| 2 | # Installs the T3 Code CLI from a GitHub Release archive. Needs only sh, tar, | ||
| 3 | # sha256sum or shasum, and curl or wget; no Node, npm, or compiler. | ||
| 4 | # | ||
| 5 | # curl -fsSL https://t3.codes/install.sh | sh | ||
| 6 | # | ||
| 7 | # Environment: | ||
| 8 | # T3CODE_CHANNEL release train to follow: stable, nightly, or preview | ||
| 9 | # (default: stable; preview is a maintainers' test train) | ||
| 10 | # T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL) | ||
| 11 | # T3CODE_HOME T3 home directory (default: ~/.t3) | ||
| 12 | # T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin) | ||
| 13 | # T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub) | ||
| 14 | # | ||
| 15 | # The archive is unpacked into $T3CODE_HOME/runtime/versions/<version>, the | ||
| 16 | # same layout `t3 service install` uses, so the service reuses this download | ||
| 17 | # instead of fetching the release again. | ||
| 18 | set -eu | ||
| 19 | |||
| 20 | repo="pingdotgg/t3code" | ||
| 21 | base_url="${T3CODE_RELEASE_BASE_URL:-https://github.com/${repo}/releases/download}" | ||
| 22 | t3_home="${T3CODE_HOME:-$HOME/.t3}" | ||
| 23 | bin_dir="${T3CODE_INSTALL_BIN_DIR:-$HOME/.local/bin}" | ||
| 24 | |||
| 25 | fail() { | ||
| 26 | printf 't3 install: %s\n' "$1" >&2 | ||
| 27 | exit 1 | ||
| 28 | } | ||
| 29 | |||
| 30 | # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. | ||
| 31 | fetch() { | ||
| 32 | if command -v curl >/dev/null 2>&1; then | ||
| 33 | status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 | ||
| 34 | case "$status" in | ||
| 35 | 2??) return 0 ;; | ||
| 36 | 404) return 44 ;; | ||
| 37 | *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; | ||
| 38 | esac | ||
| 39 | elif command -v wget >/dev/null 2>&1; then | ||
| 40 | wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 | ||
| 41 | if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi | ||
| 42 | cat "$2.headers" >&2; rm -f "$2.headers"; return 1 | ||
| 43 | else | ||
| 44 | fail "curl or wget is required" | ||
| 45 | fi | ||
| 46 | } | ||
| 47 | |||
| 48 | case "$(uname -s)" in | ||
| 49 | Darwin) platform="darwin" ;; | ||
| 50 | Linux) platform="linux" ;; | ||
| 51 | *) fail "unsupported operating system $(uname -s); use the desktop app or npm" ;; | ||
| 52 | esac | ||
| 53 | case "$(uname -m)" in | ||
| 54 | arm64 | aarch64) arch="arm64" ;; | ||
| 55 | x86_64 | amd64) arch="x64" ;; | ||
| 56 | *) fail "unsupported architecture $(uname -m)" ;; | ||
| 57 | esac | ||
| 58 | command -v tar >/dev/null 2>&1 || fail "tar is required" | ||
| 59 | if command -v sha256sum >/dev/null 2>&1; then | ||
| 60 | checksum() { sha256sum "$1" | cut -d' ' -f1; } | ||
| 61 | elif command -v shasum >/dev/null 2>&1; then | ||
| 62 | checksum() { shasum -a 256 "$1" | cut -d' ' -f1; } | ||
| 63 | else | ||
| 64 | fail "sha256sum or shasum is required" | ||
| 65 | fi | ||
| 66 | |||
| 67 | channel="${T3CODE_CHANNEL:-stable}" | ||
| 68 | version="${T3CODE_VERSION:-}" | ||
| 69 | if [ -z "$version" ]; then | ||
| 70 | # Tags are v<semver>; the channel is the prerelease identifier, or none for | ||
| 71 | # stable. Only tags of the requested train are considered, so a stable | ||
| 72 | # install can never pick up a nightly or preview build by accident. | ||
| 73 | case "$channel" in | ||
| 74 | stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;; | ||
| 75 | nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;; | ||
| 76 | *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; | ||
| 77 | esac | ||
| 78 | tmp_index="$(mktemp)" | ||
| 79 | fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" | ||
| 80 | version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" | ||
| 81 | rm -f "$tmp_index" | ||
| 82 | [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" | ||
| 83 | fi | ||
| 84 | case "$version" in | ||
| 85 | *-preview.*) | ||
| 86 | printf '%s\n' \ | ||
| 87 | "t3 ${version} is a preview build." \ | ||
| 88 | " Preview builds are cut by maintainers from unreleased branches to exercise the release" \ | ||
| 89 | " pipeline. They can be broken, receive no fixes, and are never offered as updates." \ | ||
| 90 | " Set T3CODE_CHANNEL=stable (the default) for a supported build." >&2 | ||
| 91 | if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then | ||
| 92 | fail "refusing a preview build that was not explicitly requested" | ||
| 93 | fi | ||
| 94 | ;; | ||
| 95 | esac | ||
| 96 | |||
| 97 | stem="t3-${version}-${platform}-${arch}" | ||
| 98 | archive="${stem}.tar.gz" | ||
| 99 | versions_dir="${t3_home}/runtime/versions" | ||
| 100 | target_dir="${versions_dir}/${version}" | ||
| 101 | |||
| 102 | if [ -f "${target_dir}/.install-complete" ] && [ "$(cat "${target_dir}/.install-complete")" = "$version" ]; then | ||
| 103 | printf 't3 %s is already installed at %s\n' "$version" "$target_dir" | ||
| 104 | else | ||
| 105 | mkdir -p "$versions_dir" | ||
| 106 | staging="$(mktemp -d "${versions_dir}/.staging-XXXXXX")" | ||
| 107 | trap 'rm -rf "$staging"' EXIT | ||
| 108 | |||
| 109 | printf 'Downloading %s...\n' "$archive" | ||
| 110 | fetch_status=0 | ||
| 111 | fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$? | ||
| 112 | if [ "$fetch_status" -eq 44 ]; then | ||
| 113 | fail "t3 ${version} has no release archive for ${platform}-${arch}; releases before the self-contained CLI can only be installed with \`npm install -g t3@${version}\`" | ||
| 114 | elif [ "$fetch_status" -ne 0 ]; then | ||
| 115 | fail "could not download the release checksums" | ||
| 116 | fi | ||
| 117 | fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}" | ||
| 118 | |||
| 119 | expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)" | ||
| 120 | [ -n "$expected" ] || fail "${archive} is not listed in SHA256SUMS" | ||
| 121 | actual="$(checksum "${staging}/${archive}")" | ||
| 122 | [ "$actual" = "$expected" ] || fail "checksum mismatch for ${archive}" | ||
| 123 | |||
| 124 | tar -xzf "${staging}/${archive}" -C "$staging" --strip-components=1 | ||
| 125 | rm -f "${staging}/${archive}" "${staging}/SHA256SUMS" | ||
| 126 | "${staging}/t3" --version >/dev/null || fail "the downloaded executable does not run" | ||
| 127 | printf '%s\n' "$version" > "${staging}/.install-complete" | ||
| 128 | |||
| 129 | rm -rf "$target_dir" | ||
| 130 | mv "$staging" "$target_dir" | ||
| 131 | trap - EXIT | ||
| 132 | fi | ||
| 133 | |||
| 134 | mkdir -p "$bin_dir" | ||
| 135 | ln -sfn "${target_dir}/t3" "${bin_dir}/t3" | ||
| 136 | printf 'Installed t3 %s\n %s -> %s\n' "$version" "${bin_dir}/t3" "${target_dir}/t3" | ||
| 137 | case ":${PATH}:" in | ||
| 138 | *":${bin_dir}:"*) ;; | ||
| 139 | *) printf 'Add %s to your PATH to run `t3`.\n' "$bin_dir" ;; | ||
| 140 | esac | ||