Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ jobs:
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "make_latest=false" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "preview" ]]; then
# Temporary channel for dogfooding the archive-based CLI runtime.
# Manual-only test train: exercises the whole release flow for a
# commit end users must never receive. Never scheduled.
# Same versioning as nightly under its own prerelease identifier.
# A preview release is reachable only by downloading it by hand:
# it is never published to npm, its desktop builds carry no update
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/updates/updateChannels.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { DesktopUpdateChannel } from "@t3tools/contracts";

const NIGHTLY_VERSION_PATTERN = /^[^-+]+-nightly\.\d{8}\.\d+$/;
// Preview builds are a temporary dogfooding train cut from nightly. They share
// nightly's branding but are packaged without an update feed (see
// Preview builds are the maintainers' test train, cut by hand from unreleased
// branches to exercise the release flow. They share nightly's branding but
// are packaged without an update feed (see
// isDesktopPreviewVersion in scripts/build-desktop-artifact.ts), so the
// channel a preview install reports is cosmetic: it never checks for updates
// and no updater feed ever lists a preview release.
Expand Down
37 changes: 31 additions & 6 deletions apps/marketing/public/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
# irm https://t3.codes/install.ps1 | iex
#
# Environment:
# T3CODE_VERSION exact version to install (default: latest preview release)
# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
# (default: stable; preview is a maintainers' test train)
# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
# T3CODE_HOME T3 home directory (default: ~\.t3)
# T3CODE_INSTALL_BIN_DIR where t3.exe is linked (default: ~\.local\bin)
# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
Expand Down Expand Up @@ -34,14 +36,29 @@ $arch = switch ($rawArch) {
default { Fail "unsupported architecture $rawArch" }
}

$channel = if ($env:T3CODE_CHANNEL) { $env:T3CODE_CHANNEL } else { "stable" }
$version = $env:T3CODE_VERSION
if (-not $version) {
# Preview is the only train shipping archives while they are being dogfooded.
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=50" -Headers @{ "User-Agent" = "t3-install" }
$tag = ($releases | Where-Object { $_.tag_name -match '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } | Select-Object -First 1).tag_name
if (-not $tag) { Fail "could not find a preview release; set T3CODE_VERSION" }
# Tags are v<semver>; the channel is the prerelease identifier, or none for
# stable. Only tags of the requested train are considered, so a stable
# install can never pick up a nightly or preview build by accident.
$tagPattern = switch ($channel) {
"stable" { '^v\d+\.\d+\.\d+$' }
"nightly" { '^v\d+\.\d+\.\d+-nightly\.\d+\.\d+$' }
"preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' }
default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" }
}
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" }
$tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name
if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" }
$version = $tag.Substring(1)
}
if ($version -match '-preview\.') {
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."
if ($channel -ne "preview" -and -not $env:T3CODE_VERSION) {
Fail "refusing a preview build that was not explicitly requested"
}
}

$stem = "t3-$version-win32-$arch"
$archive = "$stem.zip"
Expand All @@ -57,7 +74,15 @@ if ((Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $version)) {
New-Item -ItemType Directory -Path $staging | Out-Null
try {
Write-Host "Downloading $archive..."
Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing
try {
Invoke-WebRequest -Uri "$baseUrl/v$version/SHA256SUMS" -OutFile (Join-Path $staging "SHA256SUMS") -UseBasicParsing
} catch {
$status = $_.Exception.Response.StatusCode.value__
if ($status -eq 404) {
Fail "t3 $version has no self-contained archive; install it with 'npm install -g t3@$version' instead"
}
throw
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Invoke-WebRequest -Uri "$baseUrl/v$version/$archive" -OutFile (Join-Path $staging $archive) -UseBasicParsing

$expected = (Get-Content (Join-Path $staging "SHA256SUMS") | Where-Object { $_ -match "\s\*?$([regex]::Escape($archive))$" } | Select-Object -First 1)
Expand Down
52 changes: 44 additions & 8 deletions apps/marketing/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
# curl -fsSL https://t3.codes/install.sh | sh
#
# Environment:
# T3CODE_VERSION exact version to install (default: latest preview release)
# T3CODE_CHANNEL release train to follow: stable, nightly, or preview
# (default: stable; preview is a maintainers' test train)
# T3CODE_VERSION exact version to install (overrides T3CODE_CHANNEL)
# T3CODE_HOME T3 home directory (default: ~/.t3)
# T3CODE_INSTALL_BIN_DIR where the `t3` symlink goes (default: ~/.local/bin)
# T3CODE_RELEASE_BASE_URL mirror for releases/download (default: GitHub)
Expand All @@ -25,11 +27,19 @@ fail() {
exit 1
}

# Exit 44 on a 404 so callers can tell "no such asset" from a network failure.
fetch() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1" -o "$2"
status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1
case "$status" in
2??) return 0 ;;
404) return 44 ;;
*) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;;
esac
elif command -v wget >/dev/null 2>&1; then
wget -q "$1" -O "$2"
wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0
if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi
cat "$2.headers" >&2; rm -f "$2.headers"; return 1
else
fail "curl or wget is required"
fi
Expand All @@ -54,15 +64,35 @@ else
fail "sha256sum or shasum is required"
fi

channel="${T3CODE_CHANNEL:-stable}"
version="${T3CODE_VERSION:-}"
if [ -z "$version" ]; then
# Preview is the only train shipping archives while they are being dogfooded.
# Tags are v<semver>; the channel is the prerelease identifier, or none for
# stable. Only tags of the requested train are considered, so a stable
# install can never pick up a nightly or preview build by accident.
case "$channel" in
stable) tag_pattern='v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)' ;;
nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High public/install.sh:75

The nightly/preview selector accepts foreign prerelease tags such as v1.2.3-rc-nightly.20260913.1, so T3CODE_CHANNEL=nightly can download a build whose first prerelease identifier is rc. The [^"]* prefix is too permissive; require the exact v<major>.<minor>.<patch>-<channel>.<date>.<run> shape.

Suggested change
nightly | preview) tag_pattern="v\([0-9][^\"]*-${channel}\.[0-9]*\.[0-9]*\)" ;;
nightly | preview) tag_pattern="v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-${channel}\.[0-9][0-9]*\.[0-9][0-9]*\)" ;;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/marketing/public/install.sh around line 75:

The nightly/preview selector accepts foreign prerelease tags such as `v1.2.3-rc-nightly.20260913.1`, so `T3CODE_CHANNEL=nightly` can download a build whose first prerelease identifier is `rc`. The `[^"]*` prefix is too permissive; require the exact `v<major>.<minor>.<patch>-<channel>.<date>.<run>` shape.

*) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;;
esac
tmp_index="$(mktemp)"
fetch "https://api.github.com/repos/${repo}/releases?per_page=50" "$tmp_index"
version="$(sed -n 's/.*"tag_name": *"v\([0-9][^"]*-preview\.[0-9]*\.[0-9]*\)".*/\1/p' "$tmp_index" | head -n 1)"
fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index"
version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)"
rm -f "$tmp_index"
[ -n "$version" ] || fail "could not find a preview release; set T3CODE_VERSION"
[ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION"
fi
case "$version" in
*-preview.*)
printf '%s\n' \
"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." >&2
if [ "$channel" != "preview" ] && [ -z "${T3CODE_VERSION:-}" ]; then
fail "refusing a preview build that was not explicitly requested"
fi
;;
esac

stem="t3-${version}-${platform}-${arch}"
archive="${stem}.tar.gz"
Expand All @@ -77,7 +107,13 @@ else
trap 'rm -rf "$staging"' EXIT

printf 'Downloading %s...\n' "$archive"
fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS"
fetch_status=0
fetch "${base_url}/v${version}/SHA256SUMS" "${staging}/SHA256SUMS" || fetch_status=$?
if [ "$fetch_status" -eq 44 ]; then
fail "t3 ${version} has no self-contained archive; install it with \`npm install -g t3@${version}\` instead"
elif [ "$fetch_status" -ne 0 ]; then
fail "could not download the release checksums"
fi
fetch "${base_url}/v${version}/${archive}" "${staging}/${archive}"

expected="$(grep " \*\{0,1\}${archive}\$" "${staging}/SHA256SUMS" | cut -d' ' -f1)"
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { isEntrypoint } from "./entrypoint.ts";
import { projectCommand } from "./cli/project.ts";
import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts";
import { serviceCommand } from "./cli/service.ts";
import { updateCommand } from "./cli/update.ts";
import { claudeHistoryCommand } from "./cli/claudeHistory.ts";
import { serviceLauncherCommand } from "./cli/serviceLauncher.ts";
import { servicePreflightCommand } from "./cli/servicePreflight.ts";
Expand Down Expand Up @@ -62,6 +63,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
authCommand,
projectCommand,
serviceCommand,
updateCommand,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
serviceLauncherCommand,
claudeHistoryCommand,
servicePreflightCommand,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/cli/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pip

const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
Command.withDescription(
"Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.",
"Update or repair the background service using this CLI version. Use `t3 update` to move to a newer release first.",
),
Command.withHandler((flags) =>
runServiceCommand(
Expand Down
109 changes: 109 additions & 0 deletions apps/server/src/cli/update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import {
HostProcessEnvironment,
HostProcessInvokedAs,
HostProcessPlatform,
HostProcessWorkingDirectory,
} from "@t3tools/shared/hostProcess";

import { repointLauncher, resolveLauncherPath } from "./update.ts";

it.layer(NodeServices.layer)("t3 update launcher", (it) => {
it.effect("repoints a symlink that lives in a runtime versions tree", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
const oldExe = path.join(root, "runtime/versions/1.0.0/t3");
const newExe = path.join(root, "runtime/versions/2.0.0/t3");
const launcher = path.join(root, "bin/t3");
for (const file of [oldExe, newExe]) {
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
yield* fs.writeFileString(file, "");
}
yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
yield* fs.symlink(oldExe, launcher);

const repointed = yield* repointLauncher({
launchedAs: launcher,
versionsDir: path.join(root, "runtime/versions"),
targetEntryPath: newExe,
});

assert.deepStrictEqual(Option.getOrUndefined(repointed), launcher);
assert.equal(yield* fs.readLink(launcher), newExe);
}).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
);

it.effect("leaves a plain copy or a foreign symlink alone", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
const newExe = path.join(root, "runtime/versions/2.0.0/t3");
const copy = path.join(root, "copy/t3");
const foreign = path.join(root, "foreign/t3");
const elsewhere = path.join(root, "elsewhere/t3");
// Another install's versions tree: same shape, different home.
const otherHome = path.join(root, "other/runtime/versions/1.0.0/t3");
const otherLauncher = path.join(root, "other/bin/t3");
for (const file of [newExe, copy, elsewhere, otherHome]) {
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
yield* fs.writeFileString(file, "");
}
yield* fs.makeDirectory(path.dirname(foreign), { recursive: true });
yield* fs.symlink(elsewhere, foreign);
yield* fs.makeDirectory(path.dirname(otherLauncher), { recursive: true });
yield* fs.symlink(otherHome, otherLauncher);

for (const launchedAs of [copy, foreign, otherLauncher, undefined]) {
const repointed = yield* repointLauncher({
launchedAs,
versionsDir: path.join(root, "runtime/versions"),
targetEntryPath: newExe,
});
assert.equal(repointed._tag, "None", launchedAs ?? "undefined");
}
assert.equal(yield* fs.readLink(foreign), elsewhere);
assert.equal(yield* fs.readLink(otherLauncher), otherHome);
}).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
);

it.effect("finds the launcher a bare command name resolved to on PATH", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-update-" });
const launcher = path.join(root, "bin/t3");
yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
yield* fs.writeFileString(launcher, "");

const bare = yield* resolveLauncherPath.pipe(
Effect.provideService(HostProcessInvokedAs, "t3"),
Effect.provideService(HostProcessEnvironment, {
PATH: `${path.join(root, "missing")}:${path.join(root, "bin")}`,
}),
Effect.provideService(HostProcessWorkingDirectory, root),
);
const relative = yield* resolveLauncherPath.pipe(
Effect.provideService(HostProcessInvokedAs, "./bin/t3"),
Effect.provideService(HostProcessEnvironment, { PATH: "" }),
Effect.provideService(HostProcessWorkingDirectory, root),
);
const absent = yield* resolveLauncherPath.pipe(
Effect.provideService(HostProcessInvokedAs, "t3"),
Effect.provideService(HostProcessEnvironment, { PATH: path.join(root, "missing") }),
Effect.provideService(HostProcessWorkingDirectory, root),
);

assert.equal(bare, launcher);
assert.equal(relative, launcher);
assert.equal(absent, undefined);
}).pipe(Effect.scoped, Effect.provideService(HostProcessPlatform, "linux")),
);
});
Loading
Loading