diff --git a/README.md b/README.md index 9a36cb5..d804999 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,14 @@ export interface Backend { Nothing else in the codebase needs to change โ€” the picker, the live preview, and the theme format are all backend-agnostic. +### Updates + +`ttm` periodically checks for a new version in the background (throttled to once per 24 hours) and prints a one-line notice to let you know when an update is available. The notice includes the right command for how you installed it โ€” `npm i -g @bumaruf/ttm-cli` if you used npm, a download link if you used the binary, or `sudo apt upgrade ttm` if you used the `.deb`. It never installs anything on its own. + +To disable the check, set `TTM_NO_UPDATE_CHECK=1` in your environment. + +Note: new **themes** arrive automatically via `ttm update` without needing to update `ttm` itself โ€” the update notifier only matters when there are changes to the tool's code. + ## ๐Ÿ”ฅ Contribute **Adding a theme needs no code at all**: copy `themes/core/nord.toml` into `themes/community/`, change the colors, open a PR. That's the whole contribution โ€” once merged, CI republishes the catalogue and it shows up in everyone's picker with no `ttm` release. diff --git a/src/cli.ts b/src/cli.ts index c986082..d1f05ea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -158,6 +158,7 @@ export async function runCli( } import { dirname, join } from "node:path"; +import pkg from "../package.json"; import { createAlacrittyBackend } from "./backends/alacritty"; import { createGnomeBackend, realRun } from "./backends/gnome"; import { createIterm2Backend } from "./backends/iterm2"; @@ -170,8 +171,27 @@ import { fetchCatalogue } from "./catalogue/remote"; import { loadThemes } from "./core/theme"; import { BUILTIN_THEMES } from "./generated/builtin-themes"; import { realFs } from "./platform/fs"; +import { + maybeScheduleCheck, + readNotice, + runCheck, +} from "./platform/update-notifier"; import { runTui } from "./tui/loop"; +/** Bun.spawn's the child detached, ignoring stdio, and never throws. */ +function spawnDetached(cmd: string[]): void { + try { + const child = Bun.spawn(cmd, { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + child.unref(); + } catch { + // Scheduling a background check must never break the actual command. + } +} + /** * Resolve the theme catalogue, in order: * 1. TTM_THEMES env var, if set โ€” lets someone test a theme they're writing. @@ -198,6 +218,14 @@ export async function resolveThemes( if (import.meta.main) { const argv = process.argv.slice(2); + // Hidden subcommand run by the detached background check. Never printed, + // never advertised in --help, handled before anything else touches a + // backend or the catalogue. + if (argv[0] === "__notifier-check") { + await runCheck(fetch, realFs, process.env, Date.now()); + process.exit(0); + } + // `--backend ` can appear anywhere; strip it from argv before parsing // the rest of the command line. let requested: string | undefined; @@ -234,6 +262,25 @@ if (import.meta.main) { } const backend = selection.backend; + + const notifierCtx = { + runningVersion: pkg.version, + mainPath: Bun.main, + execPath: process.execPath, + platform: process.platform, + }; + let updateNotice: string | null = null; + if (process.stdout.isTTY) { + await maybeScheduleCheck( + realFs, + process.env, + spawnDetached, + notifierCtx, + Date.now(), + ); + updateNotice = await readNotice(realFs, process.env, notifierCtx); + } + const builtin = await resolveThemes(); if (builtin.length === 0) { @@ -280,6 +327,7 @@ if (import.meta.main) { } console.log(`applied ${applied.name}`); } + if (updateNotice) console.error(updateNotice); process.exit(0); } @@ -290,5 +338,6 @@ if (import.meta.main) { (s) => console.log(s), deps, ); + if (updateNotice) console.error(updateNotice); process.exit(code); } diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..9a45656 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,36 @@ +/** Parse "x.y.z" (ignoring any "-prerelease" suffix). Returns null if not x.y.z. */ +function parse( + v: string, +): { nums: [number, number, number]; pre: boolean } | null { + const trimmed = v.trim(); + const dashIdx = trimmed.indexOf("-"); + const core = dashIdx === -1 ? trimmed : trimmed.substring(0, dashIdx); + const pre = dashIdx !== -1; + + const parts = core.split("."); + if (parts.length !== 3) return null; + const segmentPattern = /^(0|[1-9]\d*)$/; + if (!parts.every((p) => segmentPattern.test(p))) return null; + const nums = parts.map((p) => Number(p)); + return { nums: nums as [number, number, number], pre }; +} + +/** True if `candidate` is a strictly newer x.y.z release than `current`. */ +export function isNewer(candidate: string, current: string): boolean { + const c = parse(candidate); + const r = parse(current); + if (c === null || r === null) return false; + + const [c0, c1, c2] = c.nums; + const [r0, r1, r2] = r.nums; + + if (c0 > r0) return true; + if (c0 < r0) return false; + if (c1 > r1) return true; + if (c1 < r1) return false; + if (c2 > r2) return true; + if (c2 < r2) return false; + + // Same x.y.z: a final (candidate not pre) beats a running prerelease. + return !c.pre && r.pre; +} diff --git a/src/platform/update-notifier.ts b/src/platform/update-notifier.ts new file mode 100644 index 0000000..800c2e2 --- /dev/null +++ b/src/platform/update-notifier.ts @@ -0,0 +1,143 @@ +import type { Env } from "../core/env"; +import { isNewer } from "../core/version"; +import type { Fs } from "./fs"; + +export const NOTICE_PACKAGE = "@bumaruf/ttm-cli"; + +export type Channel = "npm" | "deb" | "binary"; + +/** + * How was this ttm installed? The compiled binary has a virtual `$bunfs` main; + * a real .ts main means it's being run by Bun (npm/bun install). A compiled + * binary under /usr/bin on linux is the .deb; anywhere else, a release download. + */ +export function detectChannel( + mainPath: string, + execPath: string, + platform: NodeJS.Platform, +): Channel { + const compiled = mainPath.includes("$bunfs"); + if (!compiled) return "npm"; + if (platform === "linux" && execPath.startsWith("/usr/bin/")) return "deb"; + return "binary"; +} + +export function updateCommand(channel: Channel): string { + switch (channel) { + case "npm": + return `npm i -g ${NOTICE_PACKAGE}`; + case "deb": + return "sudo apt update && sudo apt upgrade ttm"; + case "binary": + return "download the latest from github.com/bumaruf/ttm-cli/releases/latest"; + } +} + +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; + +function cacheDir(env: Env): string { + const base = env.XDG_CACHE_HOME ?? `${env.HOME ?? ""}/.cache`; + return `${base}/ttm`; +} + +export function cacheFile(env: Env): string { + return `${cacheDir(env)}/update.json`; +} + +function suppressed(env: Env): boolean { + return Boolean(env.TTM_NO_UPDATE_CHECK) || Boolean(env.CI); +} + +export interface NoticeContext { + runningVersion: string; + mainPath: string; + execPath: string; + platform: NodeJS.Platform; +} + +export async function readNotice( + fs: Fs, + env: Env, + ctx: NoticeContext, +): Promise { + if (suppressed(env)) return null; + + const path = cacheFile(env); + let latest: string; + try { + if (!(await fs.exists(path))) return null; + latest = JSON.parse(await fs.readFile(path)).latest; + } catch { + return null; + } + + if (typeof latest !== "string" || !isNewer(latest, ctx.runningVersion)) { + return null; + } + + const channel = detectChannel(ctx.mainPath, ctx.execPath, ctx.platform); + return `${DIM}update available: ${ctx.runningVersion} โ†’ ${latest} ยท run ${updateCommand(channel)}${RESET}`; +} + +export type Fetch = (url: string) => Promise; +export type Spawn = (cmd: string[]) => void; + +export const REGISTRY_URL = + "https://registry.npmjs.org/@bumaruf/ttm-cli/latest"; + +const TTL_MS = 24 * 60 * 60 * 1000; + +/** The command a detached child runs to refresh the cache. */ +export function checkCommand(ctx: NoticeContext): string[] { + const compiled = ctx.mainPath.includes("$bunfs"); + return compiled + ? [ctx.execPath, "__notifier-check"] + : [ctx.execPath, ctx.mainPath, "__notifier-check"]; +} + +/** Runs in the detached child. Fetches the registry, writes the cache. Silent. */ +export async function runCheck( + fetchFn: Fetch, + fs: Fs, + env: Env, + now: number, +): Promise { + try { + const response = await fetchFn(REGISTRY_URL); + if (!response.ok) return; + const body = (await response.json()) as { version: unknown }; + const latest = body.version; + if (typeof latest !== "string") return; + await fs.writeFile( + cacheFile(env), + `${JSON.stringify({ checkedAt: now, latest })}\n`, + ); + } catch { + // Silent by design: a background check must never surface an error. + } +} + +async function cacheAgeOk(fs: Fs, env: Env, now: number): Promise { + const path = cacheFile(env); + try { + if (!(await fs.exists(path))) return false; + const checkedAt = JSON.parse(await fs.readFile(path)).checkedAt; + return typeof checkedAt === "number" && now - checkedAt < TTL_MS; + } catch { + return false; + } +} + +/** Spawns the detached check if the cache is missing or older than the TTL. */ +export async function maybeScheduleCheck( + fs: Fs, + env: Env, + spawn: Spawn, + ctx: NoticeContext, + now: number, +): Promise { + if (suppressed(env)) return; + if (await cacheAgeOk(fs, env, now)) return; + spawn(checkCommand(ctx)); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 418960c..ece5bdc 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -182,6 +182,13 @@ test("apply of a remote theme installs it first, and aborts if that fails", asyn expect(text()).toMatch(/disk full/); }); +test("the hidden notifier subcommand is not advertised in help", async () => { + const { backend } = fakeBackend(); + const { out, text } = capture(); + await runCli(["--help"], backend, ENTRIES, out); + expect(text()).not.toContain("__notifier-check"); +}); + test("apply of a remote theme installs it, then applies it", async () => { const { backend, applied } = fakeBackend(); const { out } = capture(); diff --git a/tests/core/version.test.ts b/tests/core/version.test.ts new file mode 100644 index 0000000..61351b1 --- /dev/null +++ b/tests/core/version.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { isNewer } from "../../src/core/version"; + +test("a higher version is newer", () => { + expect(isNewer("0.5.0", "0.4.0")).toBe(true); + expect(isNewer("1.0.0", "0.9.9")).toBe(true); + expect(isNewer("0.4.1", "0.4.0")).toBe(true); +}); + +test("equal or lower is not newer", () => { + expect(isNewer("0.4.0", "0.4.0")).toBe(false); + expect(isNewer("0.3.0", "0.4.0")).toBe(false); + expect(isNewer("0.4.0", "0.4.1")).toBe(false); +}); + +test("a prerelease is older than the final of the same number", () => { + expect(isNewer("0.5.0-rc.1", "0.5.0")).toBe(false); + // ...and the final is newer than a running prerelease of the same number + expect(isNewer("0.5.0", "0.5.0-rc.1")).toBe(true); +}); + +test("garbage never reports an update", () => { + expect(isNewer("nonsense", "0.4.0")).toBe(false); + expect(isNewer("0.5.0", "")).toBe(false); + expect(isNewer("", "")).toBe(false); + expect(isNewer("1.2", "1.1.9")).toBe(false); // not x.y.z +}); + +test("malformed segments never report an update", () => { + expect(isNewer("1..3", "1.0.2")).toBe(false); // empty segment + expect(isNewer("1.2.-3", "1.1.9")).toBe(false); // negative sign in segment + expect(isNewer("1. 2.3", "1.1.9")).toBe(false); // embedded whitespace + expect(isNewer("01.2.3", "1.1.9")).toBe(false); // leading zero + expect(isNewer("v1.2.3", "1.1.9")).toBe(false); // non-digit prefix + expect(isNewer("1.2.3.4", "1.1.9")).toBe(false); // too many segments +}); diff --git a/tests/platform/update-notifier.test.ts b/tests/platform/update-notifier.test.ts new file mode 100644 index 0000000..11c4d54 --- /dev/null +++ b/tests/platform/update-notifier.test.ts @@ -0,0 +1,298 @@ +import { expect, test } from "bun:test"; +import { createMemoryFs } from "../../src/platform/fs"; +import { + cacheFile, + checkCommand, + detectChannel, + maybeScheduleCheck, + readNotice, + runCheck, + updateCommand, +} from "../../src/platform/update-notifier"; + +test("a real .ts main means an npm/bun install", () => { + expect( + detectChannel( + "/home/me/.bun/install/global/node_modules/@bumaruf/ttm-cli/src/cli.ts", + "/home/me/.bun/bin/bun", + "linux", + ), + ).toBe("npm"); +}); + +test("a compiled binary under /usr/bin on linux is the deb", () => { + expect(detectChannel("/$bunfs/root/cli.ts", "/usr/bin/ttm", "linux")).toBe( + "deb", + ); +}); + +test("a compiled binary elsewhere is a downloaded release binary", () => { + expect( + detectChannel("/$bunfs/root/cli.ts", "/home/me/.local/bin/ttm", "linux"), + ).toBe("binary"); + expect(detectChannel("/$bunfs/root/cli.ts", "/opt/ttm", "darwin")).toBe( + "binary", + ); +}); + +test("each channel yields its own update command", () => { + expect(updateCommand("npm")).toContain("npm i -g @bumaruf/ttm-cli"); + expect(updateCommand("deb")).toContain("apt"); + expect(updateCommand("binary")).toContain("releases/latest"); +}); + +const ENV = { HOME: "/home/me" }; +const CACHE = "/home/me/.cache/ttm/update.json"; +const CTX = { + runningVersion: "0.4.0", + mainPath: "/pkg/src/cli.ts", // npm channel + execPath: "/home/me/.bun/bin/bun", + platform: "linux" as NodeJS.Platform, +}; + +test("no cache means no notice", async () => { + expect(await readNotice(createMemoryFs(), ENV, CTX)).toBeNull(); +}); + +test("a newer latest yields a notice with the arrow and the command", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.5.0" }), + }); + const notice = await readNotice(fs, ENV, CTX); + expect(notice).toContain("0.4.0"); + expect(notice).toContain("0.5.0"); + expect(notice).toContain("npm i -g @bumaruf/ttm-cli"); +}); + +test("latest not newer than running yields no notice", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.4.0" }), + }); + expect(await readNotice(fs, ENV, CTX)).toBeNull(); +}); + +// After the user updates, the notice disappears immediately โ€” it compares +// against the RUNNING version, not the one that did the check. +test("once running >= cached latest, the stale notice is gone", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.5.0" }), + }); + const updated = { ...CTX, runningVersion: "0.5.0" }; + expect(await readNotice(fs, ENV, updated)).toBeNull(); +}); + +test("a corrupt cache is silent", async () => { + const fs = createMemoryFs({ [CACHE]: "not json" }); + expect(await readNotice(fs, ENV, CTX)).toBeNull(); +}); + +// Valid JSON, but `latest` is the wrong shape (a number, or missing). The type +// guard must yield null, not throw โ€” a hand-mangled cache can't crash the CLI. +test("a cache with a non-string latest is silent, not a crash", async () => { + const numberLatest = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: 500 }), + }); + expect(await readNotice(numberLatest, ENV, CTX)).toBeNull(); + + const missingLatest = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1 }), + }); + expect(await readNotice(missingLatest, ENV, CTX)).toBeNull(); +}); + +test("the deb channel shows the apt command", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.5.0" }), + }); + const deb = { + ...CTX, + mainPath: "/$bunfs/root/cli.ts", + execPath: "/usr/bin/ttm", + }; + expect(await readNotice(fs, ENV, deb)).toContain("apt"); +}); + +// Guards suppress everything. +test("TTM_NO_UPDATE_CHECK suppresses the notice", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.5.0" }), + }); + expect( + await readNotice(fs, { ...ENV, TTM_NO_UPDATE_CHECK: "1" }, CTX), + ).toBeNull(); +}); + +test("CI suppresses the notice", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 1, latest: "0.5.0" }), + }); + expect(await readNotice(fs, { ...ENV, CI: "true" }, CTX)).toBeNull(); +}); + +test("cacheFile honours XDG_CACHE_HOME", () => { + expect(cacheFile({ HOME: "/home/me" })).toBe( + "/home/me/.cache/ttm/update.json", + ); + expect(cacheFile({ HOME: "/home/me", XDG_CACHE_HOME: "/x" })).toBe( + "/x/ttm/update.json", + ); +}); + +const DAY = 24 * 60 * 60 * 1000; + +test("runCheck stores the latest version from the registry", async () => { + const fs = createMemoryFs(); + const fetchFn = async () => + new Response(JSON.stringify({ version: "0.6.0" }), { status: 200 }); + await runCheck(fetchFn, fs, ENV, 1000); + + const meta = JSON.parse(fs.files()[CACHE]!); + expect(meta.latest).toBe("0.6.0"); + expect(meta.checkedAt).toBe(1000); +}); + +test("runCheck never throws on a network error, and writes nothing", async () => { + const fs = createMemoryFs(); + const fetchFn = async () => { + throw new Error("offline"); + }; + await expect(runCheck(fetchFn, fs, ENV, 1000)).resolves.toBeUndefined(); + expect(fs.files()[CACHE]).toBeUndefined(); +}); + +test("runCheck ignores a non-200 response", async () => { + const fs = createMemoryFs(); + const fetchFn = async () => new Response("nope", { status: 500 }); + await runCheck(fetchFn, fs, ENV, 1000); + expect(fs.files()[CACHE]).toBeUndefined(); +}); + +// The whole point: browsing must not wait on the network. A fresh cache does +// not spawn anything. +test("maybeScheduleCheck does nothing when the cache is fresh", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 5 * DAY, latest: "0.4.0" }), + }); + let spawned = false; + await maybeScheduleCheck( + fs, + ENV, + () => (spawned = true), + CTX, + 5 * DAY + 1000, + ); + expect(spawned).toBe(false); +}); + +test("maybeScheduleCheck spawns the detached check when the cache is stale", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "0.4.0" }), + }); + let cmd: string[] | null = null; + await maybeScheduleCheck(fs, ENV, (c) => (cmd = c), CTX, 2 * DAY); + expect(cmd).not.toBeNull(); + expect(cmd!).toContain("__notifier-check"); +}); + +test("maybeScheduleCheck spawns when there is no cache at all", async () => { + let spawned = false; + await maybeScheduleCheck( + createMemoryFs(), + ENV, + () => (spawned = true), + CTX, + DAY, + ); + expect(spawned).toBe(true); +}); + +test("guards suppress scheduling", async () => { + let spawned = false; + await maybeScheduleCheck( + createMemoryFs(), + { ...ENV, CI: "true" }, + () => (spawned = true), + CTX, + DAY, + ); + expect(spawned).toBe(false); +}); + +test("checkCommand reinvokes the binary itself when compiled", () => { + const cmd = checkCommand({ + ...CTX, + mainPath: "/$bunfs/root/cli.ts", + execPath: "/usr/bin/ttm", + }); + expect(cmd).toEqual(["/usr/bin/ttm", "__notifier-check"]); +}); + +test("checkCommand reinvokes bun + the script when run from source", () => { + const cmd = checkCommand({ + ...CTX, + mainPath: "/pkg/src/cli.ts", + execPath: "/home/me/.bun/bin/bun", + }); + expect(cmd).toEqual([ + "/home/me/.bun/bin/bun", + "/pkg/src/cli.ts", + "__notifier-check", + ]); +}); + +// runCheck must never throw, and never write junk, on any 200 that isn't a +// clean {version: string} โ€” these are the "background check never surfaces an +// error" guarantees, pinned directly on runCheck. +test("runCheck ignores a 200 whose version is not a string", async () => { + const fs = createMemoryFs(); + const fetchFn = async () => + new Response(JSON.stringify({ version: 123 }), { status: 200 }); + await expect(runCheck(fetchFn, fs, ENV, 1000)).resolves.toBeUndefined(); + expect(fs.files()[CACHE]).toBeUndefined(); +}); + +test("runCheck ignores a 200 with a malformed JSON body", async () => { + const fs = createMemoryFs(); + const fetchFn = async () => new Response("not json", { status: 200 }); + await expect(runCheck(fetchFn, fs, ENV, 1000)).resolves.toBeUndefined(); + expect(fs.files()[CACHE]).toBeUndefined(); +}); + +// TTL boundary: a cache exactly 24h old is NOT fresh (< TTL is false), so it +// schedules a check. +test("maybeScheduleCheck treats an exactly-24h-old cache as stale", async () => { + const fs = createMemoryFs({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "0.4.0" }), + }); + let spawned = false; + await maybeScheduleCheck(fs, ENV, () => (spawned = true), CTX, DAY); + expect(spawned).toBe(true); +}); + +// The contract is "never crash the command". Bun's fs.exists can REJECT (not +// just resolve false) on some filesystem errors (e.g. EACCES/ENOTDIR). Both +// readNotice and maybeScheduleCheck must degrade to silence, not let the +// rejection escape. +function brokenExistsFs() { + const fs = createMemoryFs(); + return { + ...fs, + exists: async () => { + throw new Error("EACCES"); + }, + }; +} + +test("readNotice is silent when fs.exists rejects", async () => { + await expect(readNotice(brokenExistsFs(), ENV, CTX)).resolves.toBeNull(); +}); + +test("maybeScheduleCheck never throws when fs.exists rejects", async () => { + let spawned = false; + await expect( + maybeScheduleCheck(brokenExistsFs(), ENV, () => (spawned = true), CTX, DAY), + ).resolves.toBeUndefined(); + // cacheAgeOk treats the failure as stale, so a spawn attempt is fine โ€” the + // spawn itself is try/catch-wrapped in cli.ts. What matters is no throw. + expect(spawned).toBe(true); +}); diff --git a/tests/version-embed.test.ts b/tests/version-embed.test.ts new file mode 100644 index 0000000..48f257f --- /dev/null +++ b/tests/version-embed.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import pkg from "../package.json"; +import { isNewer } from "../src/core/version"; + +test("the package version is a parseable x.y.z", () => { + expect(typeof pkg.version).toBe("string"); + // isNewer returns false for non-x.y.z; a version equal to itself is not newer, + // but a clearly-lower one must be โ€” proving the string parses as x.y.z. + expect(isNewer(pkg.version, "0.0.0")).toBe(true); +}); diff --git a/tsconfig.json b/tsconfig.json index 2f75b5e..264239a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "bundler", "types": ["bun-types"], "strict": true, + "resolveJsonModule": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true,