From d61da89ed3e635d70e47c795ce29311ba6554696 Mon Sep 17 00:00:00 2001 From: Benny Yen Date: Sat, 19 Apr 2025 08:00:45 +0800 Subject: [PATCH] feat: notify when a newer version is available (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 ![outdated‑alert](https://github.com/user-attachments/assets/294dad45-d858-45d1-bf34-55e672ab883a) 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 --- codex-cli/package.json | 5 +- codex-cli/src/cli.tsx | 5 + codex-cli/src/utils/check-updates.ts | 143 ++++++++++++++++++ .../__snapshots__/check-updates.test.ts.snap | 12 ++ codex-cli/tests/check-updates.test.ts | 112 ++++++++++++++ codex-cli/tsconfig.json | 2 +- 6 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 codex-cli/src/utils/check-updates.ts create mode 100644 codex-cli/tests/__snapshots__/check-updates.test.ts.snap create mode 100644 codex-cli/tests/check-updates.test.ts diff --git a/codex-cli/package.json b/codex-cli/package.json index d66b2895..cc1714f4 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -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", diff --git a/codex-cli/src/cli.tsx b/codex-cli/src/cli.tsx index fdc0ea77..2f2b8c09 100644 --- a/codex-cli/src/cli.tsx +++ b/codex-cli/src/cli.tsx @@ -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( diff --git a/codex-cli/src/utils/check-updates.ts b/codex-cli/src/utils/check-updates.ts new file mode 100644 index 00000000..b3d6f85a --- /dev/null +++ b/codex-cli/src/utils/check-updates.ts @@ -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 { + try { + return await which(process.platform === "win32" ? "npm.cmd" : "npm"); + } catch { + return undefined; + } +} + +export async function checkOutdated( + npmCommandPath: string, +): Promise { + 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 = 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 { + 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", + }); +} diff --git a/codex-cli/tests/__snapshots__/check-updates.test.ts.snap b/codex-cli/tests/__snapshots__/check-updates.test.ts.snap new file mode 100644 index 00000000..2c1631fb --- /dev/null +++ b/codex-cli/tests/__snapshots__/check-updates.test.ts.snap @@ -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. │ + │ │ + ╰─────────────────────────────────────────────────────────────╯ +" +`; diff --git a/codex-cli/tests/check-updates.test.ts b/codex-cli/tests/check-updates.test.ts new file mode 100644 index 00000000..a7778968 --- /dev/null +++ b/codex-cli/tests/check-updates.test.ts @@ -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 = {}; + +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(); + }); +}); diff --git a/codex-cli/tsconfig.json b/codex-cli/tsconfig.json index d1dacc91..e441160f 100644 --- a/codex-cli/tsconfig.json +++ b/codex-cli/tsconfig.json @@ -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",