From 70295df8def9f18c7368d471845edac77bcdc848 Mon Sep 17 00:00:00 2001 From: Juha Litola Date: Tue, 28 Apr 2026 19:09:48 +0300 Subject: [PATCH] feat: add npm update notifier Checks npm dist-tags in the background for sticky CLI installs and prints stale-version notices to stderr only. Stores non-secret cache state under the XDG config path and skips CI, non-TTY, ephemeral runner, help/version, and MCP stdio invocations. --- README.md | 1 + bun.lock | 6 + docs/implementation/config.md | 14 +- docs/implementation/update-check.md | 128 +++++++ package.json | 2 + src/cli.ts | 28 +- src/cli/update-check.test.ts | 128 +++++++ src/cli/update-check.ts | 96 +++++ src/services/index.ts | 11 + src/services/test-helpers.ts | 13 + src/services/update-check-service.test.ts | 429 ++++++++++++++++++++++ src/services/update-check-service.ts | 275 ++++++++++++++ 12 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 docs/implementation/update-check.md create mode 100644 src/cli/update-check.test.ts create mode 100644 src/cli/update-check.ts create mode 100644 src/services/update-check-service.test.ts create mode 100644 src/services/update-check-service.ts diff --git a/README.md b/README.md index 74e9150b..705e30ba 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ githits code ... Dependency source inspection: search, files, read, grep | `GITHITS_API_URL` | Override REST API URL | `https://api.githits.com` | | `GITHITS_CODE_NAV_URL` | Override package/source service URL | `https://pkgseer.dev` | | `GITHITS_TELEMETRY` | Emit end-of-run timing spans to stderr for local profiling | — | +| `GITHITS_DISABLE_UPDATE_CHECK` | Disable npm latest-version update notices | — | ## Manual Setup diff --git a/bun.lock b/bun.lock index 522aa183..54a5e357 100644 --- a/bun.lock +++ b/bun.lock @@ -11,11 +11,13 @@ "commander": "^14.0.2", "jsonc-parser": "^3.3.1", "open": "^11.0.0", + "semver": "^7.7.4", "zod": "^4.1.13", }, "devDependencies": { "@biomejs/biome": "^2.3.8", "@types/bun": "latest", + "@types/semver": "^7.7.1", "bunup": "^0.16.10", "husky": "^9.1.7", "lint-staged": "^16.2.7", @@ -230,6 +232,8 @@ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], @@ -506,6 +510,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], diff --git a/docs/implementation/config.md b/docs/implementation/config.md index 01fa153d..0d638853 100644 --- a/docs/implementation/config.md +++ b/docs/implementation/config.md @@ -52,7 +52,7 @@ Package/source access uses the package/source service URL from `GITHITS_CODE_NAV ## Local Storage -All configuration lives in `~/.githits/`: +Authentication state lives in `~/.githits/`: ``` ~/.githits/ (0700) @@ -62,6 +62,17 @@ All configuration lives in `~/.githits/`: The secure file permissions prevent other users from reading tokens. When writing new files to `~/.githits/`, use `FileSystemService` rather than `node:fs` directly — this enables testing via mock implementations from `src/services/test-helpers.ts`. +Non-secret update-check state uses the XDG config location: + +``` +~/.config/githits/update-check.json +``` + +If `XDG_CONFIG_HOME` is set, the update-check cache lives under +`$XDG_CONFIG_HOME/githits/update-check.json`. See +`docs/implementation/update-check.md` for the update-check cache contract and +eligibility rules. + ## How Config Flows Through the System ``` @@ -99,5 +110,6 @@ Commands receive the full `Dependencies` object. Services receive only what they | `src/container.ts` | Auth priority logic and dependency wiring | | `src/services/auth-storage.ts` | File-based token storage with secure permissions | | `src/services/filesystem-service.ts` | File system abstraction for testable storage | +| `src/services/update-check-service.ts` | Non-secret update-check cache and npm latest lookup | | `src/commands/auth-status.ts` | Diagnosing current auth state (reached via `githits auth status`) | | `src/commands/mcp.ts` | MCP tool registration and deferred-auth startup behavior | diff --git a/docs/implementation/update-check.md b/docs/implementation/update-check.md new file mode 100644 index 00000000..011487ae --- /dev/null +++ b/docs/implementation/update-check.md @@ -0,0 +1,128 @@ +# Update Check + +## Purpose + +The CLI warns users when their installed `githits` package is behind the npm +`latest` dist-tag. This supports sticky installs such as `npm i -g githits` +without introducing forced updates, backend policy, or automatic package-manager +execution. + +## Background + +Generated MCP configs already run GitHits through: + +```sh +npx -y githits@latest mcp start +``` + +That path asks the package manager for the latest published version at startup. +The update checker targets long-lived local/global installs where the binary +stays on the installed version until the user updates it. + +## Behavior + +The check is advisory only: + +- it never blocks the command +- network, cache, and parse failures fail open silently +- notices are written to stderr only +- stdout is never touched +- every eligible invocation reports the stale version once a newer npm `latest` + is known + +The notice format is: + +```text +Update available: githits 0.2.0 -> 0.3.0 +Run: npm i -g githits@latest +``` + +## Registry Source + +The checker fetches npm dist-tags directly: + +```text +https://registry.npmjs.org/-/package/githits/dist-tags +``` + +Only the `latest` field is used. The CLI does not shell out to `npm info`, +because subprocess behavior depends on the user's package-manager setup and is +slower than one HTTPS request. + +## Cache + +Update-check state is stored under the XDG config location: + +```text +~/.config/githits/update-check.json +``` + +When `XDG_CONFIG_HOME` is set, the path is: + +```text +$XDG_CONFIG_HOME/githits/update-check.json +``` + +The directory is created with mode `0o700`; the cache file is written with mode +`0o600`. The cache contains no credentials. + +Cache shape: + +```json +{ + "checkedAt": "2026-04-28T12:00:00.000Z", + "latestVersion": "0.3.0" +} +``` + +The CLI checks npm at most once every 24 hours. If the cached latest version is +newer than the running CLI, the notice is printed on every eligible invocation. +When the cache is stale, the CLI refreshes npm first and falls back to the cached +notice if the refresh fails. A missing or malformed cache is treated as stale. +Concurrent writes use last-writer-wins semantics, and redundant fetches from +racing processes are acceptable. + +## Eligibility + +The CLI decides eligibility from raw argv and process state before Commander +parses the command. + +Checks are skipped for: + +- help and version invocations +- CI +- non-TTY stderr +- truthy `GITHITS_DISABLE_UPDATE_CHECK` +- likely ephemeral package-runner invocations (`npx`, `bunx`, npm/bun `exec`) +- MCP stdio server invocations + +MCP has two forms: + +- `githits mcp start` always starts the stdio server and is skipped +- `githits mcp` starts stdio when stdin or stdout is non-TTY and is skipped +- interactive `githits mcp` shows setup instructions and remains eligible + +## Implementation + +Key modules: + +| File | Purpose | +|---|---| +| `src/services/update-check-service.ts` | Registry fetch, cache handling, eligibility helpers, notice formatting | +| `src/cli/update-check.ts` | Cancellable background task and post-command stderr notice | +| `src/cli.ts` | Starts and flushes the update-check task around Commander parsing | +| `src/services/update-check-service.test.ts` | Service and eligibility coverage | +| `src/cli/update-check.test.ts` | CLI orchestration coverage | + +The service accepts injected dependencies for the current version, fetcher, +clock, and file-system service. Tests should mock those dependencies rather than +patch global state. + +## Future Work + +This mechanism is not a hard compatibility gate. Future phases can add: + +- CDN-hosted version policy with `recommended` and `minimumSupported` +- recurring checks for long-running MCP servers +- `githits update` +- backend `426 Upgrade Required` enforcement diff --git a/package.json b/package.json index 62ee0996..23421528 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,13 @@ "commander": "^14.0.2", "jsonc-parser": "^3.3.1", "open": "^11.0.0", + "semver": "^7.7.4", "zod": "^4.1.13" }, "devDependencies": { "@biomejs/biome": "^2.3.8", "@types/bun": "latest", + "@types/semver": "^7.7.1", "bunup": "^0.16.10", "husky": "^9.1.7", "lint-staged": "^16.2.7", diff --git a/src/cli.ts b/src/cli.ts index e6c92c5c..9070346c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,10 @@ #!/usr/bin/env node import { Command } from "commander"; import { version } from "../package.json"; +import { + runWithUpdateCheckFlush, + startUpdateCheckTaskForInvocation, +} from "./cli/update-check.js"; import { registerAuthStatusCommand, registerCodeCommandGroup, @@ -15,6 +19,10 @@ import { registerPkgCommandGroup, registerUnifiedSearchCommands, } from "./commands/index.js"; +import { + FileSystemServiceImpl, + NpmRegistryUpdateCheckService, +} from "./services/index.js"; import { endTelemetrySpan, flushTelemetry, @@ -24,10 +32,23 @@ import { } from "./shared/index.js"; const program = new Command(); +const argv = process.argv.slice(2); const commandSpans = new WeakMap< Command, ReturnType >(); +const updateCheckTask = startUpdateCheckTaskForInvocation({ + args: argv, + env: process.env, + stderrIsTTY: process.stderr.isTTY === true, + stdinIsTTY: process.stdin.isTTY === true, + stdoutIsTTY: process.stdout.isTTY === true, + createService: () => + new NpmRegistryUpdateCheckService({ + currentVersion: version, + fileSystemService: new FileSystemServiceImpl(), + }), +}); if (isTelemetryEnabled()) { process.once("exit", (exitCode) => { @@ -82,7 +103,6 @@ registerMcpCommand(program); registerExampleCommand(program); registerLanguagesCommand(program); registerFeedbackCommand(program); -const argv = process.argv.slice(2); const registrationArgv = stripRootRegistrationOptions(argv); if (shouldEagerLoadSearchCommands(registrationArgv)) { @@ -113,7 +133,11 @@ const authCommand = program .description("Manage authentication with GitHits."); registerAuthStatusCommand(authCommand); -await withTelemetrySpan("cli.parse", () => program.parseAsync()); +await runWithUpdateCheckFlush( + () => withTelemetrySpan("cli.parse", () => program.parseAsync()), + updateCheckTask, + { stderr: process.stderr }, +); /** * Commander supports root options before subcommands, e.g. diff --git a/src/cli/update-check.test.ts b/src/cli/update-check.test.ts new file mode 100644 index 00000000..44dbb849 --- /dev/null +++ b/src/cli/update-check.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createMockUpdateCheckService } from "../services/test-helpers.js"; +import { + flushUpdateCheckNotice, + runWithUpdateCheckFlush, + startUpdateCheckTask, + startUpdateCheckTaskForInvocation, +} from "./update-check.js"; + +describe("update-check CLI orchestration", () => { + it("starts a cancellable update check task", async () => { + let aborted = false; + const service = createMockUpdateCheckService({ + checkForUpdate: mock((signal?: AbortSignal) => { + signal?.addEventListener("abort", () => { + aborted = true; + }); + return Promise.resolve(undefined); + }), + }); + + const task = startUpdateCheckTask(service); + task.abort(); + await task.promise; + + expect(service.checkForUpdate).toHaveBeenCalledWith( + expect.any(AbortSignal), + ); + expect(aborted).toBe(true); + }); + + it("prints completed notices to stderr", async () => { + const service = createMockUpdateCheckService({ + checkForUpdate: mock(() => + Promise.resolve({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateCommand: "npm i -g githits@latest", + }), + ), + }); + const stderr = { write: mock(() => undefined) }; + const task = startUpdateCheckTask(service); + + await flushUpdateCheckNotice(task, { stderr, timeoutMs: 50 }); + + expect(stderr.write).toHaveBeenCalledWith( + "Update available: githits 0.2.0 -> 0.3.0\nRun: npm i -g githits@latest\n", + ); + }); + + it("does not write when no notice is returned", async () => { + const service = createMockUpdateCheckService(); + const stderr = { write: mock(() => undefined) }; + const task = startUpdateCheckTask(service); + + await flushUpdateCheckNotice(task, { stderr, timeoutMs: 50 }); + + expect(stderr.write).not.toHaveBeenCalled(); + }); + + it("aborts pending checks after the post-command budget", async () => { + let aborted = false; + const service = createMockUpdateCheckService({ + checkForUpdate: mock( + (signal?: AbortSignal) => + new Promise((resolve) => { + signal?.addEventListener("abort", () => { + aborted = true; + resolve(undefined); + }); + }), + ), + }); + const stderr = { write: mock(() => undefined) }; + const task = startUpdateCheckTask(service); + + await flushUpdateCheckNotice(task, { stderr, timeoutMs: 0 }); + + expect(aborted).toBe(true); + expect(stderr.write).not.toHaveBeenCalled(); + }); + + it("does not start an update check task for MCP stdio invocations", () => { + const createService = mock(() => createMockUpdateCheckService()); + + const task = startUpdateCheckTaskForInvocation({ + args: ["mcp", "start"], + env: {}, + stderrIsTTY: true, + stdinIsTTY: true, + stdoutIsTTY: true, + createService, + }); + + expect(task).toBeUndefined(); + expect(createService).not.toHaveBeenCalled(); + }); + + it("flushes update notices when the wrapped action throws", async () => { + const service = createMockUpdateCheckService({ + checkForUpdate: mock(() => + Promise.resolve({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateCommand: "npm i -g githits@latest", + }), + ), + }); + const stderr = { write: mock(() => undefined) }; + const task = startUpdateCheckTask(service); + const error = new Error("command failed"); + + await expect( + runWithUpdateCheckFlush( + async () => { + throw error; + }, + task, + { stderr, timeoutMs: 50 }, + ), + ).rejects.toBe(error); + + expect(stderr.write).toHaveBeenCalledWith( + "Update available: githits 0.2.0 -> 0.3.0\nRun: npm i -g githits@latest\n", + ); + }); +}); diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts new file mode 100644 index 00000000..4ea6cf27 --- /dev/null +++ b/src/cli/update-check.ts @@ -0,0 +1,96 @@ +import type { + UpdateCheckNotice, + UpdateCheckService, +} from "../services/index.js"; +import { formatUpdateNotice, shouldRunUpdateCheck } from "../services/index.js"; + +export interface UpdateCheckTask { + promise: Promise; + abort: () => void; +} + +export interface UpdateCheckOutput { + write(chunk: string): unknown; +} + +export function startUpdateCheckTask( + service: UpdateCheckService, +): UpdateCheckTask { + const controller = new AbortController(); + return { + promise: service.checkForUpdate(controller.signal), + abort: () => controller.abort(), + }; +} + +export function startUpdateCheckTaskForInvocation(options: { + args: string[]; + env: Record; + stderrIsTTY: boolean; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; + createService: () => UpdateCheckService; +}): UpdateCheckTask | undefined { + if ( + !shouldRunUpdateCheck({ + args: options.args, + env: options.env, + stderrIsTTY: options.stderrIsTTY, + stdinIsTTY: options.stdinIsTTY, + stdoutIsTTY: options.stdoutIsTTY, + }) + ) { + return undefined; + } + + return startUpdateCheckTask(options.createService()); +} + +export async function runWithUpdateCheckFlush( + action: () => Promise, + task: UpdateCheckTask | undefined, + options: { + stderr: UpdateCheckOutput; + timeoutMs?: number; + }, +): Promise { + try { + return await action(); + } finally { + await flushUpdateCheckNotice(task, options); + } +} + +export async function flushUpdateCheckNotice( + task: UpdateCheckTask | undefined, + options: { + stderr: UpdateCheckOutput; + timeoutMs?: number; + }, +): Promise { + if (!task) { + return; + } + + const timeoutMs = options.timeoutMs ?? 50; + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise<"timeout">((resolve) => { + timeout = setTimeout(() => resolve("timeout"), timeoutMs); + }); + + const result = await Promise.race([task.promise, timeoutPromise]); + if (timeout) { + clearTimeout(timeout); + } + + if (result === "timeout") { + task.abort(); + return; + } + + if (!result) { + return; + } + + options.stderr.write(`${formatUpdateNotice(result)}\n`); +} diff --git a/src/services/index.ts b/src/services/index.ts index f162ba8b..96194732 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -167,3 +167,14 @@ export { PromptServiceImpl } from "./prompt-service.js"; export { RefreshingGitHitsService } from "./refreshing-githits-service.js"; export type { TokenProvider } from "./token-manager.js"; export { refreshExpiredToken, TokenManager } from "./token-manager.js"; +export type { + UpdateCheckFetcher, + UpdateCheckNotice, + UpdateCheckService, +} from "./update-check-service.js"; +export { + formatUpdateNotice, + NpmRegistryUpdateCheckService, + resolveConfigHome, + shouldRunUpdateCheck, +} from "./update-check-service.js"; diff --git a/src/services/test-helpers.ts b/src/services/test-helpers.ts index 3596020c..10002d30 100644 --- a/src/services/test-helpers.ts +++ b/src/services/test-helpers.ts @@ -32,6 +32,7 @@ import type { } from "./package-intelligence-service.js"; import type { ConfirmChoice, PromptService } from "./prompt-service.js"; import type { TokenProvider } from "./token-manager.js"; +import type { UpdateCheckService } from "./update-check-service.js"; /** * Default OAuth metadata for testing. @@ -227,6 +228,18 @@ export function createMockFileSystemService( }; } +/** + * Creates a mock UpdateCheckService with default implementations. + */ +export function createMockUpdateCheckService( + impl: Partial = {}, +): UpdateCheckService { + return { + checkForUpdate: mock(() => Promise.resolve(undefined)), + ...impl, + }; +} + /** * Creates a mock GitHitsService with default implementations. */ diff --git a/src/services/update-check-service.test.ts b/src/services/update-check-service.test.ts new file mode 100644 index 00000000..f6c92291 --- /dev/null +++ b/src/services/update-check-service.test.ts @@ -0,0 +1,429 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createMockFileSystemService } from "./test-helpers.js"; +import { + formatUpdateNotice, + NpmRegistryUpdateCheckService, + resolveConfigHome, + shouldRunUpdateCheck, + type UpdateCheckFetcher, +} from "./update-check-service.js"; + +const NOW = new Date("2026-04-28T12:00:00.000Z"); +const STALE = new Date("2026-04-26T12:00:00.000Z").toISOString(); +const FRESH = new Date("2026-04-28T11:30:00.000Z").toISOString(); + +describe("NpmRegistryUpdateCheckService", () => { + it("fetches npm dist-tags when cache is missing and returns update notice", async () => { + const fetcher = createJsonFetcher({ latest: "0.3.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(false)), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).toHaveBeenCalledWith( + "https://registry.npmjs.org/-/package/githits/dist-tags", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(notice).toEqual({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateCommand: "npm i -g githits@latest", + }); + expect(fs.ensureDir).toHaveBeenCalledWith( + "/home/test/.config/githits", + 0o700, + ); + expect(fs.writeFile).toHaveBeenCalledWith( + "/home/test/.config/githits/update-check.json", + expect.any(String), + 0o600, + ); + }); + + it("uses XDG_CONFIG_HOME when set", async () => { + const fetcher = createJsonFetcher({ latest: "0.3.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(false)), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: { XDG_CONFIG_HOME: "/tmp/xdg" }, + }); + + await service.checkForUpdate(); + + expect(fs.ensureDir).toHaveBeenCalledWith("/tmp/xdg/githits", 0o700); + expect(fs.writeFile).toHaveBeenCalledWith( + "/tmp/xdg/githits/update-check.json", + expect.any(String), + 0o600, + ); + }); + + it("returns a fresh cached update without fetching", async () => { + const fetcher = createJsonFetcher({ latest: "0.4.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(true)), + readFile: mock(() => + Promise.resolve( + JSON.stringify({ + checkedAt: FRESH, + latestVersion: "0.3.0", + }), + ), + ), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).not.toHaveBeenCalled(); + expect(notice?.latestVersion).toBe("0.3.0"); + }); + + it("returns a fresh cached update even when a legacy notification marker exists", async () => { + const fetcher = createJsonFetcher({ latest: "0.4.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(true)), + readFile: mock(() => + Promise.resolve( + JSON.stringify({ + checkedAt: FRESH, + latestVersion: "0.3.0", + lastNotifiedVersion: "0.3.0", + }), + ), + ), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).not.toHaveBeenCalled(); + expect(notice?.latestVersion).toBe("0.3.0"); + }); + + it("refetches stale cache and returns the newer remote latest", async () => { + const fetcher = createJsonFetcher({ latest: "0.4.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(true)), + readFile: mock(() => + Promise.resolve( + JSON.stringify({ + checkedAt: STALE, + latestVersion: "0.3.0", + lastNotifiedVersion: "0.3.0", + }), + ), + ), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).toHaveBeenCalled(); + expect(notice?.latestVersion).toBe("0.4.0"); + const written = getWrittenCache(fs); + expect(written.checkedAt).toBe(NOW.toISOString()); + expect(written.latestVersion).toBe("0.4.0"); + expect(written.lastNotifiedVersion).toBeUndefined(); + }); + + it("does not notify when remote latest equals current", async () => { + const service = createService({ + currentVersion: "0.3.0", + body: { latest: "0.3.0" }, + }); + + await expect(service.checkForUpdate()).resolves.toBeUndefined(); + }); + + it("does not notify when remote latest is lower than current", async () => { + const fetcher = createJsonFetcher({ latest: "0.3.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(false)), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.4.0-beta.1", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(notice).toBeUndefined(); + const written = getWrittenCache(fs); + expect(written.latestVersion).toBe("0.3.0"); + }); + + it("ignores invalid semver in registry response", async () => { + const service = createService({ body: { latest: "not-semver" } }); + + await expect(service.checkForUpdate()).resolves.toBeUndefined(); + }); + + it("ignores malformed registry response", async () => { + const service = createService({ body: { version: "0.3.0" } }); + + await expect(service.checkForUpdate()).resolves.toBeUndefined(); + }); + + it("ignores non-2xx registry response", async () => { + const fetcher = mock(() => + Promise.resolve(new Response("not found", { status: 404 })), + ) as UpdateCheckFetcher & ReturnType; + const service = createService({ fetcher }); + + await expect(service.checkForUpdate()).resolves.toBeUndefined(); + }); + + it("ignores fetch rejections", async () => { + const fetcher = mock(() => Promise.reject(new Error("network down"))); + const service = createService({ fetcher }); + + await expect(service.checkForUpdate()).resolves.toBeUndefined(); + }); + + it("ignores corrupt cache and fetches", async () => { + const fetcher = createJsonFetcher({ latest: "0.3.0" }); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(true)), + readFile: mock(() => Promise.resolve("{")), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).toHaveBeenCalled(); + expect(notice?.latestVersion).toBe("0.3.0"); + }); + + it("falls back to stale cached update when refresh fails", async () => { + const fetcher = mock(() => Promise.reject(new Error("network down"))); + const fs = createMockFileSystemService({ + exists: mock(() => Promise.resolve(true)), + readFile: mock(() => + Promise.resolve( + JSON.stringify({ + checkedAt: STALE, + latestVersion: "0.3.0", + }), + ), + ), + }); + const service = new NpmRegistryUpdateCheckService({ + currentVersion: "0.2.0", + fileSystemService: fs, + fetcher, + now: () => NOW, + env: {}, + }); + + const notice = await service.checkForUpdate(); + + expect(fetcher).toHaveBeenCalled(); + expect(notice?.latestVersion).toBe("0.3.0"); + }); +}); + +describe("resolveConfigHome", () => { + it("uses XDG_CONFIG_HOME when provided", () => { + const fs = createMockFileSystemService(); + + expect(resolveConfigHome({ XDG_CONFIG_HOME: "/xdg/config" }, fs)).toBe( + "/xdg/config", + ); + }); + + it("falls back to ~/.config", () => { + const fs = createMockFileSystemService(); + + expect(resolveConfigHome({}, fs)).toBe("/home/test/.config"); + }); +}); + +describe("shouldRunUpdateCheck", () => { + const base = { + env: {}, + stderrIsTTY: true, + stdinIsTTY: true, + stdoutIsTTY: true, + }; + + it("allows normal command invocations", () => { + expect(shouldRunUpdateCheck({ ...base, args: ["example", "query"] })).toBe( + true, + ); + }); + + it("skips help and version invocations", () => { + expect(shouldRunUpdateCheck({ ...base, args: ["--help"] })).toBe(false); + expect(shouldRunUpdateCheck({ ...base, args: ["help"] })).toBe(false); + expect(shouldRunUpdateCheck({ ...base, args: ["--version"] })).toBe(false); + expect(shouldRunUpdateCheck({ ...base, args: ["-V"] })).toBe(false); + }); + + it("skips npx and bunx invocations", () => { + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { npm_lifecycle_event: "npx" }, + }), + ).toBe(false); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { npm_lifecycle_event: "bunx" }, + }), + ).toBe(false); + }); + + it("skips npm exec and bun exec style invocations", () => { + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { npm_command: "exec", npm_config_user_agent: "npm/11.6.2" }, + }), + ).toBe(false); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { npm_command: "exec", npm_config_user_agent: "bun/1.3.11" }, + }), + ).toBe(false); + }); + + it("skips MCP stdio server invocations", () => { + expect(shouldRunUpdateCheck({ ...base, args: ["mcp", "start"] })).toBe( + false, + ); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["mcp"], + stdinIsTTY: false, + }), + ).toBe(false); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["mcp"], + stdoutIsTTY: false, + }), + ).toBe(false); + }); + + it("allows interactive MCP setup invocation", () => { + expect(shouldRunUpdateCheck({ ...base, args: ["mcp"] })).toBe(true); + }); + + it("skips CI, disabled, and non-TTY stderr", () => { + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { CI: "1" }, + }), + ).toBe(false); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + env: { GITHITS_DISABLE_UPDATE_CHECK: "1" }, + }), + ).toBe(false); + expect( + shouldRunUpdateCheck({ + ...base, + args: ["example", "query"], + stderrIsTTY: false, + }), + ).toBe(false); + }); +}); + +describe("formatUpdateNotice", () => { + it("formats stderr-only update notice text", () => { + expect( + formatUpdateNotice({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateCommand: "npm i -g githits@latest", + }), + ).toBe( + "Update available: githits 0.2.0 -> 0.3.0\nRun: npm i -g githits@latest", + ); + }); +}); + +function createService(options: { + currentVersion?: string; + body?: unknown; + fetcher?: UpdateCheckFetcher; +}): NpmRegistryUpdateCheckService { + return new NpmRegistryUpdateCheckService({ + currentVersion: options.currentVersion ?? "0.2.0", + fileSystemService: createMockFileSystemService({ + exists: mock(() => Promise.resolve(false)), + }), + fetcher: options.fetcher ?? createJsonFetcher(options.body), + now: () => NOW, + env: {}, + }); +} + +function createJsonFetcher( + body: unknown, +): UpdateCheckFetcher & ReturnType { + return mock(() => + Promise.resolve(Response.json(body)), + ) as UpdateCheckFetcher & ReturnType; +} + +function getWrittenCache( + fs: ReturnType, +): Record { + const calls = (fs.writeFile as ReturnType).mock.calls; + return JSON.parse(calls.at(-1)?.[1] as string) as Record; +} diff --git a/src/services/update-check-service.ts b/src/services/update-check-service.ts new file mode 100644 index 00000000..d65a8f40 --- /dev/null +++ b/src/services/update-check-service.ts @@ -0,0 +1,275 @@ +import semver from "semver"; +import type { FileSystemService } from "./filesystem-service.js"; + +const NPM_DIST_TAGS_URL = + "https://registry.npmjs.org/-/package/githits/dist-tags"; +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 1000; +const DIR_MODE = 0o700; +const FILE_MODE = 0o600; +const UPDATE_COMMAND = "npm i -g githits@latest"; + +export interface UpdateCheckNotice { + currentVersion: string; + latestVersion: string; + updateCommand: string; +} + +export interface UpdateCheckService { + checkForUpdate(signal?: AbortSignal): Promise; +} + +export type UpdateCheckFetcher = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export interface UpdateCheckServiceOptions { + currentVersion: string; + fileSystemService: FileSystemService; + fetcher?: UpdateCheckFetcher; + env?: Record; + now?: () => Date; + checkIntervalMs?: number; + fetchTimeoutMs?: number; +} + +interface UpdateCheckCache { + checkedAt: string; + latestVersion: string; +} + +export interface UpdateCheckEligibilityInput { + args: string[]; + env?: Record; + stderrIsTTY?: boolean; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; +} + +export class NpmRegistryUpdateCheckService implements UpdateCheckService { + private readonly currentVersion: string; + private readonly fs: FileSystemService; + private readonly fetcher: UpdateCheckFetcher; + private readonly env: Record; + private readonly now: () => Date; + private readonly checkIntervalMs: number; + private readonly fetchTimeoutMs: number; + private readonly configDir: string; + private readonly cachePath: string; + + constructor(options: UpdateCheckServiceOptions) { + this.currentVersion = options.currentVersion; + this.fs = options.fileSystemService; + this.fetcher = options.fetcher ?? fetch; + this.env = options.env ?? process.env; + this.now = options.now ?? (() => new Date()); + this.checkIntervalMs = options.checkIntervalMs ?? CHECK_INTERVAL_MS; + this.fetchTimeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS; + this.configDir = this.fs.joinPath( + resolveConfigHome(this.env, this.fs), + "githits", + ); + this.cachePath = this.fs.joinPath(this.configDir, "update-check.json"); + } + + async checkForUpdate( + signal?: AbortSignal, + ): Promise { + const cache = await this.loadCache(); + + if (cache && !this.isCheckDue(cache)) { + return this.noticeFromLatest(cache.latestVersion); + } + + const latestVersion = await this.fetchLatestVersion(signal); + if (!latestVersion) { + return this.noticeFromLatest(cache?.latestVersion); + } + + await this.saveCache({ + checkedAt: this.now().toISOString(), + latestVersion, + }); + + return this.noticeFromLatest(latestVersion); + } + + private noticeFromLatest( + latestVersion: string | undefined, + ): UpdateCheckNotice | undefined { + if ( + !latestVersion || + !semver.valid(latestVersion) || + !semver.valid(this.currentVersion) || + !semver.gt(latestVersion, this.currentVersion) + ) { + return undefined; + } + + return { + currentVersion: this.currentVersion, + latestVersion, + updateCommand: UPDATE_COMMAND, + }; + } + + private isCheckDue(cache: UpdateCheckCache): boolean { + const checkedAtMs = Date.parse(cache.checkedAt); + if (Number.isNaN(checkedAtMs)) { + return true; + } + return this.now().getTime() - checkedAtMs >= this.checkIntervalMs; + } + + private async fetchLatestVersion( + signal: AbortSignal | undefined, + ): Promise { + try { + const timeoutSignal = AbortSignal.timeout(this.fetchTimeoutMs); + const response = await this.fetcher(NPM_DIST_TAGS_URL, { + signal: signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal, + }); + if (!response.ok) { + return undefined; + } + const body = await response.json(); + if ( + !body || + typeof body !== "object" || + typeof (body as { latest?: unknown }).latest !== "string" + ) { + return undefined; + } + const latest = (body as { latest: string }).latest; + return semver.valid(latest) ? latest : undefined; + } catch { + return undefined; + } + } + + private async loadCache(): Promise { + try { + if (!(await this.fs.exists(this.cachePath))) { + return undefined; + } + const raw = await this.fs.readFile(this.cachePath); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.checkedAt !== "string" || + typeof parsed.latestVersion !== "string" + ) { + return undefined; + } + return { + checkedAt: parsed.checkedAt, + latestVersion: parsed.latestVersion, + }; + } catch { + return undefined; + } + } + + private async saveCache(cache: UpdateCheckCache): Promise { + try { + await this.fs.ensureDir(this.configDir, DIR_MODE); + await this.fs.writeFile( + this.cachePath, + `${JSON.stringify(cache, null, 2)}\n`, + FILE_MODE, + ); + } catch { + // Update checks must never break the real command. + } + } +} + +export function resolveConfigHome( + env: Record, + fs: FileSystemService, +): string { + const xdgConfigHome = env.XDG_CONFIG_HOME?.trim(); + if (xdgConfigHome) { + return xdgConfigHome; + } + return fs.joinPath(fs.getHomeDir(), ".config"); +} + +export function shouldRunUpdateCheck( + input: UpdateCheckEligibilityInput, +): boolean { + if (input.stderrIsTTY !== true) { + return false; + } + + const env = input.env ?? process.env; + if (env.CI || env.GITHITS_DISABLE_UPDATE_CHECK) { + return false; + } + + if (isHelpOrVersionInvocation(input.args)) { + return false; + } + + if (isLikelyEphemeralPackageRunner(env)) { + return false; + } + + if ( + isMcpStdioInvocation( + input.args, + input.stdinIsTTY === true, + input.stdoutIsTTY === true, + ) + ) { + return false; + } + + return true; +} + +export function formatUpdateNotice(notice: UpdateCheckNotice): string { + return `Update available: githits ${notice.currentVersion} -> ${notice.latestVersion}\nRun: ${notice.updateCommand}`; +} + +function isHelpOrVersionInvocation(args: string[]): boolean { + return ( + args.length === 0 || + args[0] === "help" || + args.includes("--help") || + args.includes("-h") || + args.includes("--version") || + args.includes("-V") + ); +} + +function isLikelyEphemeralPackageRunner( + env: Record, +): boolean { + const lifecycleEvent = env.npm_lifecycle_event; + if (lifecycleEvent === "npx" || lifecycleEvent === "bunx") { + return true; + } + + const userAgent = env.npm_config_user_agent ?? ""; + return ( + env.npm_command === "exec" && + (userAgent.startsWith("npm/") || userAgent.startsWith("bun/")) + ); +} + +function isMcpStdioInvocation( + args: string[], + stdinIsTTY: boolean, + stdoutIsTTY: boolean, +): boolean { + const firstTokenIndex = args.findIndex((arg) => !arg.startsWith("-")); + if (firstTokenIndex === -1 || args[firstTokenIndex] !== "mcp") { + return false; + } + + const remainingArgs = args.slice(firstTokenIndex + 1); + return remainingArgs.includes("start") || !stdinIsTTY || !stdoutIsTTY; +}