**Summary**
This change introduces a new startup check that notifies users if a
newer `@openai/codex` version is available. To avoid spamming, it writes
a small state file recording the last check time and will only re‑check
once every 24 hours.
**What’s Changed**
- **New file** `src/utils/check-updates.ts`
- Runs `npm outdated --global @openai/codex`
- Reads/writes `codex-state.json` under `CONFIG_DIR`
- Limits checks to once per day (`UPDATE_CHECK_FREQUENCY = 24h`)
- Uses `boxen` for a styled alert and `which` to locate the npm binary
- **Hooked into** `src/cli.tsx` entrypoint:
```ts
import { checkForUpdates } from "./utils/check-updates";
// …
// after loading config
await checkForUpdates().catch();
```
- **Dependencies**
- Added `boxen@^8.0.1`, `which@^5.0.0`, `@types/which@^3.0.4`
- **Tests**
- Vitest suite under `tests/check-updates.test.ts`
- Snapshot in `__snapshots__/check-updates.test.ts.snap`
**Motivation**
Addresses issue #244. Users running a stale global install will now see
a friendly reminder—at most once per day—to upgrade and enjoy the latest
features.
**Test Plan**
- `getNPMCommandPath()` resolves npm correctly
- `checkOutdated()` parses `npm outdated` JSON
- State file prevents repeat alerts within 24h
- Boxen snapshot matches expected output
- No console output when state indicates a recent check
**Related Issue**
try resolves #244
**Preview**
Prompt a pnpm‑style alert when outdated

Let me know if you’d tweak any of the messaging, throttle frequency,
placement in the startup flow, or anything else.
---------
Co-authored-by: Thibault Sottiaux <tibo@openai.com>
113 lines
3.4 KiB
TypeScript
113 lines
3.4 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
||
import {
|
||
checkForUpdates,
|
||
checkOutdated,
|
||
getNPMCommandPath,
|
||
} from "../src/utils/check-updates.js";
|
||
import { execFile } from "node:child_process";
|
||
import { join } from "node:path";
|
||
import { CONFIG_DIR } from "src/utils/config.js";
|
||
import { beforeEach } from "node:test";
|
||
|
||
vi.mock("which", () => ({
|
||
default: vi.fn(() => "/usr/local/bin/npm"),
|
||
}));
|
||
|
||
vi.mock("child_process", () => ({
|
||
execFile: vi.fn((_cmd, _args, _opts, callback) => {
|
||
const stdout = JSON.stringify({
|
||
"@openai/codex": {
|
||
current: "1.0.0",
|
||
latest: "2.0.0",
|
||
},
|
||
});
|
||
callback?.(null, stdout, "");
|
||
return {} as any;
|
||
}),
|
||
}));
|
||
|
||
let memfs: Record<string, string> = {};
|
||
|
||
vi.mock("node:fs/promises", async (importOriginal) => ({
|
||
...(await importOriginal()),
|
||
readFile: async (path: string) => {
|
||
if (memfs[path] === undefined) {
|
||
throw new Error("ENOENT");
|
||
}
|
||
return memfs[path];
|
||
},
|
||
}));
|
||
|
||
beforeEach(() => {
|
||
memfs = {}; // reset in‑memory store
|
||
});
|
||
|
||
describe("Check for updates", () => {
|
||
it("should return the path to npm", async () => {
|
||
const npmPath = await getNPMCommandPath();
|
||
expect(npmPath).toBeDefined();
|
||
});
|
||
|
||
it("should return undefined if npm is not found", async () => {
|
||
vi.mocked(await import("which")).default.mockImplementationOnce(() => {
|
||
throw new Error("not found");
|
||
});
|
||
|
||
const npmPath = await getNPMCommandPath();
|
||
expect(npmPath).toBeUndefined();
|
||
});
|
||
|
||
it("should return the return value when package is outdated", async () => {
|
||
const npmPath = await getNPMCommandPath();
|
||
|
||
const info = await checkOutdated(npmPath!);
|
||
expect(info).toStrictEqual({
|
||
currentVersion: "1.0.0",
|
||
latestVersion: "2.0.0",
|
||
});
|
||
});
|
||
|
||
it("should return undefined when package is not outdated", async () => {
|
||
const npmPath = await getNPMCommandPath();
|
||
vi.mocked(execFile).mockImplementationOnce(
|
||
(_cmd, _args, _opts, callback) => {
|
||
// Simulate the case where the package is not outdated, returning an empty object
|
||
const stdout = JSON.stringify({});
|
||
callback?.(null, stdout, "");
|
||
return {} as any;
|
||
},
|
||
);
|
||
|
||
const info = await checkOutdated(npmPath!);
|
||
expect(info).toBeUndefined();
|
||
});
|
||
|
||
it("should outputs the update message when package is outdated", async () => {
|
||
const codexStatePath = join(CONFIG_DIR, "update-check.json");
|
||
// Use a fixed early date far in the past to ensure it's always at least 1 day before now
|
||
memfs[codexStatePath] = JSON.stringify({
|
||
lastUpdateCheck: new Date("2000-01-01T00:00:00Z").toUTCString(),
|
||
});
|
||
await checkForUpdates();
|
||
// Spy on console.log to capture output
|
||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||
await checkForUpdates();
|
||
expect(logSpy).toHaveBeenCalled();
|
||
// The last call should be the boxen message
|
||
const lastCallArg = logSpy.mock.calls.at(-1)?.[0];
|
||
expect(lastCallArg).toMatchSnapshot();
|
||
});
|
||
|
||
it("should not output the update message when package is not outdated", async () => {
|
||
const codexStatePath = join(CONFIG_DIR, "update-check.json");
|
||
memfs[codexStatePath] = JSON.stringify({
|
||
lastUpdateCheck: new Date().toUTCString(),
|
||
});
|
||
await checkForUpdates();
|
||
// Spy on console.log to capture output
|
||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||
await checkForUpdates();
|
||
expect(logSpy).not.toHaveBeenCalled();
|
||
});
|
||
});
|