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

feat(cli): add t3 update for self-contained installs (#11451)

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

20 files changed, 990 insertions(+), 29 deletions(-)

.github/workflows/release.yml+2-1
...@@ -147,7 +147,8 @@ jobs:...@@ -147,7 +147,8 @@ jobs:
147 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"147 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
148 echo "make_latest=false" >> "$GITHUB_OUTPUT"148 echo "make_latest=false" >> "$GITHUB_OUTPUT"
149 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then149 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then
150 # Temporary channel for dogfooding the archive-based CLI runtime.150 # Manual-only test train: exercises the whole release flow for a
151 # commit end users must never receive. Never scheduled.
151 # Same versioning as nightly under its own prerelease identifier.152 # Same versioning as nightly under its own prerelease identifier.
152 # A preview release is reachable only by downloading it by hand:153 # A preview release is reachable only by downloading it by hand:
153 # it is never published to npm, its desktop builds carry no update154 # it is never published to npm, its desktop builds carry no update
apps/desktop/src/updates/updateChannels.ts+3-2
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";1import type { DesktopUpdateChannel } from "@t3tools/contracts";
22
3const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/;3const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/;
4// Preview builds are a temporary dogfooding train cut from nightly. They share4// Preview builds are the maintainers' test train, cut by hand from unreleased
5// nightly's branding but are packaged without an update feed (see5// branches to exercise the release flow. They share nightly's branding but
6// are packaged without an update feed (see
6// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the7// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the
7// channel a preview install reports is cosmetic: it never checks for updates8// channel a preview install reports is cosmetic: it never checks for updates
8// and no updater feed ever lists a preview release.9// and no updater feed ever lists a preview release.
apps/marketing/public/install.ps1+31-6
...@@ -4,7 +4,9 @@...@@ -4,7 +4,9 @@
4# irm https://t3.codes/install.ps1 | iex4# irm https://t3.codes/install.ps1 | iex
5#5#
6# Environment:6# Environment:
7# T3CODE_VERSION exact version to install (default: latest preview release)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)
8# T3CODE_HOME T3 home directory (default: ~\.t3)10# T3CODE_HOME T3 home directory (default: ~\.t3)
9# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)11# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
10# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)12# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
...@@ -34,14 +36,29 @@ $arch = switch ($rawArch) {...@@ -34,14 +36,29 @@ $arch = switch ($rawArch) {
34 default { Fail "unsupported architecture $rawArch" }36 default { Fail "unsupported architecture $rawArch" }
35}37}
3638
39$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
37$version = $env:T3CODE_VERSION40$version = $env:T3CODE_VERSION
38if (-not $version) {41if (-not $version) {
39 # Preview is the only train shipping archives while they are being dogfooded.42 # Tags are v<semver>; the channel is the prerelease identifier, or none for
40 $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" }43 # stable. Only tags of the requested train are considered, so a stable
41 $tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name44 # install can never pick up a nightly or preview build by accident.
42 if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" }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" }
43 $version = $tag.Substring(1)54 $version = $tag.Substring(1)
44}55}
56if ($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}
4562
46$stem = "t3-$version-win32-$arch"63$stem = "t3-$version-win32-$arch"
47$archive = "$stem.zip"64$archive = "$stem.zip"
...@@ -57,7 +74,15 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {...@@ -57,7 +74,15 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
57 New-Item -ItemType Directory -Path $staging | Out-Null74 New-Item -ItemType Directory -Path $staging | Out-Null
58 try {75 try {
59 Write-Host "Downloading $archive..."76 Write-Host "Downloading $archive..."
60 Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing77 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 }
61 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing86 Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing
6287
63 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)88 $expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
apps/marketing/public/install.sh+44-8
...@@ -5,7 +5,9 @@...@@ -5,7 +5,9 @@
5# curl -fsSL https://t3.codes/install.sh | sh5# curl -fsSL https://t3.codes/install.sh | sh
6#6#
7# Environment:7# Environment:
8# T3CODE_VERSION exact version to install (default: latest preview release)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)
9# T3CODE_HOME T3 home directory (default: ~/.t3)11# T3CODE_HOME T3 home directory (default: ~/.t3)
10# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)12# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)
11# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)13# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
...@@ -25,11 +27,19 @@ fail() {...@@ -25,11 +27,19 @@ fail() {
25 exit 127 exit 1
26}28}
2729
30# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
28fetch() {31fetch() {
29 if command -v curl >/dev/null 2>&1; then32 if command -v curl >/dev/null 2>&1; then
30 curl -fsSL "$1" -o "$2"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
31 elif command -v wget >/dev/null 2>&1; then39 elif command -v wget >/dev/null 2>&1; then
32 wget -q "$1" -O "$2"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
33 else43 else
34 fail "curl or wget is required"44 fail "curl or wget is required"
35 fi45 fi
...@@ -54,15 +64,35 @@ else...@@ -54,15 +64,35 @@ else
54 fail "sha256sum or shasum is required"64 fail "sha256sum or shasum is required"
55fi65fi
5666
67channel="${T3CODE_CHANNEL:-stable}"
57version="${T3CODE_VERSION:-}"68version="${T3CODE_VERSION:-}"
58if [ -z "$version" ]; then69if [ -z "$version" ]; then
59 # Preview is the only train shipping archives while they are being dogfooded.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
60 tmp_index="$(mktemp)"78 tmp_index="$(mktemp)"
61 fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index"79 fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index"
62 version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)"80 version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
63 rm -f "$tmp_index"81 rm -f "$tmp_index"
64 [ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION"82 [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION"
65fi83fi
84case "$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 ;;
95esac
6696
67stem="t3-${version}-${platform}-${arch}"97stem="t3-${version}-${platform}-${arch}"
68archive="${stem}.tar.gz"98archive="${stem}.tar.gz"
...@@ -77,7 +107,13 @@ else...@@ -77,7 +107,13 @@ else
77 trap 'rm -rf "$staging"' EXIT107 trap 'rm -rf "$staging"' EXIT
78108
79 printf 'Downloading %s...\n' "$archive"109 printf 'Downloading %s...\n' "$archive"
80 fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS"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
81 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"117 fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"
82118
83 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"119 expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
apps/server/src/bin.ts+2
...@@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts";...@@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts";
17import { projectCommand } from "./cli/project.ts";17import { projectCommand } from "./cli/project.ts";
18import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";18import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
19import { serviceCommand } from "./cli/service.ts";19import { serviceCommand } from "./cli/service.ts";
20import { updateCommand } from "./cli/update.ts";
20import { claudeHistoryCommand } from "./cli/claudeHistory.ts";21import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
21import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";22import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
22import { servicePreflightCommand } from "./cli/servicePreflight.ts";23import { servicePreflightCommand } from "./cli/servicePreflight.ts";
...@@ -62,6 +63,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -62,6 +63,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
62 authCommand,63 authCommand,
63 projectCommand,64 projectCommand,
64 serviceCommand,65 serviceCommand,
66 updateCommand,
65 serviceLauncherCommand,67 serviceLauncherCommand,
66 claudeHistoryCommand,68 claudeHistoryCommand,
67 servicePreflightCommand,69 servicePreflightCommand,
apps/server/src/cli/service.ts+1-1
...@@ -140,7 +140,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip...@@ -140,7 +140,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip
140140
141const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(141const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
142 Command.withDescription(142 Command.withDescription(
143 "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",143 "Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.",
144 ),144 ),
145 Command.withHandler((flags) =>145 Command.withHandler((flags) =>
146 runServiceCommand(146 runServiceCommand(
apps/server/src/cli/update.test.ts created+109
...@@ -0,0 +1,109 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import {
8 HostProcessEnvironment,
9 HostProcessInvokedAs,
10 HostProcessPlatform,
11 HostProcessWorkingDirectory,
12} from "@t3tools/shared/hostProcess";
13
14import { repointLauncher, resolveLauncherPath } from "./update.ts";
15
16it.layer(NodeServices.layer)("t3 update launcher", (it) => {
17 it.effect("repoints a symlink that lives in a runtime versions tree", () =>
18 Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
22 const oldExe = path.join(root, "runtime/versions/1.0.0/t3");
23 const newExe = path.join(root, "runtime/versions/2.0.0/t3");
24 const launcher = path.join(root, "bin/t3");
25 for (const file of [oldExe, newExe]) {
26 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
27 yield* fs.writeFileString(file, "");
28 }
29 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
30 yield* fs.symlink(oldExe, launcher);
31
32 const repointed = yield* repointLauncher({
33 launchedAs: launcher,
34 versionsDir: path.join(root, "runtime/versions"),
35 targetEntryPath: newExe,
36 });
37
38 assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher);
39 assert.equal(yield* fs.readLink(launcher), newExe);
40 }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
41 );
42
43 it.effect("leaves a plain copy or a foreign symlink alone", () =>
44 Effect.gen(function* () {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
47 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
48 const newExe = path.join(root, "runtime/versions/2.0.0/t3");
49 const copy = path.join(root, "copy/t3");
50 const foreign = path.join(root, "foreign/t3");
51 const elsewhere = path.join(root, "elsewhere/t3");
52 // Another install's versions tree: same shape, different home.
53 const otherHome = path.join(root, "other/runtime/versions/1.0.0/t3");
54 const otherLauncher = path.join(root, "other/bin/t3");
55 for (const file of [newExe, copy, elsewhere, otherHome]) {
56 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
57 yield* fs.writeFileString(file, "");
58 }
59 yield* fs.makeDirectory(path.dirname(foreign), { recursive: true });
60 yield* fs.symlink(elsewhere, foreign);
61 yield* fs.makeDirectory(path.dirname(otherLauncher), { recursive: true });
62 yield* fs.symlink(otherHome, otherLauncher);
63
64 for (const launchedAs of [copy, foreign, otherLauncher, undefined]) {
65 const repointed = yield* repointLauncher({
66 launchedAs,
67 versionsDir: path.join(root, "runtime/versions"),
68 targetEntryPath: newExe,
69 });
70 assert.equal(repointed._tag, "None", launchedAs ?? "undefined");
71 }
72 assert.equal(yield* fs.readLink(foreign), elsewhere);
73 assert.equal(yield* fs.readLink(otherLauncher), otherHome);
74 }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
75 );
76
77 it.effect("finds the launcher a bare command name resolved to on PATH", () =>
78 Effect.gen(function* () {
79 const fs = yield* FileSystem.FileSystem;
80 const path = yield* Path.Path;
81 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
82 const launcher = path.join(root, "bin/t3");
83 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
84 yield* fs.writeFileString(launcher, "");
85
86 const bare = yield* resolveLauncherPath.pipe(
87 Effect.provideService(HostProcessInvokedAs, "t3"),
88 Effect.provideService(HostProcessEnvironment, {
89 PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`,
90 }),
91 Effect.provideService(HostProcessWorkingDirectory, root),
92 );
93 const relative = yield* resolveLauncherPath.pipe(
94 Effect.provideService(HostProcessInvokedAs, "./bin/t3"),
95 Effect.provideService(HostProcessEnvironment, { PATH: "" }),
96 Effect.provideService(HostProcessWorkingDirectory, root),
97 );
98 const absent = yield* resolveLauncherPath.pipe(
99 Effect.provideService(HostProcessInvokedAs, "t3"),
100 Effect.provideService(HostProcessEnvironment, { PATH: path.join(root, "missing") }),
101 Effect.provideService(HostProcessWorkingDirectory, root),
102 );
103
104 assert.equal(bare, launcher);
105 assert.equal(relative, launcher);
106 assert.equal(absent, undefined);
107 }).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
108 );
109});
apps/server/src/cli/update.ts created+573
...@@ -0,0 +1,573 @@
1import {
2 HostProcessArchitecture,
3 HostProcessEnvironment,
4 HostProcessExecutablePath,
5 HostProcessInvokedAs,
6 HostProcessIsExecutable,
7 HostProcessPlatform,
8 HostProcessWorkingDirectory,
9} from "@t3tools/shared/hostProcess";
10import {
11 CLI_RELEASE_BASE_URL_ENV,
12 CLI_RELEASE_CHANNELS,
13 cliReleaseIndexPageUrl,
14 cliReleaseChannelOf,
15 isArchiveDistributedVersion,
16 newestCliReleaseVersion,
17 type CliReleaseChannel,
18} from "@t3tools/shared/cliRelease";
19import * as Console from "effect/Console";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";
24import * as Option from "effect/Option";
25import * as Path from "effect/Path";
26import * as Schema from "effect/Schema";
27import { Argument, Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
28import {
29 FetchHttpClient,
30 HttpClient,
31 HttpClientRequest,
32 HttpClientResponse,
33} from "effect/unstable/http";
34
35import packageJson from "../../package.json" with { type: "json" };
36import * as BootService from "../cloud/bootService.ts";
37import {
38 ensurePinnedRuntimeInstalled,
39 pinnedRuntimeCommand,
40 PinnedRuntimeInstallError,
41 pinnedRuntimePaths,
42} from "../cloud/pinnedRuntime.ts";
43import { compareExactServiceVersions, isExactServiceVersion } from "../cloud/serviceProtocol.ts";
44import * as ProcessRunner from "../processRunner.ts";
45import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
46import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
47import { bootServiceLayer } from "./service.ts";
48
49export class CliUpdateError extends Schema.TaggedError<CliUpdateError>()("CliUpdateError", {
50 reason: Schema.String,
51}) {
52 override get message(): string {
53 return this.reason;
54 }
55}
56
57const ReleaseIndex = Schema.Array(
58 Schema.Struct({
59 tag_name: Schema.String,
60 draft: Schema.optional(Schema.Boolean),
61 }),
62);
63const decodeReleaseIndex = Schema.decodeUnknownEffect(Schema.fromJsonString(ReleaseIndex));
64
65const RELEASE_INDEX_TIMEOUT = Duration.seconds(30);
66// Enough to walk past a long run of nightlies without hammering the API when
67// a channel genuinely has nothing published.
68const RELEASE_INDEX_MAX_PAGES = 10;
69
70/** Asks GitHub for the newest published version on a channel, page by page. */
71const resolveNewestVersion = Effect.fn("cli.update.resolve_newest")(function* (
72 channel: CliReleaseChannel,
73) {
74 const httpClient = yield* HttpClient.HttpClient;
75 for (let page = 1; page <= RELEASE_INDEX_MAX_PAGES; page += 1) {
76 const body = yield* httpClient
77 .execute(
78 HttpClientRequest.get(cliReleaseIndexPageUrl(page)).pipe(
79 HttpClientRequest.setHeader("Accept", "application/vnd.github+json"),
80 ),
81 )
82 .pipe(
83 Effect.flatMap(HttpClientResponse.filterStatusOk),
84 Effect.flatMap((response) => response.text),
85 Effect.mapError(() => new CliUpdateError({ reason: "Could not list t3 releases." })),
86 Effect.timeoutOrElse({
87 duration: RELEASE_INDEX_TIMEOUT,
88 orElse: () =>
89 Effect.fail(new CliUpdateError({ reason: "Timed out listing t3 releases." })),
90 }),
91 );
92 const releases = yield* decodeReleaseIndex(body).pipe(
93 Effect.mapError(
94 () => new CliUpdateError({ reason: "The t3 release index had an unexpected shape." }),
95 ),
96 );
97 const version = newestCliReleaseVersion(releases, channel);
98 if (version !== undefined) return version;
99 if (releases.length === 0) break;
100 }
101 return yield* new CliUpdateError({ reason: `No published ${channel} release was found.` });
102});
103
104/**
105 * The launcher the install scripts leave behind: a symlink at `<bin>/t3` on
106 * POSIX, a `t3.cmd` shim on Windows. `t3 update` repoints it so the next `t3`
107 * invocation is the new version. Only a launcher that already points into
108 * this home's `runtime/versions` tree is touched; a plain copy of the
109 * executable, or a launcher for some other install, is left alone.
110 */
111export const repointLauncher = Effect.fn("cli.update.repoint_launcher")(function* (input: {
112 /** Path the current process was started through, if known. */
113 readonly launchedAs: string | undefined;
114 /** `<baseDir>/runtime/versions` of the home being updated. */
115 readonly versionsDir: string;
116 readonly targetEntryPath: string;
117}) {
118 const fs = yield* FileSystem.FileSystem;
119 const path = yield* Path.Path;
120 const platform = yield* HostProcessPlatform;
121 if (input.launchedAs === undefined) return Option.none<string>();
122 const ownsTarget = (candidate: string) => {
123 const relative = path.relative(input.versionsDir, path.resolve(candidate));
124 return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
125 };
126
127 if (platform === "win32") {
128 // The shim runs the executable by absolute path, so the executable sees
129 // itself as argv0; the shim is the `t3.cmd` next to it only when launched
130 // from an install script's bin directory. Find it by searching the
131 // directories that would resolve `t3` on this shell's PATH.
132 const shimPath = yield* findWindowsShim(input.launchedAs);
133 if (shimPath === undefined) return Option.none<string>();
134 const current = yield* fs.readFileString(shimPath).pipe(Effect.option);
135 const quoted = Option.isSome(current) ? /^"([^"]+)"/m.exec(current.value)?.[1] : undefined;
136 if (quoted === undefined || !ownsTarget(quoted)) return Option.none<string>();
137 yield* fs
138 .writeFileString(shimPath, `@echo off\r\n"${input.targetEntryPath}" %*`)
139 .pipe(
140 Effect.mapError(
141 () => new CliUpdateError({ reason: `Could not rewrite the t3 launcher at ${shimPath}.` }),
142 ),
143 );
144 return Option.some(shimPath);
145 }
146
147 const linkTarget = yield* fs.readLink(input.launchedAs).pipe(Effect.option);
148 if (Option.isNone(linkTarget)) return Option.none<string>();
149 const resolvedTarget = path.resolve(path.dirname(input.launchedAs), linkTarget.value);
150 if (!ownsTarget(resolvedTarget)) return Option.none<string>();
151 const tempLink = `${input.launchedAs}.${process.pid}.tmp`;
152 yield* fs.symlink(input.targetEntryPath, tempLink).pipe(
153 Effect.andThen(fs.rename(tempLink, input.launchedAs)),
154 Effect.mapError(
155 () =>
156 new CliUpdateError({ reason: `Could not repoint the t3 launcher at ${input.launchedAs}.` }),
157 ),
158 );
159 return Option.some(input.launchedAs);
160});
161
162/**
163 * The path the executable was started through. Node keeps the shell's
164 * spelling in argv0: a launcher symlink or `./t3` resolves against the
165 * working directory, while a bare `t3` was found on PATH and has to be
166 * looked up there again, or the launcher symlink is never seen.
167 */
168export const resolveLauncherPath = Effect.gen(function* () {
169 const path = yield* Path.Path;
170 const fs = yield* FileSystem.FileSystem;
171 const invokedAs = yield* HostProcessInvokedAs;
172 const cwd = yield* HostProcessWorkingDirectory;
173 const environment = yield* HostProcessEnvironment;
174 const platform = yield* HostProcessPlatform;
175 if (invokedAs.includes("/") || invokedAs.includes("\\")) {
176 return path.resolve(cwd, invokedAs);
177 }
178 const delimiter = platform === "win32" ? ";" : ":";
179 for (const directory of (environment["PATH"] ?? "").split(delimiter)) {
180 if (directory.length === 0) continue;
181 const candidate = path.join(directory, invokedAs);
182 if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
183 return candidate;
184 }
185 }
186 return undefined;
187});
188
189/**
190 * On Windows a `.cmd` shim is what PATH resolves, but the executable it runs
191 * only ever sees its own path. Walk PATH for a `t3.cmd` whose target is the
192 * running executable; that is the launcher the install script wrote.
193 */
194const findWindowsShim = Effect.fn("cli.update.find_windows_shim")(function* (
195 executablePath: string,
196) {
197 const fs = yield* FileSystem.FileSystem;
198 const path = yield* Path.Path;
199 const environment = yield* HostProcessEnvironment;
200 const candidates = [
201 ...(environment["T3CODE_INSTALL_BIN_DIR"] ? [environment["T3CODE_INSTALL_BIN_DIR"]] : []),
202 ...(environment["PATH"] ?? environment["Path"] ?? "").split(";"),
203 ].filter((entry) => entry.trim().length > 0);
204 for (const directory of candidates) {
205 const shimPath = path.join(directory, "t3.cmd");
206 const contents = yield* fs.readFileString(shimPath).pipe(Effect.option);
207 if (Option.isNone(contents)) continue;
208 const target = /^"([^"]+)"/m.exec(contents.value)?.[1];
209 if (
210 target !== undefined &&
211 path.resolve(target).toLowerCase() === path.resolve(executablePath).toLowerCase()
212 ) {
213 return shimPath;
214 }
215 }
216 return undefined;
217});
218
219const updateFlags = {
220 ...projectLocationFlags,
221 channel: Flag.choice("channel", CLI_RELEASE_CHANNELS).pipe(
222 Flag.withDescription(
223 "Release channel to follow. Defaults to the channel this t3 was published on.",
224 ),
225 Flag.optional,
226 ),
227 allowDowngrade: Flag.boolean("allow-downgrade").pipe(
228 Flag.withDescription("Allow moving to an older version than the one running."),
229 Flag.withDefault(false),
230 ),
231 yes: Flag.boolean("yes").pipe(
232 Flag.withAlias("y"),
233 Flag.withDescription(
234 "Restart the background service without asking. Required to restart it from a script, where there is no prompt.",
235 ),
236 Flag.withDefault(false),
237 ),
238};
239
240const versionArgument = Argument.string("version").pipe(
241 Argument.withDescription(
242 "Exact version to install. Defaults to the newest release on the channel.",
243 ),
244 Argument.optional,
245);
246
247export const updateCommand = Command.make("update", {
248 ...updateFlags,
249 version: versionArgument,
250}).pipe(
251 Command.withDescription(
252 "Download a newer t3 and switch this machine to it, including the background service when one is installed.",
253 ),
254 Command.withHandler((flags) =>
255 Effect.gen(function* () {
256 const logLevel = yield* GlobalFlag.LogLevel;
257 const config = yield* resolveCliAuthConfig(flags, logLevel);
258 return yield* runUpdate({
259 baseDir: config.baseDir,
260 serverRuntimeStatePath: config.serverRuntimeStatePath,
261 channel: Option.getOrUndefined(flags.channel),
262 requestedVersion: Option.getOrUndefined(flags.version),
263 allowDowngrade: flags.allowDowngrade,
264 assumeYes: flags.yes,
265 }).pipe(
266 Effect.provide(
267 Layer.mergeAll(bootServiceLayer(config), ProcessRunner.layer, FetchHttpClient.layer),
268 ),
269 );
270 }),
271 ),
272);
273
274/**
275 * A `t3 serve` or `t3` someone started by hand, as opposed to the one the
276 * background service supervises. The server records its pid on startup; a
277 * stale file from a crashed server is ignored by checking the pid is alive.
278 *
279 * Servers from before `serviceManaged` was recorded cannot be told apart by
280 * the file alone, so the launcher-supervised case is also recognised by
281 * lineage: a service server's parent is the launcher, and on Linux that
282 * launcher runs inside the unit's cgroup.
283 */
284const findForegroundServer = Effect.fn("cli.update.find_foreground_server")(function* (input: {
285 readonly serverRuntimeStatePath: string;
286 readonly serviceInstalled: boolean;
287}) {
288 const state = yield* readPersistedServerRuntimeState(input.serverRuntimeStatePath);
289 if (Option.isNone(state) || state.value.serviceManaged || !isProcessAlive(state.value.pid)) {
290 return undefined;
291 }
292 if (input.serviceInstalled && (yield* belongsToBootService(state.value.pid))) return undefined;
293 return state.value;
294});
295
296const belongsToBootService = Effect.fn("cli.update.belongs_to_boot_service")(function* (
297 pid: number,
298) {
299 const platform = yield* HostProcessPlatform;
300 const fs = yield* FileSystem.FileSystem;
301 const runner = yield* ProcessRunner.ProcessRunner;
302 if (platform === "linux") {
303 const cgroup = yield* fs.readFileString(`/proc/${pid}/cgroup`).pipe(Effect.option);
304 return Option.isSome(cgroup) && cgroup.value.includes("/t3code.service");
305 }
306 if (platform === "darwin") {
307 // The service server's parent is the launcher process.
308 const parent = yield* runner
309 .run({
310 command: "ps",
311 args: ["-o", "ppid=", "-p", String(pid)],
312 timeout: Duration.seconds(5),
313 })
314 .pipe(Effect.option);
315 const ppid = Option.isSome(parent) && parent.value.code === 0 ? parent.value.stdout.trim() : "";
316 if (!/^\d+$/.test(ppid)) return false;
317 const command = yield* runner
318 .run({ command: "ps", args: ["-o", "command=", "-p", ppid], timeout: Duration.seconds(5) })
319 .pipe(
320 Effect.map((result) => (result.code === 0 ? result.stdout : "")),
321 Effect.orElseSucceed(() => ""),
322 );
323 return /__service-launcher|service-launcher\.mjs/.test(command);
324 }
325 return false;
326});
327
328const runUpdate = Effect.fn("cli.update.run")(function* (input: {
329 readonly baseDir: string;
330 readonly serverRuntimeStatePath: string;
331 readonly channel: CliReleaseChannel | undefined;
332 readonly requestedVersion: string | undefined;
333 readonly allowDowngrade: boolean;
334 readonly assumeYes: boolean;
335}) {
336 const fs = yield* FileSystem.FileSystem;
337 const path = yield* Path.Path;
338 const runner = yield* ProcessRunner.ProcessRunner;
339 const platform = yield* HostProcessPlatform;
340 const arch = yield* HostProcessArchitecture;
341 const execPath = yield* HostProcessExecutablePath;
342 const environment = yield* HostProcessEnvironment;
343 const httpClient = yield* HttpClient.HttpClient;
344 const service = yield* BootService.BootService;
345
346 const currentVersion = packageJson.version;
347 const channel = input.channel ?? cliReleaseChannelOf(currentVersion);
348 if (input.requestedVersion !== undefined && !isExactServiceVersion(input.requestedVersion)) {
349 return yield* new CliUpdateError({
350 reason: `'${input.requestedVersion}' is not an exact t3 version.`,
351 });
352 }
353 const targetVersion = input.requestedVersion ?? (yield* resolveNewestVersion(channel));
354 const targetChannel = cliReleaseChannelOf(targetVersion);
355
356 // Preview is a maintainers' dogfooding train: it is cut by hand from
357 // unmerged branches, receives no fixes, and is never offered to anyone.
358 // Reaching it from stable or nightly takes an explicit ask and an explicit
359 // acknowledgement; the flag alone is not enough from a script.
360 const currentChannel = cliReleaseChannelOf(currentVersion);
361 if (targetChannel === "preview" && currentChannel !== "preview") {
362 yield* Console.log(
363 [
364 `t3@${targetVersion} is a preview build.`,
365 " Preview builds are cut by maintainers from unreleased branches to exercise the release",
366 " pipeline. They can be broken, receive no fixes, and are never offered as updates; you",
367 ` will have to switch back to ${currentChannel} yourself with \`t3 update --channel ${currentChannel} --allow-downgrade\`.`,
368 ].join("\n"),
369 );
370 if (!(process.stdin.isTTY && process.stdout.isTTY)) {
371 return yield* new CliUpdateError({
372 reason:
373 "Refusing to install a preview build without confirmation. Run this from a terminal to confirm, or pass --channel preview from an interactive shell.",
374 });
375 }
376 const confirmed = yield* Prompt.run(
377 Prompt.confirm({ message: "Install the preview build anyway?", initial: false }),
378 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
379 if (!confirmed) {
380 yield* Console.log("Left as is.");
381 return;
382 }
383 }
384
385 // Work out everything that will be touched before touching anything, so the
386 // user sees one plan and one question rather than a surprise restart.
387 const status = yield* service.status;
388 // The unit name is per user, not per T3 home. Only touch the service when it
389 // serves the home this update targets; otherwise it belongs to another
390 // install on this machine and restarting it would take that server down.
391 const servesThisHome =
392 status.installedBaseDir !== undefined &&
393 path.resolve(status.installedBaseDir) === path.resolve(input.baseDir);
394 const serviceInstalled = status.supported && status.installed && servesThisHome;
395 const foreground = yield* findForegroundServer({
396 serverRuntimeStatePath: input.serverRuntimeStatePath,
397 serviceInstalled,
398 });
399 // What this machine runs is the executable behind the launcher and, when a
400 // service is installed for this home, the version that service runs. Either
401 // being stale is an update to do, and the newest of the two is what the
402 // downgrade check protects.
403 const serviceVersion = serviceInstalled ? status.installedVersion : undefined;
404 const executableCurrent = targetVersion === currentVersion;
405 // A service whose recorded version is missing or unreadable is not known
406 // to be current, so it gets the update rather than being skipped.
407 const serviceCurrent = !serviceInstalled || serviceVersion === targetVersion;
408 const newestInstalled =
409 serviceVersion !== undefined && compareExactServiceVersions(serviceVersion, currentVersion) > 0
410 ? serviceVersion
411 : currentVersion;
412
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) {
422 yield* Console.log(
423 serviceVersion !== undefined
424 ? `t3 and its background service are already on ${targetVersion} (${targetChannel}).`
425 : `t3 is already on ${targetVersion} (${targetChannel}).`,
426 );
427 return;
428 }
429 if (!input.allowDowngrade && compareExactServiceVersions(targetVersion, newestInstalled) < 0) {
430 return yield* new CliUpdateError({
431 reason: `t3@${targetVersion} is older than the installed ${newestInstalled}. Pass --allow-downgrade to install it anyway.`,
432 });
433 }
434
435 const alreadyOnDisk = yield* fs
436 .readFileString(pinnedRuntimePaths(path, input.baseDir, targetVersion, platform).sentinelPath)
437 .pipe(
438 Effect.map((sentinel) => sentinel.trim() === targetVersion),
439 Effect.orElseSucceed(() => false),
440 );
441
442 yield* Console.log(
443 executableCurrent
444 ? `Updating the background service ${serviceVersion ?? "(unknown version)"} -> ${targetVersion} (${targetChannel}).`
445 : alreadyOnDisk
446 ? `Switching t3 ${currentVersion} -> ${targetVersion} (${targetChannel}, already downloaded).`
447 : `Updating t3 ${currentVersion} -> ${targetVersion} (${targetChannel}).`,
448 );
449 let restartService = false;
450 if (serviceInstalled && !serviceCurrent) {
451 yield* Console.log(
452 " A background service is installed for this T3 home. Restarting it interrupts anything running in it: agent turns, terminals, remote clients.",
453 );
454 if (input.assumeYes) {
455 restartService = true;
456 } else if (process.stdin.isTTY && process.stdout.isTTY) {
457 restartService = yield* Prompt.run(
458 Prompt.confirm({
459 message: "Restart the background service once the download is verified?",
460 initial: true,
461 }),
462 ).pipe(Effect.catchTag("QuitError", () => Effect.succeed(false)));
463 } else {
464 yield* Console.log(
465 " Not a terminal, so the service is left on its current version. Rerun with --yes to restart it, or run `t3 service update` later.",
466 );
467 }
468 }
469
470 const runtime = yield* ensurePinnedRuntimeInstalled({
471 baseDir: input.baseDir,
472 version: targetVersion,
473 fs,
474 path,
475 runner,
476 httpClient,
477 platform,
478 arch,
479 releaseBaseUrl: environment[CLI_RELEASE_BASE_URL_ENV]?.trim() || undefined,
480 validate: (paths) =>
481 runner
482 .run({
483 command: pinnedRuntimeCommand(paths, execPath).command,
484 args: [...pinnedRuntimeCommand(paths, execPath).args, "--version"],
485 timeout: Duration.seconds(30),
486 })
487 .pipe(
488 Effect.mapError(
489 (cause) =>
490 new PinnedRuntimeInstallError({ step: "verifying the downloaded t3", cause }),
491 ),
492 Effect.flatMap((result) =>
493 result.code === 0 && /\bv(\S+)\s*$/.exec(result.stdout)?.[1] === targetVersion
494 ? Effect.void
495 : Effect.fail(
496 new PinnedRuntimeInstallError({
497 step: "verifying the downloaded t3",
498 exitCode: Number(result.code),
499 }),
500 ),
501 ),
502 ),
503 }).pipe(
504 Effect.catchIf(
505 (error): error is PinnedRuntimeInstallError =>
506 error._tag === "PinnedRuntimeInstallError" &&
507 error.step.startsWith("downloading the t3 release checksums") &&
508 String(error.cause).includes("404"),
509 () =>
510 Effect.fail(
511 new CliUpdateError({
512 reason: `No release archive was published for t3@${targetVersion}.`,
513 }),
514 ),
515 ),
516 );
517
518 const launchedAs = (yield* HostProcessIsExecutable) ? yield* resolveLauncherPath : undefined;
519 const repointed = yield* repointLauncher({
520 launchedAs,
521 versionsDir: path.dirname(runtime.versionDir),
522 targetEntryPath: runtime.entryPath,
523 });
524
525 // The new executable owns the service switch: it verifies itself, writes its
526 // own version into the unit, and restarts the service on it.
527 let serviceUpdated = false;
528 if (restartService) {
529 const result = yield* runner.run({
530 command: runtime.entryPath,
531 args: [
532 "service",
533 "update",
534 "--base-dir",
535 input.baseDir,
536 ...(input.allowDowngrade ? ["--allow-downgrade"] : []),
537 ],
538 timeout: Duration.minutes(5),
539 });
540 if (result.code !== 0) {
541 return yield* new CliUpdateError({
542 reason: `t3@${targetVersion} is installed but the background service could not be updated (exit ${String(result.code)}).\n${result.stderr.trim() || result.stdout.trim()}`,
543 });
544 }
545 serviceUpdated = true;
546 }
547
548 yield* Console.log("");
549 yield* Console.log(`t3 ${targetVersion} is installed at ${runtime.entryPath}`);
550 if (Option.isSome(repointed)) {
551 yield* Console.log(` ${repointed.value} now runs ${targetVersion}`);
552 } else {
553 yield* Console.log(` Run it as ${runtime.entryPath}, or point your \`t3\` launcher at it.`);
554 }
555 if (serviceUpdated) {
556 yield* Console.log(` Background service restarted on ${targetVersion}`);
557 } else if (serviceInstalled && serviceCurrent) {
558 yield* Console.log(` Background service already on ${targetVersion}`);
559 } else if (serviceInstalled) {
560 yield* Console.log(
561 ` Background service still running ${serviceVersion ?? "an unknown version"}. Run \`t3 service update\` when you are ready to restart it.`,
562 );
563 } else if (status.installed && !servesThisHome) {
564 yield* Console.log(
565 ` The background service serves ${status.installedBaseDir ?? "another T3 home"} and was left unchanged.`,
566 );
567 }
568 if (foreground !== undefined) {
569 yield* Console.log(
570 ` A server started by hand is still running at ${foreground.origin} (pid ${foreground.pid}). Stop it and start it again to pick up ${targetVersion}.`,
571 );
572 }
573});
apps/server/src/cloud/bootService.test.ts+29
...@@ -37,6 +37,35 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",...@@ -37,6 +37,35 @@ it("keeps systemd pinned to the stable launcher rather than a versioned server",
37 expect(unit).not.toContain("versions/1.2.3");37 expect(unit).not.toContain("versions/1.2.3");
38});38});
3939
40it("reads the served T3 home back out of a rendered unit or plist", () => {
41 const plan = (baseDir: string) => ({
42 program: ["/usr/bin/node", `${baseDir}/runtime/service-launcher.mjs`],
43 launcherPath: `${baseDir}/runtime/service-launcher.mjs`,
44 baseDir,
45 logPath: `${baseDir}/userdata/logs/boot-service.log`,
46 unitPath: "/home/theo/.config/systemd/user/t3code.service",
47 });
48
49 expect(
50 BootService.bootServiceBaseDirOf(BootService.renderBootServiceUnit(plan("/home/theo/.t3"))),
51 ).toBe("/home/theo/.t3");
52 // Spaces and specifiers are quoted and escaped on the way in.
53 expect(
54 BootService.bootServiceBaseDirOf(
55 BootService.renderBootServiceUnit(plan("/home/theo/T3 Data/100%")),
56 ),
57 ).toBe("/home/theo/T3 Data/100%");
58 expect(
59 BootService.bootServiceBaseDirOf(
60 BootService.renderBootServicePlist(plan("/Users/theo/a&b"), {
61 homeDir: "/Users/theo",
62 environmentPath: "/usr/bin",
63 }),
64 ),
65 ).toBe("/Users/theo/a&b");
66 expect(BootService.bootServiceBaseDirOf("[Service]\nExecStart=/x\n")).toBeUndefined();
67});
68
40it("runs archive-distributed runtimes as their own executable", () => {69it("runs archive-distributed runtimes as their own executable", () => {
41 const unit = BootService.renderBootServiceUnit({70 const unit = BootService.renderBootServiceUnit({
42 program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"],71 program: ["/home/theo/.t3/runtime/versions/1.3.0-preview.20260911.7/t3", "__service-launcher"],
apps/server/src/cloud/bootService.ts+31
...@@ -56,6 +56,28 @@ function quoteSystemdValue(value: string): string {...@@ -56,6 +56,28 @@ function quoteSystemdValue(value: string): string {
56 : escaped;56 : escaped;
57}57}
5858
59/**
60 * Reads `T3CODE_HOME` back out of a rendered unit or plist. Only values this
61 * file writes are expected, so a quoted systemd value is unquoted and
62 * unescaped the same way `quoteSystemdValue` produced it.
63 */
64export function bootServiceBaseDirOf(contents: string): string | undefined {
65 const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1];
66 if (systemd !== undefined) {
67 const raw = systemd.trim();
68 const unquoted =
69 raw.startsWith('"') && raw.endsWith('"')
70 ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
71 : raw;
72 return unquoted.replaceAll("%%", "%");
73 }
74 const plist = /<key>T3CODE_HOME<\/key>\s*<string>([^<]*)<\/string>/.exec(contents)?.[1];
75 if (plist !== undefined) {
76 return plist.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&amp;", "&");
77 }
78 return undefined;
79}
80
59export interface BootServicePlan {81export interface BootServicePlan {
60 /**82 /**
61 * What the service manager executes. npm-distributed runtimes run the83 * What the service manager executes. npm-distributed runtimes run the
...@@ -486,6 +508,13 @@ export interface BootServiceStatus {...@@ -486,6 +508,13 @@ export interface BootServiceStatus {
486 readonly installed: boolean;508 readonly installed: boolean;
487 readonly current: boolean;509 readonly current: boolean;
488 readonly installedVersion?: string;510 readonly installedVersion?: string;
511 /**
512 * The T3 home the installed unit serves. The unit name is fixed per user,
513 * so a caller working against another base dir must not treat this service
514 * as its own; `t3 update --base-dir` learned that by restarting the live
515 * server of the machine it ran on.
516 */
517 readonly installedBaseDir?: string;
489 readonly problems?: ReadonlyArray<BootServiceProblem>;518 readonly problems?: ReadonlyArray<BootServiceProblem>;
490 readonly unitPath: string;519 readonly unitPath: string;
491 readonly logPath: string;520 readonly logPath: string;
...@@ -876,6 +905,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -876,6 +905,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
876 const installedVersion = Option.isSome(stateText)905 const installedVersion = Option.isSome(stateText)
877 ? serviceStateActiveVersion(stateText.value)906 ? serviceStateActiveVersion(stateText.value)
878 : undefined;907 : undefined;
908 const installedBaseDir = bootServiceBaseDirOf(unit);
879 const normalizeUnit = (contents: string) =>909 const normalizeUnit = (contents: string) =>
880 detectedManager.kind === "launchd"910 detectedManager.kind === "launchd"
881 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")911 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
...@@ -885,6 +915,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {...@@ -885,6 +915,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
885 supported: true,915 supported: true,
886 installed: true,916 installed: true,
887 ...(installedVersion === undefined ? {} : { installedVersion }),917 ...(installedVersion === undefined ? {} : { installedVersion }),
918 ...(installedBaseDir === undefined ? {} : { installedBaseDir }),
888 problems,919 problems,
889 current:920 current:
890 problems.length === 0 &&921 problems.length === 0 &&
apps/server/src/provider/Layers/ClaudeAdapter.ts+2-2
...@@ -7,7 +7,6 @@...@@ -7,7 +7,6 @@
7 *7 *
8 * @module ClaudeAdapterLive8 * @module ClaudeAdapterLive
9 */9 */
10import * as NodeSea from "node:sea";
1110
12import {11import {
13 type CanUseTool,12 type CanUseTool,
...@@ -70,6 +69,7 @@ import {...@@ -70,6 +69,7 @@ import {
70 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,69 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
71 formatClaudeResumeCompactionQuestion,70 formatClaudeResumeCompactionQuestion,
72} from "@t3tools/shared/claudeCompaction";71} from "@t3tools/shared/claudeCompaction";
72import { HostProcessIsExecutable } from "@t3tools/shared/hostProcess";
73import * as Cause from "effect/Cause";73import * as Cause from "effect/Cause";
74import * as Crypto from "effect/Crypto";74import * as Crypto from "effect/Crypto";
75import * as DateTime from "effect/DateTime";75import * as DateTime from "effect/DateTime";
...@@ -5133,7 +5133,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -5133,7 +5133,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
5133 }5133 }
5134 // The single-executable has no sibling script and no Node to run one5134 // The single-executable has no sibling script and no Node to run one
5135 // with, so it hosts the worker as a hidden subcommand of itself.5135 // with, so it hosts the worker as a hidden subcommand of itself.
5136 const historyWorkerArguments = NodeSea.isSea()5136 const historyWorkerArguments = (yield* HostProcessIsExecutable)
5137 ? ["__claude-history"]5137 ? ["__claude-history"]
5138 : [5138 : [
5139 yield* path5139 yield* path
apps/server/src/server.ts+3-1
...@@ -613,9 +613,11 @@ const makeServerLayer = Layer.unwrap(...@@ -613,9 +613,11 @@ const makeServerLayer = Layer.unwrap(
613 return;613 return;
614 }614 }
615615
616 const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;
616 const state = yield* makePersistedServerRuntimeState({617 const state = yield* makePersistedServerRuntimeState({
617 config,618 config,
618 port: address.port,619 port: address.port,
620 serviceManaged: launcher.managed,
619 });621 });
620 yield* persistServerRuntimeState({622 yield* persistServerRuntimeState({
621 path: config.serverRuntimeStatePath,623 path: config.serverRuntimeStatePath,
...@@ -775,7 +777,7 @@ const makeServerLayer = Layer.unwrap(...@@ -775,7 +777,7 @@ const makeServerLayer = Layer.unwrap(
775 const serverApplicationLayer = Layer.mergeAll(777 const serverApplicationLayer = Layer.mergeAll(
776 routesLayer,778 routesLayer,
777 httpListeningLayer,779 httpListeningLayer,
778 runtimeStateLayer,780 runtimeStateLayer.pipe(Layer.provide(launcherLayer)),
779 tailscaleServeLayer,781 tailscaleServeLayer,
780 cloudDesiredLinkReconcileLayer,782 cloudDesiredLinkReconcileLayer,
781 );783 );
apps/server/src/serverRuntimeState.test.ts+19
...@@ -62,6 +62,25 @@ describe("serverRuntimeState", () => {...@@ -62,6 +62,25 @@ describe("serverRuntimeState", () => {
62 }),62 }),
63 );63 );
6464
65 it.effect("marks a service-supervised server so CLIs can tell it from a manual one", () =>
66 Effect.gen(function* () {
67 const managed = yield* ServerRuntimeState.makePersistedServerRuntimeState({
68 config: { host: undefined, devUrl: undefined },
69 port: 13_773,
70 serviceManaged: true,
71 });
72 const manual = yield* ServerRuntimeState.makePersistedServerRuntimeState({
73 config: { host: undefined, devUrl: undefined },
74 port: 13_773,
75 });
76
77 assert.isTrue(managed.serviceManaged);
78 // Older readers decode the file without the field, so it is omitted
79 // rather than written as false.
80 assert.isFalse("serviceManaged" in manual);
81 }),
82 );
83
65 it.effect("treats a missing runtime state file as absent", () =>84 it.effect("treats a missing runtime state file as absent", () =>
66 Effect.gen(function* () {85 Effect.gen(function* () {
67 const fileSystem = yield* FileSystem.FileSystem;86 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/serverRuntimeState.ts+8
...@@ -18,6 +18,12 @@ export const PersistedServerRuntimeState = Schema.Struct({...@@ -18,6 +18,12 @@ export const PersistedServerRuntimeState = Schema.Struct({
18 // Dev is single-origin: browsers must pair through this URL, not `origin`.18 // Dev is single-origin: browsers must pair through this URL, not `origin`.
19 devUrl: Schema.optional(Schema.String),19 devUrl: Schema.optional(Schema.String),
20 startedAt: Schema.String,20 startedAt: Schema.String,
21 /**
22 * Set when the boot-service launcher supervises this server. Lets a CLI
23 * tell a service-managed server apart from one started by hand, which is
24 * the difference between "restart the service" and "stop your terminal".
25 */
26 serviceManaged: Schema.optional(Schema.Boolean),
21});27});
22export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;28export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
2329
...@@ -50,6 +56,7 @@ const runtimeOriginForConfig = (...@@ -50,6 +56,7 @@ const runtimeOriginForConfig = (
50export const makePersistedServerRuntimeState = (input: {56export const makePersistedServerRuntimeState = (input: {
51 readonly config: Pick<ServerConfig.ServerConfig["Service"], "host" | "devUrl">;57 readonly config: Pick<ServerConfig.ServerConfig["Service"], "host" | "devUrl">;
52 readonly port: number;58 readonly port: number;
59 readonly serviceManaged?: boolean;
53}): Effect.Effect<PersistedServerRuntimeState> =>60}): Effect.Effect<PersistedServerRuntimeState> =>
54 Effect.map(DateTime.now, (now) => ({61 Effect.map(DateTime.now, (now) => ({
55 version: 1,62 version: 1,
...@@ -59,6 +66,7 @@ export const makePersistedServerRuntimeState = (input: {...@@ -59,6 +66,7 @@ export const makePersistedServerRuntimeState = (input: {
59 origin: runtimeOriginForConfig(input.config, input.port),66 origin: runtimeOriginForConfig(input.config, input.port),
60 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),67 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
61 startedAt: DateTime.formatIso(now),68 startedAt: DateTime.formatIso(now),
69 ...(input.serviceManaged ? { serviceManaged: true } : {}),
62 }));70 }));
6371
64export const persistServerRuntimeState = (input: {72export const persistServerRuntimeState = (input: {
docs/operations/release.md+1-1
...@@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop...@@ -12,7 +12,7 @@ This document covers the unified release workflow for stable and nightly desktop
12 - push tag matching `v*.*.*` for a stable release of an explicit commit12 - push tag matching `v*.*.*` for a stable release of an explicit commit
13 - scheduled nightly check every 30 minutes13 - scheduled nightly check every 30 minutes
14 - manual `workflow_dispatch` with `channel=nightly`14 - manual `workflow_dispatch` with `channel=nightly`
15 - manual `workflow_dispatch` with `channel=preview`, a temporary train for dogfooding the archive-based CLI runtime. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes only a GitHub prerelease. A preview is reachable solely by downloading it from that release: it is not published to npm, its desktop builds are packaged without an update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so stable and nightly installs can never be offered one. The hosted web app, AUR, and Discord announcements are skipped. Remove the channel once archives are the default on nightly and stable.15 - manual `workflow_dispatch` with `channel=preview`, the maintainers' test train. It exercises the whole release flow (build, sign, notarize, smoke, publish) for a commit that end users must never receive, which is how an unmerged branch or a risky change gets a real release run before it lands. It builds the triggering commit with nightly's versioning under the `preview` prerelease identifier (`0.0.41-preview.<date>.<run>`) and publishes only a GitHub prerelease. Nothing ever selects preview on its own: it is not on the schedule, not published to npm, its desktop builds carry no update feed, and no updater manifest (`latest*.yml`, `nightly*.yml`, blockmaps) is attached, so a stable or nightly install cannot be offered one. The only ways onto it are downloading the release by hand, `T3CODE_CHANNEL=preview` for the install scripts, or `t3 update --channel preview` from a terminal; each prints a warning, and the CLI asks for confirmation when the running build is not itself a preview. The release itself is named as a maintainer test build and its body is a warning rather than generated notes: a changelog of unmerged branch history is not a changelog, and nightly and stable notes are unaffected because each series resolves its previous tag within its own channel. The hosted web app, AUR, and Discord announcements are skipped. Keep it; it costs nothing when idle.
16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.16- A manual stable release builds the commit of the latest published nightly, not `main` HEAD.
17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep17 Nightly is the release candidate: verify the nightly, then promote it. Merges to `main` keep
18 landing while you verify and never leak into the stable build.18 landing while you verify and never leak into the stable build.
docs/user/background-service.md+28-6
...@@ -25,10 +25,10 @@ Updating restarts the server. Finish active work first, and wait for any remote...@@ -25,10 +25,10 @@ Updating restarts the server. Finish active work first, and wait for any remote
25update already in progress. To match a remote client's version, follow25update already in progress. To match a remote client's version, follow
26[Updating T3 Code](./updating.md).26[Updating T3 Code](./updating.md).
2727
28Preview builds (`t3@preview`) install as a self-contained download from the28Self-contained builds install as a download from the T3 Code GitHub release
29T3 Code GitHub release instead of through npm, so the machine running the29instead of through npm, so the machine running the service does not need
30service does not need Node.js or npm once the CLI is on it. To get the CLI30Node.js or npm once the CLI is on it. To get the CLI onto a machine without
31onto a machine without Node, run the install script:31Node, run the install script:
3232
33```sh33```sh
34curl -fsSL https://t3.codes/install.sh | sh34curl -fsSL https://t3.codes/install.sh | sh
...@@ -37,8 +37,30 @@ curl -fsSL https://t3.codes/install.sh | sh...@@ -37,8 +37,30 @@ curl -fsSL https://t3.codes/install.sh | sh
37On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead.37On Windows, run `irm https://t3.codes/install.ps1 | iex` in PowerShell instead.
3838
39It places `t3` in `~/.local/bin` and reuses the same download when you later39It places `t3` in `~/.local/bin` and reuses the same download when you later
40run `t3 service install`. Set `T3CODE_VERSION` to pin an exact version, or40run `t3 service install`. It follows the stable train by default; set
41`T3CODE_RELEASE_BASE_URL` to download from a mirror.41`T3CODE_CHANNEL=nightly` for nightlies, `T3CODE_VERSION` to pin an exact
42version, or `T3CODE_RELEASE_BASE_URL` to download from a mirror. Versions that
43were only published to npm are refused with the `npm install` to run instead.
44
45`preview` is a third train that maintainers cut from unreleased branches to
46exercise the release pipeline. Those builds can be broken, receive no fixes,
47and are never offered as updates; the installer and `t3 update` only take you
48there when you ask for the channel explicitly, and warn you when they do.
49
50Once a self-contained `t3` is installed, `t3 update` moves the machine to a
51newer one without npm: it downloads the newest release on the channel the
52running `t3` came from, verifies it, and points the `t3` launcher at it. When
53a background service is installed for the same T3 home it asks before
54restarting it, since a restart interrupts running agent turns, terminals, and
55remote clients; answer no and the service keeps the old version until you run
56`t3 service update`. From a script there is no prompt, so pass `--yes` to
57restart the service. A server you started by hand is never touched; the
58command tells you it is still on the old version so you can restart it
59yourself. Pass an exact version (`t3 update 0.0.41-preview.20260912.1595`) to
60pin 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
62be installed this way; the command says so and names the `npm install` to run
63instead.
4264
43## Platform support65## Platform support
4466
packages/shared/src/cliRelease.test.ts+33
...@@ -5,7 +5,10 @@ import {...@@ -5,7 +5,10 @@ import {
5 cliArchivePlatformKey,5 cliArchivePlatformKey,
6 cliArchiveTarCommand,6 cliArchiveTarCommand,
7 cliReleaseDownloadBaseUrl,7 cliReleaseDownloadBaseUrl,
8 cliReleaseChannelOf,
9 cliReleaseIndexPageUrl,
8 isArchiveDistributedVersion,10 isArchiveDistributedVersion,
11 newestCliReleaseVersion,
9 parseChecksums,12 parseChecksums,
10} from "./cliRelease.ts";13} from "./cliRelease.ts";
1114
...@@ -66,4 +69,34 @@ describe("cliRelease", () => {...@@ -66,4 +69,34 @@ describe("cliRelease", () => {
66 );69 );
67 expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe");70 expect(cliArchiveTarCommand("win32", {})).toBe("C:\\Windows\\System32\\tar.exe");
68 });71 });
72
73 it("derives the release channel from the version alone", () => {
74 expect(cliReleaseChannelOf("1.2.3")).toBe("stable");
75 expect(cliReleaseChannelOf("1.2.3-nightly.20260911.4")).toBe("nightly");
76 expect(cliReleaseChannelOf("1.2.3-preview.20260911.4")).toBe("preview");
77 // A prerelease that is not one of our trains is not silently a nightly.
78 expect(cliReleaseChannelOf("1.2.3-rc.1")).toBe("stable");
79 });
80
81 it("picks the newest non-draft release on the requested channel", () => {
82 const releases = [
83 { tag_name: "v1.2.4-preview.20260912.9", draft: true },
84 { tag_name: "v1.2.4-preview.20260912.8" },
85 { tag_name: "v1.2.4-nightly.20260912.7" },
86 { tag_name: "desktop-preview" },
87 { tag_name: "v1.2.3" },
88 { tag_name: "v1.2.3-nightly.20260911.2" },
89 ];
90 expect(newestCliReleaseVersion(releases, "preview")).toBe("1.2.4-preview.20260912.8");
91 expect(newestCliReleaseVersion(releases, "nightly")).toBe("1.2.4-nightly.20260912.7");
92 expect(newestCliReleaseVersion(releases, "stable")).toBe("1.2.3");
93 expect(newestCliReleaseVersion([{ tag_name: "v1.2.3" }], "preview")).toBeUndefined();
94 });
95
96 it("pages through the release index at the largest page GitHub allows", () => {
97 expect(cliReleaseIndexPageUrl(1)).toBe(
98 "https://api.github.com/repos/pingdotgg/t3code/releases?per_page=100&page=1",
99 );
100 expect(cliReleaseIndexPageUrl(3)).toContain("page=3");
101 });
69});102});
packages/shared/src/cliRelease.ts+44
...@@ -74,3 +74,47 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> {...@@ -74,3 +74,47 @@ export function parseChecksums(text: string): ReadonlyMap<string, string> {
74export function isArchiveDistributedVersion(version: string): boolean {74export function isArchiveDistributedVersion(version: string): boolean {
75 return /-preview\.\d{8}\.\d+$/.test(version);75 return /-preview\.\d{8}\.\d+$/.test(version);
76}76}
77
78export type CliReleaseChannel = "stable" | "nightly" | "preview";
79export const CLI_RELEASE_CHANNELS: ReadonlyArray<CliReleaseChannel> = [
80 "stable",
81 "nightly",
82 "preview",
83];
84
85/** The release train a version was published on, derived from its prerelease tag. */
86export function cliReleaseChannelOf(version: string): CliReleaseChannel {
87 const channel = /^[^-+]+-(nightly|preview)\.\d{8}\.\d+$/.exec(version)?.[1];
88 return channel === "nightly" || channel === "preview" ? channel : "stable";
89}
90
91/**
92 * One page of GitHub's list-releases endpoint, newest first. Callers walk pages
93 * until a channel match turns up; a busy nightly train can push the newest
94 * preview or stable release past any single page.
95 */
96export function cliReleaseIndexPageUrl(page: number): string {
97 return `https://api.github.com/repos/${CLI_RELEASE_REPOSITORY}/releases?per_page=100&page=${page}`;
98}
99
100/**
101 * Picks the newest version on a channel from the release index. Tags are
102 * `v<version>`; the channel is decided by the same rule the runtime uses, so
103 * a preview tag never satisfies a nightly lookup and vice versa. Drafts are
104 * skipped because their assets are not downloadable.
105 */
106export function newestCliReleaseVersion(
107 releases: ReadonlyArray<{
108 readonly tag_name: string;
109 readonly draft?: boolean | undefined;
110 }>,
111 channel: CliReleaseChannel,
112): string | undefined {
113 for (const release of releases) {
114 if (release.draft) continue;
115 const version = /^v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(release.tag_name)?.[1];
116 if (version === undefined) continue;
117 if (cliReleaseChannelOf(version) === channel) return version;
118 }
119 return undefined;
120}
packages/shared/src/hostProcess.ts+26
...@@ -2,6 +2,7 @@ import * as Context from "effect/Context";...@@ -2,6 +2,7 @@ import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as NodeDns from "node:dns";3import * as NodeDns from "node:dns";
4import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
5import * as NodeSea from "node:sea";
56
6export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(7export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(
7 "@t3tools/shared/hostProcess/HostProcessPlatform",8 "@t3tools/shared/hostProcess/HostProcessPlatform",
...@@ -52,6 +53,31 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(...@@ -52,6 +53,31 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
52 },53 },
53);54);
5455
56/**
57 * The command the shell was given, before Node resolved it to the binary:
58 * `t3` for a PATH lookup, `./t3` or the launcher symlink for an explicit
59 * path. `process.argv[0]` and `execPath` are always the resolved binary.
60 */
61export const HostProcessInvokedAs = Context.Reference<string>(
62 "@t3tools/shared/hostProcess/HostProcessInvokedAs",
63 {
64 defaultValue: () => process.argv0,
65 },
66);
67
68/**
69 * Whether this process is a Node single-executable rather than a script run
70 * by a Node on the machine. Code that needs a sibling file or a Node to run
71 * one branches on this: an executable hosts such things as hidden
72 * subcommands of itself.
73 */
74export const HostProcessIsExecutable = Context.Reference<boolean>(
75 "@t3tools/shared/hostProcess/HostProcessIsExecutable",
76 {
77 defaultValue: () => NodeSea.isSea(),
78 },
79);
80
55/**81/**
56 * Every IP address this machine answers to: the interface addresses, plus82 * Every IP address this machine answers to: the interface addresses, plus
57 * whatever the resolver returns for the machine's own hostname. The latter83 * whatever the resolver returns for the machine's own hostname. The latter
scripts/build-desktop-artifact.ts+1-1
...@@ -2616,7 +2616,7 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl...@@ -2616,7 +2616,7 @@ export function resolveDesktopUpdateChannel(version: string): "latest" | "nightl
2616 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";2616 return /-nightly\.\d{8}\.\d+$/.test(version) ? "nightly" : "latest";
2617}2617}
26182618
2619// Pull request builds (`-pr.<n>.`) and the temporary preview train2619// Pull request builds (`-pr.<n>.`) and the maintainers' preview train
2620// (`-preview.<date>.<run>`) are downloaded by hand and never through an2620// (`-preview.<date>.<run>`) are downloaded by hand and never through an
2621// updater. Building them without a publish config means electron-builder2621// updater. Building them without a publish config means electron-builder
2622// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and2622// emits no `latest*.yml`/`nightly*.yml` manifests or blockmaps for them and