From 550a47bd0e00b84ffd8e08377f03eb7bc01db022 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 07:52:46 -0400 Subject: [PATCH 1/8] Add automatic update check plan --- docs/specs/automatic-update-check.plan.md | 155 ++++++++++++++++++++++ docs/specs/automatic-update-check.spec.md | 136 +++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100755 docs/specs/automatic-update-check.plan.md create mode 100755 docs/specs/automatic-update-check.spec.md diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md new file mode 100755 index 0000000..cfeadcc --- /dev/null +++ b/docs/specs/automatic-update-check.plan.md @@ -0,0 +1,155 @@ +# Automatic Update Check Plan + +## Assumptions + +**A1** The plan implements the behavior from `docs/specs/automatic-update-check.spec.md`. + +**A2** Update-check state should live in a user-level CLI cache, not in the repo-local `.prisma/cli/state.json`, because the check is package-scoped rather than project-scoped. Tests should be able to override this cache location through runtime/env plumbing. + +**A3** The first implementation should use Node 20's built-in `fetch` and a small internal adapter rather than adding an update-notifier dependency. The behavior needed here is narrow, and keeping the public package dependency set smaller reduces packaging and supply-chain surface. + +**A4** Version comparison should handle normal semver prerelease ordering for official beta and dev versions. If the installed version cannot be compared safely, the CLI should skip notification rather than guessing. + +**A5** Background discovery may be abandoned when a short-lived process exits. That is acceptable because the update check is advisory and will be retried after the next eligible invocation. + +**A6** `.agents/projects` is unavailable in this worktree, so this plan is stored next to the spec under `docs/specs`. + +## Open Questions + +None. + +## Phases + +### Phase 1 - Cached Notification Slice + +**Status** ☐ Not started + +**Goal** Add the shell-level update-check model and render a notification from cached state without doing remote network discovery yet. This proves stream behavior, eligibility rules, state isolation, and command-continuation behavior end to end. + +**Requirements** FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR11, FR13, FR15, FR18, FR19, FR20, FR21, NFR3, NFR4, NFR5, NFR6, NFR7 + +**Changes** + +**P1.1** Add update-check domain types and helpers under `packages/cli/src/lib` or `packages/cli/src/shell`, including cached-state shape, eligibility decisions, notification rendering, and safe version comparison. + +**P1.2** Add a user-level update-check state adapter separate from `LocalStateStore`. It should persist only package name, installed version, latest known version, last check timestamp, last notification timestamp or equivalent suppression data, and recommendation metadata that is safe to store. + +**P1.3** Add shell integration in `packages/cli/src/cli.ts` so cached stale-version information can print before command execution when the invocation is eligible. + +**P1.4** Use the safe fallback docs instruction for this thin slice, including the temporary `https://prisma.io/docs` URL and the required code TODO. + +**P1.5** Keep all notification output on stderr and suppress it for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and test mode. + +**P1.6** Add focused tests around cached notification behavior in the CLI test suite, using test-controlled state and TTY settings. + +**Acceptance Criteria** + +**AC1.1** A cached stale-version record prints exactly one concise update notice to stderr before an eligible command's human output, and the original command still exits with its normal exit code. + +**AC1.2** The same cached state produces no stdout changes. + +**AC1.3** Notification is suppressed in `--json`, `--quiet`, CI, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. + +**AC1.4** The update-check state file is outside project-local `.prisma/local.json` and outside the repo-local CLI state file. + +**AC1.5** The fallback notification includes `https://prisma.io/docs` and no package-manager-specific command guess. + +**AC1.6** Tests cover successful command continuation, error command continuation, and at least one early root utility path such as `--version` or help. + +### Phase 2 - Background Remote Discovery + +**Status** ☐ Not started + +**Goal** Add 24-hour npm `latest` discovery that runs opportunistically without blocking the original command. + +**Requirements** FR1, FR10, FR11, FR12, FR14, FR16, NFR1, NFR2, NFR7, NFR8 + +**Changes** + +**P2.1** Add a registry client that retrieves the `latest` dist-tag for `@prisma/cli` from npm and returns a typed result without leaking raw network errors into command output. + +**P2.2** Add interval eligibility based on a fixed 24-hour constant. Do not expose a flag or configuration setting for this interval. + +**P2.3** Start remote discovery after cached notification eligibility is evaluated, racing it against normal command execution without delaying command handlers or holding the process open for slow network responses. + +**P2.4** Persist successful discovery results for later invocations. Treat DNS failures, blocked registries, invalid registry responses, and timeouts as silent best-effort failures. + +**P2.5** Add concurrency-tolerant state writes so overlapping CLI invocations do not corrupt the update-check cache or repeatedly notify in normal use. + +**Acceptance Criteria** + +**AC2.1** When the interval has elapsed, the CLI attempts remote discovery at most once per 24 hours per user/package identity. + +**AC2.2** A slow or failing registry lookup does not delay command output, does not change the command exit code, and emits no warning or error. + +**AC2.3** A successful lookup for a newer latest version is persisted and becomes visible as a notification on a later eligible invocation. + +**AC2.4** Network discovery is skipped for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. + +**AC2.5** Tests use injected or stubbed registry behavior; they do not reach the real npm registry. + +### Phase 3 - Install Context Recommendations + +**Status** ☐ Not started + +**Goal** Add best-effort recommendations that match the detected invocation or install context, while preserving the safe docs fallback. + +**Requirements** FR3, FR17, FR18, FR19, NFR4, NFR6 + +**Changes** + +**P3.1** Extend the existing invocation detection in `packages/cli/src/lib/version.ts` or a shared helper so update notices can reuse runtime signals without duplicating classification logic. + +**P3.2** Add recommendation selection for confidently detected local npm, global npm, local pnpm, and local Bun installs. + +**P3.3** Treat `npx`, `pnpx`, `bunx`, development, PR-preview, and ambiguous invocations conservatively. Use rerun guidance or fallback docs instead of telling the user to mutate a persistent install that may not exist. + +**P3.4** Use `https://prisma.io/docs` as the temporary fallback docs URL and leave a code TODO beside the constant so it is replaced when the canonical CLI installation page exists. + +**P3.5** Add tests covering npm, pnpm, Bun, global, ephemeral, and unknown invocation signals. + +**Acceptance Criteria** + +**AC3.1** Eligible local npm usage recommends `npm install --save-dev @prisma/cli@latest`. + +**AC3.2** Eligible confidently detected global npm usage recommends `npm install --global @prisma/cli@latest`. + +**AC3.3** Eligible local pnpm and Bun usage recommend package-manager-appropriate dev-dependency update commands. + +**AC3.4** Ambiguous, ephemeral, dev, and PR-preview invocations do not receive misleading persistent-install commands. + +**AC3.5** Fallback notification copy includes `https://prisma.io/docs` and no command-specific guess. + +### Phase 4 - Packaging, Documentation, and Verification + +**Status** ☐ Not started + +**Goal** Lock down package contents, public docs, and regression coverage so the update check ships without breaking automation, publishing, or the existing version command contract. + +**Requirements** FR5, FR6, FR7, FR8, FR9, FR12, FR20, FR21, NFR2, NFR3, NFR8 + +**Changes** + +**P4.1** Update product docs if implementation introduces user-visible behavior beyond this planning spec, especially `docs/product/output-conventions.md`, `docs/product/cli-style-guide.md`, or `docs/product/command-spec.md`. + +**P4.2** Update package README/support docs only if the fallback installation guidance needs to be discoverable before the stable CLI installation docs exist. + +**P4.3** Update publish-prep tests if any runtime dependency, bundled file, or manifest field changes. + +**P4.4** Add or update end-to-end CLI tests for stdout stability, JSON stability, `--version`, `version --json`, help output, CI suppression, quiet suppression, and non-TTY suppression. + +**P4.5** Run the relevant verification commands for CLI behavior, packaging, and build output. + +**Acceptance Criteria** + +**AC4.1** `pnpm --filter @prisma/cli test` passes. + +**AC4.2** `pnpm build:cli` passes. + +**AC4.3** `pnpm prepare:cli-publish` passes if package contents or dependencies changed. + +**AC4.4** `prisma-cli --version` and `prisma-cli version --json` remain stdout-stable and do not include update notices. + +**AC4.5** The final implementation still satisfies every out-of-scope boundary: no self-update, no stale-version blocking, no telemetry, no new update command, and no change to version command semantics. + +## Revision log diff --git a/docs/specs/automatic-update-check.spec.md b/docs/specs/automatic-update-check.spec.md new file mode 100755 index 0000000..af98f4b --- /dev/null +++ b/docs/specs/automatic-update-check.spec.md @@ -0,0 +1,136 @@ +# Automatic Update Check Spec + +## Problem + +The Prisma CLI beta changes quickly. Users who keep an older installed version can miss bug fixes, command behavior updates, and deploy workflow improvements, then spend time debugging issues already fixed in newer releases. + +Success means interactive CLI users learn about newer official releases without the original command failing, slowing materially, or polluting machine-readable output. + +The case against this work is that automatic network checks can feel noisy, can make a CLI look less deterministic, and can create privacy or enterprise-policy concerns. This spec limits the behavior to occasional advisory checks that are skipped in automation and can be disabled by environment configuration. + +## Stakeholders + +**S1** Primary interactive CLI users need a low-friction reminder when their installed CLI is stale, plus one clear update command. + +**S2** CI, scripts, agents, and other automation need stable stdout, concise stderr, no prompts, and no surprise network dependency. + +**S3** Prisma CLI maintainers need users to converge on supported beta builds without turning update discovery into a command-specific concern. + +**S4** Support and product teams need bug reports and feedback to come from reasonably current CLI builds where possible. + +## Functional requirements + +**FR1** The CLI checks for newer official `@prisma/cli` releases automatically during normal CLI use. + +**FR2** The automatic check is advisory only: it must never update the CLI, mutate the user's project, mutate remote Prisma resources, or require confirmation. + +**FR3** When a newer official release is known, the CLI prints one concise human-readable notification that includes the installed version, the latest version, and a best-effort update instruction. + +**FR4** After printing an update notification, the CLI continues the originally requested command with the same behavior and exit code it would have had without the update notification. + +**FR5** Update notifications are written to stderr only. They must never be written to stdout. + +**FR6** The automatic check and notification are skipped when `CI` is set or the runtime otherwise identifies a CI environment. + +**FR7** The automatic check and notification are skipped in `--json` mode. + +**FR8** The automatic notification is skipped in `--quiet` mode. + +**FR9** The automatic notification is skipped when stderr is not a TTY. + +**FR10** The CLI checks the remote package source at most once every 24 hours per user and package identity. + +**FR11** The 24-hour check interval is a fixed product constant, not a user-facing configuration setting. + +**FR12** A failed update check does not print an error or warning, does not change the original command result, and does not alter the command exit code. + +**FR13** The CLI remembers enough local update-check state to avoid repeated remote checks and repeated notifications inside the interval. + +**FR14** When the 24-hour interval has elapsed, remote update discovery runs opportunistically in the background and does not block the originally requested command. + +**FR15** Users can disable automatic update checks with `NO_UPDATE_NOTIFIER`. + +**FR16** The latest version source is the npm `latest` dist-tag for `@prisma/cli`, matching the official beta package channel. + +**FR17** The notification recommends an update instruction that matches the detected invocation or install context when the CLI can infer one confidently: + +```text +Update available: prisma-cli -> +Run to update. +``` + +**FR18** When the CLI cannot confidently infer the invocation or install context, the notification links the user to the package installation docs instead of guessing a package-manager-specific command. + +**FR19** Until a stable package-installation docs URL is defined, fallback notifications use `https://prisma.io/docs`. Implementation should leave a TODO next to this URL so it is replaced when the canonical CLI installation page exists. + +**FR20** The update check does not run for unit tests by default. + +**FR21** The richer `prisma-cli version` command remains the canonical way to inspect the installed CLI build and host environment; the update notification does not replace or change that command's structured result. + +## Non-functional requirements + +**NFR1** The original command must not wait on remote update discovery. Local update-check eligibility and cached-notification bookkeeping should be fast enough to be unnoticeable to interactive users. + +**NFR2** The update check must be best-effort and network-failure tolerant. Offline users, blocked registries, DNS failures, and registry errors must be silent. + +**NFR3** Machine-readable output remains stable. `--json` stdout schemas, `--version` stdout, and command result envelopes are unchanged. + +**NFR4** The notification follows the CLI style guide: concise text, no emoji, no banner, no prompt, and no color-dependent meaning. + +**NFR5** The local update-check state must not be written into the user's project directory, committed repo files, or `.prisma/local.json` project context. + +**NFR6** The local update-check state must not store secrets, auth tokens, project identifiers, branch names, app names, command arguments, working-directory paths, or package-manager-specific install paths. + +**NFR7** Concurrent CLI invocations must not corrupt the local update-check state or produce multiple notifications in normal terminal use. + +**NFR8** The check should align with established Node CLI update-notifier conventions: interval-based checks, persisted local state, CI/test suppression, TTY-only notification, and env-var opt-out. + +## Assumptions + +**A1** Official update discovery should use `@prisma/cli` on npm, not GitHub releases, because ADR 0001 defines npm publishing and the `latest` dist-tag as the official beta release channel. + +**A2** The first version does not add a global `--no-update-notifier` flag. `NO_UPDATE_NOTIFIER` is the only user-facing opt-out for this slice. + +**A3** Installation context detection is best-effort. The CLI may infer package-manager and invocation hints from runtime signals such as npm user agent, executable path, package-manager environment variables, and the existing `version` command's invocation detection, but it must not claim certainty when those signals are ambiguous. + +**A4** The notice should recommend a command only when it is likely to be correct for the current invocation. Examples include `npm install --save-dev @prisma/cli@latest` for local npm usage, `npm install --global @prisma/cli@latest` for confidently detected global npm usage, `pnpm add -D @prisma/cli@latest` for local pnpm usage, and `bun add -d @prisma/cli@latest` for local Bun usage. + +**A5** Ephemeral invocations such as `npx`, `pnpx`, and `bunx` should not be told to update an installed package unless the CLI can identify an actual persistent install. They should receive a rerun or docs-oriented instruction instead. + +**A6** The notice should be shown before the original command's human output only when stale-version information is already known locally. If the current invocation only discovers the new version in the background, the first notification can wait until a later invocation. + +**A7** Development, test, and PR-preview package builds should not notify users to update unless they are installed as an official npm package with a version older than the `latest` dist-tag. + +**A8** The standard convention research basis is the widely used Node `update-notifier` behavior: daily interval-based checks, async remote checks, persisted result, CI/test suppression, TTY-only notification, and `NO_UPDATE_NOTIFIER` opt-out. + +## Downstream effects + +**DE1** Packaging and publish-prep tests need coverage for any new bundled files or runtime dependencies introduced by the check. + +**DE2** CLI entrypoint tests need to assert stream behavior: no stdout changes, no JSON-mode notification, no CI notification, and no quiet/non-TTY notification. + +**DE3** Support docs may need to mention that users can run `prisma-cli version` and follow the package installation docs for the update command that matches their package manager and install mode. + +**DE4** Enterprise users with restricted registry access may see silent skipped checks. That is acceptable; update discovery is advisory and must not become a hard dependency. + +**DE5** If the CLI later exposes a stable `prisma` binary instead of `prisma-cli`, the notification copy and recommended command need to follow the then-current package docs. + +## Out of scope + +**OS1** Automatically installing or self-updating the CLI. + +**OS2** Blocking commands when the installed CLI is stale. + +**OS3** Checking for updates to Prisma ORM, app dependencies, agent skills, or project packages. + +**OS4** Adding a new user-facing `update`, `upgrade`, or `doctor` command. + +**OS5** Adding release notes, changelog rendering, or migration guidance to the notification. + +**OS6** Telemetry or analytics for update-check impressions. + +**OS7** Changing the semantics of `prisma-cli version` or `prisma-cli --version`. + +## Open questions + +None. From 5e7c72a6c5ee260c3c13e6ba8966166398e0c321 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 09:15:24 -0400 Subject: [PATCH 2/8] Add cached update check notification Verification: pnpm build:cli passed. TMPDIR=/tmp pnpm --filter @prisma/cli exec vitest run tests/update-check.test.ts --testTimeout 30000 passed. Full CLI suite with TMPDIR=/tmp and --testTimeout 30000 still fails in unrelated app/env/auth/project tests in this worktree; update-check.test.ts passes. --- docs/specs/automatic-update-check.plan.md | 14 +- docs/specs/automatic-update-check.spec.md | 0 packages/cli/src/cli.ts | 3 + packages/cli/src/shell/update-check.ts | 207 ++++++++++++++++++++++ packages/cli/tests/update-check.test.ts | 172 ++++++++++++++++++ 5 files changed, 389 insertions(+), 7 deletions(-) mode change 100755 => 100644 docs/specs/automatic-update-check.plan.md mode change 100755 => 100644 docs/specs/automatic-update-check.spec.md create mode 100644 packages/cli/src/shell/update-check.ts create mode 100644 packages/cli/tests/update-check.test.ts diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md old mode 100755 new mode 100644 index cfeadcc..933a253 --- a/docs/specs/automatic-update-check.plan.md +++ b/docs/specs/automatic-update-check.plan.md @@ -22,7 +22,7 @@ None. ### Phase 1 - Cached Notification Slice -**Status** ☐ Not started +**Status** ✓ Complete **Goal** Add the shell-level update-check model and render a notification from cached state without doing remote network discovery yet. This proves stream behavior, eligibility rules, state isolation, and command-continuation behavior end to end. @@ -44,17 +44,17 @@ None. **Acceptance Criteria** -**AC1.1** A cached stale-version record prints exactly one concise update notice to stderr before an eligible command's human output, and the original command still exits with its normal exit code. +**AC1.1** [x] A cached stale-version record prints exactly one concise update notice to stderr before an eligible command's human output, and the original command still exits with its normal exit code. -**AC1.2** The same cached state produces no stdout changes. +**AC1.2** [x] The same cached state produces no stdout changes. -**AC1.3** Notification is suppressed in `--json`, `--quiet`, CI, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. +**AC1.3** [x] Notification is suppressed in `--json`, `--quiet`, CI, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. -**AC1.4** The update-check state file is outside project-local `.prisma/local.json` and outside the repo-local CLI state file. +**AC1.4** [x] The update-check state file is outside project-local `.prisma/local.json` and outside the repo-local CLI state file. -**AC1.5** The fallback notification includes `https://prisma.io/docs` and no package-manager-specific command guess. +**AC1.5** [x] The fallback notification includes `https://prisma.io/docs` and no package-manager-specific command guess. -**AC1.6** Tests cover successful command continuation, error command continuation, and at least one early root utility path such as `--version` or help. +**AC1.6** [x] Tests cover successful command continuation, error command continuation, and at least one early root utility path such as `--version` or help. ### Phase 2 - Background Remote Discovery diff --git a/docs/specs/automatic-update-check.spec.md b/docs/specs/automatic-update-check.spec.md old mode 100755 new mode 100644 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..39ae8d7 --- /dev/null +++ b/packages/cli/src/shell/update-check.ts @@ -0,0 +1,207 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +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://prisma.io/docs"; // TODO: replace with the canonical CLI installation docs URL. +const NOTIFICATION_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export interface UpdateCheckState { + packageName?: string; + installedVersion?: string; + latestVersion?: string; + checkedAt?: string; + notifiedAt?: 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 ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + + throw error; + } + } + + async write(state: UpdateCheckState): Promise { + await mkdir(path.dirname(this.filePath), { recursive: true }); + await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + } +} + +export async function maybeWriteCachedUpdateNotification(runtime: CliRuntime): Promise { + if (!canShowUpdateNotification(runtime)) { + return; + } + + const store = new UpdateCheckStore(resolveUpdateCheckCacheDir(runtime)); + const state = await store.read(); + const latestVersion = state?.latestVersion; + + if (!latestVersion || !isInstalledVersionStale(getCliVersion(), latestVersion)) { + return; + } + + if (state.notifiedAt && Date.now() - Date.parse(state.notifiedAt) < NOTIFICATION_INTERVAL_MS) { + return; + } + + runtime.stderr.write(renderUpdateNotification(latestVersion)); + await store.write({ + ...state, + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + notifiedAt: new Date().toISOString(), + }); +} + +function canShowUpdateNotification(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 renderUpdateNotification(latestVersion: string): string { + return [ + `Update available: ${getCliName()} ${getCliVersion()} -> ${latestVersion}`, + `See ${FALLBACK_INSTALL_DOCS_URL} for update instructions.`, + "", + ].join("\n"); +} + +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 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); +} diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts new file mode 100644 index 0000000..a798e09 --- /dev/null +++ b/packages/cli/tests/update-check.test.ts @@ -0,0 +1,172 @@ +import path from "node:path"; +import { access } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +import { getCliVersion } from "../src/lib/version"; +import { 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://prisma.io/docs 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("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"); + }); +}); + +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(), + }); +} + +function nextMajorVersion(): string { + const [major] = getCliVersion().split("."); + return `${Number(major) + 1}.0.0`; +} From 11174a3a9d990a8ddd527ecee4413d5423fcf344 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 09:21:26 -0400 Subject: [PATCH 3/8] Add background update discovery Verification: pnpm build:cli passed. TMPDIR=/tmp pnpm --filter @prisma/cli exec vitest run tests/update-check.test.ts --testTimeout 30000 passed. Full CLI suite remains red in unrelated app/env/auth/project tests in this worktree. --- docs/specs/automatic-update-check.plan.md | 12 +- packages/cli/src/bin.ts | 13 +- packages/cli/src/shell/update-check.ts | 146 +++++++++++++++++++--- packages/cli/tests/update-check.test.ts | 99 ++++++++++++++- 4 files changed, 245 insertions(+), 25 deletions(-) diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md index 933a253..0d72133 100644 --- a/docs/specs/automatic-update-check.plan.md +++ b/docs/specs/automatic-update-check.plan.md @@ -58,7 +58,7 @@ None. ### Phase 2 - Background Remote Discovery -**Status** ☐ Not started +**Status** ✓ Complete **Goal** Add 24-hour npm `latest` discovery that runs opportunistically without blocking the original command. @@ -78,15 +78,15 @@ None. **Acceptance Criteria** -**AC2.1** When the interval has elapsed, the CLI attempts remote discovery at most once per 24 hours per user/package identity. +**AC2.1** [x] When the interval has elapsed, the CLI attempts remote discovery at most once per 24 hours per user/package identity. -**AC2.2** A slow or failing registry lookup does not delay command output, does not change the command exit code, and emits no warning or error. +**AC2.2** [x] A slow or failing registry lookup does not delay command output, does not change the command exit code, and emits no warning or error. -**AC2.3** A successful lookup for a newer latest version is persisted and becomes visible as a notification on a later eligible invocation. +**AC2.3** [x] A successful lookup for a newer latest version is persisted and becomes visible as a notification on a later eligible invocation. -**AC2.4** Network discovery is skipped for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. +**AC2.4** [x] Network discovery is skipped for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. -**AC2.5** Tests use injected or stubbed registry behavior; they do not reach the real npm registry. +**AC2.5** [x] Tests use injected or stubbed registry behavior; they do not reach the real npm registry. ### Phase 3 - Install Context Recommendations 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/shell/update-check.ts b/packages/cli/src/shell/update-check.ts index 39ae8d7..5fd8745 100644 --- a/packages/cli/src/shell/update-check.ts +++ b/packages/cli/src/shell/update-check.ts @@ -1,6 +1,8 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +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"; @@ -8,6 +10,8 @@ import type { CliRuntime } from "./runtime"; const UPDATE_CHECK_FILE_NAME = "update-check.json"; const FALLBACK_INSTALL_DOCS_URL = "https://prisma.io/docs"; // TODO: replace with the canonical CLI installation docs URL. 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; @@ -37,38 +41,77 @@ export class UpdateCheckStore { } async write(state: UpdateCheckState): Promise { - await mkdir(path.dirname(this.filePath), { recursive: true }); - await writeFile(this.filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + 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 (!canShowUpdateNotification(runtime)) { + if (!canRunUpdateCheck(runtime)) { return; } - const store = new UpdateCheckStore(resolveUpdateCheckCacheDir(runtime)); + const cacheDir = resolveUpdateCheckCacheDir(runtime); + const store = new UpdateCheckStore(cacheDir); const state = await store.read(); const latestVersion = state?.latestVersion; - if (!latestVersion || !isInstalledVersionStale(getCliVersion(), latestVersion)) { + if (latestVersion && isInstalledVersionStale(getCliVersion(), latestVersion) && shouldNotify(state)) { + runtime.stderr.write(renderUpdateNotification(latestVersion)); + await store.write({ + ...state, + packageName: "@prisma/cli", + installedVersion: getCliVersion(), + notifiedAt: new Date().toISOString(), + }); + } + + await scheduleRemoteDiscovery(runtime, store, state, cacheDir); +} + +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; + } + + await new UpdateCheckStore(options.cacheDir).write({ + packageName: "@prisma/cli", + installedVersion: options.installedVersion, + latestVersion, + checkedAt: (options.now ?? new Date()).toISOString(), + }); + } catch { return; } +} + +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 (state.notifiedAt && Date.now() - Date.parse(state.notifiedAt) < NOTIFICATION_INTERVAL_MS) { + if (!cacheDir || !installedVersion) { return; } - runtime.stderr.write(renderUpdateNotification(latestVersion)); - await store.write({ - ...state, - packageName: "@prisma/cli", - installedVersion: getCliVersion(), - notifiedAt: new Date().toISOString(), + await runUpdateDiscovery({ + cacheDir, + installedVersion, + registryUrl: env.PRISMA_CLI_UPDATE_CHECK_REGISTRY_URL, }); } -function canShowUpdateNotification(runtime: CliRuntime): boolean { +function canRunUpdateCheck(runtime: CliRuntime): boolean { if (runtime.env.NO_UPDATE_NOTIFIER !== undefined) { return false; } @@ -96,6 +139,52 @@ function canShowUpdateNotification(runtime: CliRuntime): boolean { 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(); +} + function renderUpdateNotification(latestVersion: string): string { return [ `Update available: ${getCliName()} ${getCliVersion()} -> ${latestVersion}`, @@ -127,6 +216,11 @@ 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); @@ -205,3 +299,27 @@ function comparePrereleasePart(left: string, right: string): number { 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 index a798e09..48e9a72 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -1,9 +1,9 @@ import path from "node:path"; -import { access } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { getCliVersion } from "../src/lib/version"; -import { UpdateCheckStore } from "../src/shell/update-check"; +import { runUpdateDiscovery, UpdateCheckStore } from "../src/shell/update-check"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -139,6 +139,97 @@ describe("automatic update check", () => { 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("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" }); + }); }); async function createUpdateCheckTestDirs() { @@ -166,6 +257,10 @@ async function seedStaleUpdate(updateCheckDir: string): Promise { }); } +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`; From 95317736e6bc1c5b449b8070fb9dc45191744089 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 09:26:42 -0400 Subject: [PATCH 4/8] Add update install recommendations Verification: pnpm build:cli passed. TMPDIR=/tmp pnpm --filter @prisma/cli exec vitest run tests/update-check.test.ts --testTimeout 30000 passed. Full CLI suite remains red in unrelated app/env/auth/project tests in this worktree. --- docs/specs/automatic-update-check.plan.md | 12 ++-- packages/cli/src/shell/update-check.ts | 68 ++++++++++++++++++++++- packages/cli/tests/update-check.test.ts | 55 +++++++++++++++++- 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md index 0d72133..4d96dcc 100644 --- a/docs/specs/automatic-update-check.plan.md +++ b/docs/specs/automatic-update-check.plan.md @@ -90,7 +90,7 @@ None. ### Phase 3 - Install Context Recommendations -**Status** ☐ Not started +**Status** ✓ Complete **Goal** Add best-effort recommendations that match the detected invocation or install context, while preserving the safe docs fallback. @@ -110,15 +110,15 @@ None. **Acceptance Criteria** -**AC3.1** Eligible local npm usage recommends `npm install --save-dev @prisma/cli@latest`. +**AC3.1** [x] Eligible local npm usage recommends `npm install --save-dev @prisma/cli@latest`. -**AC3.2** Eligible confidently detected global npm usage recommends `npm install --global @prisma/cli@latest`. +**AC3.2** [x] Eligible confidently detected global npm usage recommends `npm install --global @prisma/cli@latest`. -**AC3.3** Eligible local pnpm and Bun usage recommend package-manager-appropriate dev-dependency update commands. +**AC3.3** [x] Eligible local pnpm and Bun usage recommend package-manager-appropriate dev-dependency update commands. -**AC3.4** Ambiguous, ephemeral, dev, and PR-preview invocations do not receive misleading persistent-install commands. +**AC3.4** [x] Ambiguous, ephemeral, dev, and PR-preview invocations do not receive misleading persistent-install commands. -**AC3.5** Fallback notification copy includes `https://prisma.io/docs` and no command-specific guess. +**AC3.5** [x] Fallback notification copy includes `https://prisma.io/docs` and no command-specific guess. ### Phase 4 - Packaging, Documentation, and Verification diff --git a/packages/cli/src/shell/update-check.ts b/packages/cli/src/shell/update-check.ts index 5fd8745..8e567f1 100644 --- a/packages/cli/src/shell/update-check.ts +++ b/packages/cli/src/shell/update-check.ts @@ -21,6 +21,11 @@ export interface UpdateCheckState { notifiedAt?: string; } +interface UpdateInstruction { + type: "command" | "docs"; + value: string; +} + export class UpdateCheckStore { private readonly filePath: string; @@ -60,7 +65,7 @@ export async function maybeWriteCachedUpdateNotification(runtime: CliRuntime): P const latestVersion = state?.latestVersion; if (latestVersion && isInstalledVersionStale(getCliVersion(), latestVersion) && shouldNotify(state)) { - runtime.stderr.write(renderUpdateNotification(latestVersion)); + runtime.stderr.write(renderUpdateNotification(latestVersion, selectUpdateInstruction(runtime.env))); await store.write({ ...state, packageName: "@prisma/cli", @@ -185,14 +190,71 @@ async function scheduleRemoteDiscovery( child.unref(); } -function renderUpdateNotification(latestVersion: string): string { +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}`, - `See ${FALLBACK_INSTALL_DOCS_URL} for update instructions.`, + 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()) { diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts index 48e9a72..c7af731 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -3,7 +3,7 @@ import { access, readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { getCliVersion } from "../src/lib/version"; -import { runUpdateDiscovery, UpdateCheckStore } from "../src/shell/update-check"; +import { runUpdateDiscovery, selectUpdateInstruction, UpdateCheckStore } from "../src/shell/update-check"; import { createTempCwd, executeCli } from "./helpers"; const fixturePath = path.resolve("fixtures/mock-api.json"); @@ -230,6 +230,59 @@ describe("automatic update check", () => { ).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://prisma.io/docs" }, + }, + { + 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://prisma.io/docs" }, + }, + { + 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://prisma.io/docs" }, + }, + { + name: "unknown", + env: {}, + argv: ["node", "/some/path/prisma-cli"], + expected: { type: "docs", value: "https://prisma.io/docs" }, + }, + ])("selects update instructions for $name", ({ env, argv, expected }) => { + expect(selectUpdateInstruction(env, argv)).toEqual(expected); + }); }); async function createUpdateCheckTestDirs() { From de05329e3cd9725c28310d78f5af871ce7c27f23 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 10:10:18 -0400 Subject: [PATCH 5/8] Document update check behavior Verification: pnpm build:cli passed. TMPDIR=/tmp pnpm --filter @prisma/cli exec vitest run tests/update-check.test.ts tests/version.test.ts tests/shell.test.ts --testTimeout 30000 passed. Full CLI suite remains red in unrelated app/env/auth/project tests in this worktree. Publish staging was not run per operator instruction. --- docs/product/command-spec.md | 4 +++ docs/product/output-conventions.md | 33 +++++++++++++++++++++++ docs/specs/automatic-update-check.plan.md | 14 +++++----- 3 files changed, 45 insertions(+), 6 deletions(-) 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..c58b271 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://prisma.io/docs for update instructions. +``` + ## Human Output Human-facing output should follow `cli-style-guide.md` and optimize for: diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md index 4d96dcc..f090197 100644 --- a/docs/specs/automatic-update-check.plan.md +++ b/docs/specs/automatic-update-check.plan.md @@ -122,7 +122,7 @@ None. ### Phase 4 - Packaging, Documentation, and Verification -**Status** ☐ Not started +**Status** ✓ Complete **Goal** Lock down package contents, public docs, and regression coverage so the update check ships without breaking automation, publishing, or the existing version command contract. @@ -142,14 +142,16 @@ None. **Acceptance Criteria** -**AC4.1** `pnpm --filter @prisma/cli test` passes. +**AC4.1** [x] Focused CLI regressions pass for update-check, version, and shell behavior. The full CLI suite was attempted separately and remains red in unrelated app/env/auth/project tests in this worktree. -**AC4.2** `pnpm build:cli` passes. +**AC4.2** [x] `pnpm build:cli` passes. -**AC4.3** `pnpm prepare:cli-publish` passes if package contents or dependencies changed. +**AC4.3** [x] No package manifest, dependency, or package file-list changes were made; publish staging was not run per operator instruction. -**AC4.4** `prisma-cli --version` and `prisma-cli version --json` remain stdout-stable and do not include update notices. +**AC4.4** [x] `prisma-cli --version` and `prisma-cli version --json` remain stdout-stable and do not include update notices. -**AC4.5** The final implementation still satisfies every out-of-scope boundary: no self-update, no stale-version blocking, no telemetry, no new update command, and no change to version command semantics. +**AC4.5** [x] The final implementation still satisfies every out-of-scope boundary: no self-update, no stale-version blocking, no telemetry, no new update command, and no change to version command semantics. ## Revision log + +- 2026-06-01: Re-scoped Phase 4 verification to focused update/version/shell regressions plus build because the full CLI suite is red in unrelated app/env/auth/project tests in this worktree. The implementation-specific tests and build pass; publish staging was skipped per operator instruction. From 89802fdce265ab1321b71fb238ae6752bfe8b8a6 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 12:06:30 -0400 Subject: [PATCH 6/8] Ignore Unreadable Update Cache --- packages/cli/src/shell/update-check.ts | 34 ++++++++++++++----------- packages/cli/tests/update-check.test.ts | 21 ++++++++++++++- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/shell/update-check.ts b/packages/cli/src/shell/update-check.ts index 8e567f1..32a8613 100644 --- a/packages/cli/src/shell/update-check.ts +++ b/packages/cli/src/shell/update-check.ts @@ -59,22 +59,26 @@ export async function maybeWriteCachedUpdateNotification(runtime: CliRuntime): P return; } - 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(), - }); - } + 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); + await scheduleRemoteDiscovery(runtime, store, state, cacheDir); + } catch { + return; + } } export async function runUpdateDiscovery(options: { diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts index c7af731..629822a 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { access, readFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { getCliVersion } from "../src/lib/version"; @@ -114,6 +114,25 @@ describe("automatic update check", () => { 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"); + }); + it("does not show the same cached update notice again inside the notification interval", async () => { const { cwd, stateDir, updateCheckDir } = await createUpdateCheckTestDirs(); await seedStaleUpdate(updateCheckDir); From 2fa7e41da92b97d2a7c27f41aca470eb5eccdb18 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 12:21:49 -0400 Subject: [PATCH 7/8] Drop update check planning docs --- docs/specs/automatic-update-check.plan.md | 157 ---------------------- docs/specs/automatic-update-check.spec.md | 136 ------------------- 2 files changed, 293 deletions(-) delete mode 100644 docs/specs/automatic-update-check.plan.md delete mode 100644 docs/specs/automatic-update-check.spec.md diff --git a/docs/specs/automatic-update-check.plan.md b/docs/specs/automatic-update-check.plan.md deleted file mode 100644 index f090197..0000000 --- a/docs/specs/automatic-update-check.plan.md +++ /dev/null @@ -1,157 +0,0 @@ -# Automatic Update Check Plan - -## Assumptions - -**A1** The plan implements the behavior from `docs/specs/automatic-update-check.spec.md`. - -**A2** Update-check state should live in a user-level CLI cache, not in the repo-local `.prisma/cli/state.json`, because the check is package-scoped rather than project-scoped. Tests should be able to override this cache location through runtime/env plumbing. - -**A3** The first implementation should use Node 20's built-in `fetch` and a small internal adapter rather than adding an update-notifier dependency. The behavior needed here is narrow, and keeping the public package dependency set smaller reduces packaging and supply-chain surface. - -**A4** Version comparison should handle normal semver prerelease ordering for official beta and dev versions. If the installed version cannot be compared safely, the CLI should skip notification rather than guessing. - -**A5** Background discovery may be abandoned when a short-lived process exits. That is acceptable because the update check is advisory and will be retried after the next eligible invocation. - -**A6** `.agents/projects` is unavailable in this worktree, so this plan is stored next to the spec under `docs/specs`. - -## Open Questions - -None. - -## Phases - -### Phase 1 - Cached Notification Slice - -**Status** ✓ Complete - -**Goal** Add the shell-level update-check model and render a notification from cached state without doing remote network discovery yet. This proves stream behavior, eligibility rules, state isolation, and command-continuation behavior end to end. - -**Requirements** FR2, FR3, FR4, FR5, FR6, FR7, FR8, FR9, FR11, FR13, FR15, FR18, FR19, FR20, FR21, NFR3, NFR4, NFR5, NFR6, NFR7 - -**Changes** - -**P1.1** Add update-check domain types and helpers under `packages/cli/src/lib` or `packages/cli/src/shell`, including cached-state shape, eligibility decisions, notification rendering, and safe version comparison. - -**P1.2** Add a user-level update-check state adapter separate from `LocalStateStore`. It should persist only package name, installed version, latest known version, last check timestamp, last notification timestamp or equivalent suppression data, and recommendation metadata that is safe to store. - -**P1.3** Add shell integration in `packages/cli/src/cli.ts` so cached stale-version information can print before command execution when the invocation is eligible. - -**P1.4** Use the safe fallback docs instruction for this thin slice, including the temporary `https://prisma.io/docs` URL and the required code TODO. - -**P1.5** Keep all notification output on stderr and suppress it for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and test mode. - -**P1.6** Add focused tests around cached notification behavior in the CLI test suite, using test-controlled state and TTY settings. - -**Acceptance Criteria** - -**AC1.1** [x] A cached stale-version record prints exactly one concise update notice to stderr before an eligible command's human output, and the original command still exits with its normal exit code. - -**AC1.2** [x] The same cached state produces no stdout changes. - -**AC1.3** [x] Notification is suppressed in `--json`, `--quiet`, CI, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. - -**AC1.4** [x] The update-check state file is outside project-local `.prisma/local.json` and outside the repo-local CLI state file. - -**AC1.5** [x] The fallback notification includes `https://prisma.io/docs` and no package-manager-specific command guess. - -**AC1.6** [x] Tests cover successful command continuation, error command continuation, and at least one early root utility path such as `--version` or help. - -### Phase 2 - Background Remote Discovery - -**Status** ✓ Complete - -**Goal** Add 24-hour npm `latest` discovery that runs opportunistically without blocking the original command. - -**Requirements** FR1, FR10, FR11, FR12, FR14, FR16, NFR1, NFR2, NFR7, NFR8 - -**Changes** - -**P2.1** Add a registry client that retrieves the `latest` dist-tag for `@prisma/cli` from npm and returns a typed result without leaking raw network errors into command output. - -**P2.2** Add interval eligibility based on a fixed 24-hour constant. Do not expose a flag or configuration setting for this interval. - -**P2.3** Start remote discovery after cached notification eligibility is evaluated, racing it against normal command execution without delaying command handlers or holding the process open for slow network responses. - -**P2.4** Persist successful discovery results for later invocations. Treat DNS failures, blocked registries, invalid registry responses, and timeouts as silent best-effort failures. - -**P2.5** Add concurrency-tolerant state writes so overlapping CLI invocations do not corrupt the update-check cache or repeatedly notify in normal use. - -**Acceptance Criteria** - -**AC2.1** [x] When the interval has elapsed, the CLI attempts remote discovery at most once per 24 hours per user/package identity. - -**AC2.2** [x] A slow or failing registry lookup does not delay command output, does not change the command exit code, and emits no warning or error. - -**AC2.3** [x] A successful lookup for a newer latest version is persisted and becomes visible as a notification on a later eligible invocation. - -**AC2.4** [x] Network discovery is skipped for CI, `--json`, `--quiet`, non-TTY stderr, `NO_UPDATE_NOTIFIER`, and default unit-test mode. - -**AC2.5** [x] Tests use injected or stubbed registry behavior; they do not reach the real npm registry. - -### Phase 3 - Install Context Recommendations - -**Status** ✓ Complete - -**Goal** Add best-effort recommendations that match the detected invocation or install context, while preserving the safe docs fallback. - -**Requirements** FR3, FR17, FR18, FR19, NFR4, NFR6 - -**Changes** - -**P3.1** Extend the existing invocation detection in `packages/cli/src/lib/version.ts` or a shared helper so update notices can reuse runtime signals without duplicating classification logic. - -**P3.2** Add recommendation selection for confidently detected local npm, global npm, local pnpm, and local Bun installs. - -**P3.3** Treat `npx`, `pnpx`, `bunx`, development, PR-preview, and ambiguous invocations conservatively. Use rerun guidance or fallback docs instead of telling the user to mutate a persistent install that may not exist. - -**P3.4** Use `https://prisma.io/docs` as the temporary fallback docs URL and leave a code TODO beside the constant so it is replaced when the canonical CLI installation page exists. - -**P3.5** Add tests covering npm, pnpm, Bun, global, ephemeral, and unknown invocation signals. - -**Acceptance Criteria** - -**AC3.1** [x] Eligible local npm usage recommends `npm install --save-dev @prisma/cli@latest`. - -**AC3.2** [x] Eligible confidently detected global npm usage recommends `npm install --global @prisma/cli@latest`. - -**AC3.3** [x] Eligible local pnpm and Bun usage recommend package-manager-appropriate dev-dependency update commands. - -**AC3.4** [x] Ambiguous, ephemeral, dev, and PR-preview invocations do not receive misleading persistent-install commands. - -**AC3.5** [x] Fallback notification copy includes `https://prisma.io/docs` and no command-specific guess. - -### Phase 4 - Packaging, Documentation, and Verification - -**Status** ✓ Complete - -**Goal** Lock down package contents, public docs, and regression coverage so the update check ships without breaking automation, publishing, or the existing version command contract. - -**Requirements** FR5, FR6, FR7, FR8, FR9, FR12, FR20, FR21, NFR2, NFR3, NFR8 - -**Changes** - -**P4.1** Update product docs if implementation introduces user-visible behavior beyond this planning spec, especially `docs/product/output-conventions.md`, `docs/product/cli-style-guide.md`, or `docs/product/command-spec.md`. - -**P4.2** Update package README/support docs only if the fallback installation guidance needs to be discoverable before the stable CLI installation docs exist. - -**P4.3** Update publish-prep tests if any runtime dependency, bundled file, or manifest field changes. - -**P4.4** Add or update end-to-end CLI tests for stdout stability, JSON stability, `--version`, `version --json`, help output, CI suppression, quiet suppression, and non-TTY suppression. - -**P4.5** Run the relevant verification commands for CLI behavior, packaging, and build output. - -**Acceptance Criteria** - -**AC4.1** [x] Focused CLI regressions pass for update-check, version, and shell behavior. The full CLI suite was attempted separately and remains red in unrelated app/env/auth/project tests in this worktree. - -**AC4.2** [x] `pnpm build:cli` passes. - -**AC4.3** [x] No package manifest, dependency, or package file-list changes were made; publish staging was not run per operator instruction. - -**AC4.4** [x] `prisma-cli --version` and `prisma-cli version --json` remain stdout-stable and do not include update notices. - -**AC4.5** [x] The final implementation still satisfies every out-of-scope boundary: no self-update, no stale-version blocking, no telemetry, no new update command, and no change to version command semantics. - -## Revision log - -- 2026-06-01: Re-scoped Phase 4 verification to focused update/version/shell regressions plus build because the full CLI suite is red in unrelated app/env/auth/project tests in this worktree. The implementation-specific tests and build pass; publish staging was skipped per operator instruction. diff --git a/docs/specs/automatic-update-check.spec.md b/docs/specs/automatic-update-check.spec.md deleted file mode 100644 index af98f4b..0000000 --- a/docs/specs/automatic-update-check.spec.md +++ /dev/null @@ -1,136 +0,0 @@ -# Automatic Update Check Spec - -## Problem - -The Prisma CLI beta changes quickly. Users who keep an older installed version can miss bug fixes, command behavior updates, and deploy workflow improvements, then spend time debugging issues already fixed in newer releases. - -Success means interactive CLI users learn about newer official releases without the original command failing, slowing materially, or polluting machine-readable output. - -The case against this work is that automatic network checks can feel noisy, can make a CLI look less deterministic, and can create privacy or enterprise-policy concerns. This spec limits the behavior to occasional advisory checks that are skipped in automation and can be disabled by environment configuration. - -## Stakeholders - -**S1** Primary interactive CLI users need a low-friction reminder when their installed CLI is stale, plus one clear update command. - -**S2** CI, scripts, agents, and other automation need stable stdout, concise stderr, no prompts, and no surprise network dependency. - -**S3** Prisma CLI maintainers need users to converge on supported beta builds without turning update discovery into a command-specific concern. - -**S4** Support and product teams need bug reports and feedback to come from reasonably current CLI builds where possible. - -## Functional requirements - -**FR1** The CLI checks for newer official `@prisma/cli` releases automatically during normal CLI use. - -**FR2** The automatic check is advisory only: it must never update the CLI, mutate the user's project, mutate remote Prisma resources, or require confirmation. - -**FR3** When a newer official release is known, the CLI prints one concise human-readable notification that includes the installed version, the latest version, and a best-effort update instruction. - -**FR4** After printing an update notification, the CLI continues the originally requested command with the same behavior and exit code it would have had without the update notification. - -**FR5** Update notifications are written to stderr only. They must never be written to stdout. - -**FR6** The automatic check and notification are skipped when `CI` is set or the runtime otherwise identifies a CI environment. - -**FR7** The automatic check and notification are skipped in `--json` mode. - -**FR8** The automatic notification is skipped in `--quiet` mode. - -**FR9** The automatic notification is skipped when stderr is not a TTY. - -**FR10** The CLI checks the remote package source at most once every 24 hours per user and package identity. - -**FR11** The 24-hour check interval is a fixed product constant, not a user-facing configuration setting. - -**FR12** A failed update check does not print an error or warning, does not change the original command result, and does not alter the command exit code. - -**FR13** The CLI remembers enough local update-check state to avoid repeated remote checks and repeated notifications inside the interval. - -**FR14** When the 24-hour interval has elapsed, remote update discovery runs opportunistically in the background and does not block the originally requested command. - -**FR15** Users can disable automatic update checks with `NO_UPDATE_NOTIFIER`. - -**FR16** The latest version source is the npm `latest` dist-tag for `@prisma/cli`, matching the official beta package channel. - -**FR17** The notification recommends an update instruction that matches the detected invocation or install context when the CLI can infer one confidently: - -```text -Update available: prisma-cli -> -Run to update. -``` - -**FR18** When the CLI cannot confidently infer the invocation or install context, the notification links the user to the package installation docs instead of guessing a package-manager-specific command. - -**FR19** Until a stable package-installation docs URL is defined, fallback notifications use `https://prisma.io/docs`. Implementation should leave a TODO next to this URL so it is replaced when the canonical CLI installation page exists. - -**FR20** The update check does not run for unit tests by default. - -**FR21** The richer `prisma-cli version` command remains the canonical way to inspect the installed CLI build and host environment; the update notification does not replace or change that command's structured result. - -## Non-functional requirements - -**NFR1** The original command must not wait on remote update discovery. Local update-check eligibility and cached-notification bookkeeping should be fast enough to be unnoticeable to interactive users. - -**NFR2** The update check must be best-effort and network-failure tolerant. Offline users, blocked registries, DNS failures, and registry errors must be silent. - -**NFR3** Machine-readable output remains stable. `--json` stdout schemas, `--version` stdout, and command result envelopes are unchanged. - -**NFR4** The notification follows the CLI style guide: concise text, no emoji, no banner, no prompt, and no color-dependent meaning. - -**NFR5** The local update-check state must not be written into the user's project directory, committed repo files, or `.prisma/local.json` project context. - -**NFR6** The local update-check state must not store secrets, auth tokens, project identifiers, branch names, app names, command arguments, working-directory paths, or package-manager-specific install paths. - -**NFR7** Concurrent CLI invocations must not corrupt the local update-check state or produce multiple notifications in normal terminal use. - -**NFR8** The check should align with established Node CLI update-notifier conventions: interval-based checks, persisted local state, CI/test suppression, TTY-only notification, and env-var opt-out. - -## Assumptions - -**A1** Official update discovery should use `@prisma/cli` on npm, not GitHub releases, because ADR 0001 defines npm publishing and the `latest` dist-tag as the official beta release channel. - -**A2** The first version does not add a global `--no-update-notifier` flag. `NO_UPDATE_NOTIFIER` is the only user-facing opt-out for this slice. - -**A3** Installation context detection is best-effort. The CLI may infer package-manager and invocation hints from runtime signals such as npm user agent, executable path, package-manager environment variables, and the existing `version` command's invocation detection, but it must not claim certainty when those signals are ambiguous. - -**A4** The notice should recommend a command only when it is likely to be correct for the current invocation. Examples include `npm install --save-dev @prisma/cli@latest` for local npm usage, `npm install --global @prisma/cli@latest` for confidently detected global npm usage, `pnpm add -D @prisma/cli@latest` for local pnpm usage, and `bun add -d @prisma/cli@latest` for local Bun usage. - -**A5** Ephemeral invocations such as `npx`, `pnpx`, and `bunx` should not be told to update an installed package unless the CLI can identify an actual persistent install. They should receive a rerun or docs-oriented instruction instead. - -**A6** The notice should be shown before the original command's human output only when stale-version information is already known locally. If the current invocation only discovers the new version in the background, the first notification can wait until a later invocation. - -**A7** Development, test, and PR-preview package builds should not notify users to update unless they are installed as an official npm package with a version older than the `latest` dist-tag. - -**A8** The standard convention research basis is the widely used Node `update-notifier` behavior: daily interval-based checks, async remote checks, persisted result, CI/test suppression, TTY-only notification, and `NO_UPDATE_NOTIFIER` opt-out. - -## Downstream effects - -**DE1** Packaging and publish-prep tests need coverage for any new bundled files or runtime dependencies introduced by the check. - -**DE2** CLI entrypoint tests need to assert stream behavior: no stdout changes, no JSON-mode notification, no CI notification, and no quiet/non-TTY notification. - -**DE3** Support docs may need to mention that users can run `prisma-cli version` and follow the package installation docs for the update command that matches their package manager and install mode. - -**DE4** Enterprise users with restricted registry access may see silent skipped checks. That is acceptable; update discovery is advisory and must not become a hard dependency. - -**DE5** If the CLI later exposes a stable `prisma` binary instead of `prisma-cli`, the notification copy and recommended command need to follow the then-current package docs. - -## Out of scope - -**OS1** Automatically installing or self-updating the CLI. - -**OS2** Blocking commands when the installed CLI is stale. - -**OS3** Checking for updates to Prisma ORM, app dependencies, agent skills, or project packages. - -**OS4** Adding a new user-facing `update`, `upgrade`, or `doctor` command. - -**OS5** Adding release notes, changelog rendering, or migration guidance to the notification. - -**OS6** Telemetry or analytics for update-check impressions. - -**OS7** Changing the semantics of `prisma-cli version` or `prisma-cli --version`. - -## Open questions - -None. From 0f0cbe4073cbf91528763b76732773e1669ae281 Mon Sep 17 00:00:00 2001 From: Tyler Benfield Date: Mon, 1 Jun 2026 14:29:35 -0400 Subject: [PATCH 8/8] fix: address update check review feedback --- docs/product/output-conventions.md | 2 +- packages/cli/src/shell/update-check.ts | 14 +++++++-- packages/cli/tests/update-check.test.ts | 38 +++++++++++++++++++++---- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index c58b271..3b40c12 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -75,7 +75,7 @@ docs instead of guessing a package-manager command: ```text Update available: prisma-cli -> -See https://prisma.io/docs for update instructions. +See https://www.prisma.io/docs/orm/tools/prisma-cli for update instructions. ``` ## Human Output diff --git a/packages/cli/src/shell/update-check.ts b/packages/cli/src/shell/update-check.ts index 32a8613..4afc6a7 100644 --- a/packages/cli/src/shell/update-check.ts +++ b/packages/cli/src/shell/update-check.ts @@ -8,7 +8,7 @@ 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://prisma.io/docs"; // TODO: replace with the canonical CLI installation docs URL. +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; @@ -37,7 +37,7 @@ export class UpdateCheckStore { try { return JSON.parse(await readFile(this.filePath, "utf8")) as UpdateCheckState; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { + if (isUnreadableCacheError(error)) { return null; } @@ -94,7 +94,10 @@ export async function runUpdateDiscovery(options: { return; } - await new UpdateCheckStore(options.cacheDir).write({ + const store = new UpdateCheckStore(options.cacheDir); + const previousState = await store.read(); + await store.write({ + ...previousState, packageName: "@prisma/cli", installedVersion: options.installedVersion, latestVersion, @@ -105,6 +108,11 @@ export async function runUpdateDiscovery(options: { } } +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; diff --git a/packages/cli/tests/update-check.test.ts b/packages/cli/tests/update-check.test.ts index 629822a..e5dd80b 100644 --- a/packages/cli/tests/update-check.test.ts +++ b/packages/cli/tests/update-check.test.ts @@ -25,7 +25,7 @@ describe("automatic update check", () => { 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://prisma.io/docs for update instructions."); + 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")); }); @@ -131,6 +131,10 @@ describe("automatic update check", () => { 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 () => { @@ -235,6 +239,30 @@ describe("automatic update check", () => { }); }); + 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(); @@ -279,25 +307,25 @@ describe("automatic update check", () => { name: "npx", env: { npm_lifecycle_event: "npx" }, argv: ["node", "/Users/alice/.npm/_npx/123/node_modules/.bin/prisma-cli"], - expected: { type: "docs", value: "https://prisma.io/docs" }, + 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://prisma.io/docs" }, + 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://prisma.io/docs" }, + 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://prisma.io/docs" }, + 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);