diff --git a/.changeset/update-check-notice.md b/.changeset/update-check-notice.md new file mode 100644 index 000000000..3b1f8a7a4 --- /dev/null +++ b/.changeset/update-check-notice.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +The CLI now checks the npm registry for a newer published version while a command runs. After the command completes, the CLI shows an update notice one time for each new version. Human mode shows a styled note. Agent mode writes plain text to stderr. JSON mode writes a note diagnostic to stderr and does not change stdout. Set QAWOLF_NO_UPDATE_CHECK=1 to disable the check. diff --git a/AGENTS.md b/AGENTS.md index bf55ac6b3..6ac1d3ebd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,8 @@ src/ │ ├── patternArgs.ts # CLI pattern argument parsing │ ├── pluralize.ts # pluralize │ ├── sleep.ts # sleep -│ └── types.ts # BrowserName, VideoMode, TraceMode, HarMode, TestCounts +│ ├── types.ts # BrowserName, VideoMode, TraceMode, HarMode, TestCounts +│ └── version.ts # isNewerVersion ├── shell/ # I/O executors — process spawning, UI, API clients │ ├── appium/ # Android emulator + Appium server lifecycle │ ├── commandContext.ts # CommandContext, CommandResult types @@ -52,6 +53,7 @@ src/ │ ├── logger.ts # pino-based structured logger │ ├── manifest/ # bundle manifest read/lookup │ ├── npm.ts # resolveNpmCommand +│ ├── npmRegistry.ts # fetchLatestVersion (update-check registry lookup) │ ├── platform/ # tRPC client, getIdentity, signed-URL/bundle download, team storage │ ├── reporter/ # Reporter interface, console + JUnit + composite reporters │ ├── resolveExport.ts # ESM export resolution @@ -68,7 +70,8 @@ src/ │ ├── flows/ # expandPatterns, peekFlowMeta, flowsList, pull/ │ ├── init/ # init handler + templates │ ├── install/ # installBrowsers, installBrowserList -│ └── runner/ # flowsRun, runWebFlow, runAndroidFlow, worker dispatch + pool +│ ├── runner/ # flowsRun, runWebFlow, runAndroidFlow, worker dispatch + pool +│ └── updateCheck/ # startUpdateCheck: new-version notice after commands └── commands/ # Thin CLI glue — Commander registration + composite root ├── context.ts # withContext() Commander action wrapper ├── program.ts # createProgram() factory diff --git a/src/commands/context.ts b/src/commands/context.ts index 0ec6961b2..d7b1b58ab 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -27,6 +27,12 @@ import type { CommandContext, CommandResult, } from "~/shell/commandContext.js"; +import { fetchLatestVersion } from "~/shell/npmRegistry.js"; +import { + startUpdateCheck, + type UpdateNotifier, +} from "~/domains/updateCheck/updateCheck.js"; +import packageJson from "../../package.json" with { type: "json" }; type ContextAction = (ctx: CommandContext) => Promise; type AuthContextAction = (ctx: AuthCommandContext) => Promise; @@ -39,6 +45,7 @@ export function buildBaseContext( ctx: CommandContext; apiBaseUrl: string; loggingSystem: LoggingSystem; + updateNotifier: UpdateNotifier; } { const env = process.env; const flags = command.optsWithGlobals(); @@ -61,12 +68,22 @@ export function buildBaseContext( ...(verboseWrite ? { verboseWrite } : {}), }); const apiBaseUrl = resolveHostUrl(env); + const fs = makeDefaultFs(); + const ui = createUI(outputMode, { + clack, + ...(verboseTarget ? { verboseTarget } : {}), + }); + const updateNotifier = startUpdateCheck({ + env, + currentVersion: packageJson.version, + configDir: getConfigDir(), + fs, + fetchLatestVersion: () => fetchLatestVersion(packageJson.name), + renderNotice: (body, title) => ui.note(body, title), + }); return { ctx: { - ui: createUI(outputMode, { - clack, - ...(verboseTarget ? { verboseTarget } : {}), - }), + ui, configDir: getConfigDir(), outputMode, isInteractive: isInteractive({ @@ -76,10 +93,11 @@ export function buildBaseContext( apiBaseUrl, signals, log: (scope) => loggingSystem.createLogger(scope), - fs: makeDefaultFs(), + fs, }, apiBaseUrl, loggingSystem, + updateNotifier, }; } @@ -88,7 +106,10 @@ export function withContext( fn: ContextAction, ): (opts: unknown, command: Command) => Promise { return async (_opts: unknown, command: Command): Promise => { - const { ctx, loggingSystem } = buildBaseContext(command, signals); + const { ctx, loggingSystem, updateNotifier } = buildBaseContext( + command, + signals, + ); try { const result = await fn(ctx); if (result !== undefined) { @@ -100,6 +121,7 @@ export function withContext( process.exitCode = 1; } finally { loggingSystem.flush(); + await updateNotifier.notifyIfOutdated(); } }; } @@ -113,7 +135,7 @@ export function withAuthContext( } = {}, ): (opts: unknown, command: Command) => Promise { return async (_opts: unknown, command: Command): Promise => { - const { ctx, apiBaseUrl, loggingSystem } = buildBaseContext( + const { ctx, apiBaseUrl, loggingSystem, updateNotifier } = buildBaseContext( command, signals, ); @@ -127,6 +149,7 @@ export function withAuthContext( }); if (resolved === undefined) { loggingSystem.flush(); + await updateNotifier.notifyIfOutdated(); return; } @@ -150,6 +173,7 @@ export function withAuthContext( process.exitCode = 1; } finally { loggingSystem.flush(); + await updateNotifier.notifyIfOutdated(); } }; } diff --git a/src/core/messages/index.ts b/src/core/messages/index.ts index a0ed9f14b..69ddb4510 100644 --- a/src/core/messages/index.ts +++ b/src/core/messages/index.ts @@ -5,3 +5,4 @@ export { initMessages } from "./init.js"; export { installMessages } from "./install.js"; export { runnerMessages } from "./runner.js"; export { packageLoadFailed } from "./toolNotFound.js"; +export { updateCheckMessages } from "./updateCheck.js"; diff --git a/src/core/messages/updateCheck.ts b/src/core/messages/updateCheck.ts new file mode 100644 index 000000000..27968414b --- /dev/null +++ b/src/core/messages/updateCheck.ts @@ -0,0 +1,5 @@ +export const updateCheckMessages = { + title: "Update available", + body: (current: string, latest: string): string => + `@qawolf/cli ${current} → ${latest}\nRun \`npm install -g @qawolf/cli\` to update.`, +}; diff --git a/src/core/version.test.ts b/src/core/version.test.ts new file mode 100644 index 000000000..ab7ed2b54 --- /dev/null +++ b/src/core/version.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; + +import { isNewerVersion } from "./version.js"; + +describe("isNewerVersion", () => { + it("detects newer patch, minor, and major releases", () => { + expect(isNewerVersion("1.3.2", "1.3.3")).toBe(true); + expect(isNewerVersion("1.3.2", "1.4.0")).toBe(true); + expect(isNewerVersion("1.3.2", "2.0.0")).toBe(true); + }); + + it("compares numerically, not lexicographically", () => { + expect(isNewerVersion("1.9.0", "1.10.0")).toBe(true); + expect(isNewerVersion("1.10.0", "1.9.0")).toBe(false); + }); + + it("returns false for equal or older versions", () => { + expect(isNewerVersion("1.3.2", "1.3.2")).toBe(false); + expect(isNewerVersion("1.3.2", "1.3.1")).toBe(false); + expect(isNewerVersion("2.0.0", "1.9.9")).toBe(false); + }); + + it("returns false when either version is not a plain release", () => { + expect(isNewerVersion("1.3.2", "1.4.0-beta.1")).toBe(false); + expect(isNewerVersion("1.3.2-beta.1", "1.4.0")).toBe(false); + expect(isNewerVersion("1.3.2", "not-a-version")).toBe(false); + expect(isNewerVersion("", "1.4.0")).toBe(false); + }); +}); diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 000000000..c6c1c4f48 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,25 @@ +type Release = { major: number; minor: number; patch: number }; + +function parseRelease(version: string): Release | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); + if (!match) return undefined; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +/** + * Only plain `major.minor.patch` versions compare. Prereleases and + * unparseable input return false, so callers stay silent rather than + * mis-notify. + */ +export function isNewerVersion(current: string, latest: string): boolean { + const cur = parseRelease(current); + const next = parseRelease(latest); + if (cur === undefined || next === undefined) return false; + if (next.major !== cur.major) return next.major > cur.major; + if (next.minor !== cur.minor) return next.minor > cur.minor; + return next.patch > cur.patch; +} diff --git a/src/domains/updateCheck/updateCheck.test.ts b/src/domains/updateCheck/updateCheck.test.ts new file mode 100644 index 000000000..a5d1e14b7 --- /dev/null +++ b/src/domains/updateCheck/updateCheck.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; + +import type { Fs } from "~/shell/fs.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import { startUpdateCheck } from "./updateCheck.js"; + +// Let the fetch's .then chain run before reading the settled value. +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeNotifier(overrides: { + env?: Record; + currentVersion?: string; + fs?: Fs; + fetchLatestVersion?: () => Promise; + renderNotice?: () => void; +}) { + const written: { body: string; title: string }[] = []; + let fetchCalls = 0; + const notifier = startUpdateCheck({ + env: overrides.env ?? {}, + currentVersion: overrides.currentVersion ?? "1.0.0", + configDir: "/config", + fs: overrides.fs ?? makeMemoryFs(), + fetchLatestVersion: () => { + fetchCalls += 1; + return ( + overrides.fetchLatestVersion ?? (() => Promise.resolve("2.0.0")) + )(); + }, + renderNotice: (body, title) => { + written.push({ body, title }); + overrides.renderNotice?.(); + }, + }); + return { notifier, written, fetchCalls: () => fetchCalls }; +} + +describe("startUpdateCheck", () => { + it("announces a newer version after the fetch settles", async () => { + const { notifier, written } = makeNotifier({}); + await settle(); + await notifier.notifyIfOutdated(); + expect(written).toHaveLength(1); + expect(written[0]?.body).toContain("1.0.0 → 2.0.0"); + expect(written[0]?.title).toBe("Update available"); + }); + + it("stays silent while the fetch is still pending", async () => { + const { notifier, written } = makeNotifier({ + fetchLatestVersion: () => new Promise(() => {}), + }); + await notifier.notifyIfOutdated(); + expect(written).toHaveLength(0); + }); + + it("stays silent when the published version is not newer", async () => { + const { notifier, written } = makeNotifier({ + currentVersion: "2.0.0", + fetchLatestVersion: () => Promise.resolve("2.0.0"), + }); + await settle(); + await notifier.notifyIfOutdated(); + expect(written).toHaveLength(0); + }); + + it("announces each version at most once across runs", async () => { + const fs = makeMemoryFs(); + const first = makeNotifier({ fs }); + await settle(); + await first.notifier.notifyIfOutdated(); + expect(first.written).toHaveLength(1); + + const second = makeNotifier({ fs }); + await settle(); + await second.notifier.notifyIfOutdated(); + expect(second.written).toHaveLength(0); + }); + + it("announces again when an even newer version ships", async () => { + const fs = makeMemoryFs(); + const first = makeNotifier({ fs }); + await settle(); + await first.notifier.notifyIfOutdated(); + + const second = makeNotifier({ + fs, + fetchLatestVersion: () => Promise.resolve("3.0.0"), + }); + await settle(); + await second.notifier.notifyIfOutdated(); + expect(second.written).toHaveLength(1); + expect(second.written[0]?.body).toContain("1.0.0 → 3.0.0"); + }); + + it("does not fetch when QAWOLF_NO_UPDATE_CHECK is set", async () => { + const { notifier, written, fetchCalls } = makeNotifier({ + env: { QAWOLF_NO_UPDATE_CHECK: "1" }, + }); + await settle(); + await notifier.notifyIfOutdated(); + expect(fetchCalls()).toBe(0); + expect(written).toHaveLength(0); + }); + + it("still announces (and never throws) when the state file is unwritable", async () => { + const fs: Fs = { + ...makeMemoryFs(), + readFile: () => Promise.reject(new Error("boom")), + mkdir: () => Promise.reject(new Error("boom")), + writeFile: () => Promise.reject(new Error("boom")), + }; + const { notifier, written } = makeNotifier({ fs }); + await settle(); + await notifier.notifyIfOutdated(); + expect(written).toHaveLength(1); + }); + + it("does not throw when rendering fails, and retries next run", async () => { + const fs = makeMemoryFs(); + const first = makeNotifier({ + fs, + renderNotice: () => { + throw new Error("EPIPE"); + }, + }); + await settle(); + await first.notifier.notifyIfOutdated(); + + // A failed render must not record the version as announced. + const second = makeNotifier({ fs }); + await settle(); + await second.notifier.notifyIfOutdated(); + expect(second.written).toHaveLength(1); + }); +}); diff --git a/src/domains/updateCheck/updateCheck.ts b/src/domains/updateCheck/updateCheck.ts new file mode 100644 index 000000000..781028506 --- /dev/null +++ b/src/domains/updateCheck/updateCheck.ts @@ -0,0 +1,78 @@ +import { join } from "node:path"; + +import { updateCheckMessages } from "~/core/messages/index.js"; +import { isNewerVersion } from "~/core/version.js"; +import type { Fs } from "~/shell/fs.js"; + +const noticeFile = "last-update-notice"; + +export type UpdateNotifier = { + /** + * Announces only if the check already settled on a newer, not-yet-announced + * version. Never waits on the network; never throws. + */ + notifyIfOutdated(): Promise; +}; + +const noopNotifier: UpdateNotifier = { + notifyIfOutdated: () => Promise.resolve(), +}; + +/** + * Starts a background check for a newer published CLI version. Call + * `notifyIfOutdated` after the command finishes. Wire `renderNotice` to + * `ui.note` so each output mode formats the notice itself. + */ +export function startUpdateCheck(deps: { + env: Record; + currentVersion: string; + configDir: string; + fs: Fs; + fetchLatestVersion: () => Promise; + renderNotice: (body: string, title: string) => void; +}): UpdateNotifier { + if (deps.env["QAWOLF_NO_UPDATE_CHECK"]) { + return noopNotifier; + } + + let latest: string | undefined; + void deps.fetchLatestVersion().then( + (version) => { + latest = version; + }, + () => undefined, + ); + + return { + async notifyIfOutdated() { + if (latest === undefined) return; + if (!isNewerVersion(deps.currentVersion, latest)) return; + + const noticePath = join(deps.configDir, noticeFile); + try { + const notified = (await deps.fs.readFile(noticePath)).trim(); + if (notified === latest) return; + } catch { + // No notice recorded yet. Fall through and announce. + } + + try { + deps.renderNotice( + updateCheckMessages.body(deps.currentVersion, latest), + updateCheckMessages.title, + ); + } catch { + // The command already finished, and a write can still fail (EPIPE on a + // closed pipe). Leave the marker unwritten so the next run retries. + return; + } + + try { + await deps.fs.mkdir(deps.configDir, { recursive: true }); + await deps.fs.writeFile(noticePath, `${latest}\n`); + } catch { + // Best-effort: worst case the notice repeats next run. + } + }, + }; +} diff --git a/src/shell/npmRegistry.test.ts b/src/shell/npmRegistry.test.ts new file mode 100644 index 000000000..796597e10 --- /dev/null +++ b/src/shell/npmRegistry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; + +import { fetchLatestVersion } from "./npmRegistry.js"; + +// Bodies are raw response text, matching what the registry puts on the wire. +function registryResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("fetchLatestVersion", () => { + it("returns the version from the registry's latest dist-tag", async () => { + let requestedUrl = ""; + const version = await fetchLatestVersion("@qawolf/cli", { + fetchFn: (url) => { + requestedUrl = url; + return Promise.resolve(registryResponse('{"version":"9.9.9"}')); + }, + }); + expect(version).toBe("9.9.9"); + expect(requestedUrl).toBe("https://registry.npmjs.org/@qawolf/cli/latest"); + }); + + it("returns undefined on non-2xx responses", async () => { + const version = await fetchLatestVersion("@qawolf/cli", { + fetchFn: () => + Promise.resolve(registryResponse('{"error":"not found"}', 404)), + }); + expect(version).toBeUndefined(); + }); + + it("returns undefined when the fetch rejects", async () => { + const version = await fetchLatestVersion("@qawolf/cli", { + fetchFn: () => Promise.reject(new Error("offline")), + }); + expect(version).toBeUndefined(); + }); + + it("returns undefined on unexpected payloads", async () => { + for (const body of ["null", '"1.2.3"', "{}", '{"version":123}']) { + const version = await fetchLatestVersion("@qawolf/cli", { + fetchFn: () => Promise.resolve(registryResponse(body)), + }); + expect(version).toBeUndefined(); + } + }); + + it("returns undefined when the body is not JSON", async () => { + const version = await fetchLatestVersion("@qawolf/cli", { + fetchFn: () => Promise.resolve(registryResponse("")), + }); + expect(version).toBeUndefined(); + }); +}); diff --git a/src/shell/npmRegistry.ts b/src/shell/npmRegistry.ts new file mode 100644 index 000000000..f1a2f8b84 --- /dev/null +++ b/src/shell/npmRegistry.ts @@ -0,0 +1,32 @@ +const registryUrl = "https://registry.npmjs.org"; +const timeoutMs = 3000; + +type FetchLike = ( + url: string, + init: { signal: AbortSignal }, +) => Promise; + +/** + * Reads the registry's `latest` dist-tag. Returns undefined on any failure + * (offline, timeout, bad payload), because the update check must never break + * a command. + */ +export async function fetchLatestVersion( + packageName: string, + deps: { fetchFn?: FetchLike } = {}, +): Promise { + const fetchFn = deps.fetchFn ?? globalThis.fetch; + try { + const response = await fetchFn(`${registryUrl}/${packageName}/latest`, { + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + if (body === null || typeof body !== "object" || !("version" in body)) { + return undefined; + } + return typeof body.version === "string" ? body.version : undefined; + } catch { + return undefined; + } +}