diff --git a/cli/src/__tests__/channels.test.ts b/cli/src/__tests__/channels.test.ts new file mode 100644 index 0000000000..97c0ad6222 --- /dev/null +++ b/cli/src/__tests__/channels.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + channelForVersion, + collectChannelState, + RELEASE_CHANNELS, +} from "../commands/channels.js"; +import type { CommandRunner } from "../commands/install.js"; + +describe("channelForVersion", () => { + it("maps published versions to their lane", () => { + expect(channelForVersion("2026.811.0")).toBe("stable"); + expect(channelForVersion("2026.811.0-beta.0")).toBe("beta"); + expect(channelForVersion("2026.811.0-nightly.0")).toBe("nightly"); + expect(channelForVersion("2026.811.0-canary.3")).toBe("canary"); + }); + + it("treats non-CalVer versions as unknown instead of guessing", () => { + expect(channelForVersion("0.3.1")).toBe("unknown"); + expect(channelForVersion("2026.811.0-rc.1")).toBe("unknown"); + expect(channelForVersion("garbage")).toBe("unknown"); + }); +}); + +describe("collectChannelState", () => { + const versionsByTag: Record = { + latest: "2026.722.0", + beta: "2026.811.0-beta.0", + nightly: "2026.811.0-nightly.0", + canary: "2026.811.0-canary.3", + }; + + const fakeRunner: CommandRunner = async (_command, args) => { + const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@")); + const tag = spec?.slice("paperclipai@".length) ?? ""; + const version = versionsByTag[tag]; + if (!version) throw new Error(`unexpected dist-tag: ${tag}`); + return { stdout: JSON.stringify(version), stderr: "" }; + }; + + it("resolves every channel's dist-tag from the registry", async () => { + const state = await collectChannelState(fakeRunner); + + expect(state.map((entry) => entry.channel)).toEqual([ + "stable", + "beta", + "nightly", + "canary", + ]); + expect(state.map((entry) => entry.version)).toEqual([ + "2026.722.0", + "2026.811.0-beta.0", + "2026.811.0-nightly.0", + "2026.811.0-canary.3", + ]); + }); + + it("degrades a single unavailable channel to null without failing the rest", async () => { + const flakyRunner: CommandRunner = async (command, args, options) => { + const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@")); + if (spec === "paperclipai@nightly") throw new Error("registry timeout"); + return fakeRunner(command, args, options); + }; + + const state = await collectChannelState(flakyRunner); + const byChannel = Object.fromEntries(state.map((entry) => [entry.channel, entry.version])); + + expect(byChannel.nightly).toBeNull(); + expect(byChannel.stable).toBe("2026.722.0"); + expect(byChannel.beta).toBe("2026.811.0-beta.0"); + expect(byChannel.canary).toBe("2026.811.0-canary.3"); + }); + + it("keeps the channel table and dist-tags in sync", () => { + expect(RELEASE_CHANNELS.map((entry) => entry.distTag)).toEqual([ + "latest", + "beta", + "nightly", + "canary", + ]); + }); +}); diff --git a/cli/src/commands/channels.ts b/cli/src/commands/channels.ts new file mode 100644 index 0000000000..3fe211b495 --- /dev/null +++ b/cli/src/commands/channels.ts @@ -0,0 +1,119 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import pc from "picocolors"; +import { resolvePublishedVersion, type CommandRunner } from "./install.js"; +import { packageVersion } from "../version.js"; + +const execFileAsync = promisify(execFile); + +const defaultRunCommand: CommandRunner = (command, args, options) => + execFileAsync(command, args, { ...options, encoding: "utf8" }); + +export type ChannelName = "stable" | "beta" | "nightly" | "canary"; + +export type ChannelDescriptor = { + channel: ChannelName; + distTag: string; + cadence: string; + audience: string; +}; + +// Ordered from most to least stable — the order users should consider them. +export const RELEASE_CHANNELS: readonly ChannelDescriptor[] = [ + { + channel: "stable", + distTag: "latest", + cadence: "manual, soaked in beta for 3+ days", + audience: "the recommended release for almost everyone", + }, + { + channel: "beta", + distTag: "beta", + cadence: "manual promotion behind an approval gate", + audience: "release candidates: what stable becomes a few days later", + }, + { + channel: "nightly", + distTag: "nightly", + cadence: "once a night, smoke-gated", + audience: "yesterday's merges, tested as a unit", + }, + { + channel: "canary", + distTag: "canary", + cadence: "every merge to master", + audience: "the bleeding edge", + }, +]; + +const CALVER_RE = /^\d{4}\.\d{1,4}\.\d+$/; +const PRERELEASE_RE = /^\d{4}\.\d{1,4}\.\d+-(canary|nightly|beta)\.\d+$/; + +// Published versions carry their lane in the version string; the source +// checkout's package.json holds a placeholder that matches neither form. +export function channelForVersion(version: string): ChannelName | "unknown" { + const prerelease = version.match(PRERELEASE_RE); + if (prerelease) return prerelease[1] as ChannelName; + if (CALVER_RE.test(version)) return "stable"; + return "unknown"; +} + +export type ChannelState = ChannelDescriptor & { version: string | null }; + +export async function collectChannelState( + runCommand: CommandRunner = defaultRunCommand, +): Promise { + const resolved = await Promise.allSettled( + RELEASE_CHANNELS.map((entry) => resolvePublishedVersion(entry.distTag, runCommand)), + ); + return RELEASE_CHANNELS.map((entry, index) => { + const outcome = resolved[index]; + return { + ...entry, + version: outcome.status === "fulfilled" ? outcome.value : null, + }; + }); +} + +export type ChannelsOptions = { json?: boolean }; + +export async function channelsCommand( + options: ChannelsOptions = {}, + runCommand: CommandRunner = defaultRunCommand, +): Promise { + const state = await collectChannelState(runCommand); + const currentChannel = channelForVersion(packageVersion); + + if (options.json) { + console.log( + JSON.stringify( + { + current: { version: packageVersion, channel: currentChannel }, + channels: state, + }, + null, + 2, + ), + ); + return; + } + + console.log(pc.bold("Paperclip release channels")); + console.log(""); + for (const entry of state) { + const version = entry.version ?? pc.yellow("unavailable"); + console.log(` ${pc.bold(entry.channel.padEnd(8))} ${version}`); + console.log(` ${" ".repeat(8)} ${pc.dim(`${entry.cadence} — ${entry.audience}`)}`); + console.log(` ${" ".repeat(8)} ${pc.dim(`npx paperclipai@${entry.distTag} onboard`)}`); + console.log(""); + } + + if (currentChannel === "unknown") { + console.log( + `This install reports version ${pc.bold(packageVersion)}, which does not map to a published channel (source checkouts report the repository placeholder).`, + ); + } else { + console.log(`This install is version ${pc.bold(packageVersion)} on the ${pc.bold(currentChannel)} channel.`); + } + console.log(`Docker images use the same names: ghcr.io/paperclipai/paperclip:{latest,beta,nightly,canary}`); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index fd8d1ee4f7..b26b5cbe18 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -2,6 +2,7 @@ import { Command } from "commander"; import { onboard } from "./commands/onboard.js"; import { doctor } from "./commands/doctor.js"; import { envCommand } from "./commands/env.js"; +import { channelsCommand } from "./commands/channels.js"; import { configure } from "./commands/configure.js"; import { addAllowedHostname } from "./commands/allowed-hostname.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; @@ -130,6 +131,14 @@ program .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .action(envCommand); +program + .command("channels") + .description("Show the release channels and which one this install follows") + .option("--json", "Machine-readable output") + .action(async (opts) => { + await channelsCommand(opts); + }); + program .command("configure") .description("Update configuration sections") diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md index b99ae33a3d..f809a386fc 100644 --- a/doc/CHANNELS.md +++ b/doc/CHANNELS.md @@ -56,6 +56,16 @@ docker pull ghcr.io/paperclipai/paperclip:canary Every image is also published as `:sha-` for exact pinning, and stable images additionally get `:YYYY.MDD.P` version tags. +## Seeing where you are + +```bash +npx paperclipai channels +``` + +prints every channel with the version it currently resolves to, the install +command for each, and which channel your install follows (with `--json` for +scripting). + ## Switching channels Channel choice is per-install: install from a different tag and you're on that