Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 82 additions & 11 deletions docs/implementation/update-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
## 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.
`latest` dist-tag. It also uses npm deprecation metadata as a blunt kill switch
for versions that become backend-incompatible or unsafe.

## Background

Expand All @@ -19,6 +18,23 @@ 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.

## Support Policy

GitHits supports the current and recent CLI versions during normal operation;
the target support window is about seven days. Older versions may continue to
work, but are not guaranteed.

When a version must stop running, maintainers deprecate it on npm with a clear
reason:

```sh
npm deprecate 'githits@<0.3.0' 'Backend protocol changed'
```

The CLI treats npm deprecation as runtime policy once observed. npm or network
failure does not break commands unless the installed version is already cached as
deprecated.

## Behavior

The check is advisory only:
Expand All @@ -30,13 +46,39 @@ The check is advisory only:
- every eligible invocation reports the stale version once a newer npm `latest`
is known

Required-update enforcement is separate:

- background refresh records whether the installed version is npm-deprecated
- the current command is not blocked by newly fetched deprecation metadata
- the next eligible invocation blocks from cached metadata only
- help/version and ephemeral package-runner invocations are not blocked
- CI, non-TTY, and MCP stdio invocations can be blocked because compatibility
matters there too
- successful non-deprecated metadata clears a cached block
- fetch failures preserve any cached deprecated status and otherwise fail open

The notice format is:

```text
Update available: githits 0.2.0 -> 0.3.0
Run: npm i -g githits@latest
```

The required-update format is:

```text
Update required: Backend protocol changed

Installed githits 0.2.0 is no longer supported.
Latest known version: 0.3.0
Update with:
npm i -g githits@latest
```

The `Latest known version` line is omitted when the advisory latest-version cache
is missing. Required-update enforcement does not fetch npm just to render this
line.

## Registry Source

The checker fetches npm dist-tags directly:
Expand All @@ -45,9 +87,17 @@ The checker fetches npm dist-tags directly:
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.
Only the `latest` field is used for advisory update notices. Required-update
refresh uses the installed package-version metadata endpoint:

```text
https://registry.npmjs.org/githits/<currentVersion>
```

The optional `deprecated` string is sanitized, cached, and later displayed as the
required-update reason. The CLI does not shell out to `npm info`, because
subprocess behavior depends on the user's package-manager setup and is slower
than direct HTTPS requests.

## Cache

Expand All @@ -71,16 +121,21 @@ Cache shape:
```json
{
"checkedAt": "2026-04-28T12:00:00.000Z",
"latestVersion": "0.3.0"
"latestVersion": "0.3.0",
"currentVersionStatus": {
"version": "0.2.0",
"checkedAt": "2026-04-28T12:00:00.000Z",
"deprecatedReason": "Backend protocol changed"
}
}
```

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.
Concurrent writes use atomic write-then-rename with last-writer-wins semantics,
and redundant fetches from racing processes are acceptable.

## Eligibility

Expand All @@ -96,6 +151,11 @@ Checks are skipped for:
- likely ephemeral package-runner invocations (`npx`, `bunx`, npm/bun `exec`)
- MCP stdio server invocations

Required-update refresh/enforcement uses broader eligibility: it skips
help/version and ephemeral package runners, but not CI, non-TTY, disabled
advisory checks, or MCP stdio. This keeps compatibility enforcement effective
for automation and MCP clients while keeping advisory output conservative.

MCP has two forms:

- `githits mcp start` always starts the stdio server and is skipped
Expand All @@ -109,7 +169,7 @@ 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/update-check.ts` | Cancellable background tasks, post-command advisory notice, cached required-update enforcement |
| `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 |
Expand All @@ -118,11 +178,22 @@ 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.

## Backend Compatibility Signals

All backend requests already include `x-githits-client-version` and a
`User-Agent`, so backend telemetry can identify old clients. Until the backend
has a structured `CLIENT_UPDATE_REQUIRED` error, the CLI maps GraphQL schema
validation failures such as `Cannot query field` to an `UPDATE_REQUIRED` error
because they are strong evidence that the client query is incompatible with the
backend schema.

## Future Work

This mechanism is not a hard compatibility gate. Future phases can add:
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
- structured GraphQL `CLIENT_UPDATE_REQUIRED` extensions with minimum version
and upgrade instructions
29 changes: 23 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import { Command } from "commander";
import { version } from "../package.json";
import {
enforceCachedRequiredUpdateForInvocation,
runWithUpdateCheckFlush,
startRequiredUpdateRefreshTaskForInvocation,
startUpdateCheckTaskForInvocation,
} from "./cli/update-check.js";
import {
Expand Down Expand Up @@ -38,17 +40,32 @@ const commandSpans = new WeakMap<
Command,
ReturnType<typeof startTelemetrySpan>
>();
const createUpdateCheckService = () =>
new NpmRegistryUpdateCheckService({
currentVersion: version,
fileSystemService: new FileSystemServiceImpl(),
});

await enforceCachedRequiredUpdateForInvocation({
args: argv,
env: process.env,
createService: createUpdateCheckService,
stderr: process.stderr,
exit: process.exit as (code: number) => never,
});

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(),
}),
createService: createUpdateCheckService,
});
const requiredUpdateRefreshTask = startRequiredUpdateRefreshTaskForInvocation({
args: argv,
env: process.env,
createService: createUpdateCheckService,
});

if (isTelemetryEnabled()) {
Expand Down Expand Up @@ -138,7 +155,7 @@ try {
await runWithUpdateCheckFlush(
() => withTelemetrySpan("cli.parse", () => program.parseAsync()),
updateCheckTask,
{ stderr: process.stderr },
{ stderr: process.stderr, requiredUpdateRefreshTask },
);
} catch (error) {
if (isUserFacingError(error)) {
Expand Down
123 changes: 123 additions & 0 deletions src/cli/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { describe, expect, it, mock } from "bun:test";
import { createMockUpdateCheckService } from "../services/test-helpers.js";
import {
enforceCachedRequiredUpdateForInvocation,
flushRequiredUpdateRefresh,
flushUpdateCheckNotice,
runWithUpdateCheckFlush,
startRequiredUpdateRefreshTask,
startRequiredUpdateRefreshTaskForInvocation,
startUpdateCheckTask,
startUpdateCheckTaskForInvocation,
} from "./update-check.js";
Expand Down Expand Up @@ -97,6 +101,125 @@ describe("update-check CLI orchestration", () => {
expect(createService).not.toHaveBeenCalled();
});

it("starts required update refresh task for MCP stdio invocations", () => {
const createService = mock(() => createMockUpdateCheckService());

const task = startRequiredUpdateRefreshTaskForInvocation({
args: ["mcp", "start"],
env: {},
createService,
});

expect(task).toBeDefined();
expect(createService).toHaveBeenCalled();
task?.abort();
});

it("flushes required update refresh task without writing notices", async () => {
const service = createMockUpdateCheckService({
refreshRequiredUpdateStatus: mock(() => Promise.resolve()),
});
const task = startRequiredUpdateRefreshTask(service);

await flushRequiredUpdateRefresh(task, { timeoutMs: 50 });

expect(service.refreshRequiredUpdateStatus).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
});

it("enforces cached required update with terminal output", async () => {
const service = createMockUpdateCheckService({
getRequiredUpdateNotice: mock(() =>
Promise.resolve({
currentVersion: "0.2.0",
latestKnownVersion: "0.3.0",
reason: "Backend protocol changed",
updateCommand: "npm i -g githits@latest",
}),
),
});
const stderr = { write: mock(() => undefined) };
const exit = mock((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never;

await expect(
enforceCachedRequiredUpdateForInvocation({
args: ["example", "query"],
env: {},
createService: () => service,
stderr,
exit,
}),
).rejects.toThrow("exit 1");

expect(stderr.write).toHaveBeenCalledWith(
"Update required: Backend protocol changed\n\nInstalled githits 0.2.0 is no longer supported.\nLatest known version: 0.3.0\nUpdate with:\n npm i -g githits@latest\n",
);
});

it("enforces cached required update with JSON output", async () => {
const service = createMockUpdateCheckService({
getRequiredUpdateNotice: mock(() =>
Promise.resolve({
currentVersion: "0.2.0",
latestKnownVersion: "0.3.0",
reason: "Backend protocol changed",
updateCommand: "npm i -g githits@latest",
}),
),
});
const stderr = { write: mock(() => undefined) };
const exit = mock((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never;

await expect(
enforceCachedRequiredUpdateForInvocation({
args: ["pkg", "info", "npm:express", "--json"],
env: {},
createService: () => service,
stderr,
exit,
}),
).rejects.toThrow("exit 1");

const payload = JSON.parse(
(stderr.write as ReturnType<typeof mock>).mock.calls[0]?.[0] as string,
);
expect(payload).toEqual({
error: "Update required: Backend protocol changed",
code: "UPDATE_REQUIRED",
retryable: false,
details: {
currentVersion: "0.2.0",
latestKnownVersion: "0.3.0",
updateCommand: "npm i -g githits@latest",
reason: "Backend protocol changed",
},
});
});

it("does not enforce cached required update for help", async () => {
const createService = mock(() => createMockUpdateCheckService());
const stderr = { write: mock(() => undefined) };
const exit = mock(() => {
throw new Error("exit");
}) as (code: number) => never;

await enforceCachedRequiredUpdateForInvocation({
args: ["--help"],
env: {},
createService,
stderr,
exit,
});

expect(createService).not.toHaveBeenCalled();
expect(stderr.write).not.toHaveBeenCalled();
});

it("flushes update notices when the wrapped action throws", async () => {
const service = createMockUpdateCheckService({
checkForUpdate: mock(() =>
Expand Down
Loading
Loading