diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index dc40cdbc..b814cfba 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -27,7 +27,7 @@ Report this as **Patchstack Connector installed**, with any remaining preview or 1. Check what is already done with `npx @patchstack/connect guide` (read-only). If the project is already provisioned, reuse it — see "Before you start — never install twice". 2. Install `@patchstack/connect` as a runtime dependency with the project's package manager. 3. Run `npx @patchstack/connect setup`. In a hosted builder, scope `PATCHSTACK_ENVIRONMENT=sandbox` to that command — see "Automated setup". -4. Finish any step the checklist at the end of `setup` still lists. "Automated setup" names each one. +4. Finish any `✘` line under `Missing` in the report at the end of `setup`. "Automated setup" names each one. 5. Tell the person the dashboard link, which parts are active and which are not, to refresh their preview, and to deploy when they are ready. If your tool will not run the command, see "When your tool will not run this CLI". The sections below describe what each command reads, writes, and sends. @@ -78,7 +78,7 @@ Only `map` analyses your source, and only `map --upload` sends anything derived ## Before you start — never install twice -- `npx @patchstack/connect guide` prints a read-only live checklist showing which steps are already done in the current project (install, provisioning, build hooks, widget, runtime protection). +- `npx @patchstack/connect guide` prints a read-only live checklist of the four steps (install, connect, sync, deploy) and the one next step, with any missing build hook, widget tag or runtime protection wiring listed under `Missing`. Add `--verbose` to `guide`, `scan` or `setup` for the technical detail (site UUID, endpoint, environment and what decided it, checksum, files written, the exact package.json lines and the runtime protection checks). - If `.patchstackrc.json` contains a `siteUuid` key, the project is already provisioned. Reuse that UUID; run `npx @patchstack/connect status` to re-print it and the dashboard URL. **Do not delete the file and provision a second site.** (A `.patchstackrc.json` with other keys — e.g. an `endpoint` override — but no `siteUuid` is *not* provisioned yet; scan normally.) - If `@patchstack/connect` is already in `dependencies`, skip the install command. If it is only in `devDependencies`, move it with the matching package manager so production runtimes that prune dev dependencies can load the generated guard. - If the widget script tag (`cdn.patchstack.com/patchstack-widget.js`) is already in the layout, don't add a second one — `scan` also respects an existing tag: it updates its own managed tag in place and leaves a manual one untouched. @@ -133,11 +133,13 @@ This is a request, not a mechanism: nothing in the install depends on it. Do it **What runtime protection can report:** a positively identified static build reports runtime protection as not applicable. A bundler-only project, including plain Vite, can remain runtime unknown and receive a generic scaffold with incomplete wiring. Report that limitation; do not add an artificial server merely to make the check pass, and never describe a widget or an unwired scaffold as runtime protection. - **A step the checklist still lists is yours to finish, not a result to report.** `setup` applies what it can apply safely and prints the exact edit for anything it would have had to overwrite user code to do. They are: moving `@patchstack/connect` out of `devDependencies`, the widget tag in a root layout `setup` could not edit, the production marker on a server-rendered root, and wiring a generic guard into the server entry. The last three are steps 3 and 4 of "Manual setup" below; after the guard one, `npx @patchstack/connect protect --check` must exit 0. + **A `✘` line under `Missing` is yours to finish, not a result to report.** `setup` applies what it can apply safely and prints the exact edit (or the command that prints it) for anything it would have had to overwrite user code to do. They are: moving `@patchstack/connect` out of `devDependencies`, the widget tag in a root layout `setup` could not edit, the production marker on a server-rendered root, and wiring a generic guard into the server entry. The last three are steps 3 and 4 of "Manual setup" below; after the guard one, `npx @patchstack/connect protect --check` must exit 0. - **A tick is "nothing owed here", not "this part is on".** The checklist marks steps this project still owes, so a part it cannot carry — or one that is switched off — is green with nothing outstanding. `No build script to integrate`, `Patchstack Connector disabled by config` and `Runtime protection: not applicable` all read that way. So report what the project ended up with by name — dependency scans, the Patchstack Connector, the build hooks, runtime protection — and say which of them are not active and why, rather than reporting an empty checklist as a finished install. Of those, the widget is the one that can be off by setting rather than by the shape of the project: if `.patchstackrc.json` carries `"widget": false` and the person did not ask for that, tell them it is off and ask whether they want it on. + **Read the report.** `setup` and `scan` start with `Done` (what this run did) and `Missing` (what is still missing, each with the one thing to do); `guide` shows `Missing` only. All three end on the same four steps — `Install the Patchstack connector`, `Connect project to Patchstack account`, `Sync and monitor in local environment`, `Deploy project to protect live app` — each marked `✔` (done) or `✘` (not yet), followed by the one next step. A part the project cannot carry (no build script, no request path for runtime protection) is simply not listed. The CLI does not store whether the site has an owner, so `Connect` stays `✘` until a scan hears otherwise from Patchstack; do not report the site as connected from that line alone, and do not report it as unconnected once the person has signed in. Report what the project ended up with by name — dependency scans, the Patchstack Connector, the build hooks, runtime protection — and say which are not active and why. - **Finish by telling the user to refresh their preview.** The widget loads with the page, so a preview that was already open still shows the HTML from before setup — the widget is missing there until it reloads. Tell them what to expect after the refresh: a site that is not yet connected to an account shows the "Connect this website" panel, and the "Report a vulnerability" button takes its place once the site is claimed. A freshly set up site is unclaimed unless setup ran with a claim token. Nothing in the CLI can reach the user's browser, so relaying this is your job. Phrase it as a check rather than a required step: a builder that hot reloads, or a preview server you restarted, may have refreshed it already. + **The widget is part of the install.** It is on by default; add it without asking the person whether to. Honour `"widget": false` in `.patchstackrc.json` only when the person set it themselves. If it is there and they did not ask for it, remove it, run `setup` again, and tell them the widget is back on. + + **Finish by telling the user to refresh their preview.** The widget loads with the page, so a preview that was already open still shows the HTML from before setup — the widget is missing there until it reloads. Tell them what to expect after the refresh: a site that is not yet connected to an account shows the "Connect this website" panel. A freshly set up site is unclaimed unless setup ran with a claim token. Nothing in the CLI can reach the user's browser, so relaying this is your job. Phrase it as a check rather than a required step: a builder that hot reloads, or a preview server you restarted, may have refreshed it already. **Then tell them to deploy.** Setup changes source files, and the deployed site keeps serving its previous build until the next deploy — so visitors get no widget, and on a server-rendered root no production marker, until the user deploys (or hits Publish) again. Say it as a reminder; do not deploy anything yourself. @@ -155,8 +157,8 @@ Handle it in this order: and the source tree as they were. 2. **Hand the person the ways forward, with the exact text.** Say what the command does in plain words — - it registers the site with Patchstack, writes two small config files, adds the "Report a vulnerability" - line to the page, and adds the protection files and build steps described above — then give them: + it registers the site with Patchstack, writes two small config files, adds the Patchstack widget to the + page, and adds the protection files and build steps described above — then give them: - **Run it themselves, in this session.** In Claude Code a line that starts with `!` runs in their shell and its output lands in the conversation: `! npx @patchstack/connect setup`. Other tools have a @@ -187,9 +189,9 @@ Handle it in this order: `npx --yes patchstack-connect setup` are different texts, and the rules above do not cover them. On a developer's machine the sandbox label is not needed anyway: a scan there reports `local` on its own. -4. **Resume from the output.** `setup` prints the same checklist, dashboard link and outcome block whoever +4. **Resume from the output.** `setup` prints the same checklist, next step and dashboard link whoever ran it, and re-running it changes nothing that is already done. If the person ran it, relay the - dashboard link and the outcome block from their output as they are. If your tool still will not run + checklist and the next step from their output as they are. If your tool still will not run `guide` or `status` for you, verify from the files instead of guessing: `siteUuid` in `.patchstackrc.json` means the site is provisioned; `patchstack-connect scan` and `patchstack-connect mark-build` in the `package.json` scripts mean the hooks are wired; @@ -209,7 +211,7 @@ Handle it in this order: npx @patchstack/connect scan ``` - It prints a dashboard link but never opens it. Open that link in a browser to view reports. It also prints what it did about the widget — if it added the tag, reload the preview and confirm the widget appears: the "Connect this website" panel while the site is unclaimed, the "Report a vulnerability" button once it is claimed. + It prints a dashboard link but never opens it. Open that link in a browser to view reports. It also prints what it did about the widget — if it added the tag, reload the preview and confirm the widget appears (the "Connect this website" panel while the site is unclaimed). 2. **Wire builds** in `package.json`: @@ -227,7 +229,7 @@ Handle it in this order: **Bun-managed projects:** `bun run` does not execute npm-style `pre`/`post` scripts, so wire the build script directly instead: `"build": "patchstack-connect scan && && patchstack-connect mark-build"`. -3. **Verify the Patchstack Connector** — a floating control whose form follows the site's claim state: while the site is unclaimed it is a one-time "Connect this website" panel, and it becomes the public "Report a vulnerability" button once the site is claimed. Do not tell the user the report button will appear on a site that has not been connected to an account yet. `scan` installs it automatically into a plain HTML shell **or a JSX root** (Next, Remix, React Router, TanStack Start, Gatsby), and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell at all — a root whose head mechanism is not a plain script tag, e.g. Nuxt's `useHead` or an Astro layout — add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: +3. **Verify the Patchstack Connector** — a floating control whose form follows the site's claim state: while the site is unclaimed it is a one-time "Connect this website" panel. It is part of the install and on by default; do not ask whether to add it. `scan` installs it automatically into a plain HTML shell **or a JSX root** (Next, Remix, React Router, TanStack Start, Gatsby), and `mark-build` carries it into built HTML. Only when `scan` reported that it found no editable shell at all — a root whose head mechanism is not a plain script tag, e.g. Nuxt's `useHead` or an Astro layout — add the one-liner it printed to the root layout yourself, just before `` (never a JS entry point), reading `siteUuid` from `.patchstackrc.json`. On those same roots the widget also needs the production marker above the tag — `scan` adds it automatically to a JSX root, and prints it to paste when it finds no anchor. A server-rendered site without the marker serves the build-mode claim flow to its visitors: ```html @@ -297,7 +299,7 @@ It is server-only. Never put it in the widget tag, client bundles, or public env 6. **Connect the site to a Patchstack account.** The site is monitored either way, but its vulnerability reports are only visible once it is attached to an account, and an unattached site stays claimable by anyone who loads the page — the site UUID ships in the HTML and claiming is first-come. Three routes reach the same place; tell the user all three and lead with the first, which needs no terminal and no copied URL: - 1. **The widget's "Connect this website" panel**, already on the preview. While the site is unclaimed the widget serves this panel *instead of* the report button, and signing in there attaches the site. On a published build it is hidden from visitors; the owner reveals it by appending `#patchstack` (or `?patchstack`) to the live URL. + 1. **The widget's "Connect this website" panel**, already on the preview. While the site is unclaimed the widget serves this panel, and signing in there attaches the site. On a published build it is hidden from visitors; the owner reveals it by appending `#patchstack` (or `?patchstack`) to the live URL. 2. **The dashboard link** the scan printed — open it in a browser and sign in. 3. **`npx @patchstack/connect claim`** from the terminal, which prints a link to sign in with and then attaches the site. @@ -591,7 +593,7 @@ Two more endpoints the package can call, for completeness: - `npx @patchstack/connect status` re-prints the site UUID and dashboard URL, and checks whether the site still exists on Patchstack (`Site status: active / removed / could not be verified`). - `npx @patchstack/connect protect --check` verifies from the source that the runtime guard is connected to the request path. It does not run the app. - `npx @patchstack/connect protect --check --runtime` additionally **starts the app** on a loopback port and sends it one request, to establish that a request reaches the guard seam. Opt-in, and the only command that runs the application; exit `0`/`1`/`2` as described in step 4. -- Load the site in a browser — the widget should appear: the "Connect this website" panel while the site is unclaimed, the "Report a vulnerability" button once it is claimed. Refresh a page that was already open before the tag was added: the widget only loads with the page. +- Load the site in a browser — the widget should appear, as the "Connect this website" panel while the site is unclaimed. Refresh a page that was already open before the tag was added: the widget only loads with the page. - On the deployed site, the button appears only after a deploy that includes these source changes. ## Answering "is Patchstack installed?" / "is Patchstack removed?" diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index edea8402..6b0a3420 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -8,11 +8,11 @@ The fastest path from "I have a JS/Node project" to "Patchstack is monitoring it For an existing JS/Node project on a platform that can install npm packages and run project commands. A standalone HTML/CSS/JavaScript site without a package-managed app uses the [plain HTML widget instructions](AGENT-INSTALL.md#plain-html-sites) instead; it does not need a new Node project, build hooks, or a runtime guard. -> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the "Report a vulnerability" button is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. +> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. When setup finishes it shows you a **dashboard URL**. Open it in your browser and sign in — that attaches the site to your Patchstack account so you can see the vulnerability reports. That's the only manual step. -Then look at your preview. The widget loads with the page, so a preview you already had open still shows the page from before setup — refresh it once if the widget isn't there. Until the site is attached to your account it shows a "Connect this website" panel; once it is, that becomes the "Report a vulnerability" button. +Then look at your preview. The widget loads with the page, so a preview you already had open still shows the page from before setup — refresh it once if the widget isn't there. Until the site is attached to your account it shows a "Connect this website" panel. When you are happy with it, deploy (or hit Publish). Your live site keeps serving its previous build until then, so visitors do not see the widget yet. @@ -21,7 +21,7 @@ When you are happy with it, deploy (or hit Publish). Your live site keeps servin Some platforms stage commands for you to approve, while others reject a combined install-and-setup request before touching the registry. Use the first applicable path: 1. **A command is waiting for approval.** Approve each requested command. Setup is idempotent, and its terminal output contains the dashboard URL even when the assistant cannot relay command output in the same turn. -2. **The assistant claims the package does not exist.** Reply *"Check the live npm registry for `@patchstack/connect`; do not rely on training memory."* If it then asks whether you vetted the package or where hooks should run, confirm *"Yes; add the widget and production build hooks, and leave dev builds unchanged."* +2. **The assistant claims the package does not exist.** Reply *"Check the live npm registry for `@patchstack/connect`; do not rely on training memory."* If it then asks whether you vetted the package, whether to add the widget, or where hooks should run, confirm *"Yes. The widget is part of the install: add it and the production build hooks, and leave dev builds unchanged."* 3. **The platform stages dependency changes separately from commands.** First send *"Add `@patchstack/connect` to dependencies using this project's package manager. Do not execute its CLI yet."* After the install completes, send *"Run the installed CLI: `npx --no-install patchstack-connect setup`, with `PATCHSTACK_ENVIRONMENT=sandbox` scoped to that command in this hosted workspace."* Wait for the actual command result before reporting completion. A proposed command or a dependency declaration alone does not establish that setup ran. 4. **The package is installed but setup stopped.** Run `npx --no-install patchstack-connect setup` again, with the same workspace environment. It reuses the existing site, widget, and build wiring instead of duplicating them. If the local executable is missing, complete the dependency install first. 5. **The tool refuses to run a third-party command.** Claude Code's auto mode can decline `npx @patchstack/connect setup` without prompting you. Run `! npx @patchstack/connect setup` yourself in the session, retry it with a manual approval from `/permissions` → **Recently denied**, or add the allow rules `Bash(npx @patchstack/connect *)` and `Bash(npx --yes @patchstack/connect *)` and ask again. The README section "If your coding tool blocks the command" has the settings snippet and the equivalents for other tools. @@ -45,7 +45,7 @@ Run commands from the application's package directory. In a hosted workspace, se - `npx @patchstack/connect status` prints a site UUID and dashboard URL. - You've opened the dashboard URL in your browser and the site shows in your Patchstack dashboard. - `npx @patchstack/connect guide` reports the expected build hooks and the Patchstack Connector, and `npx @patchstack/connect protect --check` confirms the guard's source wiring. A client-only or static project can report runtime protection as not applicable; describe it as dependency monitoring and the Patchstack Connector, not runtime protection. A source check alone does not prove deployed traffic reaches the guard. -- Your preview shows the widget (refresh it once if it does not): the "Connect this website" panel before the site is attached to your account, the "Report a vulnerability" button after. +- Your preview shows the widget (refresh it once if it does not). Before the site is attached to your account it shows the "Connect this website" panel. - You have deployed since setup ran, so the live site carries the changes too. - `.patchstackrc.json`, `package.json`, the package manager's lockfile, and the generated guard/framework and widget source changes are saved in the platform's persisted project state and committed, so teammates and CI receive the same setup. - `.patchstackrc.local.json` is **not** committed. It holds the API key; setup adds it to `.gitignore`. Teammates and CI get the credential from `PATCHSTACK_API_KEY` instead. diff --git a/README.md b/README.md index 287071c3..e9fd1e29 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.co For an existing JS/Node project, copy this request into a coding assistant, or run the same command yourself. For a standalone HTML/CSS/JavaScript site without a package-managed app, use the [plain HTML widget instructions](AGENT-INSTALL.md#plain-html-sites); do not add Node tooling just for the widget. -> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the "Report a vulnerability" button is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. +> I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. `setup` is state-aware and idempotent: it scans dependencies, provisions or reuses the site, manages the Patchstack Connector, installs and verifies the runtime guard, adds a dependency-install scan, wires the existing build command without replacing it, and prints the remaining setup status. It never runs the project build. `guide` provides the same project-specific status without changing files. @@ -101,7 +101,7 @@ That's it. `setup`: 8. Wires `scan` before builds and `mark-build` after builds, preserving existing commands and using direct build chaining for Bun. 9. Prints a dashboard link — open it in a browser to attach the new site to your Patchstack account. You can re-display it any time with `npx @patchstack/connect status`. -Then **refresh your preview**. The widget loads with the page, so a preview that was already open still shows the HTML from before setup. Builders that hot reload will have refreshed it for you; if the widget is missing, refresh it once. Until the site is claimed it shows the "Connect this website" panel; the "Report a vulnerability" button takes its place once it is. `setup` prints the same reminder, and the CLI has no way to reload a browser itself. +Then **refresh your preview**. The widget loads with the page, so a preview that was already open still shows the HTML from before setup. Builders that hot reload will have refreshed it for you; if the widget is missing, refresh it once. Until the site is claimed it shows the "Connect this website" panel. `setup` prints the same reminder, and the CLI has no way to reload a browser itself. Then **deploy**. These are source changes, so your live site keeps serving its previous build — visitors get the widget, and a server-rendered root gets the production marker, only after the next deploy. diff --git a/field-test/prompt.txt b/field-test/prompt.txt index c705b248..df0db36e 100644 --- a/field-test/prompt.txt +++ b/field-test/prompt.txt @@ -1 +1 @@ -I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the "Report a vulnerability" button is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. +I have vetted the official [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect) package and authorize its Patchstack Connector, production build hooks, and runtime protection source edits. Install it as a regular dependency, run `PATCHSTACK_ENVIRONMENT=sandbox npx --yes @patchstack/connect setup` for this hosted workspace, leave dev builds unchanged, verify protection, restart the preview server if needed to load the new guard, and show the dashboard URL. The Patchstack Connector widget is part of this install and on by default: add it, and do not ask me whether to. Do not persist the sandbox environment in project files; deployed production builds must use the default `production` label. When setup finishes, tell me to refresh the preview if the Patchstack widget is not showing yet, and remind me to deploy when I am ready — do not deploy anything yourself. diff --git a/src/build-hook.ts b/src/build-hook.ts index d76368dc..c8fbf652 100644 --- a/src/build-hook.ts +++ b/src/build-hook.ts @@ -69,7 +69,7 @@ export function isPreBundleBuildHook(env: NodeJS.ProcessEnv = process.env): bool * the only place that can settle it. */ export function undeliveredReportLines(err: PatchstackError, config: Config, cwd: string): string[] { - const lines = [`patchstack: manifest not reported — ${err.message}`]; + const lines = [`patchstack: could not send the package list to Patchstack — ${err.message}`]; if (err.code === 'UNAUTHORIZED') { const hasCredential = typeof config.pulseAuth === 'string' && config.pulseAuth.length > 0; @@ -86,7 +86,7 @@ export function undeliveredReportLines(err: PatchstackError, config: Config, cwd } lines.push( - 'patchstack: continuing the build. Patchstack keeps the last manifest it accepted for this site until a scan that can report.', + 'patchstack: continuing the build. Patchstack keeps the last package list it received until a check gets through.', ); return lines; diff --git a/src/cli.ts b/src/cli.ts index 7597996f..709bb4e4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { computeManifestChecksum } from './checksum.js'; import { postInputMap, buildManifestBody, - claimOutcomeLines, + claimOutcome, DEFAULT_ENDPOINT, buildClaimUrl, fetchSiteStatus, @@ -59,11 +59,16 @@ import { collectGuideState, countRemainingSteps, detectPackageManager, + guideMissing, + guideNextStepContext, + notConnectedItem, + guideProgress, installCommand, renderGuideChecklist, resolveWidgetFileHint, - widgetTagInPlace, } from './guide.js'; +import { type NextStepContext, type Progress } from './progress.js'; +import { emptyReport, renderHookSummary, renderStatus, type StatusReport } from './report.js'; import { login, readPendingLogin, redeemIfApproved, startLogin, waitForApproval } from './login.js'; import { claim, @@ -79,7 +84,6 @@ import { runMap } from './map-command.js'; import { getStringFlag } from './flags.js'; import { isCanonicalUuid } from './endpoint-policy.js'; import { setupProtection, wireBuildScripts } from './setup.js'; -import type { SetupProtectionResult, WireBuildScriptsResult } from './setup.js'; import { isInstallOrBuildHook, isPreBundleBuildHook, undeliveredReportLines } from './build-hook.js'; import { applyBuildStamp } from './build-stamp.js'; import { detectStack, type StackDescriptor } from './stack.js'; @@ -202,6 +206,8 @@ Usage: Global: --version Print the installed version of this package and exit --help Print this help and exit + --verbose Also print the technical detail: site ID, endpoint, checksum, + environment and why, files written, installer steps Options (for scan, setup, status, and uninstall): --site-uuid Override the configured site UUID @@ -259,6 +265,17 @@ Examples: const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir', 'url', 'out', 'claim-token']); +/** Set from `--verbose`. The default output is a plain status report; this adds the technical lines. */ +let verbose = false; + +function detail(line: string): void { + if (verbose) console.log(line); +} + +function useColor(): boolean { + return process.stdout.isTTY === true && process.env.NO_COLOR === undefined; +} + interface ParsedArgs { command: string | null; positional: string[]; @@ -346,7 +363,7 @@ async function runInit(args: ParsedArgs): Promise { const target = await writeConfigFile(process.cwd(), { siteUuid: uuid }); console.log(`Wrote ${target}`); console.log(''); - console.log('Next: run `npx @patchstack/connect scan` to send your first manifest.'); + console.log('Next: run `npx @patchstack/connect scan`.'); return 0; } @@ -373,7 +390,7 @@ async function runClaim(args: ParsedArgs): Promise { if (result.credentialSaved === true) { const ignore = await secretFileIgnored(process.cwd()); // The value itself is never printed — only that it landed, and only that it is ignored when it is. - console.log(` A credential for this site was issued and saved to ${SECRET_CONFIG_FILENAME}.`); + console.log(` Saved a new credential to ${SECRET_CONFIG_FILENAME}.`); console.log(gitignoreOutcomeLine(ignore)); } console.log(''); @@ -383,8 +400,7 @@ async function runClaim(args: ParsedArgs): Promise { const prompt = (userCode: string, verificationUri: string) => { console.log(`\n Your code: ${userCode}`); console.log(` Claim at: ${verificationUri}\n`); - console.log(' Open that link and sign in to Patchstack — or create an account — to attach'); - console.log(" this site to it. Whoever approves becomes the site's owner.\n"); + console.log(' Open the link and sign in (or sign up) to connect this site. Whoever approves owns it.\n'); }; if (args.flags.has('wait')) { @@ -438,8 +454,7 @@ async function runClaim(args: ParsedArgs): Promise { } prompt(started.pending.userCode, started.pending.verificationUri); - console.log(' Give that link to the user. When they confirm they have claimed the site, run'); - console.log(' this same command again (or `claim --wait` to block until they do).\n'); + console.log(' Give the link to the user. Once they approve, run this command again (or `claim --wait`).\n'); return 0; } @@ -477,8 +492,7 @@ async function runLogin(args: ParsedArgs): Promise { // The value itself is never printed — only that it landed, and only that it is ignored when it is. console.log(`\n ✓ Credential restored and saved to ${SECRET_CONFIG_FILENAME}.`); console.log(gitignoreOutcomeLine(ignore)); - console.log(' The previous credential no longer works. Update it anywhere else it was set:'); - console.log(' CI secrets, hosting env vars, preview environments, other checkouts.\n'); + console.log(' The old credential no longer works. Update it in CI, hosting and other checkouts.\n'); return 0; }; @@ -503,9 +517,8 @@ async function runLogin(args: ParsedArgs): Promise { console.log(` Approve at: ${verificationUri}\n`); // Said before approval, not after: the person deciding needs to know it is // a rotation, and an assistant relaying this has to pass the warning on. - console.log(" Open that link and approve it as the site's owner. Approving issues a new"); - console.log(' credential and stops the current one working — CI, deploys and any other'); - console.log(' machine using it will need the new value.\n'); + console.log(" Open the link and approve as the site's owner. This replaces the current credential,"); + console.log(' so CI, deploys and other machines will need the new one.\n'); }; // Nobody is watching this stream. Blocking here would hide the link until the @@ -541,8 +554,7 @@ async function runLogin(args: ParsedArgs): Promise { } prompt(started.pending.userCode, started.pending.verificationUri); - console.log(' Give that link to the user. When they confirm they have approved it, run'); - console.log(' this same command again (or `login --wait` to block until they do).\n'); + console.log(' Give the link to the user. Once they approve, run this command again (or `login --wait`).\n'); return 0; } @@ -574,11 +586,11 @@ const MAX_RETRY_TIMEOUT_MS = 180_000; * person left it. */ /** - * Post the manifest under the label the server accepts, and say so when that was not the label asked for. + * Post the manifest under the label the server accepts, and say so (with --verbose) when that was not + * the label asked for. * * A server that predates the `local` label refuses it; the report then goes as `sandbox`, the nearest - * label that server has for "not the live site". Said out loud, because the dashboard will show the - * scan under that name. + * label that server has for "not the live site". */ async function postManifestAccepted( config: Config, @@ -586,8 +598,8 @@ async function postManifestAccepted( ): Promise { const { response, environmentUsed } = await postManifestWithEnvironmentFallback(config, payload); if (environmentUsed !== config.environment) { - console.warn( - `patchstack: this Patchstack API does not know the ${config.environment} label yet; the report was accepted as ${environmentUsed}, which also keeps it apart from production.`, + detail( + `This Patchstack API does not know the ${config.environment} label yet; the report was accepted as ${environmentUsed}, which also keeps it apart from production.`, ); } return response; @@ -614,28 +626,41 @@ async function postManifestWithPatience( const timeoutMs = Math.min(config.timeoutMs * RETRY_TIMEOUT_FACTOR, MAX_RETRY_TIMEOUT_MS); if (timeoutMs <= config.timeoutMs) throw err; - console.warn( - `patchstack: the report timed out after ${config.timeoutMs}ms; trying once more with ${timeoutMs}ms.`, - ); + console.warn('Patchstack is slow to answer. Trying once more…'); + detail(`The report timed out after ${config.timeoutMs}ms; trying once more with ${timeoutMs}ms.`); const response = await postManifestAccepted({ ...config, timeoutMs }, payload); try { const target = await persistTimeout(process.cwd(), timeoutMs); - console.log(`Saved a ${timeoutMs}ms request timeout to ${target}, so builds inherit it.`); + detail(`Saved a ${timeoutMs}ms request timeout to ${target}, so builds inherit it.`); } catch { - console.warn( - `patchstack: could not save the timeout; set PATCHSTACK_TIMEOUT_MS=${timeoutMs} where builds run.`, - ); + console.warn(`Could not save the longer wait. Set PATCHSTACK_TIMEOUT_MS=${timeoutMs} where your builds run.`); } return response; } } +function plural(count: number, one: string, many: string): string { + return `${count} ${count === 1 ? one : many}`; +} + async function runScan( args: ParsedArgs, - options: { showRemainingSetup?: boolean } = {}, + options: { + /** Collect this run's results here instead of printing them, for a caller that prints its own report. */ + report?: StatusReport; + /** False prints only what this run did, without the checklist. */ + showRemainingSetup?: boolean; + /** What the report established, for a caller that prints its own checklist. */ + onReported?: (outcome: Omit) => void; + } = {}, ): Promise { const dryRun = args.flags.get('dry-run') === true; + // A preview exists to check what would be sent, so it shows every detail. + const say = (line: string): void => { + if (verbose || dryRun) console.log(line); + }; + const report = options.report ?? emptyReport(); const config = await resolveCliConfig(args, { cliClaimToken: getStringFlag(args.flags, 'claim-token'), // The one command that reports them, so the one command that resolves them. @@ -643,63 +668,56 @@ async function runScan( }); const manifest = await scanLockfile(process.cwd()); for (const warning of manifest.warnings ?? []) { - console.warn(`patchstack: ${warning}`); + if (dryRun) console.warn(`patchstack: ${warning}`); + else report.missing.push({ text: warning }); } // Off unless asked for: locations widen what leaves the machine, so the upload that carries them is an // explicit choice rather than something an upgrade turns on. const installPaths = args.flags.get('install-paths') === true; const { payload, stats } = buildWirePayload(manifest, { installPaths }); - console.log( - `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names (${manifest.ecosystem} ecosystem).`, - ); + say(`Found ${payload.packages.length} package versions across ${stats.uniqueNames} packages (${manifest.ecosystem}).`); if (installPaths) { const located = payload.packages.filter((pkg) => pkg.paths !== undefined).length; console.log( payload.installPathsComplete - ? `Including each package's install location in the dependency tree (--install-paths), for all ${located}.` - : `Including install locations (--install-paths), for ${located} of ${payload.packages.length} — this lockfile format does not record them for the rest, which will be reported as "not recorded" rather than "not installed there".`, + ? `Including install locations (--install-paths) for all ${located}.` + : `Including install locations (--install-paths) for ${located} of ${payload.packages.length}. This lockfile does not record the rest; they are sent as "not recorded".`, ); } // The label decides how the dashboard reads this report — a production build is contact with a live - // site, a local one is inventory — so the line says which, and what decided it, every time. + // site, a local one is inventory — so the detail says which, and what decided it. if (config.environment === 'local') { - console.log( - 'Reporting from this machine as the local environment: the dashboard will show the app as configured, not deployed. A build on a platform this recognises reports production or sandbox on its own; on a platform it does not know, set PATCHSTACK_ENVIRONMENT=production for the build that goes live.', - ); + say('Environment: local (this machine). Set PATCHSTACK_ENVIRONMENT=production on a live build your host does not identify.'); } else { const because = (config.environmentEvidence ?? []).length > 0 ? ` (${config.environmentEvidence!.join('; ')})` : ''; - console.log(`Reporting under the ${config.environment} environment${because}. Override with PATCHSTACK_ENVIRONMENT.`); + say(`Environment: ${config.environment}${because}.`); } if (config.endpoint !== DEFAULT_ENDPOINT) { - console.log( - `Using endpoint override: ${config.endpoint} (set via --endpoint, PATCHSTACK_ENDPOINT, or .patchstackrc.json).`, - ); + say(`Endpoint override: ${config.endpoint}`); } if (stats.duplicateNames.length > 0) { const sample = stats.duplicateNames.slice(0, 10).join(', '); const more = stats.duplicateNames.length > 10 ? `, +${stats.duplicateNames.length - 10} more` : ''; - console.log( - `${stats.duplicateNames.length} package(s) appear at multiple versions: ${sample}${more}`, - ); + say(`${stats.duplicateNames.length} package(s) at more than one version: ${sample}${more}`); } // Ahead of the --dry-run return, so a preview says what a real run would report. This is the part of // the payload someone might disagree with, and it is easier to disagree with here than in the dashboard. const body = buildManifestBody(config, payload); if (typeof body.url === 'string') { - console.log(`Reporting this app's address as ${body.url}.`); + say(`Reporting app address: ${body.url}`); } if (typeof body.name === 'string') { - console.log(`Reporting this app's name as "${body.name}".`); + say(`Reporting app name: "${body.name}"`); } // Named but never printed: the token is a credential for the person's account, and it travels as a // header rather than in the body so that the payload preview below stays the whole body. if (typeof config.claimToken === 'string' && config.claimToken !== '') { - console.log('A claim token is set: the site will be connected to the Patchstack account that issued it.'); + say('Claim token set. The site will join the Patchstack account that issued it.'); } if (dryRun) { @@ -723,7 +741,7 @@ async function runScan( // from carrying a previous build's assertion into its artifact. if (isPreBundleBuildHook()) { const cleared = applyBuildStamp(process.cwd(), null); - if (cleared.kind === 'cleared') console.log(`Removed the previous map binding from ${cleared.file}.`); + if (cleared.kind === 'cleared') say(`Removed the previous map binding from ${cleared.file}.`); } // Ahead of the post deliberately. The marker carries no site UUID and needs no @@ -735,12 +753,12 @@ async function runScan( // The checksum of the manifest this run is posting — on a pre-build hook that is the build about // to be compiled. Without it a server-rendered app's page says it is live but not which build is, // which is the question the dashboard grades on. - reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages)); + reportSourceMarker(shellFramework, computeManifestChecksum(payload.packages), report); } const provisioning = config.siteUuid === null; if (provisioning) { - console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…'); + say('No site yet. Creating one on Patchstack…'); } // Hooked into an install or build, the report is this command's concern and the build is not: the @@ -754,11 +772,26 @@ async function runScan( return 0; } + const checked = `Checked ${plural(stats.uniqueNames, 'package', 'packages')}`; + if (response.stored) { + report.done.unshift(checked); + say(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`); + } else if (response.reason === 'duplicate') { + report.done.unshift(checked, 'No changes since the last check'); + } else { + report.missing.push({ + text: 'Patchstack did not save this check', + hint: [response.message ?? 'Run this again in a few minutes.'], + detail: [`Server response: ${JSON.stringify(response)}`], + }); + } + // The server always returns the UUID. If we didn't have one, persist it so // every subsequent scan targets the same site. if (provisioning && response.uuid !== undefined && response.uuid.length > 0) { const target = await persistSiteUuid(process.cwd(), response.uuid); - console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`); + report.done.push('Added this project to Patchstack'); + say(`Created site ${response.uuid}. Saved to ${target}.`); } if (typeof response.api_key === 'string' && response.api_key.length > 0) { // One credential for both paths: Pulse resolution falls back to apiKey, so @@ -766,108 +799,135 @@ async function runScan( // the two in step. Never printed — only the path it landed in. const hadCredentialInConfig = await credentialInCommittedConfig(process.cwd()); const saved = await persistApiKey(process.cwd(), response.api_key); - console.log(`Saved API key to ${saved.path}. Do not commit it.`); - // Only claimed when the ignore file was read back and really covers it. An assurance that turns out to - // be false is worse than none: it is the reason somebody stops checking. - console.log( - saved.ignored - ? ' Added to .gitignore.' - : ` NOT ignored by git — ${saved.reason ?? 'unknown reason'}. Add \`${SECRET_CONFIG_FILENAME}\` to .gitignore yourself before committing.`, - ); + // "Never commit" is said either way. Git-ignored only when the ignore file was read back and really + // covers it: an assurance that turns out to be false is the reason somebody stops checking. + if (saved.ignored) { + report.done.push(`Saved the project's API key in ${SECRET_CONFIG_FILENAME}. Never commit this file`); + say(`Saved API key to ${saved.path} (git-ignored).`); + } else { + report.missing.push({ + text: `${SECRET_CONFIG_FILENAME} holds your API key, and git does not ignore it`, + hint: [`Add ${SECRET_CONFIG_FILENAME} to .gitignore before you commit. Never commit this file.`], + detail: [`Saved to ${saved.path}. Not ignored: ${saved.reason ?? 'unknown reason'}.`], + }); + } if (hadCredentialInConfig) { - // Said out loud, because moving the file does not undo a commit: if it was ever pushed, the value is - // in the history and only a new credential ends that. - console.log( - 'A credential was also present in .patchstackrc.json and has been removed from it. If that file was ever committed, rotate the credential from the dashboard.', - ); + // Moving the file does not undo a commit: if it was ever pushed, only a new credential ends that. + report.missing.push({ + text: 'Your API key was in .patchstackrc.json', + hint: [ + `It is now in ${SECRET_CONFIG_FILENAME}. If .patchstackrc.json was ever committed, replace the key in the Patchstack dashboard.`, + ], + }); } } - if (response.stored) { - console.log(`Stored manifest #${response.manifest_id} (checksum ${response.checksum}).`); - } else if (response.reason === 'duplicate') { - console.log('Manifest unchanged since last scan — nothing to store.'); - } else { - console.log(`Server response: ${response.message ?? JSON.stringify(response)}`); - } - - // What became of the claim token, in the person's terms. Printed whenever one was passed — including - // when the server said nothing about it — so a token that did not connect the site is never mistaken - // for one that did. - const claimLines = claimOutcomeLines(response.claim, config); - if (claimLines.length > 0) { - console.log(''); - for (const line of claimLines) console.log(line); + // What became of the claim token, in the person's terms. Said whenever one was passed — including + // when the server said nothing about it — so a token that did not connect the project is never + // mistaken for one that did. + const claim = claimOutcome(response.claim, config); + if (claim !== null && claim.connected) { + report.done.push(claim.dashboardUrl !== null ? `${claim.summary}: ${claim.dashboardUrl}` : claim.summary); + } else if (claim !== null) { + report.missing.push(notConnectedItem([`${claim.summary}.`, ...claim.hint])); } const connected = response.claim?.state === 'claimed' || response.claim?.state === 'owned-by-you'; - // With a UUID in hand (existing or freshly provisioned), ensure the - // Patchstack Connector's managed tag in the source HTML shell so the very next - // preview reload shows the "Report a vulnerability" button. Best-effort and - // opt-out-able; a failed post never reaches this point, and --dry-run - // returned above. + // With a UUID in hand (existing or freshly provisioned), ensure the Patchstack widget's managed tag in + // the source HTML shell so the next preview reload shows it. Best-effort; a failed post never reaches + // this point, and --dry-run returned above. const effectiveUuid = config.siteUuid ?? response.uuid ?? null; if (config.widget && effectiveUuid !== null && effectiveUuid.length > 0) { - reportSourceWidget(effectiveUuid, shellFramework); + reportSourceWidget(effectiveUuid, shellFramework, report); } - // On the first scan (provisioning), surface the dashboard URL so the user can - // attach this site to their Patchstack account. `npx @patchstack/connect status` - // re-displays it any time. A site the claim token just connected needs no such - // step, and its dashboard was named above; a token that did not connect it makes - // this link the way in even on a re-scan. - const linkUuid = response.uuid ?? config.siteUuid; - if (!connected && (provisioning || claimLines.length > 0) && linkUuid !== null && linkUuid !== undefined && linkUuid.length > 0) { - console.log(''); - console.log('Connect this site to your Patchstack account — any one of these:'); - // The widget's own panel is the shortest route and is already rendered on the preview, so it - // leads. The link and `claim` follow for a project with no preview open: in a terminal the - // link is output nobody is looking at, which is why the command is named too. - if (config.widget) { - console.log(' 1. In the preview — the widget shows a "Connect this website" panel while the'); - console.log(' site is unclaimed. Signing in there attaches it.'); - console.log(' 2. Open this dashboard link in a browser:'); - console.log(` ${buildClaimUrl(config.endpoint, linkUuid)}`); - console.log(' 3. From this terminal: npx @patchstack/connect claim'); - } else { - console.log(' 1. Open this dashboard link in a browser:'); - console.log(` ${buildClaimUrl(config.endpoint, linkUuid)}`); - console.log(' 2. From this terminal: npx @patchstack/connect claim'); - } - if (config.endpoint !== DEFAULT_ENDPOINT) { - console.log(' (this URL inherits the endpoint override above)'); - } - } + const synced = + effectiveUuid !== null && effectiveUuid.length > 0 && (response.stored || response.reason === 'duplicate'); + const outcome: Omit = { + connected, + synced, + // Only the dashboard can see the live site; a production scan is reported, not confirmed. + deployed: false, + }; + options.onReported?.(outcome); - // A scan can't wire the build hooks itself — an agent that runs `scan` but not - // `guide` (a common shortcut) otherwise sees the widget + claim URL and assumes - // setup is finished. Surface whatever is still missing so the loop actually closes. - if (options.showRemainingSetup !== false) { + if (options.report === undefined) { try { - const state = await collectGuideState(process.cwd()); - const remaining = countRemainingSteps(state); - if (remaining > 0) { - const hooksMissing = !(state.prebuildWired && state.postbuildWired); - console.log(''); - console.log( - `Setup not complete — ${remaining} step(s) remaining${hooksMissing ? ", including the package.json build hooks (which scan can't wire)" : ''}.`, - ); - console.log('Run `npx @patchstack/connect guide` for the exact steps to finish for this project.'); - } + await printScanReport(config, effectiveUuid, outcome, report, options.showRemainingSetup !== false); } catch { - // Best-effort: never turn a successful scan into a failure over this nudge. + // Best-effort: never turn a successful scan into a failure over the report. } } return 0; } +/** An item the run reported replaces the working-tree item with the same key, which says less. */ +function mergeMissing(...lists: StatusReport['missing'][]): StatusReport['missing'] { + const merged: StatusReport['missing'] = []; + for (const item of lists.flat()) { + const key = item.key ?? item.text; + if (!merged.some((existing) => (existing.key ?? existing.text) === key)) merged.push(item); + } + return merged; +} + +/** Where the next step points, from the working tree plus what this run learned. */ +function scanNextStepContext(config: Config, state: GuideState, siteUuid: string | null): NextStepContext { + const uuid = siteUuid !== null && siteUuid.length > 0 ? siteUuid : state.siteUuid; + return { + ...guideNextStepContext(state), + environment: config.environment, + environmentSource: config.environmentSource, + siteUuid: uuid, + claimUrl: uuid !== null && config.endpointTrusted !== false ? buildClaimUrl(config.endpoint, uuid) : null, + }; +} + +/** + * The report a direct `scan` ends on. Inside an install or build it is two lines — what was done and what + * to do next — because those logs are long and read by nobody looking for a checklist. + */ +async function printScanReport( + config: Config, + siteUuid: string | null, + outcome: Omit, + report: StatusReport, + withProgress: boolean, +): Promise { + const state = await collectGuideState(process.cwd()); + const context = scanNextStepContext(config, state, siteUuid); + const progress = guideProgress(state, outcome); + + if ((isInstallOrBuildHook() || runningInCi()) && !verbose) { + for (const item of report.missing) console.warn(`Patchstack: ${[item.text + '.', ...(item.hint ?? [])].join(' ')}`); + for (const line of renderHookSummary(report, progress, context)) console.log(line); + return; + } + + if (verbose) console.log(''); + const missing = mergeMissing(report.missing, withProgress ? guideMissing(state, outcome) : []); + for (const line of renderStatus('Patchstack scan', { done: report.done, missing }, progress, context, { + useColor: useColor(), + verbose, + withoutProgress: !withProgress, + })) { + console.log(line); + } +} + /** - * Run the source-widget pass for `scan` and narrate the outcome. Never throws: + * Run the source-widget pass for `scan` and record the outcome. Never throws: * widget management is a convenience layered on top of a successful scan and * must not turn one into a failure. */ -function reportSourceWidget(siteUuid: string, framework: string | null): void { +function reportSourceWidget(siteUuid: string, framework: string | null, report: StatusReport): void { + const handAdd = (why: string): void => { + report.missing.push({ + text: 'The Patchstack widget is not on your page yet', + hint: [why, ` ${buildWidgetTag(siteUuid)}`], + }); + }; try { // A server-rendered project has no HTML shell to edit, so the framework's JSX root stands in for // one. Only where a literal tag is known to belong — the same set the marker will write into. @@ -877,38 +937,32 @@ function reportSourceWidget(siteUuid: string, framework: string | null): void { const result = ensureSourceWidget(process.cwd(), siteUuid, jsxShell); switch (result.action) { case 'added': - console.log(`Widget: added the Patchstack Connector tag to ${result.shell}. Reload your preview to see it.`); - console.log(' Unclaimed, it shows a "Connect this website" panel; the "Report a vulnerability" button replaces it once the site is claimed.'); + report.done.push(`Added the Patchstack widget to ${result.shell}`); break; case 'updated': - console.log(`Widget: updated the managed tag in ${result.shell} to site ${siteUuid}.`); + report.done.push(`Updated the Patchstack widget in ${result.shell}`); break; case 'unchanged': - console.log(`Widget: already installed in ${result.shell}.`); + report.done.push(`The Patchstack widget is already in ${result.shell}`); break; case 'manual': - console.log(`Widget: found an existing (manual) install in ${result.shell} — left untouched.`); + report.done.push(`Left your own Patchstack widget in ${result.shell} as it is`); break; case 'no-body': - console.log(`Widget: ${result.shell} has no tag to anchor on. Add this tag to your root layout manually:`); - console.log(` ${buildWidgetTag(siteUuid)}`); + handAdd(`${result.shell} has no . Add this to your main page layout, just before :`); break; case 'no-shell': - console.log('Widget: no root shell found to edit (index.html / public/index.html / src/app.html, or a JSX root).'); - console.log('Add this tag to your root layout before (run `guide` for framework-specific placement):'); - console.log(` ${buildWidgetTag(siteUuid)}`); + handAdd('Add this to your main page layout, just before :'); break; } - if (result.action === 'added' || result.action === 'updated') { - console.log(' (opt out any time with "widget": false in .patchstackrc.json)'); - } } catch (err) { - console.warn(`Widget: skipped (${(err as Error).message}).`); + handAdd('Add this to your main page layout, just before :'); + detail(`Widget: skipped (${(err as Error).message}).`); } } /** - * Run the production-marker pass for `scan` and narrate the outcome. Never + * Run the production-marker pass for `scan` and record the outcome. Never * throws, for the same reason the widget pass doesn't: this is a convenience on * top of a successful scan and must not turn one into a failure. * @@ -916,7 +970,7 @@ function reportSourceWidget(siteUuid: string, framework: string | null): void { * follows. On a server-rendered root that is the only way the marker reaches * production — `mark-build` runs after the build and has no HTML to stamp. */ -function reportSourceMarker(framework: string | null, checksum: string | null = null): void { +function reportSourceMarker(framework: string | null, checksum: string | null, report: StatusReport): void { try { const shell = resolveWidgetFileHint(process.cwd(), framework); if (shell === null || shell.toLowerCase().endsWith('.html')) { @@ -927,30 +981,28 @@ function reportSourceMarker(framework: string | null, checksum: string | null = const result = ensureSourceMarker(process.cwd(), shell, framework, checksum); switch (result.action) { case 'added': - console.log(`Production marker: added to ${shell} (guarded by ${productionGate(framework)}).`); - console.log(' This root is server-rendered, so the marker ships in source rather than built HTML.'); + report.done.push(`Set up ${shell} to tell Patchstack when it runs as your live app`); + detail(`Production marker: added to ${shell} (only set when ${productionGate(framework)}).`); break; case 'manual': - console.log(`Production marker: already set in ${shell} — left untouched.`); + detail(`Production marker: already in ${shell}.`); break; case 'no-anchor': case 'unsupported': - console.log( - `Production marker: ${shell} needs it by hand — without it the widget reads the published site as build mode.`, - ); - if (hasJsxShell(framework)) { - for (const line of buildSourceMarkerSnippet(framework).split('\n')) { - console.log(` ${line}`); - } - } else { - console.log( - ` Emit only when ${productionGate(framework)}.`, - ); - } + // Without it the widget cannot tell the live app from a preview, and shows the setup panel to visitors. + report.missing.push({ + text: 'Your live app does not tell Patchstack it is live yet', + hint: hasJsxShell(framework) + ? [`Add this inside in ${shell}:`, ...buildSourceMarkerSnippet(framework).split('\n').map((line) => ` ${line}`)] + : [ + `Add this inside in ${shell}, only when ${productionGate(framework)}:`, + ' ', + ], + }); break; } } catch (err) { - console.warn(`Production marker: skipped (${(err as Error).message}).`); + detail(`Production marker: skipped (${(err as Error).message}).`); } } @@ -1125,8 +1177,7 @@ async function runGuide(args: ParsedArgs): Promise { let allDone = false; try { const state = await collectGuideState(process.cwd()); - const useColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined; - console.log(renderGuideChecklist(state, useColor)); + console.log(renderGuideChecklist(state, useColor(), {}, { verbose })); allDone = countRemainingSteps(state) === 0; } catch { // fall through to the static guide @@ -1153,128 +1204,99 @@ async function runGuide(args: ParsedArgs): Promise { return 0; } +/** What setup did about runtime protection, as Done or Missing lines. The working tree covers "not wired". */ +function reportProtection(protection: ReturnType, report: StatusReport): void { + for (const line of protection.log) detail(`patchstack protect: ${line}`); + + if (protection.install.status === 'not-applicable') { + report.done.push('Runtime protection is not needed: this project has no server'); + if (protection.install.leftovers.length > 0) { + report.missing.push({ + text: 'Files from an earlier Patchstack protection setup are not used here', + hint: [`You can delete: ${protection.install.leftovers.join(', ')}`], + }); + } + return; + } + if (protection.verification.wired) { + const changed = protection.install.status === 'wired' && protection.install.changed.length > 0; + report.done.push( + changed + ? `Added runtime protection (${protection.verification.stack})` + : `Runtime protection is already set up (${protection.verification.stack})`, + ); + return; + } + // The generic installer cannot see where requests enter, so it says why rather than which file to edit. + if (protection.install.status === 'scaffolded' && protection.install.plan.includes('Could not locate a server entry')) { + report.missing.push({ + key: 'runtime-protection', + text: 'Runtime protection: no server file found', + hint: ['Add Patchstack where requests enter your app. Run npx @patchstack/connect protect for the steps.'], + }); + } +} + +/** What setup did to package.json's scripts, as one Done line. */ +function buildStepsLine(wired: ReturnType): string { + if (wired.strategy === 'postinstall-only') { + return wired.changed + ? 'Added a package check after each install to package.json' + : 'The package check after each install is already in package.json'; + } + return wired.changed ? 'Added the build steps to package.json' : 'The build steps are already in package.json'; +} + async function runSetup(args: ParsedArgs): Promise { if (args.flags.get('dry-run') === true) { - console.error('Error: setup does not support --dry-run. Use `scan --dry-run` to preview the manifest.'); + console.error('Setup cannot run as a preview. To see what would be sent, run: npx @patchstack/connect scan --dry-run'); return 1; } const before = await collectGuideState(process.cwd()); if (!before.hasPackageJson) { - console.error('Error: no package.json found. Run setup from the project root.'); + console.error('No package.json here. Run setup from the folder that has your package.json.'); console.error('For a standalone HTML site, use the widget-only instructions in AGENT-INSTALL.md; do not create a Node project just to run setup.'); return 1; } if (before.installed === null) { console.error( - `Error: @patchstack/connect is not declared in package.json.\nRun: ${installCommand(before.packageManager)}`, + `The Patchstack connector is not installed in this project yet.\nRun: ${installCommand(before.packageManager)}`, ); return 1; } - console.log('Patchstack setup — applying bounded project changes'); - console.log(' 1. Scan dependencies, provision/reuse the site, and manage the source widget'); - const scanCode = await runScan(args, { showRemainingSetup: false }); + const report = emptyReport(); + let reported: Partial = {}; + const scanCode = await runScan(args, { + report, + onReported: (outcome) => { + reported = outcome; + }, + }); if (scanCode !== 0) { return scanCode; } - console.log(''); - console.log(' 2. Install and verify runtime protection'); - const protection = setupProtection(process.cwd()); - if (protection.install.status === 'not-applicable') { - // Nothing was written, and nothing is owed. Said before the checklist so the reader has the shape of - // their project before they read a list that no longer mentions protection. - console.log('Runtime protection: not applicable to this project — nothing installed.'); - console.log(` ${protection.install.reason}`); - if (protection.install.leftovers.length > 0) { - console.log(' An earlier run scaffolded a guard here before that was established. These files do'); - console.log(' nothing on this project and can be deleted:'); - for (const file of protection.install.leftovers) console.log(` ${file}`); - } - } else if (protection.verification.wired) { - console.log(`Runtime protection: wired (${protection.verification.stack}).`); - } else { - console.log(`Runtime protection: manual wiring remains (${protection.verification.stack}):`); - for (const check of protection.verification.checks) { - console.log(` ${check.ok ? '✓' : '✗'} ${check.label}${!check.ok && check.hint ? ` — ${check.hint}` : ''}`); - } - console.log('Run `npx @patchstack/connect protect --check` after completing the failed checks.'); - } + reportProtection(setupProtection(process.cwd()), report); - console.log(''); - console.log(' 3. Wire dependency-install and production-build scans into package.json'); const wired = wireBuildScripts(process.cwd(), before.packageManager); - console.log(`Build integration: ${wired.detail}`); + report.done.push(buildStepsLine(wired)); + detail(`Build hooks: ${wired.detail}`); - console.log(''); - console.log(' 4. Verify setup status'); + const config = await resolveCliConfig(args); const after = await collectGuideState(process.cwd()); - const useColor = process.stdout.isTTY === true && process.env.NO_COLOR === undefined; - console.log(renderGuideChecklist(after, useColor)); - - const remaining = countRemainingSteps(after); - if (remaining > 0) { - console.log(''); - console.log(`Setup applied its bounded changes; ${remaining} manual step(s) remain above.`); - } - - // Setup ends on a page the user is already looking at, which loaded before the widget - // tag existed, and against a deployed site still serving its previous build. Nothing - // here can reach either one, so the agent relaying these is the whole mechanism. - // - // The outcome is a short, fixed-shape block rather than prose: an agent relays it as - // it is, and the words it does NOT contain matter as much as the ones it does. Nothing - // here says "connected" or "protected" — the app exists in a working tree, and every - // line says only what is true of that. - const outcome = setupOutcome(after, protection, wired); - console.log(''); - console.log('Outcome — relay this to the user as it is:'); - for (const [label, value] of outcome) console.log(` ${label}: ${value}`); - return 0; -} - -/** - * What setup established, as label/value pairs. - * - * Every value is a fact about the working tree or a step the person still owns. "Ready to deploy" is - * the strongest claim setup can make: it has not seen the live site and cannot, so it does not say - * anything about it. - */ -function setupOutcome( - state: GuideState, - protection: SetupProtectionResult, - wired: WireBuildScriptsResult, -): Array<[string, string]> { - const remaining = countRemainingSteps(state); - const lines: Array<[string, string]> = []; - - lines.push(['Status', remaining === 0 ? 'Ready to deploy (configured locally; nothing is live yet)' : `Configured locally; ${remaining} step(s) still to finish (see the checklist above)`]); - lines.push(['Monitoring', 'starts with the first build that runs on your hosting platform']); - - if (protection.install.status === 'not-applicable') { - lines.push(['Runtime protection', 'not applicable — this project has no request path (static build)']); - } else if (protection.verification.wired) { - lines.push(['Runtime protection', `wired (${protection.verification.stack}) — local wiring verified; verify against the live site after deploy with \`protect --check --runtime\``]); - } else { - lines.push(['Runtime protection', `not wired yet (${protection.verification.stack}) — finish the checks above, then \`npx @patchstack/connect protect --check\``]); - } - - lines.push(['Next steps', [ - 'add PATCHSTACK_API_KEY (from .patchstackrc.local.json) to your hosting platform\'s environment variables', - 'commit .patchstackrc.json, package.json and the widget change (never .patchstackrc.local.json)', - 'deploy, then check the site in the Patchstack dashboard — it reads "Deployed" once the live site is seen', - ].join('; ')]); + const missing = mergeMissing(report.missing, guideMissing(after, reported)); + const context = scanNextStepContext(config, after, after.siteUuid); - const warnings: string[] = []; - if (!wired.changed && wired.strategy === 'postinstall-only') warnings.push('no build script, so only dependency installs are scanned'); - if (protection.install.status === 'not-applicable' && protection.install.leftovers.length > 0) { - warnings.push(`earlier guard scaffold does nothing here and can be deleted: ${protection.install.leftovers.join(', ')}`); + if (verbose) console.log(''); + for (const line of renderStatus('Patchstack setup', { done: report.done, missing }, guideProgress(after, reported), context, { + useColor: useColor(), + verbose, + })) { + console.log(line); } - if (state.claimUrl !== null) warnings.push('the site is not attached to an account until someone signs in through the widget\'s "Connect this website" panel, the dashboard link is opened, or `npx @patchstack/connect claim` completes'); - if (warnings.length > 0) lines.push(['Warnings', warnings.join('; ')]); - - return lines; + return 0; } async function runStatus(args: ParsedArgs): Promise { @@ -1287,9 +1309,7 @@ async function runStatus(args: ParsedArgs): Promise { console.log(`Environment: ${config.environment}`); if (config.siteUuid !== null) { console.log(`Dashboard URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`); - console.log(' Not attached to an account yet? Sign in through the widget\'s "Connect this'); - console.log(' website" panel on the preview, open the link above, or run'); - console.log(' `npx @patchstack/connect claim`.'); + console.log(' Not connected yet? Open the link above or run `npx @patchstack/connect claim`.'); switch (await fetchSiteStatus(config)) { case 'active': @@ -1297,15 +1317,7 @@ async function runStatus(args: ParsedArgs): Promise { break; case 'removed': console.log('Site status: removed from Patchstack'); - console.log( - ' The site record no longer exists (deleted from the dashboard or via the', - ); - console.log( - ' widget uninstall flow). The local integration files are still in this', - ); - console.log( - ' project — see "Uninstalling" in AGENT-INSTALL.md to remove them.', - ); + console.log(' The local files are still here. See "Uninstalling" in AGENT-INSTALL.md to remove them.'); break; case 'unknown': console.log('Site status: could not be verified (Patchstack unreachable)'); @@ -1345,8 +1357,7 @@ async function runUninstall(args: ParsedArgs): Promise { } console.log(''); - console.log('This command only signals Patchstack. The local integration files must still be'); - console.log('removed — follow the "Uninstalling" steps in AGENT-INSTALL.md.'); + console.log('Local files are not touched. Remove them with the "Uninstalling" steps in AGENT-INSTALL.md.'); // Never fail the uninstall flow over the signal: local removal must proceed. return 0; } @@ -1413,7 +1424,7 @@ async function reportBuildStamp( await postManifestWithEnvironmentFallback(bounded, payload, marker); } catch (err) { console.warn( - `mark-build: this build was not reported to Patchstack (${(err as Error).message}). The pages were still marked as described above.`, + `mark-build: could not tell Patchstack about this build (${(err as Error).message}). The pages were still marked.`, ); } } @@ -1508,12 +1519,7 @@ async function runMarkBuild(args: ParsedArgs): Promise { // the marker only reaches production if it ships in the source shell. Silence // here reads as success, and the widget then treats the live site as a build. console.warn(`mark-build: found ${dir} but no HTML files in it.`); - console.warn( - 'mark-build: this build looks server-rendered, so the production flag has nothing to stamp.', - ); - console.warn( - 'mark-build: add the marker to your root shell instead — run `patchstack-connect guide` for the snippet.', - ); + console.warn('mark-build: this looks like a server-rendered app. Run `npx @patchstack/connect guide` to see what your main layout needs.'); await reportBuildStamp(reported, wirePayload, 'no-pages'); return 0; } @@ -1524,10 +1530,7 @@ async function runMarkBuild(args: ParsedArgs): Promise { const staleBy = staleBuildAge(files, startedAt); if (staleBy !== null) { console.warn( - `mark-build: nothing in ${dir} has been written in the last ${Math.round(staleBy / 60000)} minute(s), so this looks like output from an earlier build.`, - ); - console.warn( - 'mark-build: run it as a postbuild hook (or straight after the build) so what is stamped is what was just produced.', + `mark-build: nothing in ${dir} changed in the last ${Math.round(staleBy / 60000)} minute(s). Run it right after the build (as postbuild).`, ); } @@ -1559,7 +1562,8 @@ async function runMarkBuild(args: ParsedArgs): Promise { const stackSummary = stack !== null ? describeStack(stack) : null; if (published) { - console.log( + console.log(`Patchstack: marked ${plural(files.length, 'page', 'pages')} as your live app.`); + detail( `mark-build: marked ${marked} HTML file(s) in ${dir}` + `${checksum !== null ? ` (build ${checksum})` : ''}` + `${widgetTouched > 0 ? `, widget tag ensured in ${widgetTouched}` : ''}` + @@ -1574,16 +1578,13 @@ async function runMarkBuild(args: ParsedArgs): Promise { // came to be reported as a live, connected site in the first place. const because = environmentEvidence.length > 0 ? ` (${environmentEvidence.join('; ')})` : ''; console.log( - `mark-build: ${environment} build${because} — production marker withheld from ${files.length} HTML file(s) in ${dir}` + + `Patchstack: ${environment} build, so ${plural(files.length, 'page is', 'pages are')} not marked as your live app. Publishing this folder by hand? Run again with --production.`, + ); + detail( + `mark-build: ${environment} build${because}, so no production marker in ${files.length} HTML file(s) in ${dir}` + `${widgetTouched > 0 ? `, widget tag ensured in ${widgetTouched}` : ''}` + `${stackSummary !== null ? ` [${stackSummary}]` : ''}.`, ); - console.log( - 'mark-build: the marker tells Patchstack a page is the live site, so only the build that deploys carries it. Your hosting platform\'s build stamps it automatically.', - ); - console.log( - 'mark-build: publishing this directory by hand? Re-run with --production (or set PATCHSTACK_ENVIRONMENT=production) so the deployed pages carry it.', - ); await reportBuildStamp(reported, wirePayload, 'withheld'); return 0; } @@ -1609,8 +1610,33 @@ function packageVersion(): string { } } +/** + * What went wrong and what to do, for the errors whose own message is about the machinery (an address, a + * file format) rather than the fix. Null keeps the error's own message, which already names the fix. + */ +function plainErrorMessage(err: PatchstackError): string | null { + switch (err.code) { + case 'NETWORK_ERROR': + return 'Could not reach Patchstack. Check your internet connection and try again.'; + case 'NETWORK_TIMEOUT': + return 'Patchstack took too long to answer. Try again in a minute.'; + case 'SERVER_ERROR': + return 'Patchstack could not handle the request right now. Try again in a few minutes.'; + case 'LOCKFILE_NOT_FOUND': + return 'Could not find the list of installed packages. Install your packages first (for example npm install), then try again.'; + case 'LOCKFILE_PARSE_ERROR': + case 'LOCKFILE_UNSUPPORTED': + return 'Could not read the list of installed packages. Reinstall your packages (for example npm install), then try again.'; + case 'CONFIG_MISSING': + return 'This project is not set up with Patchstack yet. Run npx @patchstack/connect setup first.'; + default: + return null; + } +} + async function main(): Promise { const args = parseArgs(process.argv); + verbose = args.flags.get('verbose') === true; // Before help, and before any command: `--version` is what a bug report is asked for, so it must work // even when the rest of the arguments are wrong. @@ -1662,7 +1688,9 @@ main() .then((code) => process.exit(code)) .catch((err: unknown) => { if (err instanceof PatchstackError) { - console.error(`Error (${err.code}): ${err.message}`); + const plain = plainErrorMessage(err); + console.error(`${plain ?? err.message} (${err.code})`); + if (plain !== null && verbose) console.error(`Detail: ${err.message}`); process.exit(1); } console.error('Unexpected error:', err); diff --git a/src/client.ts b/src/client.ts index 22559ef1..efd11468 100644 --- a/src/client.ts +++ b/src/client.ts @@ -34,40 +34,52 @@ export function claimTokenHeader(config: Config): Record { : {}; } +export interface ClaimOutcome { + connected: boolean; + /** One line saying what happened. */ + summary: string; + /** Where the connected project lives in the dashboard, when Patchstack said. */ + dashboardUrl: string | null; + /** What to do when the token did not connect the project. */ + hint: string[]; +} + /** * What to tell the person about the claim token they passed, once Patchstack has answered. * - * Empty when no token was configured: nothing was asked, so there is nothing to report. Every other + * Null when no token was configured: nothing was asked, so there is nothing to report. Every other * case says something — including a server that did not answer the question at all — so a token that - * did not connect the site is never mistaken for one that did. + * did not connect the project is never mistaken for one that did. */ -export function claimOutcomeLines(claim: ManifestClaimOutcome | undefined, config: Config): string[] { - if (typeof config.claimToken !== 'string' || config.claimToken === '') return []; +export function claimOutcome(claim: ManifestClaimOutcome | undefined, config: Config): ClaimOutcome | null { + if (typeof config.claimToken !== 'string' || config.claimToken === '') return null; if (claim?.state === 'claimed' || claim?.state === 'owned-by-you') { - const dashboardUrl = safeRemoteUrl(claim.dashboard_url); - const dashboard = dashboardUrl === null ? [] : [`Dashboard: ${dashboardUrl}`]; - return [ - claim.state === 'claimed' - ? 'Connected to your Patchstack account.' - : 'This site is already connected to your Patchstack account.', - ...dashboard, - ]; + return { + connected: true, + summary: + claim.state === 'claimed' + ? 'Connected to your Patchstack account' + : 'This project is already connected to your Patchstack account', + dashboardUrl: safeRemoteUrl(claim.dashboard_url), + hint: [], + }; } - const why = - claim === undefined - ? 'Patchstack did not act on the claim token' - : claim.state === 'owned-by-other' - ? 'this site belongs to a different Patchstack account' - : claim.reason === 'expired' - ? 'the claim token has expired' - : 'Patchstack did not recognise the claim token'; - - return [ - `Not connected to your account: ${why}.`, - 'Open the dashboard link below to connect it, or copy a fresh prompt from the dashboard.', - ]; + const why = claimFailureReason(claim); + return { + connected: false, + summary: `Not connected to your account: ${why}`, + dashboardUrl: null, + hint: ['Open the dashboard link below to connect it, or copy a fresh prompt from the dashboard.'], + }; +} + +/** Why a claim token did not connect the project, in the person's terms. */ +export function claimFailureReason(claim: ManifestClaimOutcome | undefined): string { + if (claim === undefined) return 'Patchstack did not act on the claim token'; + if (claim.state === 'owned-by-other') return 'this project belongs to a different Patchstack account'; + return claim.reason === 'expired' ? 'the claim token has expired' : 'Patchstack did not recognise the claim token'; } export function buildEndpointUrl(base: string, siteUuid?: string | null): string { @@ -96,13 +108,13 @@ export function authFailureMessage(status: number, config: Config): string | nul const hasCredential = typeof config.pulseAuth === 'string' && config.pulseAuth.length > 0; if (status === 401 && !hasCredential) { - return 'Patchstack requires an API credential for this site and none is configured. Run `npx patchstack-connect login`, or set PATCHSTACK_API_KEY.'; + return "Patchstack needs this project's API key, and none is set here. Run npx @patchstack/connect login, or set PATCHSTACK_API_KEY."; } if (status === 401) { - return 'Patchstack rejected this API credential. It may have expired, been revoked, or the site may no longer exist. Run `npx patchstack-connect login` to issue a new one.'; + return "Patchstack did not accept this project's API key: it may have expired or been revoked, or the project may no longer exist. Run npx @patchstack/connect login to get a new one."; } if (status === 403) { - return 'This API credential is not permitted to act on this site. Check that siteUuid in .patchstackrc.json matches the credential (a credential is issued for one site).'; + return 'This API key belongs to a different project. Check that siteUuid in .patchstackrc.json is the project the key was made for.'; } return null; diff --git a/src/guide.ts b/src/guide.ts index 859f50f5..cb6480d9 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -21,6 +21,9 @@ import { } from './mark-build.js'; import { detectStack } from './stack.js'; import { buildWidgetTag } from './widget.js'; +import { type NextStepContext, type Progress } from './progress.js'; +import { renderStatus, type MissingItem } from './report.js'; +import type { Environment, EnvironmentSource } from './types.js'; /** Global the widget reads to decide it is running on a published build. */ const PROD_MARKER_NEEDLE = '__PATCHSTACK_PROD__'; @@ -41,6 +44,9 @@ export interface GuideState { claimUrl: string | null; /** Non-default API endpoint in effect (rc file, env, or flag), else null. */ endpointOverride: string | null; + /** Where a scan from here reports from, and what decided it. Null when the config is unreadable. */ + environment: Environment | null; + environmentSource: EnvironmentSource | null; hasBuildScript: boolean; installScanWired: boolean; prebuildWired: boolean; @@ -328,8 +334,12 @@ export async function collectGuideState(cwd: string): Promise { let claimUrl: string | null = null; let endpointOverride: string | null = null; let widgetOptOut = false; + let environment: Environment | null = null; + let environmentSource: EnvironmentSource | null = null; try { const config = await resolveConfig({ cwd }); + environment = config.environment; + environmentSource = config.environmentSource ?? null; siteUuid = config.siteUuid; if (siteUuid !== null && config.endpointTrusted !== false) { claimUrl = buildClaimUrl(config.endpoint, siteUuid); @@ -362,6 +372,8 @@ export async function collectGuideState(cwd: string): Promise { siteUuid, claimUrl, endpointOverride, + environment, + environmentSource, hasBuildScript: Boolean(pkg?.scripts?.build?.trim()), installScanWired: (pkg?.scripts?.postinstall ?? '').includes('patchstack-connect scan'), // The scan has to run first: a later prebuild command may upload and stamp the map that the bundle @@ -394,7 +406,6 @@ const ANSI = { cyan: '\u001B[36m', }; -/** Setup steps still missing — 0 means the checklist is fully green. */ /** * True when the site's root shell is code rather than an HTML file. Those roots * are server-rendered, so no built HTML file carries the marker to production and @@ -421,6 +432,7 @@ export function widgetTagInPlace(state: GuideState): boolean { ); } +/** Technical setup steps still missing; 0 means nothing is owed in the working tree. */ export function countRemainingSteps(state: GuideState): number { return [ state.installed?.section === 'dependencies', @@ -433,264 +445,190 @@ export function countRemainingSteps(state: GuideState): number { ].filter((step) => !step).length; } -export function renderGuideChecklist(state: GuideState, useColor: boolean): string { - const paint = (code: string, text: string): string => - useColor ? `${code}${text}${ANSI.reset}` : text; - const done = (text: string): string => ` ${paint(ANSI.green, '✔')} ${text}`; - const todo = (text: string): string => ` ${paint(ANSI.yellow, '✖')} ${paint(ANSI.bold, text)}`; - const detail = (text: string): string => ` ${paint(ANSI.dim, text)}`; - const lines: string[] = []; - - const headerParts = [state.framework, state.packageManager].filter( - (part): part is string => part !== null, - ); - const name = state.projectName ?? path.basename(process.cwd()); - lines.push(paint(ANSI.bold, `Patchstack setup status — ${name} (${headerParts.join(' · ')})`)); - if (state.endpointOverride !== null) { - lines.push( - detail( - `endpoint override in effect: ${state.endpointOverride} (set via .patchstackrc.json, PATCHSTACK_ENDPOINT, or --endpoint)`, - ), - ); - } - lines.push(''); - - if (!state.hasPackageJson) { - lines.push(todo('No package.json found in this directory.')); - lines.push(detail('For a JS/Node app, run the guide from its package directory; check that package.json is readable and valid.')); - lines.push(detail('For a standalone HTML/CSS/browser-JavaScript site, use the Patchstack Connector directly.')); - lines.push(detail('Do not create a Node project, build hooks, or a server just to install the widget.')); - lines.push(detail('Use the correct site UUID or widget snippet from the Patchstack dashboard; never invent one.')); - lines.push(detail('See "Plain HTML sites" in AGENT-INSTALL.md. Widget-only setup provides no dependency scan or runtime protection.')); - return lines.join('\n'); - } +/** The four progress steps as far as the working tree can tell. */ +export function guideProgress(state: GuideState, known: Partial = {}): Progress { + return { + installed: state.installed !== null, + // Claim state lives on the server and nothing on disk records it, so only a caller that has just + // heard from the server (a scan's claim outcome) can mark it done. + connected: false, + // `.patchstackrc.json` only gains a site UUID from a manifest the server stored. + synced: state.siteUuid !== null, + // Only the dashboard can see the live site, so nothing the CLI runs marks this done. + deployed: false, + ...known, + }; +} - // The person watching this run usually did not write the project. The install - // is the longest silence in their first hour with Patchstack, so the guide - // asks the agent to fill it — a request, never something the setup relies on. - if (countRemainingSteps(state) > 0) { - lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Tell the user what you are doing as you go.')}`); - lines.push(' Installs here take minutes and they are watching output they cannot read.'); - lines.push(' Say it in plain words: their site is built out of hundreds of ready-made'); - lines.push(' pieces of code, Patchstack keeps track of which ones it uses and warns them'); - lines.push(' when one turns out to be unsafe, and nothing their visitors see changes.'); - lines.push(' Avoid lockfile / dependency / manifest / npm / UUID when speaking to them.'); - lines.push(''); - } +export function guideNextStepContext(state: GuideState): NextStepContext { + return { + installCommand: installCommand(state.packageManager), + siteUuid: state.siteUuid, + claimUrl: state.claimUrl, + environment: state.environment, + environmentSource: state.environmentSource, + }; +} - // Until the first scan, `setup` covers every step listed below in one bounded command, so the - // checklist names it first rather than leaving the agent to assemble the steps by hand. - if (state.siteUuid === null) { - lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Nothing is set up yet — two commands cover the steps below:')}`); - if (state.installed?.section !== 'dependencies') { - lines.push(` ${installCommand(state.packageManager)}`); - } - lines.push(' npx @patchstack/connect setup'); - lines.push(' setup provisions the site, adds the widget, installs and verifies runtime protection,'); - lines.push(' and wires the install and build scans, then prints this checklist again. Anything'); - lines.push(' still listed there is a step setup could not apply safely, and is yours to finish.'); - lines.push(''); - } +/** + * An unconnected project can be connected by whoever loads it first, so the warning goes wherever the + * project is shown as not connected. `why` leads when a claim token was tried and did not connect it. + */ +export function notConnectedItem(why: string[] = []): MissingItem { + return { + text: 'Not connected to your Patchstack account', + hint: [...why, 'Until it is, anyone who opens your app can connect it to their own account.'], + }; +} - // 1. Install - if (state.installed?.section === 'dependencies') { - lines.push(done(`@patchstack/connect installed (${state.installed.version}, ${state.installed.section})`)); - } else if (state.installed !== null) { - lines.push(todo(`Move @patchstack/connect to runtime dependencies (currently ${state.installed.section})`)); - lines.push(detail(`Run → ${installCommand(state.packageManager)}`)); - lines.push(detail('The generated guard imports @patchstack/connect/protect at runtime.')); - } else { - lines.push(todo('Install @patchstack/connect as a runtime dependency')); - lines.push(detail(`Run → ${installCommand(state.packageManager)}`)); +/** The build-script lines `setup` adds, for someone adding them by hand. */ +function buildScriptLines(state: GuideState): string[] { + const lines: string[] = []; + if (!state.installScanWired) lines.push('"postinstall": "patchstack-connect scan"'); + if (state.hasBuildScript && !(state.prebuildWired && state.postbuildWired)) { if (state.packageManager === 'bun') { - lines.push(detail(`(if bun isn't available here, ${INSTALL_COMMANDS.npm} works too)`)); + // bun run skips npm-style pre/post scripts, so the hooks chain inside the build script. + lines.push('"build": "patchstack-connect scan && && patchstack-connect mark-build"'); + } else { + if (!state.prebuildWired) lines.push('"prebuild": "patchstack-connect scan"'); + if (!state.postbuildWired) lines.push('"postbuild": "patchstack-connect mark-build"'); } } + return lines; +} - // 2. Provision (first scan) - if (state.siteUuid !== null) { - lines.push(done(`Site provisioned (${state.siteUuid})`)); - } else { - lines.push(todo('Provision the site — run the first scan')); - lines.push(detail('Run → npx @patchstack/connect scan')); - lines.push(detail('Reads the lockfile, registers the project, writes .patchstackrc.json,')); - lines.push(detail('and prints a dashboard link. The CLI prints the link but never opens it.')); - lines.push(detail('If your tool refuses to run this command, hand it to the person instead of working')); - lines.push(detail('around it — see "When your tool will not run this CLI" in the reference guide.')); +/** + * What the working tree is still missing, each with the one thing to do about it. Until the site exists + * only a dev-only install is listed: before that, `setup` is the next step and applies the rest. + * + * `connected` is whether the caller heard from Patchstack that the project has an owner. Nothing on disk + * records it, so without that answer the project is treated as not connected. + */ +export function guideMissing(state: GuideState, known: Partial = {}): MissingItem[] { + const missing: MissingItem[] = []; + + if (state.installed?.section === 'devDependencies') { + missing.push({ + text: 'Patchstack is installed as a development tool only, so your live app cannot load it', + hint: [`Run: ${installCommand(state.packageManager)}`], + }); } - - // 3. Dependency-change scan - if (state.installScanWired) { - lines.push(done('Dependency-install scan wired (postinstall)')); - } else { - lines.push(todo('Scan again whenever dependencies are installed')); - lines.push(detail('Edit package.json → "postinstall": "patchstack-connect scan"')); + if (state.siteUuid === null) return missing; + + if (!state.widgetOptOut && state.widgetInstalled && state.widgetTokenMatches === false) { + missing.push({ + text: 'The Patchstack widget on your page belongs to a different project', + hint: [`Set data-site-uuid to '${state.siteUuid}' on the widget tag.`], + }); + } else if (!state.widgetOptOut && !state.widgetInstalled) { + missing.push({ + text: 'The Patchstack widget is not on your page yet', + hint: [ + state.widgetFileHint !== null + ? `Add this to ${state.widgetFileHint}, just before :` + : 'Add this to your main page layout, just before :', + ` ${buildWidgetTag(state.siteUuid)}`, + ], + }); } - // 4. Build hooks - if (!state.hasBuildScript) { - lines.push(done('No build script to integrate (postinstall covers dependency changes)')); - } else if (state.prebuildWired && state.postbuildWired) { - lines.push(done('Build hooks wired (scan before builds, mark-build after)')); - } else if (state.packageManager === 'bun') { - // bun run skips npm-style pre/post scripts, so chain inside the build script. - lines.push(todo('Wire the build hooks yourself — edit package.json (bun skips pre/post hooks, so chain inside "build")')); - lines.push(detail('Edit package.json → "build": "patchstack-connect scan && && patchstack-connect mark-build"')); - } else { - lines.push(todo('Wire the build hooks yourself — edit package.json "scripts" (chain with && if a hook already exists)')); - if (!state.prebuildWired) { - lines.push(detail('Edit package.json → "prebuild": "patchstack-connect scan"')); - } - if (!state.postbuildWired) { - lines.push(detail('Edit package.json → "postbuild": "patchstack-connect mark-build"')); - } + if (state.protectionApplicable && !state.protectionWired) { + const failing = state.protectionChecks.filter((item) => !item.ok && item.group !== 'reporting'); + const generic = state.protectionStack === 'generic'; + missing.push({ + key: 'runtime-protection', + text: generic + ? 'Runtime protection: not added to your server yet' + : `Runtime protection: not finished for ${state.protectionStack}`, + hint: generic + ? ['Add Patchstack where requests enter your app. Run npx @patchstack/connect protect for the steps.'] + : ['Run: npx @patchstack/connect protect, then npx @patchstack/connect protect --check'], + detail: failing.map((check) => `${check.label}${check.hint ? ` — ${check.hint}` : ''}`), + }); } - // 5. Patchstack Connector - const widgetOk = state.widgetInstalled && state.widgetTokenMatches !== false; - if (state.widgetOptOut && !widgetOk) { - lines.push(done('Patchstack Connector disabled by config ("widget": false in .patchstackrc.json)')); - } else if (widgetOk) { - lines.push(done('Patchstack Connector installed')); - } else if (state.widgetInstalled) { - lines.push(todo("Fix the Patchstack Connector yourself — its site UUID doesn't match this project's")); - lines.push(detail(`Edit the widget tag → set data-site-uuid (or userToken) to '${state.siteUuid}' (a wrong UUID makes the widget silently no-op)`)); - } else if (state.siteUuid === null) { - lines.push(todo('Add the Patchstack Connector — the first scan does this for you')); - lines.push(detail('Run → npx @patchstack/connect scan (provisions the site and adds the widget tag')); - lines.push(detail(' to the root HTML shell: index.html / public/index.html / src/app.html)')); - } else { - lines.push(todo('Add the Patchstack Connector yourself — this root is code, not a plain HTML shell')); - lines.push(detail('Note → a normal `scan` adds this tag to a plain HTML shell automatically; add it by hand here:')); - const placement = - state.widgetFileHint !== null - ? `Edit ${state.widgetFileHint} → put it just before :` - : "Edit your root layout → put it just before (the framework's HTML/layout mechanism, never a JS entry point):"; - lines.push(detail(placement)); - lines.push(detail(` ${buildWidgetTag(state.siteUuid)}`)); - lines.push(detail('The site UUID is public by design — it ships in client-side HTML.')); + const scripts = buildScriptLines(state); + if (scripts.length > 0) { + missing.push({ + text: 'Patchstack does not check your packages on install and build yet', + hint: ['Run: npx @patchstack/connect setup (it adds the build steps to package.json)'], + detail: [ + state.packageManager === 'bun' ? 'Add to package.json scripts:' : 'Add to package.json scripts (chain with && if one exists):', + ...scripts.map((line) => ` ${line}`), + ], + }); } - // 5b. Production marker on server-rendered roots. A code root never emits a - // static HTML file, so `mark-build` has nothing to stamp and the marker only - // reaches production if it ships in the shell alongside the widget tag. - if (needsSourceProductionMarker(state)) { - if (state.productionMarkerWired) { - lines.push(done('Production marker wired (widget switches to report mode on the published site)')); - } else { - lines.push(todo('Add the production marker — this root is server-rendered, so mark-build cannot stamp it')); - lines.push( - detail( - 'Without it the widget treats the published site as build mode and shows the claim flow to visitors.', - ), - ); - const gate = productionGate(state.framework); - if (hasJsxShell(state.framework)) { - lines.push(detail(`Run → npx @patchstack/connect scan (adds it to ${state.widgetFileHint} automatically)`)); - lines.push(detail('Or add it by hand, in above the widget tag:')); - for (const snippetLine of buildSourceMarkerSnippet(state.framework).split('\n')) { - lines.push(detail(` ${snippetLine}`)); - } - } else { - lines.push( - detail( - `Edit ${state.widgetFileHint} → using the framework's head mechanism, emit an inline`, - ), - ); - lines.push(detail(` only when ${gate}.`)); - } - lines.push(detail(`The ${gate} guard is required — an ungated marker also hides the claim flow in preview.`)) - } + // A server-rendered root has no built HTML page for `mark-build` to flag as the live site. + if (needsSourceProductionMarker(state) && !state.productionMarkerWired) { + const gate = productionGate(state.framework); + missing.push( + hasJsxShell(state.framework) + ? { + text: 'Your live app does not tell Patchstack it is live yet', + hint: [`Run: npx @patchstack/connect scan (it edits ${state.widgetFileHint})`], + detail: [`Or add inside :`, ...buildSourceMarkerSnippet(state.framework).split('\n').map((line) => ` ${line}`)], + } + : { + text: 'Your live app does not tell Patchstack it is live yet', + hint: [ + `Add this inside in ${state.widgetFileHint}, only when ${gate}:`, + ' ', + ], + }, + ); } - // 6. Runtime protection - if (!state.protectionApplicable) { - // Not a green tick for a step that was done, and not a red one for a step still owed. A guard screens - // requests, and a project that only emits files never receives one, so the honest line says the - // capability does not apply here — and says what does, since "no runtime protection" read alone - // sounds like a gap rather than a shape. - lines.push(done('Runtime protection: not applicable — this project has no request path')); - for (const check of state.protectionChecks.filter((item) => item.hint !== undefined && item.group !== 'reporting')) { - lines.push(detail(check.hint ?? '')); - } - } else if (state.protectionWired) { - lines.push(done(`Runtime protection wired (${state.protectionStack})`)); - } else { - lines.push(todo(`Finish runtime protection (${state.protectionStack})`)); - for (const check of state.protectionChecks.filter((item) => !item.ok)) { - lines.push(detail(`${check.label}${check.hint ? ` — ${check.hint}` : ''}`)); - } - lines.push(detail('Verify → npx @patchstack/connect protect --check')); - // Named here rather than run: `guide` is read-only and must never start the application. - lines.push(detail('Prove a request reaches the guard (starts your app) → npx @patchstack/connect protect --check --runtime')); - } + if (known.connected !== true) missing.push(notConnectedItem()); - // 7. Attaching the site to an account. Three routes reach the same place, and the widget's own - // panel leads because it is already on the page the person is looking at. The link and `claim` - // are for a project with no preview open, or none carrying the widget. - lines.push(''); - if (state.claimUrl !== null) { - lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Connect this site to your Patchstack account:')}`); - let route = 1; - if (widgetTagInPlace(state)) { - lines.push(` ${route++}. In the preview — while the site is unclaimed the widget shows a`); - lines.push(' "Connect this website" panel. Signing in there attaches the site.'); - } - lines.push(` ${route++}. In a browser — open the dashboard link (the CLI never opens it):`); - lines.push(` ${paint(ANSI.cyan, state.claimUrl)}`); - lines.push(` ${route}. From this terminal → npx @patchstack/connect claim`); - lines.push(' (prints a link to sign in with, then attaches the site to that account)'); - lines.push(detail('Reports have no owner to reach until one of these completes. The site UUID ships')); - lines.push(detail('in the page and claiming is first-come, so an unclaimed site stays claimable by')); - lines.push(detail('anyone who loads it.')); - if (state.endpointOverride !== null) { - lines.push(detail('(this URL inherits the endpoint override above)')); - } - } else { - lines.push(detail('The dashboard link appears after the first scan (re-print any time with `status`).')); - } + return missing; +} - // 8. Preview refresh. The tag is in the source, but a page that was already open - // loaded before it existed and renders nothing until it reloads. Which control appears - // then depends on claim state: the widget serves the connect panel until the site has an - // owner, and the report button only after. - if (widgetTagInPlace(state)) { - lines.push(''); - lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Refresh the preview to see the widget:')}`); - lines.push(' The widget loads with the page, so a preview that was already open still shows'); - lines.push(' the HTML from before this change. Builders that hot reload refresh it themselves;'); - lines.push(' if nothing appears, refresh the preview once.'); - lines.push(' Unclaimed, the widget shows the "Connect this website" panel; the public'); - lines.push(' "Report a vulnerability" button takes its place once the site is claimed.'); - } +export interface RenderGuideOptions { + verbose?: boolean; +} + +export function renderGuideChecklist( + state: GuideState, + useColor: boolean, + known: Partial = {}, + options: RenderGuideOptions = {}, +): string { + const paint = (code: string, text: string): string => + useColor ? `${code}${text}${ANSI.reset}` : text; + const name = state.projectName ?? path.basename(process.cwd()); + const title = `Patchstack status for ${name}`; - // 9. Deploy. Everything above is a source change, so the running production site keeps - // serving its previous build — including one with no widget and no production marker. - if (state.siteUuid !== null) { - lines.push(''); - lines.push(` ${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, 'Deploy to put this on your live site:')}`); - lines.push(' These are source changes. Your deployed site keeps serving its previous build,'); - lines.push(' so visitors only get the widget after you deploy (or hit Publish) again.'); + if (!state.hasPackageJson) { + return [ + paint(ANSI.bold, title), + '', + paint(ANSI.bold, 'Missing'), + ` ${paint(ANSI.yellow, '✘')} No package.json here.`, + ' For a JS/Node app, run this from its package directory.', + ' For a plain HTML site, follow "Plain HTML sites" in AGENT-INSTALL.md. It gets the widget only: no dependency scan or runtime protection.', + ' Do not create a Node project just to add the widget.', + ' Use the site UUID or widget snippet from the Patchstack dashboard. Never invent one.', + ].join('\n'); } - const remaining = countRemainingSteps(state); - lines.push(''); - if (remaining === 0) { - lines.push( - done( - paint( - ANSI.bold, - 'Ready to deploy. Everything above is in the working tree only: commit .patchstackrc.json, package.json, the runtime guard changes, and the file carrying the widget snippet; add PATCHSTACK_API_KEY to the hosting platform; then deploy. Never commit .patchstackrc.local.json — it holds the API key, and setup has already added it to .gitignore.', - ), - ), - ); - if (state.claimUrl !== null) { - lines.push(detail('Until the site is attached to an account (dashboard link above, or `claim`), nobody can see its reports.')); - } - } else { - lines.push( - ` ${paint(ANSI.yellow, String(remaining))} step(s) remaining — details in the reference guide below.`, - ); + const lines = renderStatus( + title, + { done: [], missing: guideMissing(state, known) }, + guideProgress(state, known), + guideNextStepContext(state), + { useColor, verbose: options.verbose }, + ); + + if (options.verbose === true) { + const verbose = [ + `Project: ${[state.framework, state.packageManager].filter((part) => part !== null).join(' · ')}`, + ...(state.siteUuid !== null ? [`Site UUID: ${state.siteUuid}`] : []), + ...(state.environment !== null ? [`Environment: ${state.environment}${state.environmentSource !== null ? ` (${state.environmentSource})` : ''}`] : []), + ...(state.endpointOverride !== null ? [`Endpoint override: ${state.endpointOverride}`] : []), + ...(state.widgetOptOut ? ['Widget is off ("widget": false in .patchstackrc.json).'] : []), + ]; + lines.splice(1, 0, ...verbose.map((line) => paint(ANSI.dim, line))); } return lines.join('\n'); diff --git a/src/parsers/index.ts b/src/parsers/index.ts index 4dd48f8b..daa230b0 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -160,8 +160,8 @@ export async function scanLockfile(cwd: string): Promise { } warnings.push( walkWasCandidate - ? 'Reporting node_modules/, which is what the build loads.' - : 'Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.', + ? 'Checked the installed packages in node_modules/, which is what the build loads.' + : 'Checked the installed packages in node_modules/ instead. Delete the out-of-date package list file to stop this warning.', ); return manifestWith(walked, warnings); @@ -176,7 +176,7 @@ export async function scanLockfile(cwd: string): Promise { } warnings.push( - `No fully-consistent source found; reporting ${firstParsed.filename}. The manifest may understate the real dependency set.`, + `No package list matches package.json; checked ${firstParsed.filename}, so some packages may be missing. Reinstall your packages to fix this.`, ); return manifestWith(firstParsed.packages, warnings); @@ -209,16 +209,24 @@ function conflictWarning( .join('; '); return ( - `${filename} and another lockfile disagree about installed versions (${shown}` + + `${filename} and another package list file give different versions (${shown}` + `${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ''}). ` + - 'Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.' + 'Checked node_modules/ instead. Delete the file your package manager does not use.' ); } +const INSTALL_BY_SOURCE: Record = { + 'package-lock.json': 'npm install', + 'bun.lock': 'bun install', + 'bun.lockb': 'bun install', + 'pnpm-lock.yaml': 'pnpm install', + 'yarn.lock': 'yarn install', +}; + function staleWarning(source: string, missing: string[]): string { const sample = missing.slice(0, 3).join(', '); const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : ''; - return `${source} looks stale: package.json declares ${missing.length} dependenc${missing.length === 1 ? 'y' : 'ies'} it does not contain (${sample}${suffix}).`; + return `${source} is out of date: it is missing ${missing.length} package${missing.length === 1 ? '' : 's'} listed in package.json (${sample}${suffix}). Run ${INSTALL_BY_SOURCE[source] ?? 'your install command'} to update it.`; } async function presentLockfiles(cwd: string): Promise { diff --git a/src/parsers/report.ts b/src/parsers/report.ts index 431e6cfb..8969caa3 100644 --- a/src/parsers/report.ts +++ b/src/parsers/report.ts @@ -38,8 +38,7 @@ export function unreadableWarning(filename: string, report: ParseReport): string const one = report.unreadable === 1; return ( - `${filename}: ${report.unreadable} entr${one ? 'y was' : 'ies were'} not in a form this scanner reads ` + - `(${shown}${report.unreadable > report.samples.length ? ', …' : ''}), so ${one ? 'it is' : 'they are'} absent from ` + - `this manifest. Anything installed only through ${one ? 'that entry' : 'those entries'} is not being checked.` + `${filename}: ${report.unreadable} entr${one ? 'y' : 'ies'} could not be read ` + + `(${shown}${report.unreadable > report.samples.length ? ', …' : ''}), so ${one ? 'that package is' : 'those packages are'} not being checked.` ); } diff --git a/src/progress.ts b/src/progress.ts new file mode 100644 index 00000000..31c83270 --- /dev/null +++ b/src/progress.ts @@ -0,0 +1,142 @@ +// The four-step progress checklist `guide`, `setup` and `scan` all end on. The step labels match the +// Patchstack dashboard word for word, so a person moving between the two sees one list. + +import type { Environment, EnvironmentSource } from './types.js'; + +export type ProgressStep = 'installed' | 'connected' | 'synced' | 'deployed'; + +export type Progress = Record; + +export const PROGRESS_STEPS: ReadonlyArray<{ step: ProgressStep; label: string }> = [ + { step: 'installed', label: 'Install the Patchstack connector' }, + { step: 'connected', label: 'Connect project to Patchstack account' }, + { step: 'synced', label: 'Sync and monitor your project' }, + { step: 'deployed', label: 'Deploy project to protect live app' }, +]; + +export interface NextStepContext { + /** Install command for this project's package manager. */ + installCommand: string; + siteUuid: string | null; + claimUrl: string | null; + /** Where a scan from here reports from. Names the sync step and decides what the deploy step says. */ + environment?: Environment | null; + environmentSource?: EnvironmentSource | null; +} + +/** The sync step names the environment the scan came from, as the dashboard does. */ +export function stepLabel(step: ProgressStep, environment?: Environment | null): string { + if (step === 'synced' && environment) return `Sync and monitor in ${environment} environment`; + return PROGRESS_STEPS.find((entry) => entry.step === step)!.label; +} + +/** + * A production scan is a build, not a deploy: a hosted builder such as Lovable builds without + * publishing, and a platform can build a release it never serves. Only the dashboard sees the live + * site, so the CLI reports what it sent and leaves the tick to Patchstack. + */ +export function deployNote(context: NextStepContext): string | null { + if (context.environment !== 'production') return null; + return context.environmentSource === 'builder' + ? 'Reported as a publish. Patchstack ticks this once it sees the live site.' + : 'Built for production. Patchstack ticks this once it sees the live site.'; +} + +export interface RenderProgressOptions { + useColor: boolean; +} + +const ANSI = { + reset: '\u001B[0m', + bold: '\u001B[1m', + dim: '\u001B[2m', + green: '\u001B[32m', + yellow: '\u001B[33m', + cyan: '\u001B[36m', +}; + +export function nextProgressStep(progress: Progress): ProgressStep | null { + return PROGRESS_STEPS.find(({ step }) => !progress[step])?.step ?? null; +} + +/** The next step as an action, for the `Next:` line. */ +export function nextStepTitle(step: ProgressStep, context: NextStepContext): string { + switch (step) { + case 'installed': + return 'install the Patchstack connector'; + case 'connected': + return 'connect this project to your Patchstack account'; + case 'synced': + return 'sync this project with Patchstack'; + case 'deployed': + return context.environment === 'production' + ? 'open your live app so Patchstack can see it' + : 'deploy your project to protect the live app'; + } +} + +/** What to do for one step: the command or link first, one line each. */ +export function nextStepLines( + step: ProgressStep, + context: NextStepContext, +): string[] { + switch (step) { + case 'installed': + return [`Run: ${context.installCommand}`, 'Then run: npx @patchstack/connect setup']; + case 'connected': + if (context.siteUuid === null) { + return [ + 'Run: npx @patchstack/connect setup', + 'It adds this project to Patchstack and prints the link to connect it.', + 'Cannot run commands here? See "When your tool will not run this CLI" in AGENT-INSTALL.md.', + ]; + } + return [ + context.claimUrl !== null ? `Open ${context.claimUrl}` : 'Run: npx @patchstack/connect claim', + // A production run is the deploy itself, so there is nothing further to point at. + ...(context.environment === 'production' + ? [] + : ['Already connected? Then commit, set PATCHSTACK_API_KEY on your host, and deploy.']), + ]; + case 'synced': + return ['Run: npx @patchstack/connect scan']; + case 'deployed': + if (context.environment === 'production') { + return ['Open the live site once so Patchstack can see it.', 'Not published yet? Publish or deploy it now.']; + } + return [ + 'Commit your changes. Never commit .patchstackrc.local.json.', + 'Set PATCHSTACK_API_KEY (from .patchstackrc.local.json) on your hosting platform.', + 'Deploy or publish. The live site keeps its old version until you do.', + ]; + } +} + +export function renderProgress( + progress: Progress, + context: NextStepContext, + options: RenderProgressOptions, +): string[] { + const paint = (code: string, text: string): string => + options.useColor ? `${code}${text}${ANSI.reset}` : text; + const lines: string[] = []; + + for (const { step } of PROGRESS_STEPS) { + const label = stepLabel(step, context.environment); + lines.push(progress[step] ? ` ${paint(ANSI.green, '✔')} ${label}` : ` ${paint(ANSI.yellow, '✘')} ${label}`); + const note = step === 'deployed' && !progress.deployed ? deployNote(context) : null; + if (note !== null) lines.push(` ${paint(ANSI.dim, note)}`); + } + + const next = nextProgressStep(progress); + lines.push(''); + if (next === null) { + lines.push(paint(ANSI.bold, 'All done.')); + return lines; + } + lines.push(`${paint(ANSI.cyan, '➜')} ${paint(ANSI.bold, `Next: ${nextStepTitle(next, context)}`)}`); + for (const line of nextStepLines(next, context)) { + lines.push(` ${line}`); + } + return lines; +} diff --git a/src/protect/install/util.ts b/src/protect/install/util.ts index 4db03679..89649ddb 100644 --- a/src/protect/install/util.ts +++ b/src/protect/install/util.ts @@ -6,7 +6,24 @@ import { fileURLToPath } from 'node:url'; import { writeProjectFileSync } from '../../safe-file.js'; export const read = (p: string): string => readFileSync(p, 'utf8'); -export const log = (msg: string): void => console.log(`patchstack protect: ${msg}`); +let logSink: ((msg: string) => void) | null = null; + +export const log = (msg: string): void => { + if (logSink !== null) logSink(msg); + else console.log(`patchstack protect: ${msg}`); +}; + +/** Run `fn` with the installer's log lines collected instead of printed, so a caller can summarise them. */ +export function collectProtectLog(fn: () => T): { result: T; lines: string[] } { + const lines: string[] = []; + const previous = logSink; + logSink = (msg) => lines.push(...msg.split('\n')); + try { + return { result: fn(), lines }; + } finally { + logSink = previous; + } +} /** True when `name` is in the project's dependencies or devDependencies. */ export function hasDependency(cwd: string, name: string): boolean { diff --git a/src/report.ts b/src/report.ts new file mode 100644 index 00000000..a3a3c924 --- /dev/null +++ b/src/report.ts @@ -0,0 +1,107 @@ +// The plain status report `setup`, `scan` and `guide` print: what this run did, what is still missing, +// then the progress checklist and the one next step. Technical detail is not part of it; the commands +// print that only with --verbose. + +import { + nextProgressStep, + nextStepLines, + nextStepTitle, + renderProgress, + type NextStepContext, + type Progress, +} from './progress.js'; + +export interface MissingItem { + /** Items with the same key say the same thing; the first one reported is kept. Defaults to `text`. */ + key?: string; + /** What is missing, in one short sentence. */ + text: string; + /** What to do about it, one line each. */ + hint?: string[]; + /** Technical lines printed under the hint only with --verbose. */ + detail?: string[]; +} + +export interface StatusReport { + done: string[]; + missing: MissingItem[]; +} + +export function emptyReport(): StatusReport { + return { done: [], missing: [] }; +} + +const ANSI = { + reset: '\u001B[0m', + bold: '\u001B[1m', + dim: '\u001B[2m', + green: '\u001B[32m', + yellow: '\u001B[33m', +}; + +export interface RenderStatusOptions { + useColor: boolean; + verbose?: boolean; + /** Leave out the checklist and the next step, for a caller that prints its own. */ + withoutProgress?: boolean; +} + +export function renderStatus( + title: string, + report: StatusReport, + progress: Progress, + context: NextStepContext, + options: RenderStatusOptions, +): string[] { + const paint = (code: string, text: string): string => + options.useColor ? `${code}${text}${ANSI.reset}` : text; + const lines: string[] = [paint(ANSI.bold, title), '']; + + if (report.done.length > 0) { + lines.push(paint(ANSI.bold, 'Done')); + for (const item of report.done) lines.push(` ${paint(ANSI.green, '✔')} ${item}`); + lines.push(''); + } + + if (report.missing.length > 0) { + lines.push(paint(ANSI.bold, 'Missing')); + for (const item of report.missing) { + lines.push(` ${paint(ANSI.yellow, '✘')} ${item.text}`); + for (const hint of item.hint ?? []) lines.push(` ${hint}`); + if (options.verbose === true) { + for (const detail of item.detail ?? []) lines.push(` ${paint(ANSI.dim, detail)}`); + } + } + lines.push(''); + } + + if (options.withoutProgress === true) { + if (lines[lines.length - 1] === '') lines.pop(); + return lines; + } + + lines.push(...renderProgress(progress, context, { useColor: options.useColor })); + return lines; +} + +/** + * The same report for a build log: one line for what was done and one for what to do next. A build + * hook runs on every install and build, where the full checklist is noise nobody reads. + */ +export function renderHookSummary(report: StatusReport, progress: Progress, context: NextStepContext): string[] { + const lowerFirst = (text: string): string => text.charAt(0).toLowerCase() + text.slice(1); + const lines: string[] = []; + + if (report.done.length > 0) { + lines.push(`Patchstack: ${report.done.map(lowerFirst).join('; ')}.`); + } + + const next = nextProgressStep(progress); + if (next !== null) { + // A production build is the publish itself; the title already says the one thing left. + const first = + next === 'deployed' && context.environment === 'production' ? undefined : nextStepLines(next, context)[0]; + lines.push(`Patchstack next step: ${nextStepTitle(next, context)}.${first !== undefined ? ` ${first}` : ''}`); + } + return lines; +} diff --git a/src/setup.ts b/src/setup.ts index ffebafc9..a9585182 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { PackageManager } from './guide.js'; import { runProtect, runVerify } from './protect/install/index.js'; +import { collectProtectLog } from './protect/install/util.js'; import type { ProtectResult, VerifyReport } from './protect/install/types.js'; import { writeProjectFileSync } from './safe-file.js'; @@ -23,6 +24,8 @@ export interface WireBuildScriptsResult { export interface SetupProtectionResult { install: ProtectResult; verification: VerifyReport; + /** What the installer said while it ran, for `--verbose`. */ + log: string[]; } /** @@ -32,9 +35,9 @@ export interface SetupProtectionResult { * when an existing framework seam cannot safely be overwritten. */ export function setupProtection(cwd: string): SetupProtectionResult { - const install = runProtect(cwd); + const { result: install, lines: log } = collectProtectLog(() => runProtect(cwd)); const verification = runVerify(cwd); - return { install, verification }; + return { install, verification, log }; } /** Add `command` after an existing lifecycle hook without duplicating it. */ diff --git a/tests/auth-failure-message.test.ts b/tests/auth-failure-message.test.ts index acf41cd0..e0a6f249 100644 --- a/tests/auth-failure-message.test.ts +++ b/tests/auth-failure-message.test.ts @@ -125,6 +125,6 @@ describe('a removed site is still reported as removed', () => { const outcome = await postPackageRemoved(WITH_CREDENTIAL); expect(outcome.result).toBe('failed'); - expect((outcome as { message: string }).message).toMatch(/credential/i); + expect((outcome as { message: string }).message).toMatch(/API key/i); }); }); diff --git a/tests/bin-invocation.test.ts b/tests/bin-invocation.test.ts index de58a550..800d0f2f 100644 --- a/tests/bin-invocation.test.ts +++ b/tests/bin-invocation.test.ts @@ -137,13 +137,13 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { }); // Said in prose before the preview, so it is noticed here rather than in the dashboard. - expect(stdout).toContain(`Reporting this app's address as https://recipes.example.com.`); - expect(stdout).toContain(`Reporting this app's name as "Recipe Box".`); + expect(stdout).toContain('Reporting app address: https://recipes.example.com'); + expect(stdout).toContain('Reporting app name: "Recipe Box"'); const preview = stdout.slice(stdout.indexOf('Payload preview:')); expect(preview).toContain('"url": "https://recipes.example.com"'); expect(preview).toContain('"name": "Recipe Box"'); - expect(stdout).toContain('Reporting from this machine as the local environment'); + expect(stdout).toContain('Environment: local (this machine)'); expect(preview).toContain('"environment": "local"'); expect(preview).toContain('"packages"'); } finally { @@ -223,7 +223,7 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { const result = await runScan(dir, server.endpoint, 'build'); expect(result.status).toBe(0); - expect(result.stderr).toContain('manifest not reported'); + expect(result.stderr).toContain('could not send the package list'); expect(result.stderr).toContain('PATCHSTACK_API_KEY'); } finally { await server.close(); @@ -268,7 +268,7 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { const result = await runScan(dir, server.endpoint); expect(result.status).toBe(1); - expect(result.stderr).toContain('Error (UNAUTHORIZED)'); + expect(result.stderr).toContain('(UNAUTHORIZED)'); expect(result.stderr).not.toContain('continuing the build'); } finally { await server.close(); @@ -276,4 +276,214 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => { } }); }); + + /** A direct scan ends on the same four-step checklist as `guide` and `setup`, filled in from this report. */ + describe('the progress checklist a scan ends on', () => { + const SITE = '22222222-2222-4222-8222-222222222222'; + + async function acceptingServer( + claim?: Record, + ): Promise<{ endpoint: string; close: () => Promise }> { + const server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ uuid: SITE, stored: true, manifest_id: 7, checksum: 'abc', ...(claim ? { claim } : {}) })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + + return { + endpoint: `http://127.0.0.1:${port}/monitor/pulse/manifest`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; + } + + function freshProject(): string { + const dir = mkdtempSync(path.join(tmpdir(), 'ps-bin-progress-')); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'example-app', version: '1.0.0', dependencies: { '@patchstack/connect': '^0.5.0', axios: '^1.6.0' } }), + ); + copyFileSync(path.join(root, 'tests', 'fixtures', 'package-lock-v3.json'), path.join(dir, 'package-lock.json')); + writeFileSync(path.join(dir, 'index.html'), ''); + return dir; + } + + async function scan(cwd: string, endpoint: string, extra: NodeJS.ProcessEnv = {}, args: string[] = []): Promise { + return run(cwd, endpoint, ['scan', ...args], extra); + } + + async function run(cwd: string, endpoint: string, args: string[], extra: NodeJS.ProcessEnv = {}): Promise { + const { stdout } = await promisify(execFile)('node', [bin, ...args], { + cwd, + env: { PATH: process.env.PATH, HOME: process.env.HOME, PATCHSTACK_ENDPOINT: endpoint, ...extra }, + encoding: 'utf8', + }); + return stdout; + } + + it('marks the local sync done and names connecting as the one next step', async () => { + const server = await acceptingServer(); + const dir = freshProject(); + try { + const stdout = await scan(dir, server.endpoint); + + expect(stdout).toContain(' ✔ Install the Patchstack connector'); + expect(stdout).toContain(' ✘ Connect project to Patchstack account'); + expect(stdout).toContain(' ✔ Sync and monitor in local environment'); + expect(stdout).toContain(' ✘ Deploy project to protect live app'); + expect(stdout.match(/Next: /g)).toHaveLength(1); + expect(stdout).toContain('Next: connect this project to your Patchstack account'); + expect(stdout).toContain(`/monitor/claim?site=${SITE}`); + expect(stdout).toContain(' ✔ Added the Patchstack widget to index.html'); + expect(stdout).toContain('anyone who opens your app can connect it to their own account'); + expect(stdout).not.toMatch(/report a vulnerability/i); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reports a production scan without ticking the deploy the dashboard has not seen', async () => { + const server = await acceptingServer(); + const dir = freshProject(); + try { + const stdout = await scan(dir, server.endpoint, { PATCHSTACK_ENVIRONMENT: 'production' }); + + expect(stdout).toContain(' ✔ Sync and monitor in production environment'); + expect(stdout).toContain(' ✘ Deploy project to protect live app'); + expect(stdout).toContain('Patchstack ticks this once it sees the live site.'); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('marks the site connected when the claim token connected it', async () => { + const server = await acceptingServer({ state: 'claimed' }); + const dir = freshProject(); + try { + const stdout = await scan(dir, server.endpoint, {}, ['--claim-token', 'tok-123']); + + expect(stdout).toContain(' ✔ Connect project to Patchstack account'); + expect(stdout).toContain('Next: deploy your project to protect the live app'); + expect(stdout).not.toContain('/monitor/claim?site='); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * The default output is read by people who do not write code. The technical words are still there for + * whoever needs them, behind --verbose. + */ + describe('plain by default, technical with --verbose', () => { + const BANNED = [ + /manifest/i, + /checksum/i, + /uuid/i, + /endpoint/i, + /lockfile/i, + /provision/i, + /\bguard\b/i, + /adapter/i, + /\bseam\b/i, + /scaffold/i, + /marker/i, + /npm ecosystem/i, + /environment_source/i, + /Reporting app name/, + /Environment: /, + /patchstack protect:/, + /Tell the user/, + /jargon/i, + ]; + + function expectPlain(output: string): void { + for (const word of BANNED) expect(output, String(word)).not.toMatch(word); + } + + it('keeps scan, setup and guide free of technical words', async () => { + const server = await acceptingServer(); + const dir = freshProject(); + try { + const fresh = await run(dir, server.endpoint, ['guide']); + expectPlain(fresh.slice(0, fresh.indexOf('———— Full reference guide'))); + expectPlain(await scan(dir, server.endpoint)); + const setup = await run(dir, server.endpoint, ['setup']); + expect(setup).toContain('Patchstack setup'); + expect(setup).toContain('Done\n'); + expect(setup).not.toContain('1/3'); + expectPlain(setup); + const guide = await run(dir, server.endpoint, ['guide']); + expectPlain(guide.slice(0, guide.indexOf('———— Full reference guide'))); + expectPlain(await scan(dir, server.endpoint, { PATCHSTACK_ENVIRONMENT: 'production' })); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('brings the detail back with --verbose', async () => { + const server = await acceptingServer(); + const dir = freshProject(); + try { + const scanned = await scan(dir, server.endpoint, {}, ['--verbose']); + expect(scanned).toContain('Environment: local'); + expect(scanned).toContain('Endpoint override:'); + expect(scanned).toContain(`Created site ${SITE}`); + expect(scanned).toContain('Stored manifest #7 (checksum abc)'); + + const setup = await run(dir, server.endpoint, ['setup', '--verbose']); + expect(setup).toContain('patchstack protect:'); + expect(setup).toContain('Build hooks:'); + + const guide = await run(dir, server.endpoint, ['guide', '--verbose']); + expect(guide).toContain(`Site UUID: ${SITE}`); + expect(guide).toContain('Endpoint override:'); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps a build-hook scan to two lines', async () => { + const server = await acceptingServer(); + const dir = freshProject(); + try { + const stdout = await scan(dir, server.endpoint, { npm_lifecycle_event: 'prebuild', npm_lifecycle_script: 'patchstack-connect scan' }); + const lines = stdout.trim().split('\n'); + + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/^Patchstack: checked \d+ packages?/); + expect(lines[1]).toMatch(/^Patchstack next step: connect this project to your Patchstack account\. Open /); + expectPlain(stdout.replace(/Open \S+/, '')); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('says a network failure plainly, with the code for support', async () => { + const dir = freshProject(); + try { + const result = spawnSync(process.execPath, [bin, 'scan'], { + cwd: dir, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: process.env.HOME, PATCHSTACK_ENDPOINT: 'http://127.0.0.1:1/monitor/pulse/manifest' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr.trim()).toBe( + 'Could not reach Patchstack. Check your internet connection and try again. (NETWORK_ERROR)', + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + }); }); diff --git a/tests/claim-token.test.ts b/tests/claim-token.test.ts index eabc7716..c76232b2 100644 --- a/tests/claim-token.test.ts +++ b/tests/claim-token.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { CLAIM_TOKEN_HEADER, claimOutcomeLines, claimTokenHeader, postManifest } from '../src/client.js'; +import { CLAIM_TOKEN_HEADER, claimOutcome, claimTokenHeader, postManifest } from '../src/client.js'; import { persistApiKey, persistSiteUuid, resolveConfig } from '../src/config.js'; import type { Config, ManifestClaimOutcome } from '../src/types.js'; @@ -110,21 +110,22 @@ describe('the claim token on the wire', () => { describe('what the person is told about the claim token', () => { it('says nothing when none was passed', () => { - expect(claimOutcomeLines({ state: 'claimed' }, config())).toEqual([]); - expect(claimOutcomeLines(undefined, config())).toEqual([]); + expect(claimOutcome({ state: 'claimed' }, config())).toBeNull(); + expect(claimOutcome(undefined, config())).toBeNull(); }); - it('names the dashboard when the site landed in the account, and tells a re-run apart', () => { - const claimed = claimOutcomeLines( + it('names the dashboard when the project landed in the account, and tells a re-run apart', () => { + const claimed = claimOutcome( { state: 'claimed', site_id: 7, dashboard_url: 'https://app.example.com/site/7/monitoring' }, config({ claimToken: 'tok' }), ); - expect(claimed[0]).toMatch(/connected to your patchstack account/i); - expect(claimed).toContain('Dashboard: https://app.example.com/site/7/monitoring'); - expect(claimOutcomeLines({ state: 'owned-by-you' }, config({ claimToken: 'tok' }))[0]).toMatch(/already connected/i); + expect(claimed?.connected).toBe(true); + expect(claimed?.summary).toMatch(/connected to your patchstack account/i); + expect(claimed?.dashboardUrl).toBe('https://app.example.com/site/7/monitoring'); + expect(claimOutcome({ state: 'owned-by-you' }, config({ claimToken: 'tok' }))?.summary).toMatch(/already connected/i); }); - it('says why the site is not connected, and points at the link — even when the server said nothing', () => { + it('says why the project is not connected, and points at the link — even when the server said nothing', () => { const cases: [ManifestClaimOutcome | undefined, RegExp][] = [ [{ state: 'owned-by-other' }, /different patchstack account/i], [{ state: 'rejected', reason: 'expired' }, /expired/i], @@ -132,10 +133,11 @@ describe('what the person is told about the claim token', () => { [undefined, /did not act/i], ]; for (const [claim, why] of cases) { - const lines = claimOutcomeLines(claim, config({ claimToken: 'tok' })); - expect(lines[0]).toMatch(/^Not connected to your account/); - expect(lines[0]).toMatch(why); - expect(lines.join(' ')).toMatch(/dashboard link/i); + const outcome = claimOutcome(claim, config({ claimToken: 'tok' })); + expect(outcome?.connected).toBe(false); + expect(outcome?.summary).toMatch(/^Not connected to your account/); + expect(outcome?.summary).toMatch(why); + expect(outcome?.hint.join(' ')).toMatch(/dashboard link/i); } }); }); diff --git a/tests/guide.test.ts b/tests/guide.test.ts index 453f249b..75434de6 100644 --- a/tests/guide.test.ts +++ b/tests/guide.test.ts @@ -14,6 +14,7 @@ import { renderGuideChecklist, widgetTagInPlace, } from '../src/guide.js'; +import { PROGRESS_STEPS } from '../src/progress.js'; const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'; @@ -130,6 +131,7 @@ describe('guide', () => { }); it('does not call a scan after another prebuild command wired', async () => { + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); writeJson('package.json', { scripts: { build: 'vite build', @@ -141,9 +143,7 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(state.prebuildWired).toBe(false); - expect(renderGuideChecklist(state, false)).toContain( - 'Edit package.json → "prebuild": "patchstack-connect scan"', - ); + expect(renderGuideChecklist(state, false, {}, { verbose: true })).toContain('"prebuild": "patchstack-connect scan"'); }); it('survives a project with no package.json', async () => { @@ -160,8 +160,8 @@ describe('guide', () => { expect(state.endpointOverride).toBe('http://127.0.0.1:4870/monitor/pulse/manifest'); expect(state.siteUuid).toBeNull(); - const output = renderGuideChecklist(state, false); - expect(output).toContain('endpoint override in effect: http://127.0.0.1:4870'); + expect(renderGuideChecklist(state, false)).not.toContain('Endpoint override'); + expect(renderGuideChecklist(state, false, {}, { verbose: true })).toContain('Endpoint override: http://127.0.0.1:4870'); }); it('reports no override on the default endpoint', async () => { @@ -213,28 +213,84 @@ describe('guide', () => { }); describe('renderGuideChecklist', () => { - it('prints the package-manager-specific install command for missing installs', async () => { + const wiredProject = (): void => { + writeJson('package.json', { + name: 'done-app', + dependencies: { '@patchstack/connect': '0.2.11' }, + scripts: { + postinstall: 'patchstack-connect scan', + prebuild: 'patchstack-connect scan', + postbuild: 'patchstack-connect mark-build', + }, + }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + writeFileSync(path.join(cwd, 'index.html'), `patchstack-widget.js userToken: '${VALID_UUID}'`); + writeGenericProtection(); + }; + + it('prints the four progress steps in order, with the agreed wording', async () => { + // A developer's machine. Without this, a CI runner's own variables name the environment. + process.env.PATCHSTACK_ENVIRONMENT = 'local'; + writeJson('package.json', { name: 'fresh-app' }); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + const positions = [ + ' ✘ Install the Patchstack connector', + ' ✘ Connect project to Patchstack account', + ' ✘ Sync and monitor in local environment', + ' ✘ Deploy project to protect live app', + ].map((line) => output.indexOf(line)); + + expect(PROGRESS_STEPS.map(({ label }) => label)).toEqual([ + 'Install the Patchstack connector', + 'Connect project to Patchstack account', + 'Sync and monitor your project', + 'Deploy project to protect live app', + ]); + expect(positions.every((position) => position > -1)).toBe(true); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + }); + + it('prints exactly one next step', async () => { + wiredProject(); + + const output = renderGuideChecklist(await collectGuideState(cwd), false); + + expect(output.match(/Next: /g)).toHaveLength(1); + }); + + it('names the package-manager-specific install as the next step when the package is missing', async () => { writeJson('package.json', { name: 'bun-app', scripts: { build: 'vite build' } }); writeFileSync(path.join(cwd, 'bun.lock'), ''); const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain(installCommand('bun')); - expect(output).toContain('npx @patchstack/connect scan'); - expect(output).toContain('bun skips pre/post hooks'); + expect(output).toContain('Next: install the Patchstack connector'); + expect(output).toContain(`Run: ${installCommand('bun')}\n Then run: npx @patchstack/connect setup`); + expect(output).not.toContain('\u001B['); + }); + + it('chains the hooks inside the build script on bun', async () => { + writeJson('package.json', { name: 'bun-app', scripts: { build: 'vite build' } }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); + writeFileSync(path.join(cwd, 'bun.lock'), ''); + + const output = renderGuideChecklist(await collectGuideState(cwd), false, {}, { verbose: true }); + expect(output).toContain( '"build": "patchstack-connect scan && && patchstack-connect mark-build"', ); - // Consistent action labels: "Run →" for a command, "Edit … →" for a file change. - expect(output).toContain('Run → '); - expect(output).toContain('Edit package.json → '); - expect(output).not.toContain('\u001B['); + expect(output).not.toContain('"prebuild"'); }); it('suggests prebuild/postbuild hooks on non-bun projects', async () => { writeJson('package.json', { name: 'npm-app', scripts: { build: 'vite build' } }); + writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); - const output = renderGuideChecklist(await collectGuideState(cwd), false); + expect(renderGuideChecklist(await collectGuideState(cwd), false)).toContain( + 'Run: npx @patchstack/connect setup (it adds the build steps to package.json)', + ); + const output = renderGuideChecklist(await collectGuideState(cwd), false, {}, { verbose: true }); expect(output).toContain('"prebuild": "patchstack-connect scan"'); expect(output).toContain('"postbuild": "patchstack-connect mark-build"'); @@ -250,8 +306,9 @@ describe('guide', () => { const output = renderGuideChecklist(state, false); expect(state.installed?.section).toBe('devDependencies'); - expect(output).toContain('Move @patchstack/connect to runtime dependencies'); - expect(output).toContain('@patchstack/connect/protect at runtime'); + expect(output).toContain('✔ Install the Patchstack connector'); + expect(output).toContain('installed as a development tool only, so your live app cannot load it'); + expect(output).toContain(`Run: ${installCommand('npm')}`); }); it('counts a chained build script as wired (the bun pattern)', async () => { @@ -269,7 +326,7 @@ describe('guide', () => { }); it('substitutes the real UUID into the widget snippet once provisioned', async () => { - writeJson('package.json', { name: 'uuid-app' }); + writeJson('package.json', { name: 'uuid-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); const output = renderGuideChecklist(await collectGuideState(cwd), false); @@ -278,26 +335,48 @@ describe('guide', () => { expect(output).toContain('/monitor/claim?site='); }); - it('celebrates a complete setup and keeps the dashboard URL visible', async () => { - writeJson('package.json', { - name: 'done-app', - dependencies: { '@patchstack/connect': '0.2.11' }, - scripts: { - postinstall: 'patchstack-connect scan', - prebuild: 'patchstack-connect scan', - postbuild: 'patchstack-connect mark-build', - }, - }); - writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); - writeFileSync(path.join(cwd, 'index.html'), `patchstack-widget.js userToken: '${VALID_UUID}'`); - writeGenericProtection(); + it('makes connecting the next step on a wired project, with the claim link', async () => { + // A developer's machine. Without this, a CI runner's own variables name the environment. + process.env.PATCHSTACK_ENVIRONMENT = 'local'; + wiredProject(); const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain('Ready to deploy'); - expect(output).not.toMatch(/\bconnected\b/i); - expect(output).toContain('/monitor/claim?site='); - expect(output).not.toContain('✖'); + expect(output).toContain('✔ Install the Patchstack connector'); + expect(output).toContain('✘ Connect project to Patchstack account'); + expect(output).toContain('✔ Sync and monitor in local environment'); + expect(output).toContain('Next: connect this project to your Patchstack account'); + expect(output).toContain(`Open http`); + expect(output).toContain(`/monitor/claim?site=${VALID_UUID}`); + expect(output).toContain('anyone who opens your app can connect it to their own account'); + // Nothing on disk says the site has an owner, so the checklist never marks it connected. + expect(output).not.toContain('✔ Connect'); + expect(output).not.toMatch(/^ {5}✘/m); + }); + + it('moves on to the deploy once the caller knows the site is connected', async () => { + wiredProject(); + + const output = renderGuideChecklist(await collectGuideState(cwd), false, { connected: true }); + + expect(output).toContain('✔ Connect project to Patchstack account'); + expect(output).toContain('Next: deploy your project to protect the live app'); + expect(output).toContain('Never commit .patchstackrc.local.json'); + expect(output).not.toContain('anyone who opens your app'); + expect(output).toContain('PATCHSTACK_API_KEY'); + expect(output).not.toContain('/monitor/claim?site='); + }); + + it('says all done only when every step is', async () => { + wiredProject(); + + const output = renderGuideChecklist(await collectGuideState(cwd), false, { + connected: true, + deployed: true, + }); + + expect(output).toContain('All done.'); + expect(output).not.toContain('Next: '); }); it('flags a widget whose userToken does not match the site UUID', async () => { @@ -313,11 +392,11 @@ describe('guide', () => { expect(state.widgetTokenMatches).toBe(false); const output = renderGuideChecklist(state, false); - expect(output).toContain("site UUID doesn't match"); + expect(output).toContain('The Patchstack widget on your page belongs to a different project'); expect(output).toContain(VALID_UUID); }); - it('treats "widget": false as a completed widget step', async () => { + it('treats "widget": false as a completed widget step, and says it is off', async () => { writeJson('package.json', { name: 'optout-app', dependencies: { '@patchstack/connect': '0.3.6' }, @@ -335,49 +414,34 @@ describe('guide', () => { expect(countRemainingSteps(state)).toBe(0); const output = renderGuideChecklist(state, false); - expect(output).toContain('Patchstack Connector disabled by config'); - expect(output).not.toContain('✖'); - }); - - it('tells unprovisioned projects the first scan installs the widget', async () => { - writeJson('package.json', { name: 'fresh-app' }); - - const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain('the first scan does this for you'); - }); - - it('names setup as the command that covers every step on an unprovisioned project', async () => { - writeJson('package.json', { name: 'fresh-app' }); - - const output = renderGuideChecklist(await collectGuideState(cwd), false); - const lead = output.indexOf('Nothing is set up yet'); - - expect(lead).toBeGreaterThan(-1); - expect(lead).toBeLessThan(output.indexOf('Install @patchstack/connect as a runtime dependency')); - expect(output.slice(lead)).toContain(`${installCommand('npm')}\n npx @patchstack/connect setup`); + expect(output).not.toContain('widget is not on your page'); + expect(renderGuideChecklist(state, false, {}, { verbose: true })).toContain( + 'Widget is off ("widget": false in .patchstackrc.json)', + ); }); - it('leaves the install command out of the setup lead once the package is a runtime dependency', async () => { + it('lists no technical sub-steps before the first scan, because setup applies them', async () => { writeJson('package.json', { name: 'fresh-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain('Nothing is set up yet'); + expect(output).toContain('Next: connect this project to your Patchstack account\n Run: npx @patchstack/connect setup'); expect(output).not.toContain(installCommand('npm')); + expect(output).not.toContain('Missing'); }); - it('drops the setup lead once the site is provisioned', async () => { + it('drops the setup command once the site is provisioned', async () => { writeJson('package.json', { name: 'fresh-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).not.toContain('Nothing is set up yet'); + expect(output).not.toContain('Run: npx @patchstack/connect setup\n'); }); it('points at the project root when package.json is missing', async () => { const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain('No package.json found'); + expect(output).toContain('No package.json here'); }); it('names the handoff while the provisioning scan is still to run', async () => { @@ -389,7 +453,7 @@ describe('guide', () => { const output = renderGuideChecklist(await collectGuideState(cwd), false); const heading = 'When your tool will not run this CLI'; - expect(output).toMatch(/hand it to the person instead of working/); + expect(output).toMatch(/Cannot run commands here\?/); expect(output).toContain(heading); expect(readFileSync(new URL('../AGENT-INSTALL.md', import.meta.url), 'utf8')).toContain(`## ${heading}`); }); @@ -410,26 +474,31 @@ describe('guide', () => { const output = renderGuideChecklist(state, false); expect(state.hasPackageJson).toBe(false); - expect(output).toContain('standalone HTML/CSS/browser-JavaScript'); + expect(output).toContain('Plain HTML sites'); expect(output).toContain('Do not create a Node project'); expect(output).toContain('site UUID or widget snippet from the Patchstack dashboard'); expect(output).toContain('no dependency scan or runtime protection'); expect(output).not.toContain('npm install'); - expect(output).not.toContain('Finish runtime protection'); + expect(output).not.toContain('Runtime protection'); expect(output).not.toContain('prebuild'); }); + + it('never mentions reporting a vulnerability', async () => { + wiredProject(); + + expect(renderGuideChecklist(await collectGuideState(cwd), false)).not.toMatch(/report a vulnerability/i); + }); }); /** * The widget tag only takes effect on a page load. A preview the user already has open - * loaded before the tag existed, so it shows no button and reads as a failed install. + * loaded before the tag existed, so it shows nothing and reads as a failed install. * Nothing in a Node CLI can reload that browser, so the checklist has to say it — and - * only when the tag is actually in the source, or it sends people to refresh a page - * that was never going to render a widget. + * only when the tag is actually in the source. */ - describe('the preview-refresh notice', () => { - it("asks for a refresh once the tag carries this project's UUID", async () => { - writeJson('package.json', { name: 'widgeted-app' }); + describe('the preview-reload notice', () => { + it("asks for a reload once the tag carries this project's UUID", async () => { + writeJson('package.json', { name: 'widgeted-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); writeFileSync(path.join(cwd, 'index.html'), `patchstack-widget.js userToken: '${VALID_UUID}'`); @@ -437,13 +506,7 @@ describe('guide', () => { expect(widgetTagInPlace(state)).toBe(true); const output = renderGuideChecklist(state, false); - expect(output).toContain('Refresh the preview to see the widget'); - // Not an unconditional "refresh now": a builder that hot reloads has already done it, - // and telling someone to refresh a page that just refreshed itself reads as a fault. - expect(output).toContain('if nothing appears, refresh the preview once'); - // An unclaimed site gets the connect panel, not the report button, so the checklist - // must not promise the button before there is an owner. - expect(output).toContain('"Connect this website" panel'); + expect(output).not.toContain('widget is not on your page'); }); it('stays quiet while the tag is still missing', async () => { @@ -453,11 +516,10 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(state.widgetInstalled).toBe(false); expect(widgetTagInPlace(state)).toBe(false); - expect(renderGuideChecklist(state, false)).not.toContain('Refresh the preview'); + expect(renderGuideChecklist(state, false)).not.toContain('Reload the preview'); }); it("stays quiet when the tag carries some other site's UUID", async () => { - // The button will not render with a stale token, so a refresh cannot produce it. writeJson('package.json', { name: 'stale-token-app' }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); writeFileSync( @@ -467,7 +529,7 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(widgetTagInPlace(state)).toBe(false); - expect(renderGuideChecklist(state, false)).not.toContain('Refresh the preview'); + expect(renderGuideChecklist(state, false)).not.toContain('Reload the preview'); }); it('stays quiet for a project that opted out of the widget', async () => { @@ -477,34 +539,33 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(widgetTagInPlace(state)).toBe(false); - expect(renderGuideChecklist(state, false)).not.toContain('Refresh the preview'); + expect(renderGuideChecklist(state, false)).not.toContain('Reload the preview'); }); }); /** - * Refreshing the preview is only half of it. Everything setup writes is a source change, - * so the deployed site keeps serving its previous build — no widget for visitors, and on a - * server-rendered root no production marker either — until the project is deployed again. + * Everything setup writes is a source change, so the deployed site keeps serving its previous build + * until the project is deployed again. */ describe('the deploy reminder', () => { - it('asks for a deploy once the site is provisioned', async () => { - writeJson('package.json', { name: 'provisioned-app' }); + it('names the deploy once the site is provisioned', async () => { + writeJson('package.json', { name: 'provisioned-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID }); const output = renderGuideChecklist(await collectGuideState(cwd), false); - expect(output).toContain('Deploy to put this on your live site'); - expect(output).toContain('deployed site keeps serving its previous build'); + expect(output).toContain('Already connected? Then commit, set PATCHSTACK_API_KEY on your host, and deploy.'); }); - it('still asks for it when the widget is opted out, because the rest still ships', async () => { - writeJson('package.json', { name: 'optout-app' }); + it('still names it when the widget is opted out, because the rest still ships', async () => { + writeJson('package.json', { name: 'optout-app', dependencies: { '@patchstack/connect': '^0.5.0' } }); writeJson('.patchstackrc.json', { siteUuid: VALID_UUID, widget: false }); - const output = renderGuideChecklist(await collectGuideState(cwd), false); + const output = renderGuideChecklist(await collectGuideState(cwd), false, { connected: true }); - expect(output).not.toContain('Refresh the preview'); - expect(output).toContain('Deploy to put this on your live site'); + expect(output).not.toContain('Reload the preview'); + expect(output).toContain('Next: deploy your project to protect the live app'); + expect(output).toContain('The live site keeps its old version until you do.'); }); it('stays quiet before the first scan, when nothing has been wired yet', async () => { @@ -512,7 +573,7 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(state.siteUuid).toBeNull(); - expect(renderGuideChecklist(state, false)).not.toContain('Deploy to put this'); + expect(renderGuideChecklist(state, false)).not.toContain('PATCHSTACK_API_KEY'); }); }); @@ -536,9 +597,9 @@ describe('guide', () => { expect(needsSourceProductionMarker(state)).toBe(true); expect(state.productionMarkerWired).toBe(false); - const output = renderGuideChecklist(state, false); - expect(output).toContain('Add the production marker'); - expect(output).toContain('npx @patchstack/connect scan'); + expect(renderGuideChecklist(state, false)).toContain('Your live app does not tell Patchstack it is live yet'); + const output = renderGuideChecklist(state, false, {}, { verbose: true }); + expect(output).toContain('Run: npx @patchstack/connect scan'); expect(output).toContain('import.meta.env.PROD &&'); expect(output).toContain('window.__PATCHSTACK_PROD__=true;'); }); @@ -562,7 +623,7 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(state.productionMarkerWired).toBe(true); - expect(renderGuideChecklist(state, false)).toContain('Production marker wired'); + expect(renderGuideChecklist(state, false)).not.toContain('does not tell Patchstack it is live'); }); it('stays silent for a plain HTML shell, where mark-build stamps the marker', async () => { @@ -572,7 +633,7 @@ describe('guide', () => { const state = await collectGuideState(cwd); expect(needsSourceProductionMarker(state)).toBe(false); - expect(renderGuideChecklist(state, false)).not.toContain('Add the production marker'); + expect(renderGuideChecklist(state, false)).not.toContain('does not tell Patchstack it is live'); }); }); }); @@ -592,7 +653,7 @@ describe('guide on a project with no request path', () => { await rm(cwd, { recursive: true, force: true }); }); - it('does not count runtime protection as a step still owed, and says why', async () => { + it('does not count runtime protection as a step still owed', async () => { writeFileSync( path.join(cwd, 'package.json'), JSON.stringify({ @@ -615,10 +676,7 @@ describe('guide on a project with no request path', () => { expect(state.protectionApplicable).toBe(false); expect(state.protectionWired).toBe(false); expect(countRemainingSteps(state)).toBe(0); - expect(rendered).toContain('Runtime protection: not applicable'); - expect(rendered).toContain('no request path'); - expect(rendered).not.toContain('Finish runtime protection'); - expect(rendered).toContain('Ready to deploy'); - expect(rendered).not.toMatch(/\bconnected\b/i); + expect(rendered).not.toContain('Runtime protection'); + expect(rendered).not.toContain('✔ Connect'); }); }); diff --git a/tests/install-prompt.test.ts b/tests/install-prompt.test.ts index 3ca1eb3b..b9906355 100644 --- a/tests/install-prompt.test.ts +++ b/tests/install-prompt.test.ts @@ -46,7 +46,17 @@ describe('the install prompt', () => { // The install ends on a page that loaded before the widget tag existed. The CLI cannot // reload the user's browser, so the assistant relaying this is the whole mechanism. expect(tested).toMatch(/refresh the preview/i); - expect(tested).toContain('Report a vulnerability'); + expect(tested).toContain('Patchstack widget is not showing'); + }); + + it('makes the widget part of the install rather than a question for the user', () => { + // An assistant that stops to ask whether to add the widget leaves the install half done. + expect(tested).toMatch(/widget is part of this install and on by default/i); + expect(tested).toMatch(/do not ask me whether to/i); + }); + + it('does not promise a report button the dashboard cannot receive yet', () => { + expect(tested).not.toMatch(/report a vulnerability/i); }); it('asks for a deploy reminder without authorizing a deploy', () => { diff --git a/tests/parsers/bun-and-source-selection.test.ts b/tests/parsers/bun-and-source-selection.test.ts index 8eaf08e6..b86854d6 100644 --- a/tests/parsers/bun-and-source-selection.test.ts +++ b/tests/parsers/bun-and-source-selection.test.ts @@ -227,7 +227,7 @@ describe('choosing between sources that disagree', () => { const manifest = await scanLockfile(cwd); expect(manifest.packages.find((entry) => entry.name === 'lodash')?.version).toBe('4.17.21'); - expect(manifest.warnings?.join(' ')).toMatch(/disagree about installed versions/); + expect(manifest.warnings?.join(' ')).toMatch(/give different versions/); }); it('says nothing when the sources agree', async () => { diff --git a/tests/progress.test.ts b/tests/progress.test.ts new file mode 100644 index 00000000..977a8942 --- /dev/null +++ b/tests/progress.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { nextProgressStep, nextStepLines, nextStepTitle, renderProgress, stepLabel, type NextStepContext } from '../src/progress.js'; + +const context: NextStepContext = { + installCommand: 'npm install --save @patchstack/connect', + siteUuid: '550e8400-e29b-41d4-a716-446655440000', + claimUrl: 'https://app.example.com/monitor/claim?site=550e8400-e29b-41d4-a716-446655440000', + environment: 'local', + environmentSource: null, +}; + +describe('progress checklist', () => { + it('picks the first step not done', () => { + expect(nextProgressStep({ installed: false, connected: true, synced: true, deployed: true })).toBe('installed'); + expect(nextProgressStep({ installed: true, connected: false, synced: true, deployed: false })).toBe('connected'); + expect(nextProgressStep({ installed: true, connected: true, synced: false, deployed: false })).toBe('synced'); + expect(nextProgressStep({ installed: true, connected: true, synced: true, deployed: true })).toBeNull(); + }); + + it('uses ✔ for done and ✘ for not yet', () => { + const lines = renderProgress({ installed: true, connected: false, synced: true, deployed: false }, context, { + useColor: false, + }); + + expect(lines.slice(0, 4)).toEqual([ + ' ✔ Install the Patchstack connector', + ' ✘ Connect project to Patchstack account', + ' ✔ Sync and monitor in local environment', + ' ✘ Deploy project to protect live app', + ]); + }); + + it('gives the claim link and command for connecting', () => { + expect(nextStepLines('connected', context)).toEqual([ + `Open ${context.claimUrl}`, + 'Already connected? Then commit, set PATCHSTACK_API_KEY on your host, and deploy.', + ]); + expect(nextStepLines('connected', { ...context, claimUrl: null })[0]).toBe('Run: npx @patchstack/connect claim'); + }); + + it('does not point a production run at a deploy it just made', () => { + const lines = nextStepLines('connected', { ...context, environment: 'production', environmentSource: 'platform' }); + + expect(lines.join('\n')).not.toContain('deploy'); + }); + + it('names the sync step after the environment the scan came from', () => { + expect(stepLabel('synced', 'local')).toBe('Sync and monitor in local environment'); + expect(stepLabel('synced', 'sandbox')).toBe('Sync and monitor in sandbox environment'); + expect(stepLabel('synced', 'production')).toBe('Sync and monitor in production environment'); + expect(stepLabel('synced', null)).toBe('Sync and monitor your project'); + }); + + it('leaves a production build unticked until the dashboard sees the live site', () => { + const production = { ...context, environment: 'production' as const, environmentSource: 'platform' as const }; + const lines = renderProgress({ installed: true, connected: true, synced: true, deployed: false }, production, { + useColor: false, + }); + + expect(lines).toContain(' ✔ Sync and monitor in production environment'); + expect(lines).toContain(' ✘ Deploy project to protect live app'); + expect(lines).toContain(' Built for production. Patchstack ticks this once it sees the live site.'); + expect(lines).toContain(' Open the live site once so Patchstack can see it.'); + }); + + it('says a hosted-builder build was reported as a publish, not confirmed', () => { + const lovable = { ...context, environment: 'production' as const, environmentSource: 'builder' as const }; + const lines = renderProgress({ installed: true, connected: true, synced: true, deployed: false }, lovable, { + useColor: false, + }); + + expect(lines).toContain(' Reported as a publish. Patchstack ticks this once it sees the live site.'); + expect(lines).not.toContain('All done.'); + }); + + it('sends a project with no site yet to setup', () => { + expect(nextStepLines('connected', { ...context, siteUuid: null, claimUrl: null })[0]).toBe( + 'Run: npx @patchstack/connect setup', + ); + }); + + it('names the next step as an action', () => { + const lines = renderProgress({ installed: true, connected: false, synced: true, deployed: false }, context, { + useColor: false, + }); + + expect(lines).toContain('➜ Next: connect this project to your Patchstack account'); + expect(nextStepTitle('deployed', { ...context, environment: 'production' })).toBe( + 'open your live app so Patchstack can see it', + ); + }); +});