diff --git a/.changeset/show-cli-update-notices.md b/.changeset/show-cli-update-notices.md new file mode 100644 index 0000000..8821ee2 --- /dev/null +++ b/.changeset/show-cli-update-notices.md @@ -0,0 +1,5 @@ +--- +"@alchemy/cli": patch +--- + +Show an upgrade notice when a newer CLI version is available during onboarding and interactive startup. Add an explicit `alchemy update-check` command so agents and scripts can retrieve current and latest version info with install guidance. diff --git a/README.md b/README.md index 6f4231c..1765811 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ alchemy # Agent/script-friendly command alchemy balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --json --no-interactive + +# Agent checks whether a newer CLI version is available +alchemy update-check --json --no-interactive ``` #### Agent bootstrap @@ -70,6 +73,8 @@ alchemy --json agent-prompt This returns a single JSON document with execution policy, preflight instructions, auth matrix, the full command tree with all arguments and options, error codes with recovery actions, and example invocations. No external docs required. +Agents can also call `alchemy --json --no-interactive update-check` to retrieve the current CLI version, latest known version, and install command for upgrades. + ## Command Reference Run commands as `alchemy `. @@ -151,6 +156,7 @@ Use `alchemy help` or `alchemy help ` for generated command help. | `apps origin-allowlist ` | Updates app origin allowlist | `alchemy apps origin-allowlist --origins https://example.com` | | `apps ip-allowlist ` | Updates app IP allowlist | `alchemy apps ip-allowlist --ips 1.2.3.4,5.6.7.8` | | `setup status` | Shows setup status + next commands | `alchemy setup status` | +| `update-check` | Checks whether a newer CLI version is available | `alchemy update-check --json --no-interactive` | | `config set ...` | Sets config values | `alchemy config set api-key ` | | `config get ` | Gets one config value | `alchemy config get network` | | `config list` | Lists all config values | `alchemy config list` | @@ -180,7 +186,7 @@ These apply to all commands. |---|---|---| | `--json` | — | Force JSON output | | `-q, --quiet` | — | Suppress non-essential output | -| `-v, --verbose` | — | Enable verbose output | +| `--verbose` | — | Enable verbose output | | `--no-color` | `NO_COLOR` | Disable color output | | `--reveal` | — | Show secrets in plain text (TTY only) | @@ -317,6 +323,7 @@ Use `--no-interactive` to disable REPL/prompts in automation. - TTY: formatted human output - Non-TTY: JSON output (script-friendly) +- `-v`, `--version`: prints the CLI version - `--json`: forces JSON output in any context - `--verbose` or `alchemy config set verbose true`: includes richer payload output on supported commands diff --git a/src/commands/agent-prompt.ts b/src/commands/agent-prompt.ts index b6d0607..21bc5c3 100644 --- a/src/commands/agent-prompt.ts +++ b/src/commands/agent-prompt.ts @@ -127,6 +127,7 @@ function buildAgentPrompt(program: Command): AgentPrompt { "Parse stdout as JSON on exit code 0", "Parse stderr as JSON on nonzero exit code", "Never run bare 'alchemy' without --json --no-interactive", + "Run alchemy --json --no-interactive update-check when you need to detect available CLI upgrades", ], preflight: { command: "alchemy --json setup status", @@ -191,6 +192,7 @@ function buildAgentPrompt(program: Command): AgentPrompt { errors, examples: [ "alchemy --json --no-interactive setup status", + "alchemy --json --no-interactive update-check", "alchemy --json --no-interactive balance 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --api-key $ALCHEMY_API_KEY", "alchemy --json --no-interactive apps list --access-key $ALCHEMY_ACCESS_KEY", "alchemy --json --no-interactive rpc eth_blockNumber --api-key $ALCHEMY_API_KEY", diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 1325cc2..4701850 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -17,6 +17,7 @@ import { getRPCNetworkIds } from "../lib/networks.js"; import { configDir, load as loadConfig } from "../lib/config.js"; import { getSetupMethod } from "../lib/onboarding.js"; import { bgRgb, rgb, noColor } from "../lib/colors.js"; +import { getUpdateNoticeLines } from "../lib/update-check.js"; const COMMAND_NAMES = [ "apps", @@ -81,6 +82,7 @@ const COMMAND_NAMES = [ "config get", "config list", "help", + "update-check", "network", "network list", "nfts", @@ -140,7 +142,10 @@ function saveReplHistory(lines: string[]): void { writeFileSync(historyFilePath, normalized.join("\n") + "\n", { mode: 0o600 }); } -export async function startREPL(program: Command): Promise { +export async function startREPL( + program: Command, + latestUpdate: string | null = null, +): Promise { if (!stdin.isTTY) return; setReplMode(true); setBrandedHelpSuppressed(true); @@ -251,6 +256,12 @@ export async function startREPL(program: Command): Promise { console.log(` ${green("✓")} ${dim(`Configured auth: ${formatSetupMethodLabel()}`)}`); console.log(` ${dim("Run commands directly (no 'alchemy' prefix).")}`); console.log(""); + if (latestUpdate) { + for (const line of getUpdateNoticeLines(latestUpdate)) { + console.log(line); + } + console.log(""); + } console.log(` ${brand("◆")} ${bold("Quick commands")}`); console.log(` ${dim("- rpc eth_chainId")}`); console.log(` ${dim("- config list")}`); diff --git a/src/commands/onboarding.ts b/src/commands/onboarding.ts index 0276de1..c883a01 100644 --- a/src/commands/onboarding.ts +++ b/src/commands/onboarding.ts @@ -11,6 +11,7 @@ import { maskIf, printKeyValueBox, } from "../lib/ui.js"; +import { getUpdateNoticeLines } from "../lib/update-check.js"; import { selectOrCreateApp } from "./config.js"; import { generateAndPersistWallet, importAndPersistWallet } from "./wallet.js"; @@ -115,7 +116,10 @@ async function runX402Onboarding(): Promise { console.log(` ${green("✓")} x402 enabled with wallet ${wallet.address}`); } -export async function runOnboarding(_program: Command): Promise { +export async function runOnboarding( + _program: Command, + latestUpdate: string | null = null, +): Promise { process.stdout.write(brandedHelp({ force: true })); console.log(""); console.log(` ${brand("◆")} ${bold("Welcome to Alchemy CLI")}`); @@ -124,6 +128,12 @@ export async function runOnboarding(_program: Command): Promise { console.log(` ${dim(" Choose one auth path to continue.")}`); console.log(` ${dim(" Tip: select 'exit' to skip setup for now.")}`); console.log(""); + if (latestUpdate) { + for (const line of getUpdateNoticeLines(latestUpdate)) { + console.log(line); + } + console.log(""); + } const method = await promptSelect({ message: "Choose an auth setup path", options: [ diff --git a/src/commands/update-check.ts b/src/commands/update-check.ts new file mode 100644 index 0000000..bbfb85f --- /dev/null +++ b/src/commands/update-check.ts @@ -0,0 +1,30 @@ +import { Command } from "commander"; +import { isJSONMode, printJSON } from "../lib/output.js"; +import { dim, printKeyValueBox } from "../lib/ui.js"; +import { getUpdateStatus } from "../lib/update-check.js"; + +function formatCheckedAt(checkedAt: number | null): string { + return checkedAt ? new Date(checkedAt).toISOString() : dim("(unknown)"); +} + +export function registerUpdateCheck(program: Command) { + program + .command("update-check") + .description("Check whether a newer CLI version is available") + .action(() => { + const status = getUpdateStatus(); + + if (isJSONMode()) { + printJSON(status); + return; + } + + printKeyValueBox([ + ["Current version", status.currentVersion], + ["Latest version", status.latestVersion ?? dim("(unknown)")], + ["Update available", status.updateAvailable ? "yes" : "no"], + ["Checked at", formatCheckedAt(status.checkedAt)], + ["Install", status.installCommand], + ]); + }); +} diff --git a/src/index.ts b/src/index.ts index b93caf3..d6a843f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { Command, Help } from "commander"; -import { errSetupRequired, exitWithError, setReplMode } from "./lib/errors.js"; +import { EXIT_CODES, errSetupRequired, exitWithError } from "./lib/errors.js"; import { setFlags, isJSONMode, quiet } from "./lib/output.js"; import { formatCommanderError } from "./lib/error-format.js"; import { load as loadConfig } from "./lib/config.js"; @@ -29,8 +29,10 @@ import { registerBundler } from "./commands/bundler.js"; import { registerGasManager } from "./commands/gas-manager.js"; import { registerSolana } from "./commands/solana.js"; import { registerAgentPrompt } from "./commands/agent-prompt.js"; +import { registerUpdateCheck } from "./commands/update-check.js"; import { isInteractiveAllowed } from "./lib/interaction.js"; import { getSetupStatus, isSetupComplete, shouldRunOnboarding } from "./lib/onboarding.js"; +import { getAvailableUpdate, printUpdateNotice } from "./lib/update-check.js"; // ── ANSI helpers for help formatting ──────────────────────────────── const hBrand = noColor @@ -72,7 +74,7 @@ const ROOT_COMMAND_PILLARS = [ }, { label: "Admin", - commands: ["apps", "config", "setup", "agent-prompt", "version", "help"], + commands: ["apps", "config", "setup", "agent-prompt", "update-check", "version", "help"], }, ] as const; @@ -115,12 +117,27 @@ const findCommandByPath = (root: Command, path: string[]): Command | null => { declare const __CLI_VERSION__: string; +let cachedAvailableUpdate: string | null | undefined; +let updateShownDuringInteractiveStartup = false; + +function getAvailableUpdateOnce(): string | null { + if (cachedAvailableUpdate === undefined) { + cachedAvailableUpdate = getAvailableUpdate(); + } + return cachedAvailableUpdate; +} + +function resetUpdateNoticeState(): void { + cachedAvailableUpdate = undefined; + updateShownDuringInteractiveStartup = false; +} + program .name("alchemy") .description( "The Alchemy CLI lets you query blockchain data, call JSON-RPC methods, and manage your Alchemy configuration.", ) - .version(__CLI_VERSION__) + .version(__CLI_VERSION__, "-v, --version", "display CLI version") .option("--api-key ", "Alchemy API key (env: ALCHEMY_API_KEY)") .option("--access-key ", "Alchemy access key (env: ALCHEMY_ACCESS_KEY)") .option( @@ -131,13 +148,23 @@ program .option("--wallet-key-file ", "Path to wallet private key file for x402") .option("--json", "Force JSON output") .option("-q, --quiet", "Suppress non-essential output") - .option("-v, --verbose", "Enable verbose output") + .option("--verbose", "Enable verbose output") .option("--no-color", "Disable color output") .option("--reveal", "Show secrets in plain text (TTY only)") .option("--timeout ", "Request timeout in milliseconds", parseInt) .option("--debug", "Enable debug diagnostics") .option("--no-interactive", "Disable REPL and prompt-driven interactions") .addHelpCommand(false) + .exitOverride((err) => { + if ( + err.code === "commander.help" || + err.code === "commander.helpDisplayed" || + err.code === "commander.version" + ) { + process.exit(0); + } + process.exit(EXIT_CODES.INVALID_ARGS); + }) .configureOutput({ outputError(str, write) { write(formatCommanderError(str)); @@ -301,7 +328,12 @@ program .hook("postAction", () => { if (!isJSONMode() && !quiet) { console.log(""); + if (!updateShownDuringInteractiveStartup) { + const latest = getAvailableUpdateOnce(); + if (latest) printUpdateNotice(latest); + } } + resetUpdateNoticeState(); }) .action(async () => { const cfg = loadConfig(); @@ -310,14 +342,21 @@ program } if (isInteractiveAllowed(program)) { + let latestForInteractiveStartup: string | null = null; if (shouldRunOnboarding(program, cfg)) { const { runOnboarding } = await import("./commands/onboarding.js"); - const completed = await runOnboarding(program); + const latest = getAvailableUpdateOnce(); + const completed = await runOnboarding(program, latest); + updateShownDuringInteractiveStartup = Boolean(latest); + latestForInteractiveStartup = null; if (!completed) { // User skipped or aborted onboarding while setup remains incomplete. // Do not enter REPL; return to shell without forcing interactive mode. return; } + } else { + latestForInteractiveStartup = getAvailableUpdateOnce(); + updateShownDuringInteractiveStartup = Boolean(latestForInteractiveStartup); } const { startREPL } = await import("./commands/interactive.js"); // In REPL mode, override exitOverride so errors don't kill the process @@ -325,7 +364,7 @@ program program.configureOutput({ writeErr: () => {}, }); - await startREPL(program); + await startREPL(program, latestForInteractiveStartup); return; } program.help(); @@ -363,6 +402,7 @@ registerSetup(program); registerConfig(program); registerSolana(program); registerAgentPrompt(program); +registerUpdateCheck(program); registerVersion(program); program .command("help [command...]") diff --git a/src/lib/update-check.ts b/src/lib/update-check.ts new file mode 100644 index 0000000..55f2816 --- /dev/null +++ b/src/lib/update-check.ts @@ -0,0 +1,155 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { configPath } from "./config.js"; +import { esc } from "./colors.js"; + +declare const __CLI_VERSION__: string; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const UPDATE_INSTALL_COMMAND = "npm i -g @alchemy/cli"; + +interface UpdateCache { + latest: string; + checkedAt: number; +} + +export interface UpdateStatus { + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + installCommand: string; + checkedAt: number | null; +} + +function cachePath(): string { + return configPath().replace(/config\.json$/, ".update-check"); +} + +function readCache(): UpdateCache | null { + try { + return JSON.parse(readFileSync(cachePath(), "utf-8")); + } catch { + return null; + } +} + +function writeCache(cache: UpdateCache): void { + try { + const p = cachePath(); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify(cache), { mode: 0o600 }); + } catch { + // Best-effort; don't disrupt the CLI. + } +} + +function fetchLatestVersion(): string | null { + try { + const result = execFileSync("npm", ["view", "@alchemy/cli", "version"], { + encoding: "utf-8", + timeout: 5_000, + stdio: ["pipe", "pipe", "pipe"], + }); + return result.trim() || null; + } catch { + return null; + } +} + +/** + * Compare two semver strings. Returns true if `a` is strictly less than `b`. + * Handles major.minor.patch only (no pre-release tags). + */ +function semverLT(a: string, b: string): boolean { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; + if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; + } + return false; +} + +function currentVersion(): string { + return typeof __CLI_VERSION__ === "string" ? __CLI_VERSION__ : "0.0.0"; +} + +function toUpdateStatus(latestVersion: string | null, checkedAt: number | null): UpdateStatus { + const current = currentVersion(); + return { + currentVersion: current, + latestVersion, + updateAvailable: latestVersion ? semverLT(current, latestVersion) : false, + installCommand: UPDATE_INSTALL_COMMAND, + checkedAt, + }; +} + +/** + * Resolve update status for display or machine-readable checks. + * Falls back to the most recent cached version when a refresh fails. + */ +export function getUpdateStatus(): UpdateStatus { + const cache = readCache(); + if (cache && Date.now() - cache.checkedAt < CACHE_TTL_MS) { + return toUpdateStatus(cache.latest, cache.checkedAt); + } + + const latest = fetchLatestVersion(); + if (latest) { + const checkedAt = Date.now(); + writeCache({ latest, checkedAt }); + return toUpdateStatus(latest, checkedAt); + } + + if (cache) { + return toUpdateStatus(cache.latest, cache.checkedAt); + } + + return toUpdateStatus(null, null); +} + +/** + * Check for a newer version of the CLI on npm. Uses a 24-hour cache so + * network calls happen at most once per day. Returns the latest version + * string if an update is available, or `null` otherwise. + */ +export function getAvailableUpdate(): string | null { + const current = currentVersion(); + + const cache = readCache(); + if (cache && Date.now() - cache.checkedAt < CACHE_TTL_MS) { + return semverLT(current, cache.latest) ? cache.latest : null; + } + + const latest = fetchLatestVersion(); + if (latest) { + writeCache({ latest, checkedAt: Date.now() }); + return semverLT(current, latest) ? latest : null; + } + + return null; +} + +/** + * Format the update notification so it can be rendered in multiple flows. + */ +export function getUpdateNoticeLines(latest: string): string[] { + const yellow = esc("33"); + const bold = esc("1"); + const dim = esc("2"); + + return [ + ` ${yellow("Update available")} ${dim(currentVersion())} → ${bold(latest)}`, + ` Run ${bold(UPDATE_INSTALL_COMMAND)} to update`, + ]; +} + +/** + * Print an update notification to stderr. Call this after command output + * so it doesn't interfere with JSON piping. + */ +export function printUpdateNotice(latest: string): void { + process.stderr.write(`\n${getUpdateNoticeLines(latest).join("\n")}\n\n`); +} diff --git a/tests/commands/agent-prompt.test.ts b/tests/commands/agent-prompt.test.ts index d6b1793..18ad938 100644 --- a/tests/commands/agent-prompt.test.ts +++ b/tests/commands/agent-prompt.test.ts @@ -119,6 +119,31 @@ describe("agent-prompt command", () => { expect(payload.errors.INVALID_ARGS.retryable).toBe(false); }); + it("includes update-check guidance for automation", async () => { + const printHuman = vi.fn(); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + printHuman, + })); + + const { registerAgentPrompt } = await import( + "../../src/commands/agent-prompt.js" + ); + const program = new Command(); + registerAgentPrompt(program); + + await program.parseAsync(["node", "test", "agent-prompt"], { + from: "node", + }); + + const payload = printHuman.mock.calls[0][1]; + expect(payload.executionPolicy).toContain( + "Run alchemy --json --no-interactive update-check when you need to detect available CLI upgrades", + ); + expect(payload.examples).toContain("alchemy --json --no-interactive update-check"); + }); + it("includes auth entries for all method types", async () => { const printHuman = vi.fn(); vi.doMock("../../src/lib/output.js", () => ({ diff --git a/tests/commands/onboarding.test.ts b/tests/commands/onboarding.test.ts index a00e958..ccf4530 100644 --- a/tests/commands/onboarding.test.ts +++ b/tests/commands/onboarding.test.ts @@ -11,6 +11,12 @@ describe("onboarding flow", () => { promptSelect: vi.fn().mockResolvedValue(null), promptText: vi.fn(), })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateNoticeLines: vi.fn().mockReturnValue([ + " Update available 0.2.0 -> 9.9.9", + " Run npm i -g @alchemy/cli to update", + ]), + })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), save: vi.fn(), @@ -51,6 +57,12 @@ describe("onboarding flow", () => { promptSelect: vi.fn().mockResolvedValue("api-key"), promptText: vi.fn().mockResolvedValue("api_test"), })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateNoticeLines: vi.fn().mockReturnValue([ + " Update available 0.2.0 -> 9.9.9", + " Run npm i -g @alchemy/cli to update", + ]), + })); vi.doMock("../../src/lib/config.js", () => ({ load, save, @@ -86,6 +98,12 @@ describe("onboarding flow", () => { promptSelect: vi.fn().mockResolvedValue("exit"), promptText: vi.fn(), })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateNoticeLines: vi.fn().mockReturnValue([ + " Update available 0.2.0 -> 9.9.9", + " Run npm i -g @alchemy/cli to update", + ]), + })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), save: vi.fn(), @@ -121,6 +139,12 @@ describe("onboarding flow", () => { promptSelect: vi.fn().mockResolvedValue("api-key"), promptText: vi.fn().mockResolvedValue(""), })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateNoticeLines: vi.fn().mockReturnValue([ + " Update available 0.2.0 -> 9.9.9", + " Run npm i -g @alchemy/cli to update", + ]), + })); vi.doMock("../../src/lib/config.js", () => ({ load: vi.fn().mockReturnValue({}), save: vi.fn(), @@ -151,4 +175,47 @@ describe("onboarding flow", () => { expect(logSpy).toHaveBeenCalledWith(" Next steps:"); expect(logSpy).toHaveBeenCalledWith(" - alchemy config set api-key "); }); + + it("prints the update notice on the onboarding screen when provided", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + vi.doMock("../../src/lib/terminal-ui.js", () => ({ + promptSelect: vi.fn().mockResolvedValue("exit"), + promptText: vi.fn(), + })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateNoticeLines: vi.fn().mockReturnValue([ + " Update available 0.2.0 -> 9.9.9", + " Run npm i -g @alchemy/cli to update", + ]), + })); + vi.doMock("../../src/lib/config.js", () => ({ + load: vi.fn().mockReturnValue({}), + save: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + brand: (s: string) => s, + bold: (s: string) => s, + brandedHelp: () => "", + dim: (s: string) => s, + green: (s: string) => s, + maskIf: (s: string) => s, + printKeyValueBox: vi.fn(), + })); + vi.doMock("../../src/lib/admin-client.js", () => ({ + AdminClient: vi.fn(), + })); + vi.doMock("../../src/commands/config.js", () => ({ + selectOrCreateApp: vi.fn(), + })); + vi.doMock("../../src/commands/wallet.js", () => ({ + generateAndPersistWallet: vi.fn(), + importAndPersistWallet: vi.fn(), + })); + + const { runOnboarding } = await import("../../src/commands/onboarding.js"); + const completed = await runOnboarding({} as never, "9.9.9"); + expect(completed).toBe(false); + expect(logSpy).toHaveBeenCalledWith(" Update available 0.2.0 -> 9.9.9"); + expect(logSpy).toHaveBeenCalledWith(" Run npm i -g @alchemy/cli to update"); + }); }); diff --git a/tests/commands/update-check.test.ts b/tests/commands/update-check.test.ts new file mode 100644 index 0000000..253cc41 --- /dev/null +++ b/tests/commands/update-check.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +describe("update-check command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("prints JSON status in JSON mode", async () => { + const printJSON = vi.fn(); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + dim: (s: string) => s, + printKeyValueBox: vi.fn(), + })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateStatus: () => ({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt: 1_700_000_000_000, + }), + })); + + const { registerUpdateCheck } = await import( + "../../src/commands/update-check.js" + ); + const program = new Command(); + registerUpdateCheck(program); + + await program.parseAsync(["node", "test", "update-check"], { from: "node" }); + + expect(printJSON).toHaveBeenCalledWith({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt: 1_700_000_000_000, + }); + }); + + it("prints human status with a boxed summary", async () => { + const printKeyValueBox = vi.fn(); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + dim: (s: string) => s, + printKeyValueBox, + })); + vi.doMock("../../src/lib/update-check.js", () => ({ + getUpdateStatus: () => ({ + currentVersion: "0.2.0", + latestVersion: "0.3.0", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt: 1_700_000_000_000, + }), + })); + + const { registerUpdateCheck } = await import( + "../../src/commands/update-check.js" + ); + const program = new Command(); + registerUpdateCheck(program); + + await program.parseAsync(["node", "test", "update-check"], { from: "node" }); + + expect(printKeyValueBox).toHaveBeenCalledWith([ + ["Current version", "0.2.0"], + ["Latest version", "0.3.0"], + ["Update available", "yes"], + ["Checked at", "2023-11-14T22:13:20.000Z"], + ["Install", "npm i -g @alchemy/cli"], + ]); + }); +}); diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index d0a44e5..e089071 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { FIXTURES } from "./fixtures.js"; import { runCLI } from "./helpers/run-cli.js"; @@ -388,7 +391,55 @@ describe("CLI mock E2E", () => { const commands = payload.commands as Array<{ name: string }>; expect(commands.length).toBeGreaterThan(10); expect(commands.some((c) => c.name === "balance")).toBe(true); + expect(commands.some((c) => c.name === "update-check")).toBe(true); expect(commands.some((c) => c.name === "agent-prompt")).toBe(false); + expect(payload.examples).toContain("alchemy --json --no-interactive update-check"); + }); + + it("returns update status JSON from the cached version check", async () => { + const configDir = mkdtempSync(join(tmpdir(), "alchemy-cli-update-check-")); + const configPath = join(configDir, "config.json"); + const checkedAt = Date.now(); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, ".update-check"), + JSON.stringify({ latest: "9.9.9", checkedAt }), + ); + + const result = await runCLI( + ["--json", "--no-interactive", "update-check"], + { ALCHEMY_CONFIG: configPath }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(parseJSON(result.stdout)).toEqual({ + currentVersion: "0.2.0", + latestVersion: "9.9.9", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt, + }); + }); + + it("prints version for -v without falling through to the root action", async () => { + const result = await runCLI(["-v"]); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("0.2.0"); + }); + + it("returns INVALID_ARGS for unknown commands instead of setup fallback", async () => { + const result = await runCLI(["--json", "wat"]); + + expect(result.exitCode).toBe(2); + expect(parseJSON(result.stderr)).toMatchObject({ + error: { + code: "INVALID_ARGS", + message: "too many arguments. Expected 0 arguments but got 1.", + }, + }); }); it("bare no-interactive returns SETUP_REQUIRED with remediation data", async () => { diff --git a/tests/lib/update-check.test.ts b/tests/lib/update-check.test.ts new file mode 100644 index 0000000..50a531f --- /dev/null +++ b/tests/lib/update-check.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), +})); + +vi.mock("../src/lib/config.js", () => ({ + configPath: () => "/fake/.config/alchemy/config.json", +})); + +// Must be after mocks so the module picks them up. +const { getAvailableUpdate, getUpdateStatus, printUpdateNotice } = await import( + "../../src/lib/update-check.js" +); + +describe("getAvailableUpdate", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns latest version when npm reports a newer version (no cache)", () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.mocked(execFileSync).mockReturnValue("1.0.0\n"); + + // __CLI_VERSION__ is "0.2.0" from tsup define + const result = getAvailableUpdate(); + expect(result).toBe("1.0.0"); + expect(writeFileSync).toHaveBeenCalledOnce(); + }); + + it("returns null when current version matches latest", () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + // Return the same version as __CLI_VERSION__ + vi.mocked(execFileSync).mockReturnValue("0.0.0\n"); + + const result = getAvailableUpdate(); + expect(result).toBeNull(); + }); + + it("returns null when npm check fails", () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error("network error"); + }); + + const result = getAvailableUpdate(); + expect(result).toBeNull(); + }); + + it("uses cached value when cache is fresh", () => { + const cache = { latest: "9.9.9", checkedAt: Date.now() }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(cache)); + + const result = getAvailableUpdate(); + expect(result).toBe("9.9.9"); + // Should not call npm + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it("re-checks npm when cache is stale", () => { + const staleCache = { + latest: "0.1.0", + checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(staleCache)); + vi.mocked(execFileSync).mockReturnValue("2.0.0\n"); + + const result = getAvailableUpdate(); + expect(result).toBe("2.0.0"); + expect(execFileSync).toHaveBeenCalledOnce(); + }); + + it("returns null when cached version is not newer", () => { + // __CLI_VERSION__ defaults to "0.0.0" in test env (no tsup injection) + const cache = { latest: "0.0.0", checkedAt: Date.now() }; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(cache)); + + const result = getAvailableUpdate(); + expect(result).toBeNull(); + }); +}); + +describe("getUpdateStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns structured status from a fresh cache", () => { + const checkedAt = Date.now(); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ latest: "9.9.9", checkedAt }), + ); + + const result = getUpdateStatus(); + + expect(result).toEqual({ + currentVersion: "0.0.0", + latestVersion: "9.9.9", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt, + }); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it("falls back to the cached version when refresh fails", () => { + const checkedAt = Date.now() - 25 * 60 * 60 * 1000; + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ latest: "9.9.9", checkedAt }), + ); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error("network error"); + }); + + const result = getUpdateStatus(); + + expect(result).toEqual({ + currentVersion: "0.0.0", + latestVersion: "9.9.9", + updateAvailable: true, + installCommand: "npm i -g @alchemy/cli", + checkedAt, + }); + }); + + it("returns unknown latest version when no cache or refresh is available", () => { + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error("ENOENT"); + }); + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error("network error"); + }); + + const result = getUpdateStatus(); + + expect(result).toEqual({ + currentVersion: "0.0.0", + latestVersion: null, + updateAvailable: false, + installCommand: "npm i -g @alchemy/cli", + checkedAt: null, + }); + }); +}); + +describe("printUpdateNotice", () => { + it("writes update notice to stderr", () => { + const writeSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + printUpdateNotice("1.0.0"); + + expect(writeSpy).toHaveBeenCalledOnce(); + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain("Update available"); + expect(output).toContain("1.0.0"); + expect(output).toContain("npm i -g @alchemy/cli"); + + writeSpy.mockRestore(); + }); +});