diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 175d82f..303656b 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -54,6 +54,10 @@ Out of scope for the current beta: - Long flags use kebab-case. - Boolean negation uses `--no-`. - `--json` and non-interactive mode must not block on prompts. +- Automatic update checks are advisory and skipped in CI, `--json`, `--quiet`, + non-TTY stderr, and when `NO_UPDATE_NOTIFIER` is set. When shown, update + notifications are stderr-only human output and do not change the original + command result. - Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for Project -> Branch -> App resolution. `.prisma/local.json` is a gitignored local pin/cache, not a declarative repo config file. - Remote commands do not silently change local context. diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 9bcf4f4..3b40c12 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -18,6 +18,7 @@ Human-oriented stderr output may include: - command headers - progress - warnings +- automatic update notifications - help text - target context - final human-readable success or failure summaries @@ -45,6 +46,38 @@ Non-TTY or piped behavior: This keeps pipes, captures, and automation clean. +## Automatic Update Notifications + +The CLI may print an advisory update notification before normal command output +when all of these are true: + +- stderr is a TTY +- `--json` is not active +- `--quiet` is not active +- CI is not detected +- `NO_UPDATE_NOTIFIER` is not set +- cached update-check state already shows a newer official `@prisma/cli` + version + +The notification is human-only stderr output. It must never be written to +stdout, must not change the original command exit code, and must not block the +original command on network discovery. + +Recommended shape: + +```text +Update available: prisma-cli -> +Run to update. +``` + +When the CLI cannot confidently infer the install context, link to installation +docs instead of guessing a package-manager command: + +```text +Update available: prisma-cli -> +See https://www.prisma.io/docs/orm/tools/prisma-cli for update instructions. +``` + ## Human Output Human-facing output should follow `cli-style-guide.md` and optimize for: diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 76adf97..576b906 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -2,7 +2,14 @@ import process from "node:process"; import { runCli } from "./cli"; +import { runUpdateDiscoveryWorker } from "./shell/update-check"; -runCli().then((exitCode) => { - process.exitCode = exitCode; -}); +if (process.env.PRISMA_CLI_RUN_UPDATE_CHECK_WORKER === "1") { + runUpdateDiscoveryWorker().then(() => { + process.exitCode = 0; + }); +} else { + runCli().then((exitCode) => { + process.exitCode = exitCode; + }); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5a30677..e15b183 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -17,6 +17,7 @@ import { writeHumanError, writeJsonError, writeJsonSuccess } from "./shell/outpu import { disposePromptState } from "./shell/prompt"; import { configureRuntimeCommand, createCommandContext, type CliRuntime } from "./shell/runtime"; import { createShellUi } from "./shell/ui"; +import { maybeWriteCachedUpdateNotification } from "./shell/update-check"; export interface RunCliOptions extends Partial { argv?: string[]; @@ -28,6 +29,8 @@ export async function runCli(options: RunCliOptions = {}): Promise { process.exitCode = 0; try { + await maybeWriteCachedUpdateNotification(runtime); + if (runtime.argv.includes("--version")) { return await handleVersionFlag(runtime); } diff --git a/packages/cli/src/shell/update-check.ts b/packages/cli/src/shell/update-check.ts new file mode 100644 index 0000000..4afc6a7 --- /dev/null +++ b/packages/cli/src/shell/update-check.ts @@ -0,0 +1,399 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { getCliName, getCliVersion } from "../lib/version"; +import type { CliRuntime } from "./runtime"; + +const UPDATE_CHECK_FILE_NAME = "update-check.json"; +const FALLBACK_INSTALL_DOCS_URL = "https://www.prisma.io/docs/orm/tools/prisma-cli"; +const NOTIFICATION_INTERVAL_MS = 24 * 60 * 60 * 1000; +const REGISTRY_URL = "https://registry.npmjs.org/@prisma%2fcli"; +const REGISTRY_TIMEOUT_MS = 3_000; + +export interface UpdateCheckState { + packageName?: string; + installedVersion?: string; + latestVersion?: string; + checkedAt?: string; + notifiedAt?: string; +} + +interface UpdateInstruction { + type: "command" | "docs"; + value: string; +} + +export class UpdateCheckStore { + private readonly filePath: string; + + constructor(cacheDir: string) { + this.filePath = path.join(cacheDir, UPDATE_CHECK_FILE_NAME); + } + + async read(): Promise { + try { + return JSON.parse(await readFile(this.filePath, "utf8")) as UpdateCheckState; + } catch (error) { + if (isUnreadableCacheError(error)) { + return null; + } + + throw error; + } + } + + async write(state: UpdateCheckState): Promise { + const dir = path.dirname(this.filePath); + const tempPath = path.join(dir, `${UPDATE_CHECK_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`); + await mkdir(dir, { recursive: true }); + await writeFile(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + await rename(tempPath, this.filePath); + } +} + +export async function maybeWriteCachedUpdateNotification(runtime: CliRuntime): Promise { + if (!canRunUpdateCheck(runtime)) { + return; + } + + try { + const cacheDir = resolveUpdateCheckCacheDir(runtime); + const store = new UpdateCheckStore(cacheDir); + const state = await store.read(); + const latestVersion = state?.latestVersion; + + if (latestVersion && isInstalledVersionStale(getCliVersion(), latestVersion) && shouldNotify(state)) { + runtime.stderr.write(renderUpdateNotification(latestVersion, selectUpdateInstruction(runtime.env))); + await store.write({ + ...state, + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + notifiedAt: new Date().toISOString(), + }); + } + + await scheduleRemoteDiscovery(runtime, store, state, cacheDir); + } catch { + return; + } +} + +export async function runUpdateDiscovery(options: { + cacheDir: string; + installedVersion: string; + registryUrl?: string; + fetchImpl?: typeof fetch; + now?: Date; +}): Promise { + try { + const latestVersion = await fetchLatestVersion(options.registryUrl ?? REGISTRY_URL, options.fetchImpl ?? fetch); + if (!latestVersion) { + return; + } + + const store = new UpdateCheckStore(options.cacheDir); + const previousState = await store.read(); + await store.write({ + ...previousState, + packageName: "@prisma/cli", + installedVersion: options.installedVersion, + latestVersion, + checkedAt: (options.now ?? new Date()).toISOString(), + }); + } catch { + return; + } +} + +function isUnreadableCacheError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "EACCES" || code === "EPERM" || error instanceof SyntaxError; +} + +export async function runUpdateDiscoveryWorker(env: NodeJS.ProcessEnv = process.env): Promise { + const cacheDir = env.PRISMA_CLI_UPDATE_CHECK_DIR; + const installedVersion = env.PRISMA_CLI_UPDATE_CHECK_INSTALLED_VERSION; + + if (!cacheDir || !installedVersion) { + return; + } + + await runUpdateDiscovery({ + cacheDir, + installedVersion, + registryUrl: env.PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL, + }); +} + +function canRunUpdateCheck(runtime: CliRuntime): boolean { + if (runtime.env.NO_UPDATE_NOTIFIER !== undefined) { + return false; + } + + if (isTestRuntime(runtime.env) && runtime.env.PRISMA_CLI_TEST_ENABLE_UPDATE_CHECK !== "1") { + return false; + } + + if (runtime.env.CI || runtime.env.GITHUB_ACTIONS) { + return false; + } + + if (!runtime.stderr.isTTY) { + return false; + } + + if (runtime.argv.includes("--json") || runtime.argv.includes("--quiet") || runtime.argv.includes("-q")) { + return false; + } + + if (runtime.argv.includes("--version")) { + return false; + } + + return true; +} + +function shouldNotify(state: UpdateCheckState): boolean { + return !state.notifiedAt || isAtLeastIntervalAgo(state.notifiedAt); +} + +async function scheduleRemoteDiscovery( + runtime: CliRuntime, + store: UpdateCheckStore, + state: UpdateCheckState | null, + cacheDir: string, +): Promise { + if (state?.checkedAt && !isAtLeastIntervalAgo(state.checkedAt)) { + return; + } + + const checkedAt = new Date().toISOString(); + await store.write({ + ...state, + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + checkedAt, + }); + + if (isTestRuntime(runtime.env)) { + return; + } + + const entrypoint = process.argv[1]; + if (!entrypoint) { + return; + } + + const child = spawn(process.execPath, [entrypoint], { + detached: true, + stdio: "ignore", + env: { + ...process.env, + PRISMA_CLI_RUN_UPDATE_CHECK_WORKER: "1", + PRISMA_CLI_UPDATE_CHECK_DIR: cacheDir, + PRISMA_CLI_UPDATE_CHECK_INSTALLED_VERSION: getCliVersion(), + PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL: + runtime.env.PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL ?? REGISTRY_URL, + }, + }); + child.unref(); +} + +export function selectUpdateInstruction( + env: NodeJS.ProcessEnv, + processArgv: readonly string[] = process.argv, +): UpdateInstruction { + const entrypoint = (processArgv[1] ?? "").replace(/\\/g, "/").toLowerCase(); + const userAgent = env.npm_config_user_agent?.toLowerCase() ?? ""; + const lifecycle = env.npm_lifecycle_event?.toLowerCase() ?? ""; + + if (isEphemeralInvocation(entrypoint, lifecycle)) { + return docsInstruction(); + } + + if (entrypoint.includes("/node_modules/.bin/")) { + if (userAgent.startsWith("pnpm")) { + return commandInstruction("pnpm add -D @prisma/cli@latest"); + } + + if (userAgent.startsWith("bun")) { + return commandInstruction("bun add -d @prisma/cli@latest"); + } + + if (userAgent.startsWith("npm")) { + return commandInstruction("npm install --save-dev @prisma/cli@latest"); + } + } + + if (env.npm_config_global === "true" || isLikelyGlobalNpmEntrypoint(entrypoint)) { + return commandInstruction("npm install --global @prisma/cli@latest"); + } + + return docsInstruction(); +} + +function renderUpdateNotification(latestVersion: string, instruction: UpdateInstruction): string { + return [ + `Update available: ${getCliName()} ${getCliVersion()} -> ${latestVersion}`, + renderUpdateInstruction(instruction), + "", + ].join("\n"); +} + +function renderUpdateInstruction(instruction: UpdateInstruction): string { + if (instruction.type === "command") { + return `Run ${instruction.value} to update.`; + } + + return `See ${instruction.value} for update instructions.`; +} + +function isEphemeralInvocation(entrypoint: string, lifecycle: string): boolean { + return lifecycle === "npx" || lifecycle === "pnpx" || entrypoint.includes("/_npx/") || entrypoint.includes("/.bun/"); +} + +function isLikelyGlobalNpmEntrypoint(entrypoint: string): boolean { + return /\/npm\/prisma-cli(\.cmd|\.exe)?$/.test(entrypoint) || /\/npm-global\/bin\/prisma-cli$/.test(entrypoint); +} + +function commandInstruction(value: string): UpdateInstruction { + return { type: "command", value }; +} + +function docsInstruction(): UpdateInstruction { + return { type: "docs", value: FALLBACK_INSTALL_DOCS_URL }; +} + +function resolveUpdateCheckCacheDir(runtime: CliRuntime): string { + const configured = runtime.env.PRISMA_CLI_UPDATE_CHECK_DIR; + if (configured?.trim()) { + return path.resolve(configured); + } + + if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Caches", "prisma-cli"); + } + + if (process.platform === "win32") { + const localAppData = runtime.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"); + return path.join(localAppData, "prisma-cli", "cache"); + } + + const xdgCacheHome = runtime.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache"); + return path.join(xdgCacheHome, "prisma-cli"); +} + +function isTestRuntime(env: NodeJS.ProcessEnv): boolean { + return env.VITEST !== undefined || env.NODE_ENV === "test"; +} + +function isAtLeastIntervalAgo(value: string): boolean { + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) || Date.now() - timestamp >= NOTIFICATION_INTERVAL_MS; +} + +function isInstalledVersionStale(installedVersion: string, latestVersion: string): boolean { + const installed = parseVersion(installedVersion); + const latest = parseVersion(latestVersion); + + if (!installed || !latest) { + return false; + } + + return compareVersions(installed, latest) < 0; +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease: string[]; +} + +function parseVersion(version: string): ParsedVersion | null { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version); + if (!match) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4]?.split(".") ?? [], + }; +} + +function compareVersions(left: ParsedVersion, right: ParsedVersion): number { + for (const key of ["major", "minor", "patch"] as const) { + const diff = left[key] - right[key]; + if (diff !== 0) { + return diff; + } + } + + return comparePrerelease(left.prerelease, right.prerelease); +} + +function comparePrerelease(left: string[], right: string[]): number { + if (left.length === 0 && right.length === 0) return 0; + if (left.length === 0) return 1; + if (right.length === 0) return -1; + + const count = Math.max(left.length, right.length); + for (let index = 0; index < count; index += 1) { + const leftPart = left[index]; + const rightPart = right[index]; + + if (leftPart === undefined) return -1; + if (rightPart === undefined) return 1; + + const diff = comparePrereleasePart(leftPart, rightPart); + if (diff !== 0) { + return diff; + } + } + + return 0; +} + +function comparePrereleasePart(left: string, right: string): number { + const leftNumber = /^\d+$/.test(left) ? Number(left) : null; + const rightNumber = /^\d+$/.test(right) ? Number(right) : null; + + if (leftNumber !== null && rightNumber !== null) { + return leftNumber - rightNumber; + } + + if (leftNumber !== null) return -1; + if (rightNumber !== null) return 1; + + return left.localeCompare(right); +} + +async function fetchLatestVersion(registryUrl: string, fetchImpl: typeof fetch): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); + + try { + const response = await fetchImpl(registryUrl, { + signal: controller.signal, + headers: { + accept: "application/json", + }, + }); + + if (!response.ok) { + return null; + } + + const metadata = await response.json() as { "dist-tags"?: { latest?: unknown } }; + const latest = metadata["dist-tags"]?.latest; + return typeof latest === "string" ? latest : null; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts new file mode 100644 index 0000000..e5dd80b --- /dev/null +++ b/packages/cli/tests/update-check.test.ts @@ -0,0 +1,367 @@ +import path from "node:path"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +import { getCliVersion } from "../src/lib/version"; +import { runUpdateDiscovery, selectUpdateInstruction, UpdateCheckStore } from "../src/shell/update-check"; +import { createTempCwd, executeCli } from "./helpers"; + +const fixturePath = path.resolve("fixtures/mock-api.json"); + +describe("automatic update check", () => { + it("prints a cached update notice to stderr before eligible command output", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + + const result = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`Update available: prisma-cli ${getCliVersion()} -> ${nextMajorVersion()}`); + expect(result.stderr).toContain("See https://www.prisma.io/docs/orm/tools/prisma-cli for update instructions."); + expect(result.stderr.indexOf("Update available")).toBeLessThan(result.stderr.indexOf("auth whoami")); + }); + + it("continues with the original command result when the command fails", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + + const result = await executeCli({ + argv: ["project", "show", "--no-interactive"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Update available:"); + expect(result.stderr).toContain("[AUTH_REQUIRED]"); + }); + + it("does not write update notices to stdout for JSON output", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + + const result = await executeCli({ + argv: ["--json", "auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).not.toContain("Update available"); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "auth.whoami", + }); + }); + + it.each([ + { name: "quiet mode", argv: ["auth", "whoami", "--quiet"], env: {}, isTTY: true, preserveCI: false }, + { name: "CI", argv: ["auth", "whoami"], env: { CI: "1" }, isTTY: true, preserveCI: true }, + { name: "non-TTY", argv: ["auth", "whoami"], env: {}, isTTY: false, preserveCI: false }, + { name: "opt-out", argv: ["auth", "whoami"], env: { NO_UPDATE_NOTIFIER: "1" }, isTTY: true, preserveCI: false }, + { name: "version flag", argv: ["--version"], env: {}, isTTY: true, preserveCI: false }, + ])("suppresses cached update notices for $name", async ({ argv, env, isTTY, preserveCI }) => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + + const result = await executeCli({ + argv, + cwd, + stateDir, + fixturePath, + isTTY, + preserveCI, + env: { + ...enableUpdateCheck(updateCheckDir), + ...env, + }, + }); + + expect(result.stderr).not.toContain("Update available"); + expect(result.stdout).not.toContain("Update available"); + }); + + it("does not read update check state from project-local CLI state", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + + const result = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect(result.stderr).toContain("Update available"); + await expect(access(path.join(stateDir, "update-check.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("continues with the original command result when cached update state is unreadable", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await mkdir(updateCheckDir, { recursive: true }); + await writeFile(path.join(updateCheckDir, "update-check.json"), "{not json", "utf8"); + + const result = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("SyntaxError"); + expect(result.stderr).toContain("auth whoami"); + expect(await readUpdateCheckState(updateCheckDir)).toMatchObject({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + }); + }); + + it("does not show the same cached update notice again inside the notification interval", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + await seedStaleUpdate(updateCheckDir); + const env = enableUpdateCheck(updateCheckDir); + + const first = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env, + }); + const second = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env, + }); + + expect(first.stderr).toContain("Update available"); + expect(second.stderr).not.toContain("Update available"); + }); + + it("records a remote discovery attempt without printing a notice in the same invocation", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + + const result = await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + const state = await readUpdateCheckState(updateCheckDir); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("Update available"); + expect(state).toMatchObject({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + }); + expect(state.checkedAt).toEqual(expect.any(String)); + }); + + it("does not record remote discovery attempts for suppressed invocations", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + + await executeCli({ + argv: ["--json", "auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + await expect(access(path.join(updateCheckDir, "update-check.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("skips remote discovery attempts inside the 24-hour interval", async () => { + const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); + const checkedAt = new Date().toISOString(); + await new UpdateCheckStore(updateCheckDir).write({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + checkedAt, + }); + + await executeCli({ + argv: ["auth", "whoami"], + cwd, + stateDir, + fixturePath, + isTTY: true, + env: enableUpdateCheck(updateCheckDir), + }); + + expect((await readUpdateCheckState(updateCheckDir)).checkedAt).toBe(checkedAt); + }); + + it("persists successful remote discovery results from injected registry metadata", async () => { + const { updateCheckDir } = await createUpdateCheckTestDirs(); + + await runUpdateDiscovery({ + cacheDir: updateCheckDir, + installedVersion: getCliVersion(), + now: new Date("2026-01-02T00:00:00.000Z"), + fetchImpl: async () => new Response(JSON.stringify({ "dist-tags": { latest: "9.8.7" } })), + }); + + expect(await readUpdateCheckState(updateCheckDir)).toMatchObject({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + latestVersion: "9.8.7", + checkedAt: "2026-01-02T00:00:00.000Z", + }); + }); + + it("preserves notification throttling when remote discovery succeeds", async () => { + const { updateCheckDir } = await createUpdateCheckTestDirs(); + await new UpdateCheckStore(updateCheckDir).write({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + latestVersion: "9.8.6", + checkedAt: "2026-01-01T00:00:00.000Z", + notifiedAt: "2026-01-01T01:00:00.000Z", + }); + + await runUpdateDiscovery({ + cacheDir: updateCheckDir, + installedVersion: getCliVersion(), + now: new Date("2026-01-02T00:00:00.000Z"), + fetchImpl: async () => new Response(JSON.stringify({ "dist-tags": { latest: "9.8.7" } })), + }); + + expect(await readUpdateCheckState(updateCheckDir)).toMatchObject({ + latestVersion: "9.8.7", + checkedAt: "2026-01-02T00:00:00.000Z", + notifiedAt: "2026-01-01T01:00:00.000Z", + }); + }); + + it("ignores failed remote discovery without surfacing errors", async () => { + const { updateCheckDir } = await createUpdateCheckTestDirs(); + + await expect( + runUpdateDiscovery({ + cacheDir: updateCheckDir, + installedVersion: getCliVersion(), + fetchImpl: async () => { + throw new Error("network down"); + }, + }), + ).resolves.toBeUndefined(); + await expect(access(path.join(updateCheckDir, "update-check.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it.each([ + { + name: "local npm", + env: { npm_config_user_agent: "npm/10.9.0 node/v24.14.1 darwin arm64" }, + argv: ["node", "/repo/node_modules/.bin/prisma-cli"], + expected: { type: "command", value: "npm install --save-dev @prisma/cli@latest" }, + }, + { + name: "global npm", + env: { npm_config_user_agent: "npm/10.9.0 node/v24.14.1 darwin arm64", npm_config_global: "true" }, + argv: ["node", "/usr/local/bin/prisma-cli"], + expected: { type: "command", value: "npm install --global @prisma/cli@latest" }, + }, + { + name: "local pnpm", + env: { npm_config_user_agent: "pnpm/10.30.0 npm/? node/v24.14.1 darwin arm64" }, + argv: ["node", "/repo/node_modules/.bin/prisma-cli"], + expected: { type: "command", value: "pnpm add -D @prisma/cli@latest" }, + }, + { + name: "local bun", + env: { npm_config_user_agent: "bun/1.3.0 npm/? node/v24.14.1 darwin arm64" }, + argv: ["node", "/repo/node_modules/.bin/prisma-cli"], + expected: { type: "command", value: "bun add -d @prisma/cli@latest" }, + }, + { + name: "npx", + env: { npm_lifecycle_event: "npx" }, + argv: ["node", "/Users/alice/.npm/_npx/123/node_modules/.bin/prisma-cli"], + expected: { type: "docs", value: "https://www.prisma.io/docs/orm/tools/prisma-cli" }, + }, + { + name: "pnpx", + env: { npm_lifecycle_event: "pnpx", npm_config_user_agent: "pnpm/10.30.0" }, + argv: ["node", "/repo/node_modules/.bin/prisma-cli"], + expected: { type: "docs", value: "https://www.prisma.io/docs/orm/tools/prisma-cli" }, + }, + { + name: "bunx", + env: { npm_config_user_agent: "bun/1.3.0" }, + argv: ["node", "/Users/alice/.bun/install/cache/@prisma/cli/prisma-cli"], + expected: { type: "docs", value: "https://www.prisma.io/docs/orm/tools/prisma-cli" }, + }, + { + name: "unknown", + env: {}, + argv: ["node", "/some/path/prisma-cli"], + expected: { type: "docs", value: "https://www.prisma.io/docs/orm/tools/prisma-cli" }, + }, + ])("selects update instructions for $name", ({ env, argv, expected }) => { + expect(selectUpdateInstruction(env, argv)).toEqual(expected); + }); +}); + +async function createUpdateCheckTestDirs() { + const cwd = await createTempCwd(); + return { + cwd, + stateDir: path.join(cwd, ".state"), + updateCheckDir: path.join(cwd, ".update-check"), + }; +} + +function enableUpdateCheck(updateCheckDir: string): NodeJS.ProcessEnv { + return { + PRISMA_CLI_TEST_ENABLE_UPDATE_CHECK: "1", + PRISMA_CLI_UPDATE_CHECK_DIR: updateCheckDir, + }; +} + +async function seedStaleUpdate(updateCheckDir: string): Promise { + await new UpdateCheckStore(updateCheckDir).write({ + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + latestVersion: nextMajorVersion(), + checkedAt: new Date().toISOString(), + }); +} + +async function readUpdateCheckState(updateCheckDir: string) { + return JSON.parse(await readFile(path.join(updateCheckDir, "update-check.json"), "utf8")) as Record; +} + +function nextMajorVersion(): string { + const [major] = getCliVersion().split("."); + return `${Number(major) + 1}.0.0`; +}