From b24a712a2793680451ed4dee8c6ddeb5329aede7 Mon Sep 17 00:00:00 2001 From: Carlos Manuel Gonzalez Date: Wed, 19 Aug 2026 17:17:44 -0600 Subject: [PATCH] feat(cli): show an update-available notice after every command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After any command finishes, a pnpm-style box on stderr announces a newer published version, its changelog URL, and the exact command to update — detected from how the binary was installed (npm, pnpm, yarn, bun, npx, or Homebrew; npm as the fallback). The registry lookup starts before the command runs so the round-trip overlaps real work, is cached for 24 hours in the data directory, gives up silently after 1.5 s, and can never change an exit code or touch stdout. NO_UPDATE_NOTIFIER, CI, VETA_NO_UPDATE_CHECK, a non-TTY stderr, and the completion path all disable it. --- README.md | 12 ++ src/cli/main.test.ts | 8 + src/cli/run.ts | 103 +++++++++- src/cli/update/current-version.test.ts | 13 ++ src/cli/update/current-version.ts | 21 +++ src/cli/update/package-manager.test.ts | 66 +++++++ src/cli/update/package-manager.ts | 70 +++++++ src/cli/update/render-notice.test.ts | 50 +++++ src/cli/update/render-notice.ts | 52 ++++++ src/cli/update/update-check.test.ts | 249 +++++++++++++++++++++++++ src/cli/update/update-check.ts | 133 +++++++++++++ src/cli/update/version.test.ts | 39 ++++ src/cli/update/version.ts | 36 ++++ 13 files changed, 843 insertions(+), 9 deletions(-) create mode 100644 src/cli/update/current-version.test.ts create mode 100644 src/cli/update/current-version.ts create mode 100644 src/cli/update/package-manager.test.ts create mode 100644 src/cli/update/package-manager.ts create mode 100644 src/cli/update/render-notice.test.ts create mode 100644 src/cli/update/render-notice.ts create mode 100644 src/cli/update/update-check.test.ts create mode 100644 src/cli/update/update-check.ts create mode 100644 src/cli/update/version.test.ts create mode 100644 src/cli/update/version.ts diff --git a/README.md b/README.md index f0bc8de..44c1b08 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,18 @@ notes what is missing, and stderr prefixes every per-member line with `[k/n] `. If any member did not complete, veta still prints the prompt path first, then exits non-zero. +## Update notifications + +When a newer release is on npm, veta prints a small box on stderr after the +command finishes — current version, new version, a changelog link, and the +update command for the package manager that installed it. The registry is +asked at most once every 24 hours; the answer is cached in +`~/.veta/update-check.json` (under `VETA_DATA_DIR` if set). The check only +runs in an interactive terminal and never affects the exit code. + +Opt out with `NO_UPDATE_NOTIFIER=1` (or `VETA_NO_UPDATE_CHECK=1`); it is also +skipped automatically when `CI` is set. + ## Requirements | Need | Why | diff --git a/src/cli/main.test.ts b/src/cli/main.test.ts index 31cecf3..fbfb37a 100644 --- a/src/cli/main.test.ts +++ b/src/cli/main.test.ts @@ -156,6 +156,14 @@ describe("list command", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("my-video"); }); + + it("does not check for updates when stderr is not a TTY", () => { + const result = runCli(["list"], { VETA_DATA_DIR: dataDir }); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("Update available"); + expect(existsSync(path.join(dataDir, "update-check.json"))).toBe(false); + }); }); describe("yargs completion short-circuit (D17)", () => { diff --git a/src/cli/run.ts b/src/cli/run.ts index f5649e2..54f5dd1 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -15,6 +15,19 @@ import { extractPlaylist } from "./extract-playlist.ts"; import { list } from "./list.ts"; import { purge } from "./purge.ts"; import { createProgressRenderer } from "./render/progress-renderer.ts"; +import { readCurrentVersion } from "./update/current-version.ts"; +import { + currentBinPath, + detectInstaller, + updateCommandFor, +} from "./update/package-manager.ts"; +import { changelogUrlFor, renderUpdateNotice } from "./update/render-notice.ts"; +import { + fetchLatestFromNpm, + fileUpdateCache, + shouldCheckForUpdates, + startUpdateCheck, +} from "./update/update-check.ts"; /** * Where packages live: `~/.veta` unless `VETA_DATA_DIR` overrides it. @@ -70,7 +83,8 @@ async function runExtract(url: string, preferredLang?: string, force?: boolean): // NO_COLOR convention (https://no-color.org): presence alone, any value, // turns color off. const renderer = createProgressRenderer(process.stderr, { - useColor: process.stderr.isTTY === true && process.env["NO_COLOR"] === undefined, + useColor: + process.stderr.isTTY === true && process.env["NO_COLOR"] === undefined, }); try { @@ -132,7 +146,9 @@ async function offerPromptCopy(promptPath: string | null): Promise<void> { process.stderr.write("Copied to clipboard.\n"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); - process.stderr.write(`Warning: ${reason} The prompt is still at ${promptPath}.\n`); + process.stderr.write( + `Warning: ${reason} The prompt is still at ${promptPath}.\n`, + ); } } @@ -193,7 +209,10 @@ function buildProgram(argv: readonly string[]) { if (error !== undefined) { throw error; } - throw new VetaError("INPUT_UNRECOGNIZED", msg || "Unrecognized command or option."); + throw new VetaError( + "INPUT_UNRECOGNIZED", + msg || "Unrecognized command or option.", + ); }); } @@ -219,20 +238,86 @@ export async function run(argv: readonly string[]): Promise<number> { } } +type UpdateCheck = { result(): Promise<string | null> }; + +/** + * Start the "newer version?" lookup so it overlaps the command. Anything that + * goes wrong here is swallowed: the notice is a courtesy, never a failure. + */ +function beginUpdateCheck(): UpdateCheck | null { + if ( + !shouldCheckForUpdates( + process.env, + process.stderr.isTTY === true, + process.argv, + ) + ) { + return null; + } + // Reading package.json is one file read; the registry request leaves right + // after it and runs alongside the command itself. + const pending = readCurrentVersion() + .then((currentVersion) => + startUpdateCheck({ + currentVersion, + fetchLatest: fetchLatestFromNpm, + cache: fileUpdateCache( + path.join(dataDirFromEnv(), "update-check.json"), + ), + now: Date.now, + }).result(), + ) + .catch(() => null); + + return { result: () => pending }; +} + +/** Print the update box on stderr if a newer version was found. Never throws. */ +async function printUpdateNotice(check: UpdateCheck | null): Promise<void> { + if (check === null) { + return; + } + try { + const latest = await check.result(); + if (latest === null) { + return; + } + const current = await readCurrentVersion(); + const installer = detectInstaller(await currentBinPath(), process.env); + process.stderr.write( + renderUpdateNotice({ + current, + latest, + changelogUrl: changelogUrlFor(latest), + updateCommand: updateCommandFor(installer), + useColor: + process.stderr.isTTY === true && + process.env["NO_COLOR"] === undefined, + }), + ); + } catch { + // A failed notice must never change the outcome of the command. + } +} + /** Composition root for normal CLI execution (not completion short-circuit). */ export function main(): void { + const check = beginUpdateCheck(); + run(process.argv).then( - (code) => { + async (code) => { + await printUpdateNotice(check); process.exit(code); }, - (error: unknown) => { + async (error: unknown) => { if (isVetaError(error)) { process.stderr.write(`${error.message}\n`); - process.exit(exitCodeFor(error)); - return; + } else { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); } - - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + await printUpdateNotice(check); process.exit(exitCodeFor(error)); }, ); diff --git a/src/cli/update/current-version.test.ts b/src/cli/update/current-version.test.ts new file mode 100644 index 0000000..64e817c --- /dev/null +++ b/src/cli/update/current-version.test.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { readCurrentVersion } from "./current-version.ts"; + +describe("readCurrentVersion()", () => { + it("matches the version in package.json", async () => { + const pkg = JSON.parse( + await readFile(new URL("../../../package.json", import.meta.url), "utf8"), + ) as { version: string }; + + await expect(readCurrentVersion()).resolves.toBe(pkg.version); + }); +}); diff --git a/src/cli/update/current-version.ts b/src/cli/update/current-version.ts new file mode 100644 index 0000000..f17b597 --- /dev/null +++ b/src/cli/update/current-version.ts @@ -0,0 +1,21 @@ +import { readFile } from "node:fs/promises"; +import { isRecord } from "../../domain/json.ts"; + +/** + * The running veta's version, from the package manifest. + * + * `src/cli/update/` and `dist/cli/update/` sit at the same depth, so one + * relative URL serves both the source tree and the published build. + */ +const PACKAGE_JSON = new URL("../../../package.json", import.meta.url); + +export async function readCurrentVersion(): Promise<string> { + try { + const manifest: unknown = JSON.parse(await readFile(PACKAGE_JSON, "utf8")); + return isRecord(manifest) && typeof manifest["version"] === "string" + ? manifest["version"] + : "0.0.0"; + } catch { + return "0.0.0"; + } +} diff --git a/src/cli/update/package-manager.test.ts b/src/cli/update/package-manager.test.ts new file mode 100644 index 0000000..f8e48d1 --- /dev/null +++ b/src/cli/update/package-manager.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { detectInstaller, updateCommandFor } from "./package-manager.ts"; + +describe("detectInstaller()", () => { + it("defaults to npm for an ordinary global bin path", () => { + expect(detectInstaller("/usr/local/lib/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe("npm"); + }); + + it("detects npx from an _npx segment in the path", () => { + expect(detectInstaller("/Users/me/.npm/_npx/abc123/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe( + "npx", + ); + }); + + it("detects npx from the npm user agent", () => { + expect( + detectInstaller("/some/where/bin/cli.js", { npm_config_user_agent: "npm/10.0.0 npx/10.0.0 node/v24" }), + ).toBe("npx"); + }); + + it("prefers npx over pnpm when both hints are present", () => { + expect(detectInstaller("/Users/me/.pnpm/_npx/abc/bin/cli.js")).toBe("npx"); + }); + + it("detects pnpm from /pnpm/, /.pnpm/ and /Library/pnpm/ segments", () => { + expect(detectInstaller("/Users/me/Library/pnpm/global/5/node_modules/@cmglezpdev/veta/bin/cli.js")).toBe("pnpm"); + expect(detectInstaller("/home/me/.local/share/pnpm/global/5/.pnpm/@cmglezpdev+veta/bin/cli.js")).toBe("pnpm"); + expect(detectInstaller("/opt/pnpm/store/bin/cli.js")).toBe("pnpm"); + }); + + it("detects pnpm from the user agent", () => { + expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "pnpm/11.0.0 npm/? node/v24" })).toBe("pnpm"); + }); + + it("detects yarn from path or user agent", () => { + expect(detectInstaller("/Users/me/.yarn/bin/veta")).toBe("yarn"); + expect(detectInstaller("/usr/local/share/yarn/global/node_modules/x/bin/cli.js")).toBe("yarn"); + expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "yarn/1.22.0 npm/? node/v24" })).toBe("yarn"); + }); + + it("detects bun from path or user agent", () => { + expect(detectInstaller("/Users/me/.bun/install/global/node_modules/x/bin/cli.js")).toBe("bun"); + expect(detectInstaller("/x/bin/cli.js", { npm_config_user_agent: "bun/1.1.0" })).toBe("bun"); + }); + + it("detects homebrew from Cellar, homebrew and linuxbrew segments", () => { + expect(detectInstaller("/opt/homebrew/Cellar/veta/0.10.0/libexec/bin/cli.js")).toBe("brew"); + expect(detectInstaller("/opt/homebrew/lib/node_modules/x/bin/cli.js")).toBe("brew"); + expect(detectInstaller("/home/linuxbrew/.linuxbrew/lib/node_modules/x/bin/cli.js")).toBe("brew"); + }); + + it("matches case-insensitively and accepts backslashes", () => { + expect(detectInstaller("C:\\Users\\me\\AppData\\Local\\PNPM\\global\\5\\bin\\cli.js")).toBe("pnpm"); + }); +}); + +describe("updateCommandFor()", () => { + it("maps every installer to its update command", () => { + expect(updateCommandFor("npm")).toBe("npm install -g @cmglezpdev/veta@latest"); + expect(updateCommandFor("pnpm")).toBe("pnpm add -g @cmglezpdev/veta@latest"); + expect(updateCommandFor("yarn")).toBe("yarn global add @cmglezpdev/veta@latest"); + expect(updateCommandFor("bun")).toBe("bun add -g @cmglezpdev/veta@latest"); + expect(updateCommandFor("npx")).toBe("npx @cmglezpdev/veta@latest"); + expect(updateCommandFor("brew")).toBe("brew upgrade veta"); + }); +}); diff --git a/src/cli/update/package-manager.ts b/src/cli/update/package-manager.ts new file mode 100644 index 0000000..e9a0529 --- /dev/null +++ b/src/cli/update/package-manager.ts @@ -0,0 +1,70 @@ +import { realpath } from "node:fs/promises"; + +/** + * Which tool put veta on this machine, guessed from where the binary lives. + * + * The update notice tells the user what to run, and telling a pnpm user to + * `npm install -g` would leave them with two copies and the wrong one on + * PATH. The guess is a heuristic over the resolved bin path plus npm's user + * agent; it is allowed to be wrong, so npm stays the default. + */ +export type Installer = "npm" | "pnpm" | "yarn" | "bun" | "npx" | "brew"; + +const PACKAGE = "@cmglezpdev/veta@latest"; + +const UPDATE_COMMANDS: Record<Installer, string> = { + npm: `npm install -g ${PACKAGE}`, + pnpm: `pnpm add -g ${PACKAGE}`, + yarn: `yarn global add ${PACKAGE}`, + bun: `bun add -g ${PACKAGE}`, + npx: `npx ${PACKAGE}`, + brew: "brew upgrade veta", +}; + +export function updateCommandFor(installer: Installer): string { + return UPDATE_COMMANDS[installer]; +} + +/** + * Detect the installer from the (realpath-resolved) bin path and environment. + * + * Checks run in priority order because paths overlap: an `npx` cache can sit + * inside pnpm's store, and homebrew's `lib/node_modules` contains no + * package-manager name at all. + */ +export function detectInstaller(binPath: string, env: NodeJS.ProcessEnv = {}): Installer { + const p = binPath.replaceAll("\\", "/").toLowerCase(); + const agent = (env["npm_config_user_agent"] ?? "").toLowerCase(); + + if (p.includes("/_npx/") || agent.includes("npx")) { + return "npx"; + } + if ( + p.includes("/pnpm/") || + p.includes("/.pnpm/") || + p.includes("/library/pnpm/") || + agent.startsWith("pnpm") + ) { + return "pnpm"; + } + if (p.includes("/.yarn/") || p.includes("/yarn/") || agent.startsWith("yarn")) { + return "yarn"; + } + if (p.includes("/.bun/") || agent.startsWith("bun")) { + return "bun"; + } + if (p.includes("/cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/")) { + return "brew"; + } + return "npm"; +} + +/** The running binary's real location — symlinks resolved so `/pnpm/` and friends show through. */ +export async function currentBinPath(): Promise<string> { + const raw = process.argv[1] ?? ""; + try { + return await realpath(raw); + } catch { + return raw; + } +} diff --git a/src/cli/update/render-notice.test.ts b/src/cli/update/render-notice.test.ts new file mode 100644 index 0000000..cc46d13 --- /dev/null +++ b/src/cli/update/render-notice.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { changelogUrlFor, renderUpdateNotice } from "./render-notice.ts"; + +const INPUT = { + current: "0.10.0", + latest: "0.11.0", + changelogUrl: "https://github.com/cmglezpdev/veta/releases/tag/veta-v0.11.0", + updateCommand: "npm install -g @cmglezpdev/veta@latest", +}; + +describe("changelogUrlFor()", () => { + it("points at the GitHub release for that version", () => { + expect(changelogUrlFor("0.11.0")).toBe( + "https://github.com/cmglezpdev/veta/releases/tag/veta-v0.11.0", + ); + }); +}); + +describe("renderUpdateNotice()", () => { + it("prints the three lines inside a rounded box without color", () => { + const out = renderUpdateNotice({ ...INPUT, useColor: false }); + + expect(out).toContain("Update available! 0.10.0 → 0.11.0"); + expect(out).toContain(`Changelog: ${INPUT.changelogUrl}`); + expect(out).toContain(`To update, run: ${INPUT.updateCommand}`); + expect(out).toContain("╭"); + expect(out).toContain("╮"); + expect(out).toContain("╰"); + expect(out).toContain("╯"); + expect(out).not.toContain("\x1b["); + expect(out.endsWith("\n")).toBe(true); + }); + + it("pads every row to the same visible width", () => { + const out = renderUpdateNotice({ ...INPUT, useColor: false }); + const rows = out.split("\n").filter((row) => row.length > 0); + const widths = new Set(rows.map((row) => [...row].length)); + + expect(rows.length).toBeGreaterThanOrEqual(5); + expect(widths.size).toBe(1); + }); + + it("uses ANSI codes when color is on, and stays the same visible width", () => { + const colored = renderUpdateNotice({ ...INPUT, useColor: true }); + const plain = renderUpdateNotice({ ...INPUT, useColor: false }); + + expect(colored).toContain("\x1b["); + expect(colored.replaceAll(/\x1b\[[0-9;]*m/g, "")).toBe(plain); + }); +}); diff --git a/src/cli/update/render-notice.ts b/src/cli/update/render-notice.ts new file mode 100644 index 0000000..b5a70ac --- /dev/null +++ b/src/cli/update/render-notice.ts @@ -0,0 +1,52 @@ +/** + * The update box, in the style pnpm prints after a command. + * + * Three lines of content inside a rounded border, written to stderr once the + * command has finished so it never gets in front of the output scripts parse. + */ + +export type UpdateNoticeInput = { + readonly current: string; + readonly latest: string; + readonly changelogUrl: string; + readonly updateCommand: string; + readonly useColor: boolean; +}; + +export function changelogUrlFor(latest: string): string { + return `https://github.com/cmglezpdev/veta/releases/tag/veta-v${latest}`; +} + +const PADDING = 3; + +/** Visible length: code points, with SGR sequences stripped. */ +function visibleWidth(text: string): number { + return [...text.replaceAll(/\x1b\[[0-9;]*m/g, "")].length; +} + +export function renderUpdateNotice(input: UpdateNoticeInput): string { + const paint = (code: number, text: string): string => + input.useColor ? `\x1b[${code}m${text}\x1b[0m` : text; + + const lines = [ + `Update available! ${paint(2, input.current)} → ${paint(32, input.latest)}`, + `Changelog: ${input.changelogUrl}`, + `To update, run: ${paint(36, input.updateCommand)}`, + ]; + const inner = Math.max(...lines.map(visibleWidth)) + PADDING * 2; + const pad = " ".repeat(PADDING); + const border = (text: string): string => paint(33, text); + + const rows = [ + border(`╭${"─".repeat(inner)}╮`), + border(`│${" ".repeat(inner)}│`), + ...lines.map((line) => { + const fill = " ".repeat(inner - PADDING * 2 - visibleWidth(line)); + return `${border("│")}${pad}${line}${fill}${pad}${border("│")}`; + }), + border(`│${" ".repeat(inner)}│`), + border(`╰${"─".repeat(inner)}╯`), + ]; + + return `\n${rows.join("\n")}\n\n`; +} diff --git a/src/cli/update/update-check.test.ts b/src/cli/update/update-check.test.ts new file mode 100644 index 0000000..1745d14 --- /dev/null +++ b/src/cli/update/update-check.test.ts @@ -0,0 +1,249 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + fileUpdateCache, + shouldCheckForUpdates, + startUpdateCheck, + type UpdateCache, +} from "./update-check.ts"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function memoryCache(initial: UpdateCache | null = null) { + let stored = initial; + const writes: UpdateCache[] = []; + return { + writes, + cache: { + read: async () => stored, + write: async (value: UpdateCache) => { + stored = value; + writes.push(value); + }, + }, + }; +} + +describe("startUpdateCheck()", () => { + it("uses the cached latest without hitting the network when fresh", async () => { + const { cache, writes } = memoryCache({ checkedAt: 1_000, latest: "0.11.0" }); + let fetched = 0; + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => { + fetched += 1; + return "9.9.9"; + }, + cache, + now: () => 1_000 + DAY_MS - 1, + }); + + await expect(check.result()).resolves.toBe("0.11.0"); + expect(fetched).toBe(0); + expect(writes).toEqual([]); + }); + + it("fetches and writes the cache when the cache is missing", async () => { + const { cache, writes } = memoryCache(null); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => "0.12.0", + cache, + now: () => 5_000, + }); + + await expect(check.result()).resolves.toBe("0.12.0"); + expect(writes).toEqual([{ checkedAt: 5_000, latest: "0.12.0" }]); + }); + + it("fetches and writes the cache when the cache is stale", async () => { + const { cache, writes } = memoryCache({ checkedAt: 0, latest: "0.10.1" }); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => "0.12.0", + cache, + now: () => DAY_MS, + }); + + await expect(check.result()).resolves.toBe("0.12.0"); + expect(writes).toEqual([{ checkedAt: DAY_MS, latest: "0.12.0" }]); + }); + + it("honours a custom ttl", async () => { + const { cache } = memoryCache({ checkedAt: 0, latest: "0.10.1" }); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => "0.12.0", + cache, + now: () => 50, + ttlMs: 100, + }); + + await expect(check.result()).resolves.toBe("0.10.1"); + }); + + it("resolves null when the fetch rejects", async () => { + const { cache, writes } = memoryCache(null); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => { + throw new Error("offline"); + }, + cache, + now: () => 0, + }); + + await expect(check.result()).resolves.toBeNull(); + expect(writes).toEqual([]); + }); + + it("resolves null when the fetch returns null", async () => { + const { cache } = memoryCache(null); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => null, + cache, + now: () => 0, + }); + + await expect(check.result()).resolves.toBeNull(); + }); + + it("resolves null when the fetch does not answer before the timeout", async () => { + const { cache } = memoryCache(null); + let aborted = false; + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: (signal) => + new Promise<string | null>((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }); + }), + cache, + now: () => 0, + timeoutMs: 5, + }); + + await expect(check.result()).resolves.toBeNull(); + expect(aborted).toBe(true); + }); + + it("resolves null when the cache throws", async () => { + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => "0.12.0", + cache: { + read: async () => { + throw new Error("disk"); + }, + write: async () => { + throw new Error("disk"); + }, + }, + now: () => 0, + }); + + await expect(check.result()).resolves.toBeNull(); + }); + + it("resolves null when latest is not newer than current", async () => { + const { cache } = memoryCache(null); + const check = startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => "0.10.0", + cache, + now: () => 0, + }); + + await expect(check.result()).resolves.toBeNull(); + }); + + it("starts the fetch eagerly, before result() is called", async () => { + const { cache } = memoryCache(null); + let started = false; + startUpdateCheck({ + currentVersion: "0.10.0", + fetchLatest: async () => { + started = true; + return null; + }, + cache, + now: () => 0, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(started).toBe(true); + }); +}); + +describe("fileUpdateCache()", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "veta-update-cache-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reads null when the file is missing", async () => { + const cache = fileUpdateCache(path.join(dir, "update-check.json")); + await expect(cache.read()).resolves.toBeNull(); + }); + + it("round-trips a cache entry", async () => { + const file = path.join(dir, "update-check.json"); + const cache = fileUpdateCache(file); + await cache.write({ checkedAt: 123, latest: "0.11.0" }); + + await expect(cache.read()).resolves.toEqual({ checkedAt: 123, latest: "0.11.0" }); + expect(JSON.parse(await readFile(file, "utf8"))).toEqual({ checkedAt: 123, latest: "0.11.0" }); + }); + + it("creates the parent directory when missing", async () => { + const file = path.join(dir, "nested", "deeper", "update-check.json"); + const cache = fileUpdateCache(file); + await cache.write({ checkedAt: 1, latest: "0.11.0" }); + + await expect(cache.read()).resolves.toEqual({ checkedAt: 1, latest: "0.11.0" }); + }); + + it("reads null for malformed or wrongly shaped content", async () => { + const file = path.join(dir, "update-check.json"); + await writeFile(file, "{not json", "utf8"); + await expect(fileUpdateCache(file).read()).resolves.toBeNull(); + + await writeFile(file, JSON.stringify({ checkedAt: "yesterday", latest: 3 }), "utf8"); + await expect(fileUpdateCache(file).read()).resolves.toBeNull(); + }); +}); + +describe("shouldCheckForUpdates()", () => { + const argv = ["node", "veta", "list"]; + + it("is true in an interactive session with no opt-out", () => { + expect(shouldCheckForUpdates({}, true, argv)).toBe(true); + }); + + it("is false when stderr is not a TTY", () => { + expect(shouldCheckForUpdates({}, false, argv)).toBe(false); + }); + + it("is false under NO_UPDATE_NOTIFIER, CI or VETA_NO_UPDATE_CHECK", () => { + expect(shouldCheckForUpdates({ NO_UPDATE_NOTIFIER: "1" }, true, argv)).toBe(false); + expect(shouldCheckForUpdates({ CI: "true" }, true, argv)).toBe(false); + expect(shouldCheckForUpdates({ VETA_NO_UPDATE_CHECK: "1" }, true, argv)).toBe(false); + }); + + it("is false during shell completion", () => { + expect(shouldCheckForUpdates({}, true, ["node", "veta", "--get-yargs-completions", "ex"])).toBe( + false, + ); + }); +}); diff --git a/src/cli/update/update-check.ts b/src/cli/update/update-check.ts new file mode 100644 index 0000000..844014e --- /dev/null +++ b/src/cli/update/update-check.ts @@ -0,0 +1,133 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { readJsonFile, writeJsonAtomic } from "../../adapters/store/atomic-json.ts"; +import { isRecord } from "../../domain/json.ts"; +import { isNewerVersion } from "./version.ts"; + +/** + * "Is there a newer veta?" — asked once a day, answered after the command. + * + * The check is started before the command runs so the registry round-trip + * overlaps real work, and it is allowed to lose: timeout, offline, a broken + * cache file, a malformed answer all collapse to `null`. Nothing here may + * ever change an exit code. + */ + +export type UpdateCache = { readonly checkedAt: number; readonly latest: string }; + +export type UpdateCacheStore = { + read(): Promise<UpdateCache | null>; + write(cache: UpdateCache): Promise<void>; +}; + +export type UpdateCheckDeps = { + readonly currentVersion: string; + /** Resolves to the latest published version, or `null` when unknown. */ + readonly fetchLatest: (signal: AbortSignal) => Promise<string | null>; + readonly cache: UpdateCacheStore; + /** Milliseconds since the epoch. */ + readonly now: () => number; + readonly ttlMs?: number; + readonly timeoutMs?: number; +}; + +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 1500; + +const REGISTRY_URL = "https://registry.npmjs.org/@cmglezpdev/veta/latest"; + +async function latestVersion(deps: UpdateCheckDeps): Promise<string | null> { + const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS; + const now = deps.now(); + + const cached = await deps.cache.read(); + if (cached !== null && now - cached.checkedAt < ttlMs && now >= cached.checkedAt) { + return cached.latest; + } + + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(new Error("update check timed out")), + deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ); + // Do not let a pending check hold the process open on its own. + timer.unref?.(); + let latest: string | null; + try { + latest = await deps.fetchLatest(controller.signal); + } finally { + clearTimeout(timer); + } + if (latest === null) { + return null; + } + await deps.cache.write({ checkedAt: now, latest }); + return latest; +} + +/** + * Kick off the check immediately; `result()` resolves to the newer version + * or `null`, and never rejects. + */ +export function startUpdateCheck(deps: UpdateCheckDeps): { result(): Promise<string | null> } { + const pending = latestVersion(deps) + .then((latest) => + latest !== null && isNewerVersion(latest, deps.currentVersion) ? latest : null, + ) + .catch(() => null); + + return { result: () => pending }; +} + +/** The real registry lookup: the `latest` dist-tag's manifest, `.version` out of it. */ +export async function fetchLatestFromNpm(signal: AbortSignal): Promise<string | null> { + const response = await fetch(REGISTRY_URL, { + signal, + headers: { accept: "application/json" }, + }); + if (!response.ok) { + return null; + } + const body: unknown = await response.json(); + return isRecord(body) && typeof body["version"] === "string" ? body["version"] : null; +} + +function parseCache(value: unknown): UpdateCache | null { + if (!isRecord(value)) { + return null; + } + const { checkedAt, latest } = value; + if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt) || typeof latest !== "string") { + return null; + } + return { checkedAt, latest }; +} + +/** A cache that lives in one JSON file; unreadable or malformed content reads as absent. */ +export function fileUpdateCache(filePath: string): UpdateCacheStore { + return { + read: async () => parseCache(await readJsonFile(filePath)), + write: async (cache) => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeJsonAtomic(filePath, cache); + }, + }; +} + +/** + * Whether to check at all. Opt-outs follow the `update-notifier` convention + * (`NO_UPDATE_NOTIFIER`, `CI`) plus veta's own switch; non-interactive + * sessions and completion calls never see the notice either. + */ +export function shouldCheckForUpdates( + env: NodeJS.ProcessEnv, + stderrIsTTY: boolean, + argv: readonly string[], +): boolean { + if (env["NO_UPDATE_NOTIFIER"] !== undefined) return false; + if (env["CI"] !== undefined) return false; + if (env["VETA_NO_UPDATE_CHECK"] !== undefined) return false; + if (!stderrIsTTY) return false; + if (argv.includes("--get-yargs-completions")) return false; + return true; +} diff --git a/src/cli/update/version.test.ts b/src/cli/update/version.test.ts new file mode 100644 index 0000000..35a3341 --- /dev/null +++ b/src/cli/update/version.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isNewerVersion } from "./version.ts"; + +describe("isNewerVersion()", () => { + it("is true when the minor is higher", () => { + expect(isNewerVersion("0.11.0", "0.10.0")).toBe(true); + }); + + it("compares numerically, not lexically", () => { + expect(isNewerVersion("0.10.0", "0.9.9")).toBe(true); + expect(isNewerVersion("0.9.9", "0.10.0")).toBe(false); + }); + + it("is false for equal and older versions", () => { + expect(isNewerVersion("0.10.0", "0.10.0")).toBe(false); + expect(isNewerVersion("0.9.0", "0.10.0")).toBe(false); + expect(isNewerVersion("1.0.0", "2.0.0")).toBe(false); + }); + + it("is true across major and patch bumps", () => { + expect(isNewerVersion("1.0.0", "0.99.99")).toBe(true); + expect(isNewerVersion("0.10.1", "0.10.0")).toBe(true); + }); + + it("tolerates a leading v", () => { + expect(isNewerVersion("v0.11.0", "0.10.0")).toBe(true); + }); + + it("does not count a prerelease of the same core as newer than stable", () => { + expect(isNewerVersion("0.10.0-beta.1", "0.10.0")).toBe(false); + expect(isNewerVersion("0.11.0-beta.1", "0.10.0")).toBe(true); + }); + + it("is false for malformed input", () => { + expect(isNewerVersion("latest", "0.10.0")).toBe(false); + expect(isNewerVersion("0.11.0", "garbage")).toBe(false); + expect(isNewerVersion("", "")).toBe(false); + }); +}); diff --git a/src/cli/update/version.ts b/src/cli/update/version.ts new file mode 100644 index 0000000..52b4a6c --- /dev/null +++ b/src/cli/update/version.ts @@ -0,0 +1,36 @@ +/** + * Minimal semver ordering for the update check. + * + * Only `major.minor.patch` decides; a prerelease tag matters in exactly one + * way — a prerelease of the same core is not an upgrade over the stable + * release. Anything that does not parse answers `false`, because a notice + * built on a guess is worse than no notice. + */ + +type Core = { readonly nums: readonly [number, number, number]; readonly prerelease: boolean }; + +function parse(version: string): Core | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version.trim()); + if (match === null) { + return null; + } + return { + nums: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] !== undefined, + }; +} + +export function isNewerVersion(latest: string, current: string): boolean { + const a = parse(latest); + const b = parse(current); + if (a === null || b === null) { + return false; + } + for (let i = 0; i < 3; i += 1) { + if (a.nums[i]! !== b.nums[i]!) { + return a.nums[i]! > b.nums[i]!; + } + } + // Same core: only "stable beats prerelease" counts as newer. + return b.prerelease && !a.prerelease; +}