feat: notify when a newer version is available (#333)
**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>
This commit is contained in:
@@ -62,8 +62,10 @@
|
||||
"@types/marked-terminal": "^6.1.1",
|
||||
"@types/react": "^18.0.32",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/which": "^3.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"boxen": "^8.0.1",
|
||||
"esbuild": "^0.25.2",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
@@ -77,7 +79,8 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.3",
|
||||
"vitest": "^3.0.9",
|
||||
"whatwg-url": "^14.2.0"
|
||||
"whatwg-url": "^14.2.0",
|
||||
"which": "^5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AgentLoop } from "./utils/agent/agent-loop";
|
||||
import { initLogger } from "./utils/agent/log";
|
||||
import { ReviewDecision } from "./utils/agent/review";
|
||||
import { AutoApprovalMode } from "./utils/auto-approval-mode";
|
||||
import { checkForUpdates } from "./utils/check-updates";
|
||||
import {
|
||||
loadConfig,
|
||||
PRETTY_PRINT,
|
||||
@@ -252,6 +253,10 @@ config = {
|
||||
notify: Boolean(cli.flags.notify),
|
||||
};
|
||||
|
||||
// Check for updates after loading config
|
||||
// This is important because we write state file in the config dir
|
||||
await checkForUpdates().catch();
|
||||
|
||||
if (!(await isModelSupportedForResponses(config.model))) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
|
||||
143
codex-cli/src/utils/check-updates.ts
Normal file
143
codex-cli/src/utils/check-updates.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { CONFIG_DIR } from "./config";
|
||||
import boxen from "boxen";
|
||||
import chalk from "chalk";
|
||||
import * as cp from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import which from "which";
|
||||
|
||||
interface UpdateCheckState {
|
||||
lastUpdateCheck?: string;
|
||||
}
|
||||
|
||||
interface PackageInfo {
|
||||
current: string;
|
||||
wanted: string;
|
||||
latest: string;
|
||||
dependent: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface UpdateCheckInfo {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
}
|
||||
|
||||
const UPDATE_CHECK_FREQUENCY = 1000 * 60 * 60 * 24; // 1 day
|
||||
|
||||
export async function getNPMCommandPath(): Promise<string | undefined> {
|
||||
try {
|
||||
return await which(process.platform === "win32" ? "npm.cmd" : "npm");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkOutdated(
|
||||
npmCommandPath: string,
|
||||
): Promise<UpdateCheckInfo | undefined> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
// TODO: support local installation
|
||||
// Right now we're using "--global", which only checks global packages.
|
||||
// But codex might be installed locally — we should check the local version first,
|
||||
// and only fall back to the global one if needed.
|
||||
const args = ["outdated", "--global", "--json", "--", "@openai/codex"];
|
||||
// corepack npm wrapper would automatically update package.json. disable that behavior.
|
||||
// COREPACK_ENABLE_AUTO_PIN disables the package.json overwrite, and
|
||||
// COREPACK_ENABLE_PROJECT_SPEC makes the npm view command succeed
|
||||
// even if packageManager specified a package manager other than npm.
|
||||
const env = {
|
||||
...process.env,
|
||||
COREPACK_ENABLE_AUTO_PIN: "0",
|
||||
COREPACK_ENABLE_PROJECT_SPEC: "0",
|
||||
};
|
||||
let options: cp.ExecFileOptions = { env };
|
||||
let commandPath = npmCommandPath;
|
||||
if (process.platform === "win32") {
|
||||
options = { ...options, shell: true };
|
||||
commandPath = `"${npmCommandPath}"`;
|
||||
}
|
||||
cp.execFile(commandPath, args, options, async (_error, stdout) => {
|
||||
try {
|
||||
const { name: packageName } = await import("../../package.json");
|
||||
const content: Record<string, PackageInfo> = JSON.parse(stdout);
|
||||
if (!content[packageName]) {
|
||||
// package not installed or not outdated
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = content[packageName].current;
|
||||
const latestVersion = content[packageName].latest;
|
||||
|
||||
resolve({ currentVersion, latestVersion });
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkForUpdates(): Promise<void> {
|
||||
const stateFile = join(CONFIG_DIR, "update-check.json");
|
||||
let state: UpdateCheckState | undefined;
|
||||
try {
|
||||
state = JSON.parse(await readFile(stateFile, "utf8"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (
|
||||
state?.lastUpdateCheck &&
|
||||
Date.now() - new Date(state.lastUpdateCheck).valueOf() <
|
||||
UPDATE_CHECK_FREQUENCY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const npmCommandPath = await getNPMCommandPath();
|
||||
if (!npmCommandPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInfo = await checkOutdated(npmCommandPath);
|
||||
|
||||
await writeState(stateFile, {
|
||||
...state,
|
||||
lastUpdateCheck: new Date().toUTCString(),
|
||||
});
|
||||
|
||||
if (!packageInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateMessage = `To update, run: ${chalk.cyan(
|
||||
"npm install -g @openai/codex",
|
||||
)} to update.`;
|
||||
|
||||
const box = boxen(
|
||||
`\
|
||||
Update available! ${chalk.red(packageInfo.currentVersion)} → ${chalk.green(
|
||||
packageInfo.latestVersion,
|
||||
)}.
|
||||
${updateMessage}`,
|
||||
{
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
align: "center",
|
||||
borderColor: "yellow",
|
||||
borderStyle: "round",
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(box);
|
||||
}
|
||||
|
||||
async function writeState(stateFilePath: string, state: UpdateCheckState) {
|
||||
await writeFile(stateFilePath, JSON.stringify(state, null, 2), {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
12
codex-cli/tests/__snapshots__/check-updates.test.ts.snap
Normal file
12
codex-cli/tests/__snapshots__/check-updates.test.ts.snap
Normal file
@@ -0,0 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Check for updates > should outputs the update message when package is outdated 1`] = `
|
||||
"
|
||||
╭─────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Update available! 1.0.0 → 2.0.0. │
|
||||
│ To update, run: npm install -g @openai/codex to update. │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
112
codex-cli/tests/check-updates.test.ts
Normal file
112
codex-cli/tests/check-updates.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"types": ["node"],
|
||||
"baseUrl": "./",
|
||||
"resolveJsonModule": false, // ESM doesn't yet support JSON modules.
|
||||
"resolveJsonModule": true, // ESM doesn't yet support JSON modules.
|
||||
"jsx": "react",
|
||||
"declaration": true,
|
||||
"newLine": "lf",
|
||||
|
||||
Reference in New Issue
Block a user