diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4413c57..6fbd671 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,11 +5,6 @@ on: types: - published workflow_dispatch: - inputs: - version: - description: Version to publish (e.g. 0.3.7). Leave blank for a dry-run. - type: string - default: "" permissions: contents: read @@ -20,9 +15,10 @@ concurrency: cancel-in-progress: false jobs: - validate: - name: Validate package + publish: + name: Publish package runs-on: ubuntu-latest + environment: npm steps: - name: Checkout @@ -43,6 +39,13 @@ jobs: - name: Install dependencies run: npm ci + - name: Set version from release tag + if: github.event_name == 'release' + run: | + version="${GITHUB_REF_NAME#v}" + echo "Publishing version $version (from tag $GITHUB_REF_NAME)" + npm version "$version" --no-git-tag-version --allow-same-version + - name: Typecheck run: npm run typecheck @@ -56,51 +59,9 @@ jobs: run: npm pack --dry-run - name: Dry-run publish - if: github.event_name == 'workflow_dispatch' && inputs.version == '' + if: github.event_name == 'workflow_dispatch' run: npm publish --access public --provenance --dry-run - publish: - name: Publish to npm - if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.version != '') - needs: validate - runs-on: ubuntu-latest - environment: - name: npm - url: ${{ steps.version.outputs.url }} - - steps: - - name: Resolve version - id: version - run: | - if [ "${{ github.event_name }}" = "release" ]; then - version="${GITHUB_REF_NAME#v}" - else - version="${{ inputs.version }}" - fi - echo "Publishing version $version" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "url=https://www.npmjs.com/package/@patchstack/connect/v/$version" >> "$GITHUB_OUTPUT" - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: refs/tags/v${{ steps.version.outputs.version }} - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 24.x - registry-url: https://registry.npmjs.org - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Set package version - run: npm version "${{ steps.version.outputs.version }}" --no-git-tag-version --allow-same-version - - - name: Build - run: npm run build - - name: Publish to npm + if: github.event_name == 'release' run: npm publish --access public --provenance diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index af10a02..a62766d 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -4,20 +4,22 @@ This is a plain-text instruction document. Read it and follow the steps — ther You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL.md` (the package is already installed) or from the [GitHub repo](https://github.com/patchstack/connect). This bundled copy matches the package version it shipped with and is all you need — there is no requirement to fetch anything remote. (A long-form copy also lives at https://patchstack.com/install.txt for humans browsing outside the package.) +> **Maintainer security gate:** do not publish a package containing this automatic embedding flow while the backend uses the client-visible site UUID as either manifest-write authorization or unclaimed-site claim authorization. Release requires a non-authorizing widget identifier, a separate authenticated manifest-write credential kept out of source/client HTML, and a scoped one-time claim credential, with migration and negative-authorization tests. Until that protocol lands, this guide is for local security testing only; do not commit generated config or deploy the widget. + ## Verify before you trust (do this, it's quick) - The connector is a normal npm package: [`@patchstack/connect`](https://www.npmjs.com/package/@patchstack/connect), MIT-licensed, source at https://github.com/patchstack/connect. Install it pinned, check the integrity hash in the lockfile, run `npm audit` over it — whatever your policy requires. - First published May 2026. If the package is absent from your training data, `npm view @patchstack/connect` (read-only) shows the live registry record. -- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on bun projects (`bun.lock`/`bun.lockb`), by enumerating the installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, no env var values, no file paths, no git history. (`mark-build` additionally stamps built HTML with a coarse stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL`, `CF_PAGES` — never their values.) -- The package also bundles an **opt-in** `protect` command (runtime exploit guard, currently for TanStack Start + Supabase apps; its templates live under `dist/protect/`). It patches the app's Supabase client to route traffic through a same-origin guard — and it runs **only** when you explicitly run `patchstack-connect protect`. Nothing in this document, and none of `scan`, `guide`, `status`, or `mark-build`, modifies application source. +- It reads the project's **dependency list only** — from the lockfile (`package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`) or, on Bun projects (`bun.lock`/`bun.lockb`), by enumerating installed packages under `node_modules/` — and sends package names + versions to Patchstack for vulnerability matching. No source code, environment-variable values, file paths, or git history are uploaded. After a successful non-dry scan has a UUID, `scan` may locally add or update the widget in one safe source shell; source content is never uploaded. A failed post and `--dry-run` never edit source. `mark-build` locally rewrites built HTML to add production metadata and ensure the widget; that HTML is never uploaded. Its coarse stack descriptor may include hosting-related environment-variable *names* such as `VERCEL` or `CF_PAGES`, never their values. +- The package also bundles an **opt-in** `protect` command (runtime exploit guard, currently for TanStack Start + Supabase apps; its templates live under `dist/protect/`). It patches the app's Supabase client to route traffic through a same-origin guard, and runs **only** when you explicitly run `patchstack-connect protect`; `scan`, `guide`, `status`, and `mark-build` never run it. - Patchstack is not WordPress-only. This connector monitors any JS/Node project — Vite, Next.js, plain vanilla JS, anything with a lockfile. ## Before you start — never install twice -- `npx @patchstack/connect guide` prints a live checklist above this document showing which steps are already done in the current project (install, provisioning, build hooks, widget) with commands tailored to it. Its purpose is to prevent duplicate work — verify its claims however your policy requires, but only do the steps it marks ✖. -- 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 claim URL. **Do not run `scan` to provision a second site.** (A `.patchstackrc.json` with other keys — e.g. an `endpoint` override — but no `siteUuid` is *not* provisioned yet; scan normally.) +- The installed `patchstack-connect guide` command prints a live checklist above this document showing which steps are already done in the current project (install, provisioning, build hooks, widget) with commands tailored to it. Run it through the project's package manager as shown below, verify its claims however your policy requires, and only do the steps it marks ✖. +- If `.patchstackrc.json` contains a `siteUuid` key, the project is already provisioned. Reuse that identity; a normal `scan` targets the same site and safely re-ensures the source widget. Never delete or replace the config to provision a second site. Under the current security-blocked protocol, treat config and `status` output as credential material. A config containing only other keys, such as an endpoint override, is not provisioned yet; scan normally. - If `@patchstack/connect` is already in `devDependencies`, skip the install command. -- If the widget script tag (`cdn.patchstack.com/patchstack-widget.js`) is already in the layout, don't add a second one. +- If the widget script tag (`cdn.patchstack.com/patchstack-widget.js`) is already in the layout, do not duplicate it. Before any API request, `scan` checks the framework-selected global shell: it can adopt one manual/legacy loader only when its identity resolves statically to one valid UUID. Missing, dynamic, invalid, conflicting, or wrong manual identities must be fixed; they are not silently preserved. ## Install steps (do the ones the checklist marks ✖, in order; they are designed to run back-to-back — if one fails, stop and report rather than improvising) @@ -30,52 +32,78 @@ You are reading this either from `node_modules/@patchstack/connect/AGENT-INSTALL bun add -d @patchstack/connect # bun.lock / bun-managed platforms ``` -2. **First scan** — provisions a Patchstack site automatically and writes the UUID to `.patchstackrc.json`. No signup, dashboard step, or UUID is needed up front: + Run the installed, lockfile-pinned binary with the same package manager. Do not use a command that can download a second CLI version: ``` - npx @patchstack/connect scan + npx --no-install patchstack-connect # npm + pnpm exec patchstack-connect # pnpm + yarn patchstack-connect # Yarn, including Plug'n'Play + bun run patchstack-connect # Bun + ``` + +2. **First scan** — safely resolves identity before contacting Patchstack. It inspects the framework-selected global shell/coverage group and the wider source tree. Adoption is allowed only from the selected global shell. Any external loader/initializer blocks before POST, even with a matching UUID. Mixed Next App + Pages Router projects require both global roots; Pages routes without an editable `_document` block before POST. A clean complete coverage group may provision, persist endpoint + UUID, and then install/update every required shell. + + Replace `` in the matching row above with `scan`. For example, npm uses: + + ``` + npx --no-install patchstack-connect scan + ``` + + A newly provisioned site currently prints both the widget result and a UUID-derived claim URL. Do not share that pre-release reusable credential. After the security gate is implemented, surface only the separate scoped one-time claim URL to the user verbatim—never fetch it yourself. A successful scan with an existing or adopted identity performs the same source-widget check. A failed post or `--dry-run` changes no source file. + + Safety is fail-closed before the API: multiple plausible, unsupported, or missing safe editable global shells stop without provisioning, and a manual install with a missing, dynamic, invalid, conflicting, or configured-but-different UUID stops without posting. Create/fix the actual global shell or repair/remove the unsafe manual install, then rerun. Do not look for a newly created UUID after this error; none was provisioned. + +3. **Verify the widget immediately.** When `scan` reports that it installed or updated the widget, reload the running app/editor preview now; the "Report a vulnerability" button should appear without waiting for a production build. The connector-managed source tag uses this one-tag CDN form: + + ```html + ``` - It prints a claim URL. Surface that URL to the user verbatim — never fetch it yourself; it must be opened in their browser. + If `scan` reports an existing manual/legacy widget, it has already proved that the selected shell contains one statically valid UUID matching the configured/adopted site. Leave it unchanged and reload the preview to verify that single install instead of adding the managed form. -3. **Wire builds** in `package.json`: + An ambiguous, unsupported, externally duplicated, or missing required global shell is a preflight error: repair every reported root and rerun; nothing was posted. Separately, if a shell disappears/changes/becomes unwritable after a successful POST, scan exits non-zero after saving UUID + endpoint. Preserve `.patchstackrc.json`, repair the shell, and rerun; the saved UUID prevents another bare provisioning request. Never paste a fallback into a nested component. + +4. **Wire builds** in `package.json`. npm and pnpm can use lifecycle hooks: ```jsonc { "scripts": { "prebuild": "patchstack-connect scan", - "postbuild": "patchstack-connect mark-build" + "postbuild": "patchstack-connect mark-build --strict" } } ``` If a `prebuild`/`postbuild` hook already exists, chain instead of replacing it, e.g. `"prebuild": "existing-command && patchstack-connect scan"`. - **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"`. + **Modern Yarn projects:** Yarn does not execute arbitrary npm-style `prebuild`/`postbuild` hooks. Preserve the current compiler command and wire the package-manager-native binaries directly instead: `"build": "yarn patchstack-connect scan && && yarn patchstack-connect mark-build --strict"`. -4. **Install the disclosure widget** — a floating "Report a vulnerability" button. Read the `siteUuid` value from `.patchstackrc.json` (the same site UUID step 2 provisioned) and pass it as the widget's `userToken`. Place these two snippets via the framework's HTML/layout mechanism (never a JS entry point): + **Bun projects:** Bun supports the hooks above, but the most portable hosted-builder form is `"build": "bun run patchstack-connect scan && && bun run patchstack-connect mark-build --strict"`. - ```html - - - ``` + **Detected SSR/hybrid-capable frameworks:** `mark-build --strict` intentionally fails unless `--static-output` is present. Add it to the postbuild/chain only after verifying from framework configuration and route inventory that every deployed route is complete static HTML, e.g. `mark-build --strict --static-output`. Never add it merely because an output directory contains a prerendered route or error page. If any deployed route is dynamic/hybrid, do not use the assertion; implement and test a framework/runtime production integration instead. + + The before-build `scan` rescans dependencies and re-ensures source coverage. The strict after-build pass resolves the same identity and verifies every complete built document. Without `--dir`, exactly one populated known output is required; multiple populated `dist/`, `build/`, `out/`, or `.output/public` trees fail before edits. Add persistent `--dir ` for the tree actually deployed. Other strict failures include missing output, lockfile/stack coverage, unreadable documents, unsafe identity, UUID mismatch, or marker verification. + +5. **Verify the production build.** Run the project's build and confirm `mark-build --strict` reports that it stamped and verified metadata plus the disclosure widget in one or more complete HTML documents (`marked 0` is valid when generated output was already current). If static output lives elsewhere, use `mark-build --strict --dir `. Keep the strict after-build pass even when the source widget is present. + + Static HTML under `dist/`, `build/`, `out/`, or `.output/public` is the automatic path; fragments are not deployable pages. For a detected SSR-capable framework, the explicit `--static-output` assertion closes the hybrid false-positive only when every deployed route is truly static. A few prerendered/error documents are insufficient evidence. Inventory route modes; if any response is dynamic, omit the assertion and test runtime production signaling on a real deployed dynamic route. Managed static output carries `data-production="true"`; never add an unconditional production signal to a shared SSR layout that also renders development previews. - Framework-specific placement patterns: https://cdn.patchstack.com/llm.html. The `userToken` is public by design — it ships in client-side HTML and is not a secret. + If the site enforces a Content Security Policy, check it instead of weakening it automatically: the widget needs `script-src` access to `https://cdn.patchstack.com`, `connect-src` access to the configured Patchstack API, and permission for the connector's inline build marker and the widget's injected styles. A strict nonce/hash-only policy may need a policy-specific integration; stop and report that constraint rather than adding broad CSP exceptions. -5. **Commit** `.patchstackrc.json`, the updated `package.json`, and the layout/HTML file carrying the widget snippet, so every developer and CI run reports to the same site. +6. **Commit only after the security gate is closed.** The post-split config must make repository-safe widget identity distinct from manifest-write and one-time claim credentials. Commit only explicitly non-authorizing config plus `package.json` and the managed source shell; keep write/claim credentials in secret storage. With today's single-UUID protocol, do not commit or deploy the generated config/widget. -6. **Tell the user to claim the site** (free): open the claim URL from step 2 in a browser and sign in. The site is monitored either way, but the vulnerability reports are only visible after claiming. They can also claim directly from the widget's "Connect this website" prompt. On the published site, the owner reaches the widget login by appending `#patchstack` to the live URL. +7. **After credential separation ships, tell the user to claim the site** (free) with the connector's separate one-time claim URL. Never use or construct a claim URL from the client-visible widget identifier. The site is monitored either way, but reports are visible after claiming. On a verified published site, the owner reaches widget login by appending `#patchstack` to the live URL. ## Rules - Never invent or guess a UUID — the scan provisions it, the widget silently no-ops on a fake one. - Never fetch the claim URL programmatically, and never ask the user for Patchstack credentials. - If a step fails, stop and report it. Don't proceed with placeholders. -- In CI where `.patchstackrc.json` can't be committed, set `PATCHSTACK_SITE_UUID` as an env var instead. Precedence: CLI flag → env var → `.patchstackrc.json`. +- First provisioning and manual UUID adoption persist the effective endpoint alongside the UUID. Under the current single-UUID backend semantics, treat that config as security-sensitive and local-only. The required post-split protocol must keep manifest-write and one-time claim credentials in CI/secret storage while exposing only a non-authorizing widget identifier to client HTML. Precedence remains CLI flag → environment variable → `.patchstackrc.json` for fields that are safe in each location. +- If the user explicitly wants to keep connector scans but remove the widget, first persist `"widget": false` in `.patchstackrc.json`, then remove the connector-managed source tag. Both `scan` and `mark-build` honor this opt-out; without it, the next scan restores the widget. ## Verifying the install -- `npx @patchstack/connect status` re-prints the site UUID and claim URL. -- Load the site in a browser — the "Report a vulnerability" button should appear. +- The current installed `patchstack-connect status` command re-prints the site UUID and UUID-derived claim URL; do not share either under the pre-release single-credential protocol. Before release, claim must use a separate scoped one-time credential. +- Immediately after `scan`, reload the current preview — the "Report a vulnerability" button should appear. A connector-managed install has exactly one `data-site-uuid` widget tag; a preserved legacy install may instead have one CDN tag plus one `PatchstackWidget.init` call. Never add a second widget to make the forms match. +- Build and preview the production output too; `mark-build --strict` must verify its metadata and exactly one working widget or fail the build. diff --git a/CLAUDE.md b/CLAUDE.md index 77dc0ef..a087d2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,11 @@ The one-line install prompt in `README.md` ("Install prompt (for AI coding tools Invariants when touching it: -- The prompt appears in three places that must stay identical: `README.md`, `GETTING-STARTED.md` (the teammate-facing flow), and `field-test/prompt.txt` — `prompt.txt` is the artifact the harness tests. -- Any change to the prompt, the `guide` checklist output (`src/guide.ts`), or `AGENT-INSTALL.md` must pass `node field-test/run.mjs --persona hostile --rounds 3` before shipping. Agents audit the shipped docs, so inaccuracies in `AGENT-INSTALL.md` cost trust and cause refusals. +- The prompt text appears in three places and must stay identical: `README.md`, `GETTING-STARTED.md` (the teammate-facing flow), and `field-test/prompt.txt` — `prompt.txt` is the plain-text artifact the harness tests, while the docs add Markdown blockquote syntax. `tests/docs.test.ts` enforces the invariant. +- Evaluate any change to the prompt, the `guide` checklist output (`src/guide.ts`), or `AGENT-INSTALL.md` with `node field-test/run.mjs --persona hostile --rounds 3` before shipping. If stale published artifacts or release freshness make that gate environmentally red, follow the plan B in `field-test/README.md` and record the refusal reason. Never publish merely to make a field test exercise unpublished code. - Don't add reassurance language ("it's safe", "nothing is executed remotely") — agents flag it as a manipulation signal. Don't ask the agent to "follow the guide/instructions it prints" unbounded — name the concrete steps instead. - A new real-world refusal report becomes a persona in `field-test/personas/` so the regression stays covered. -- The fixture installs the *published* package, so an unpublished `guide`/CLI change can't be exercised end-to-end — publish first, or accept the run validates only the prompt shape. +- The fixture installs the *published* package, so it cannot exercise unpublished `guide`, CLI, README, or `AGENT-INSTALL.md` changes end-to-end. Use the plan B until the normal, approved release process is safe to run. ## Releasing diff --git a/GETTING-STARTED.md b/GETTING-STARTED.md index 2f2d08e..dfe25e6 100644 --- a/GETTING-STARTED.md +++ b/GETTING-STARTED.md @@ -1,42 +1,50 @@ # Getting started — connect a project to Patchstack - + -The fastest path from "I have a JS/Node project" to "Patchstack is monitoring its dependencies". Share this doc with anyone setting up a project; the whole flow is 2–5 minutes. +The teammate-facing path from "I have a JS/Node project" to automatic dependency monitoring, source-widget installation, and strict static-build verification. + +> **Security gate:** the current pre-release backend lets the client-visible site UUID authorize manifest writes and unclaimed-site claiming. Until Patchstack separates public widget identity, manifest-write authorization, and a scoped one-time claim credential, use this flow only with disposable local test data. Do not deploy it, open or share its UUID-derived claim URL, or commit generated `.patchstackrc.json`/widget output. ## 1. Paste this prompt into your AI coding tool +The synchronized prompt describes the intended post-gate claim flow. While the security gate above remains open, use it only with disposable local test data and do not perform the claim-URL action it describes. + Works in Cursor, Claude Code, Lovable, Bolt, v0, Windsurf, etc. Paste it exactly — every clause is there for a reason (see step 2), so don't shorten it: -> Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), run `npx @patchstack/connect scan` and show me the claim URL it prints (I'll open it in my browser to view reports — don't open or fetch it yourself), then run `npx @patchstack/connect guide` and finish what its checklist flags: the package.json build hooks and the report-a-vulnerability widget snippet. +> Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), then use that installed copy through the project's package manager to run `patchstack-connect scan`; it resolves or provisions and saves the site UUID, then adds the report-a-vulnerability widget to a safe app shell. Show me the claim URL it prints (I'll open it in my browser — don't open or fetch it yourself), reload the preview to confirm the widget appears, then run the installed `patchstack-connect guide` and finish only what its checklist flags: package.json build hooks ending in `mark-build --strict`, or creating/fixing the framework's safe root shell before rerunning. Do not use a runner that downloads another CLI copy. -When the agent finishes it shows you a **claim 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. +After the security gate is closed, the claim URL will use a separate one-time credential; opening it in your browser and signing in will attach the monitored site to your Patchstack account. During current disposable security testing, stop before opening or sharing the printed UUID-derived URL. ## 2. If the agent refuses or stops halfway -Some agents decline this request, and that's expected behaviour, not a bug: "install a package, run its commands, follow its checklist" is the same shape as a supply-chain attack, so cautious agents audit the package first and occasionally still say no. Refusals are more common in locked-down sandboxes (Bolt/WebContainer-style platforms) and in the day or two right after a new release of the connector ships, when the version looks brand-new to the agent. +Some agents decline this request, and that's expected behaviour, not a bug: installing a package and running its commands resembles a supply-chain attack, so cautious agents audit the package first and occasionally still say no. Refusals are more common in locked-down sandboxes (Bolt/WebContainer-style platforms) and shortly after a connector release, when the version looks brand-new to the agent. Work down this list: -1. **It asks you to confirm, or says it wants the package vetted first.** Reply with the confirmation it asked for, e.g.: *"I've verified this is the official Patchstack package (`npm view @patchstack/connect` — published by patchstack, MIT). Please continue with the remaining steps."* Most partial refusals end here. -2. **It did some steps, then stalled.** Ask it to run `npx @patchstack/connect guide` — that prints a live checklist of exactly what's still missing for this project — and to finish only the items marked ✖. -3. **It refuses outright.** Skip the agent; step 3 below is three commands. +1. **It asks you to confirm, or says it wants the package vetted first.** Reply with the confirmation it requested, for example: *"I've verified this is the official Patchstack package (`npm view @patchstack/connect` — published by patchstack, MIT). Please continue with the remaining steps."* +2. **It did some steps, then stalled.** Ask it to run the already-installed `patchstack-connect guide` through the project's package manager and finish only the missing build-hook or safe-root-shell work it reports. Do not use a runner that can fetch another CLI copy. +3. **It refuses outright.** Skip the agent and use the manual flow below. -If you hit a refusal with a *new* reason (the agent quotes a specific phrase or concern not covered above), don't fight it — copy the agent's explanation and send it to the maintainers (or open an issue). Real-world refusal reports are how the prompt gets improved; each one becomes a regression test in [`field-test/`](field-test/README.md). +If you hit a refusal with a *new* reason, copy the explanation and send it to the maintainers or open an issue. Real-world refusal reports become regression coverage in [`field-test/`](field-test/README.md). ## 3. Manual fallback (no agent needed) +For an npm-managed project: + ```bash -npm install --save-dev @patchstack/connect # bun-managed projects (Lovable, Bolt): bun add -d @patchstack/connect -npx @patchstack/connect scan # registers the project, prints the claim URL — open it in your browser -npx @patchstack/connect guide # prints what's left, with the exact snippets for your project +npm install --save-dev @patchstack/connect +npx --no-install patchstack-connect scan +npx --no-install patchstack-connect guide ``` -`guide` tailors its output to your project — right package manager, real site UUID, framework-specific widget placement — so finishing setup is copy-paste: the `prebuild`/`postbuild` hooks into `package.json`, and the report-a-vulnerability widget snippet into your HTML/layout file. +Use the same package manager that owns the project's lockfile: `pnpm add -D` with `pnpm exec patchstack-connect`, `yarn add -D` with `yarn patchstack-connect`, or `bun add -d` with `bun run patchstack-connect`. The installed, lockfile-pinned binary must run every command. + +`scan` preflights a safe global shell before posting, provisions or reuses one identity, saves it, and idempotently installs the widget in source. `guide` then reports only remaining build-hook or safe-root-shell work. For static sites, the build must run `scan` before the compiler and `mark-build --strict` afterward, so the same identity and widget are baked into every complete generated HTML document. ## 4. You're done when -- `npx @patchstack/connect status` prints a site UUID and the claim URL. -- You've opened the claim URL in your browser and the site shows in your Patchstack dashboard. -- `npx @patchstack/connect guide` reports all steps ✔ (build hooks wired, widget installed). -- `.patchstackrc.json`, `package.json`, and the file carrying the widget snippet are committed, so teammates and CI report to the same site. +- `scan` has saved one identity and the development preview shows exactly one widget after reload. +- The installed `guide` reports all steps ✔, including build hooks and source-widget coverage. +- A production/static build succeeds with `mark-build --strict` and verifies the widget in every complete deployable HTML document. +- After the credential-separation gate closes, you have opened the separate one-time claim URL and committed only explicitly non-authorizing configuration plus `package.json` and the managed source shell. With today's single-UUID protocol, do not commit or deploy those generated files. diff --git a/MAINTAINING.md b/MAINTAINING.md index 890b737..7e61f21 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -8,7 +8,7 @@ The deep "why" — the AI-agent refusal modes each clause guards against — liv | Artifact | Rule | |---|---| -| **The install prompt** (1 sentence) — in `README.md`, `GETTING-STARTED.md` (step 1), and `field-test/prompt.txt` | 🔴 **Don't casually edit.** Load-bearing and adversarially tuned; every clause exists because an agent refused a shorter version. Must be **byte-identical** in all three places, and any change must pass the hostile field-test gate. | +| **The install prompt** (1 sentence) — in `README.md`, `GETTING-STARTED.md` (step 1), and `field-test/prompt.txt` | 🔴 **Don't casually edit.** Load-bearing and adversarially tuned; every clause exists because an agent refused a shorter version. The prompt text must match in all three places, and any change must be evaluated with the field-test gate. | | **`src/guide.ts`** — the `guide` checklist output | 🟠 **Edit with the gate.** Agents read this live and act on it; wrong commands or claims cause refusals. | | **`AGENT-INSTALL.md`** — ships inside the npm tarball | 🟠 **Edit with the gate.** Must disclose **every** capability in `dist/` (e.g. the `protect` command); an undisclosed capability or overbroad privacy claim is read as misrepresentation and refused. | | **`GETTING-STARTED.md`** — teammate-facing onboarding prose (steps 2–4) | 🟢 **Safe to improve** — *except* the quoted prompt block in step 1, which is the 🔴 artifact above. | @@ -16,9 +16,9 @@ The deep "why" — the AI-agent refusal modes each clause guards against — liv **The trap:** "update the onboarding steps" sounds like a 🟢 job, but step 1 of `GETTING-STARTED.md` *contains* the 🔴 prompt. Improving the surrounding prose is fine; changing the quoted prompt is not, unless you change it in all three places and re-gate. -## The prompt lives in three places — keep them identical +## The prompt lives in three places — keep its text identical -`README.md` (Install prompt section), `GETTING-STARTED.md` (step 1), and `field-test/prompt.txt`. `prompt.txt` is the artifact the harness actually tests. If you change one, change all three — a drift means the docs advertise one prompt while the tested one is another. +`README.md` (Install prompt section), `GETTING-STARTED.md` (step 1), and `field-test/prompt.txt`. The first two use Markdown's `> ` blockquote prefix; `prompt.txt` is plain text and is the artifact the harness actually tests. If you change one, change all three. `tests/docs.test.ts` removes the documentation prefix and enforces exact prompt-text equality. ## Before shipping a 🔴 or 🟠 change @@ -28,15 +28,17 @@ Run the gate: node field-test/run.mjs --persona hostile --rounds 3 ``` -It runs a real AI agent through the full install in a throwaway fixture against a mocked API and scores the outcome. Read each round's `report.md` (the HESITATIONS / DECISION ANALYSIS sections) — anything an agent pauses on is a future refusal. See [`field-test/README.md`](field-test/README.md) for the improve-and-retest loop, the safety model, and what to do when the gate is red for environmental reasons (stale published docs, release freshness). +It runs a real AI agent through the full install in a throwaway fixture against a mocked API and scores the outcome. Read each round's `report.md` (the HESITATIONS / DECISION ANALYSIS sections) — anything an agent pauses on is a future refusal. See [`field-test/README.md`](field-test/README.md) for the improve-and-retest loop, the safety model, and the documented plan B when stale published docs or release freshness make the hostile gate environmentally red. -Caveat: the fixture installs the **published** package, so an unpublished `guide`/CLI change can't be exercised end-to-end — publish first, or accept that the run validates only the prompt shape. +The fixture installs the **published** package. A local prompt change is exercised, but unpublished `guide`, CLI, README, or `AGENT-INSTALL.md` changes are not. Never publish merely to make the field test exercise a working tree. While a release or security gate is open, run the stub self-test, use the appropriate persona where useful, and classify hostile failures by their recorded reason as described in the plan B. Publish only through the approved release process after every release blocker is closed. + +`README.md` and `AGENT-INSTALL.md` are the npm-shipped documentation files listed in `package.json`. `GETTING-STARTED.md` is public teammate-facing documentation, but it is not currently included in the npm tarball. Confirm the package boundary with `npm pack --dry-run` whenever `package.json#files` changes. ## Don'ts (these are refusal triggers, not style nits) - **Don't add reassurance language** ("it's safe", "nothing is executed remotely"). Agents fact-check it against the code and flag the gap as a manipulation signal. -- **Don't narrate how the prompt survives refusals** in any shipped doc (`README.md`, `AGENT-INSTALL.md`, `GETTING-STARTED.md`). Agents `npm pack` the tarball and read everything; being told the message was tuned to get past them is itself read as the signal to refuse. That rationale belongs in `field-test/README.md`, which doesn't ship. -- **Don't ask the agent to "follow the guide/instructions it prints"** unbounded — name the concrete steps (build hooks + widget snippet) instead. Unbounded delegation reads as a blank check. +- **Don't narrate how the prompt survives refusals** in the npm-shipped `README.md` or `AGENT-INSTALL.md`. Agents can inspect the tarball; deep refusal rationale belongs in `field-test/README.md`, which doesn't ship. Keep `GETTING-STARTED.md`'s user-facing fallback practical and accurate without copying the internal rationale. +- **Don't ask the agent to "follow the guide/instructions it prints"** unbounded — name the concrete remaining work (strict build hooks or a safe-root/source-widget repair). Unbounded delegation reads as a blank check. - **Don't let the shipped docs claim more privacy than the code delivers**, or omit a capability `dist/` ships. Every contradiction between docs and code is treated as misrepresentation. ## When a new refusal shows up in the wild diff --git a/README.md b/README.md index 163cca5..8dcb0bc 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,19 @@ Connect a JavaScript / Node.js application to [Patchstack](https://patchstack.com) for continuous vulnerability monitoring. Scans your `package-lock.json` and reports installed packages so Patchstack can match them against its vulnerability database and notify you when something needs patching. +> **Security release blocker:** the current backend uses the same site UUID as a manifest-write credential and as the credential for claiming an unclaimed site, while this branch embeds that value in client-visible widget HTML. Do not publish this automatic embedding flow in any release or deploy the coordinated website guides yet. The backend/API and connector must first separate (1) a non-authorizing public widget site identifier, (2) an authenticated manifest-write credential kept out of client/source HTML, and (3) a scoped one-time claim credential that is never derived from or embedded as the widget identifier. Add migration and end-to-end authorization tests before lifting this gate. + For how this repo fits with the wider Patchstack ecosystem (`saas`, `hub`, `patchstack-website`, `patchstack-connect`), see [`patchstack/saas` → `docs/ecosystem.md`](https://github.com/patchstack/saas/blob/main/docs/ecosystem.md). ## Install prompt (for AI coding tools) Copy and paste this prompt into any AI coding assistant (Cursor, v0, Bolt, Lovable, Claude Code, etc.). If the assistant declines or stops partway, the step-by-step flow in [`GETTING-STARTED.md`](https://github.com/patchstack/connect/blob/main/GETTING-STARTED.md) covers what to do next: -> Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), run `npx @patchstack/connect scan` and show me the claim URL it prints (I'll open it in my browser to view reports — don't open or fetch it yourself), then run `npx @patchstack/connect guide` and finish what its checklist flags: the package.json build hooks and the report-a-vulnerability widget snippet. +While the security release blocker above remains open, retain this synchronized prompt for disposable testing only and do not perform its claim-URL action. + +> Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), then use that installed copy through the project's package manager to run `patchstack-connect scan`; it resolves or provisions and saves the site UUID, then adds the report-a-vulnerability widget to a safe app shell. Show me the claim URL it prints (I'll open it in my browser — don't open or fetch it yourself), reload the preview to confirm the widget appears, then run the installed `patchstack-connect guide` and finish only what its checklist flags: package.json build hooks ending in `mark-build --strict`, or creating/fixing the framework's safe root shell before rerunning. Do not use a runner that downloads another CLI copy. -Every clause is load-bearing. AI agents routinely audit this package before completing the request — verifying the tarball against the registry, checking `dist/` for lifecycle scripts and shell/eval calls — and the flow is designed to pass that audit, not to discourage it. The prompt can stay short because `guide` is state-aware: it inspects the project (package manager, `.patchstackrc.json`, `package.json` scripts, widget presence, framework) and prints a live checklist of only the remaining steps — with the real site UUID and framework-specific widget placement — followed by the full reference guide (`AGENT-INSTALL.md`, also at https://patchstack.com/install.txt). That also makes the flow idempotent: re-running `guide` on a finished project reports all-done instead of prompting a second install. +`guide` is state-aware: it inspects the project (package manager, `.patchstackrc.json`, `package.json` scripts, widget presence, framework) and prints a live checklist of only the remaining work, followed by the full reference guide (`AGENT-INSTALL.md`, also at https://patchstack.com/install.txt). Re-running it on a finished project reports all-done instead of prompting a second install. Before changing this prompt (or `guide` / `AGENT-INSTALL.md`), read [`field-test/README.md`](https://github.com/patchstack/connect/blob/main/field-test/README.md): it documents the AI-agent refusal modes each clause guards against, and its harness runs a real agent through the full install in a throwaway fixture against a mocked API and scores the outcome on eight checks. Validate any variant there first. @@ -18,52 +22,95 @@ Before changing this prompt (or `guide` / `AGENT-INSTALL.md`), read [`field-test ```bash npm install --save-dev @patchstack/connect -npx @patchstack/connect scan +npx --no-install patchstack-connect scan ``` > **Use your project's own package manager.** On bun-managed projects (Lovable, Bolt, most vibe-coding platforms) install with `bun add -d @patchstack/connect` instead — running `npm install` there plants a `package-lock.json` that the platform's native dependency flow never updates again, leaving a stale lockfile next to the live one. The connector detects and works around that (see *Stale lockfiles* below), but not creating the fossil is better. +Always invoke the copy pinned in the project's lockfile. Use `npx --no-install patchstack-connect ` with npm, `pnpm exec patchstack-connect ` with pnpm, `yarn patchstack-connect ` with Yarn, or `bun run patchstack-connect ` with Bun. In particular, modern Yarn Plug'n'Play projects must use `yarn patchstack-connect`; `npx` does not resolve the installed PnP package and may fetch a different registry version. + That's it. The first `scan`: 1. Reads your lockfile (see *Supported lockfiles*). -2. POSTs the package list to Patchstack with **no** UUID. -3. Patchstack provisions a fresh site and returns its UUID. -4. The connector writes the UUID to `.patchstackrc.json` so the next `scan` targets the same site. -5. The connector prints a claim URL — 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`. +2. Preflights the framework-selected global source shell or coverage group **before any API request**, then safety-scans the wider source tree. One existing manual/legacy loader in the selected global shell with one statically valid UUID is adopted. Ambiguous/unsupported/missing required shells, unsafe identity, or any loader/initializer outside the selected global shell blocks before posting—even when that external UUID matches. Mixed Next App + Pages Router projects require one covered global shell per router; Pages routes require an editable `_document`. +3. When no UUID exists to adopt, POSTs the package list with **no** UUID so Patchstack can provision a fresh site. +4. Writes the returned UUID and the endpoint that issued it to `.patchstackrc.json` as one identity pair before touching source. This keeps later scans and builds on the same backend, including custom/staging endpoints. +5. Idempotently installs or updates the disclosure widget in the safe shell and tells you to reload the app preview. A valid existing manual/legacy install is preserved rather than duplicated. +6. Prints a claim URL for a newly provisioned site — open it in a browser to attach the site to your Patchstack account. Before release, this must carry a scoped one-time claim credential separate from the client-visible widget identifier; the current reusable UUID-derived claim URL is part of the security release blocker above. -Then wire it into builds: +Every successful non-dry scan with a UUID repeats the safe source-widget check, so existing sites get the same behavior. A failed post and `--dry-run` never edit source. Ordinary discovery errors happen before posting: create/fix every required global shell, move/remove external loaders, and rerun. A rare write/race failure can happen after a successful POST; that error explicitly says the UUID and endpoint were already saved. Preserve `.patchstackrc.json`, repair the shell, and rerun `scan` so it targets the saved site instead of provisioning another. The explicit dependency-scanning-only escape is `"widget": false`. + +Then wire it into builds. npm and pnpm can use lifecycle hooks: ```jsonc // package.json { "scripts": { - "prebuild": "patchstack-connect scan" + "prebuild": "patchstack-connect scan", + "postbuild": "patchstack-connect mark-build --strict" + } +} +``` + +Modern Yarn does not run arbitrary npm-style `prebuild`/`postbuild` hooks, so Yarn projects must preserve the existing compiler command and make the ordering explicit. Bun does support the hooks, but the same explicit chain is the most portable choice across Bun-based hosted builders: + +```jsonc +{ + "scripts": { + "build": "yarn patchstack-connect scan && && yarn patchstack-connect mark-build --strict" + } +} +``` + +For a hosted Bun build, use its installed binary in the equivalent chain: + +```jsonc +{ + "scripts": { + "build": "bun run patchstack-connect scan && && bun run patchstack-connect mark-build --strict" } } ``` +The source widget uses the rolling CDN's one-tag auto-init form: + +```html + +``` + +The before-build `scan` must finish before the compiler starts. The after-build `mark-build --strict` pass reads the same identity, stamps production/build metadata, and verifies exactly one usable widget in each complete built HTML document. Without `--dir`, it selects a known output only when exactly one of `dist/`, `build/`, `out/`, or `.output/public` contains complete HTML. Two or more populated candidates are ambiguous and fail before edits; persist `--dir ` for the tree actually deployed. Strict mode also fails for missing output, failed lockfile/stack coverage detection, unsafe identity, UUID mismatch, unreadable files, or failed production-marker verification. + +Static HTML is the fully automatic production path. `mark-build --strict` rewrites and verifies complete deployable HTML documents and skips fragments. For frameworks capable of SSR/hybrid output (Next, Nuxt, TanStack Start, Remix/React Router, Astro, SvelteKit, Qwik City, Gatsby, Express, or Fastify), strict mode deliberately fails before editing output unless `--static-output` is also present. That flag is an explicit assertion that **every deployed route** is represented by complete static HTML. Use `mark-build --strict --static-output` only after verifying that fact from framework configuration and route inventory—never merely because `dist/` contains a prerendered route or error page. If any route is dynamic/hybrid, do not use the assertion; implement and test the framework/runtime production signal on a real dynamic response instead. The managed static tag carries `data-production="true"`; a shared SSR tag must set an equivalent signal conditionally in production, never in development preview. + +If source discovery cannot select every safe editable global shell in its coverage group, create or repair the framework roots and rerun rather than injecting from a JS entry point/effect or guessing between candidates. A mixed Next App + Pages Router app is intentionally covered in both roots, while duplicate alternatives inside one router family remain ambiguous. Once the source invariant passes, `mark-build --strict --dir ` is fully automatic for complete static output. + +For sites with a strict Content Security Policy, allow the widget CDN under `script-src`, the configured Patchstack API under `connect-src`, and account for the inline build marker and widget-injected styles. Nonce/hash-only policies may require a policy-specific integration; do not broadly weaken CSP just to enable the widget. + ## Quick start (existing site) If you already created an "Application" site in the Patchstack dashboard, pre-seed the UUID: ```bash npm install --save-dev @patchstack/connect -npx @patchstack/connect init -npx @patchstack/connect scan +npx --no-install patchstack-connect init +npx --no-install patchstack-connect scan ``` ## CLI ``` -patchstack-connect scan [options] Scan the lockfile and POST to Patchstack. +patchstack-connect scan [options] Scan the lockfile, POST to Patchstack, and + ensure the widget in a safe source shell. If no UUID is configured the server provisions one and the connector persists it. patchstack-connect init Optional: pre-seed .patchstackrc.json with an existing site UUID patchstack-connect status [options] Show current configuration patchstack-connect mark-build [options] Stamp built HTML with a production flag + - build fingerprint (run as a postbuild step) -patchstack-connect guide Show this project's setup status (what's done, + build fingerprint, ensure the CDN widget, + and optionally verify it strictly + (run as a postbuild step) +patchstack-connect guide [--full] Show this project's setup status (what's done, what's missing, with tailored commands), then print the full setup guide patchstack-connect protect Opt-in: install the always-on runtime exploit @@ -76,7 +123,15 @@ patchstack-connect help Print help Options (for scan and status): --site-uuid Override the configured site UUID --endpoint Override the API endpoint - --dry-run (scan only) Print the payload without posting + --dry-run (scan only) Print the payload without posting or editing source + +Options (for mark-build): + --site-uuid Override the configured site UUID + --endpoint Override the API endpoint used by the widget + --dir Override the build output directory + --strict Exit non-zero unless production HTML/widget verification succeeds + --static-output With --strict, assert every deployed route is complete static HTML + (required for detected SSR-capable frameworks; never use for SSR/hybrid) ``` ## Configuration @@ -97,11 +152,23 @@ Environment variables: ```json { - "siteUuid": "550e8400-e29b-41d4-a716-446655440000" + "siteUuid": "550e8400-e29b-41d4-a716-446655440000", + "endpoint": "https://api.patchstack.com/monitor/pulse/manifest" +} +``` + +The connector currently places `siteUuid` in client-side HTML. Under the current backend semantics that same value also authorizes manifest writes and unclaimed-site claiming, so it must **not** be described as non-secret or safe to commit; that mismatch blocks release. After the required credential split, configuration must clearly distinguish the non-authorizing widget identifier (repository/client safe) from manifest-write and one-time claim credentials (secret storage only). Provisioning/adoption must still persist the effective endpoint with the identity so custom/staging sites are never sent to production accidentally. Until that new schema/protocol lands, do not commit `.patchstackrc.json` or deploy automatic embedding outside a disposable security test. + +Widget management defaults to enabled. To keep dependency scans but remove the widget, persist the opt-out before deleting the connector-managed source tag: + +```json +{ + "siteUuid": "550e8400-e29b-41d4-a716-446655440000", + "widget": false } ``` -The site UUID identifies the site; it is not a secret — the disclosure widget ships the same UUID in client-side HTML as its `userToken`, and committing `.patchstackrc.json` is the intended workflow so every developer and CI run reports to the same site. Possession of the UUID lets someone submit dependency manifests for that site (noise, not data access). In CI setups where the file isn't committed, set `PATCHSTACK_SITE_UUID` instead. +Both `scan` and `mark-build` honor `"widget": false`; without it, a later scan restores the managed source tag. ## Programmatic API @@ -112,6 +179,8 @@ const result = await scanAndReport(); console.log(result.response.stored ? 'Reported' : 'Unchanged'); ``` +`scanAndReport()` is a reporting API, not the complete installation workflow: it does not run the CLI's safe source-shell preflight/injection or same-checkout provisioning lock. Use the installed `patchstack-connect scan` command when you want the automatic UUID-to-widget lifecycle described above. + Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postManifest`, `resolveConfig`. ## What gets sent @@ -127,7 +196,7 @@ Lower-level pieces are also exported: `scanLockfile`, `buildWirePayload`, `postM } ``` -That's the entire payload. No source code, no environment variable values, no file paths — just the package names and versions from your lockfile. Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. (`mark-build` separately stamps built HTML with a stack descriptor that may include hosting-related env variable *names* — e.g. `VERCEL` — never their values.) +That's the entire network payload. No source code, environment-variable values, or file paths are uploaded — just the package names and versions from your lockfile. After a successful non-dry response has a UUID, `scan` may locally edit one allowlisted source shell to ensure the widget; source is never uploaded. `mark-build` separately edits built HTML and stamps it with a stack descriptor that may include hosting-related environment-variable *names* such as `VERCEL`, never their values. Duplicate names with different versions are preserved so transitive vulnerabilities aren't missed. ## Supported lockfiles @@ -152,6 +221,8 @@ npm test npm run build ``` +For the maintained end-to-end local connector and static-build fixture, see [`test-build/README.md`](https://github.com/patchstack/connect/tree/main/test-build). Its normal scan/build flow performs a real Patchstack API request; follow its security warning and use it only as disposable local test data. + ### Manifest endpoint testing To post the current lockfile manifest to a local Patchstack API endpoint and provision a new site: @@ -166,23 +237,29 @@ The response should include the new site UUID. To re-test an existing site, pass bun run test:manifest -- --endpoint http://localhost:8000/monitor/pulse/manifest --site-uuid YOUR_REAL_UUID ``` -Use `--dry-run` to preview the payload without posting. +Use `--dry-run` to preview the payload without posting or editing a source shell. ## Release process Pull requests run typecheck, tests, build, package verification, and a production dependency audit in GitHub Actions. -Publishing runs when a GitHub Release is published. The release tag must match the package version in `package.json` with a leading `v`. For example, `package.json` version `0.2.0` must be released with tag `v0.2.0`; otherwise the workflow fails before publishing. +The recommended `Release` workflow creates a GitHub Release from `main` and explicitly dispatches `Publish`; a manually published GitHub Release also triggers `Publish`. The GitHub release tag is the source of truth for the package version. See [`RELEASING.md`](RELEASING.md) for the mechanics and recovery path. The workflows do not replace the release-readiness checks below. + +The automatic UUID-to-widget flow documented in this branch is not yet published. Deployment order is part of the compatibility contract: first close the credential-separation security gate above and verify authorization/migration end to end; then deploy and verify the rolling widget bundle with `data-production` support; then release this connector under an unused version greater than the live npm version and verify it on npm; and only then deploy the matching `patchstack-website` `install.txt`, `llms.txt`, and `uninstall.txt`. Reversing or skipping that order would expose write/claim authority or make the public guide promise unavailable behavior. + +Maintainer reliability note: the connector serializes first-site provisioning processes in the same checkout and re-reads config after acquiring that local lock. That prevents two ordinary local/CI processes sharing the workspace from provisioning twice. It cannot make a timed-out request idempotent after the server has already committed, nor coordinate separate checkouts. Before describing first-site creation as exactly-once, the manifest API must accept and persist an idempotency key for bare provisioning requests; client-side locking alone cannot close that network boundary. To publish a release: -1. Bump the package version, for example `npm version 0.2.0 --no-git-tag-version`. -2. Commit `package.json` and `package-lock.json`. -3. Merge the version bump to `main`. -4. Create and publish a GitHub Release tagged `v0.2.0`. -5. The `Publish` workflow verifies the package, then runs `npm publish --provenance --access public`. +1. Change the backend/API so the widget identifier cannot write manifests or claim a site; issue separate authenticated write and one-time scoped claim credentials. Update connector configuration/protocol, migration, redaction, rotation/replay, and end-to-end negative-authorization tests. +2. Deploy the tested `sass-webvdp-widget` build to `https://cdn.patchstack.com/patchstack-widget.js` and verify `data-production="true"` produces the production/report-only mode while omitted/false preserves development preview behavior. +3. Merge the tested connector feature changes to `main`. +4. From `main`, run the recommended `Release` workflow as documented in [`RELEASING.md`](RELEASING.md). It computes the next npm version, creates the `v`-prefixed release tag, and dispatches `Publish`. If it reports an existing unpublished tag, stop and follow the documented recovery checks rather than moving or recreating the tag. +5. Confirm that `Publish` validates the tagged tree and completes `npm publish --provenance --access public`. +6. Verify the new version with `npm view @patchstack/connect version` and a clean install/scan/static-build smoke test, plus a hybrid-SSR route-coverage test where applicable. +7. Deploy the coordinated website instructions only after backend, CDN, and registry verification succeed. -Before the first release, configure npm trusted publishing for this package: +Trusted publishing must remain configured for releases. To set it up or recover the configuration: 1. Merge `.github/workflows/publish.yml` to `main`. 2. Open the `@patchstack/connect` package settings on npmjs.com. @@ -194,7 +271,7 @@ Before the first release, configure npm trusted publishing for this package: - Environment name: `npm` 5. In GitHub repository settings, create an `npm` environment. Optional but recommended: require reviewer approval for that environment. -Do not add an npm publish token to GitHub secrets for this workflow. Trusted publishing uses GitHub OIDC short-lived credentials. After the first trusted publish succeeds, npm recommends setting package publishing access to require two-factor authentication and disallow tokens. +Do not add an npm publish token to GitHub secrets for this workflow. Trusted publishing uses GitHub OIDC short-lived credentials. Once trusted publishing is working, npm recommends setting package publishing access to require two-factor authentication and disallow tokens. ## License diff --git a/RELEASING.md b/RELEASING.md index abf8263..6105f0d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,21 +9,28 @@ with the published version. `package.json`'s committed `version` is just a placeholder — it does not need to be bumped before a release. +> **Release authorization is separate from release mechanics.** These workflows +> do not know whether a product or security release blocker is open. Check the +> blockers in `README.md` before dispatching either workflow. In particular, do +> not release the automatic UUID-to-widget flow until credential separation, +> authorization/migration tests, and the CDN rollout are complete in the order +> documented there. + ## How to release (recommended) -Run the **`Release`** workflow from the Actions tab (or `gh` below) and pick a -`bump` — `patch`, `minor`, or `major`. It reads the current `latest` from npm, -computes the next semver version, cuts the GitHub release + tag on the current +Run the **`Release`** workflow from the Actions tab on `main` (or `gh` below) and pick a +`bump` — `patch`, `minor`, or `major`. It reads the current release state, +computes the next available semver version, cuts the GitHub release + tag on the current `main`, and then dispatches `Publish` for that version. `Publish` validates (typecheck, test, build, `npm pack`) and publishes to npm with provenance, recording a deployment to the `npm` environment linked to the published version. ```bash -gh workflow run Release -f bump=patch +gh workflow run release.yml --ref main -f bump=patch ``` -No version math, no `npm view` lookup, no chance of colliding with an existing -version — the workflow does all of that. +No manual version math or `npm view` lookup is needed. The workflow computes the +version and fails before creating a release if that tag already exists. `Release` triggers `Publish` explicitly via `workflow_dispatch` rather than relying on the release event. This is deliberate: GitHub does **not** fire @@ -37,7 +44,7 @@ You can still cut a release by hand. Because a human token (not `GITHUB_TOKEN`) creates it, the release event fires `Publish` on its own: ```bash -gh release create v0.3.3 --generate-notes --title "v0.3.3" +gh release create v0.3.3 --target main --generate-notes --title "v0.3.3" ``` or use the GitHub UI (Releases → Draft a new release → new tag `v0.3.3`). @@ -45,9 +52,26 @@ or use the GitHub UI (Releases → Draft a new release → new tag `v0.3.3`). You can also publish an existing tag directly: ```bash -gh workflow run publish.yml -f version=0.3.3 +gh workflow run publish.yml --ref v0.3.3 -f version=0.3.3 +``` + +Use the existing-tag path only to recover an approved release whose tag was +created but whose npm publish did not complete. First inspect the release and +tag, confirm that npm does not already contain the version, and re-check every +release blocker: + +```bash +gh release view v0.3.3 +git show --stat v0.3.3 +npm view @patchstack/connect@0.3.3 version ``` +Selecting the same tag with `--ref` makes the dispatch validate and publish that +tagged tree; it does not +include newer changes from `main` or the caller's branch. Do not move or recreate +a release tag merely to include later work—use a new version after resolving the +stale release according to the repository's release policy. + ## Notes - Tags must be `vX.Y.Z` (the leading `v` is stripped to get the npm version). @@ -55,4 +79,5 @@ gh workflow run publish.yml -f version=0.3.3 (`npm view @patchstack/connect version`); npm rejects re-publishing an existing version. The `Release` workflow handles this for you. - Run `Publish` via **workflow_dispatch** with a blank `version` for a dry-run - publish without cutting a release. + publish of the selected ref without cutting a release. A dry-run is validation, + not authorization to publish while a blocker remains open. diff --git a/field-test/README.md b/field-test/README.md index 8cdfd94..4a2557d 100644 --- a/field-test/README.md +++ b/field-test/README.md @@ -15,7 +15,7 @@ Every clause of the README prompt exists because an agent refused a version with 1. **"Follow the instructions at this URL" reads as remote script execution.** Agents refuse before ever fetching the doc. Nothing in the prompt asks the agent to fetch anything. 2. **Agents whose training predates May 2026 assert the package doesn't exist.** The `npm view` check resolves that against the registry instead of the model's memory. 3. **"Install the package, then follow the instructions it ships" reads as handing control to the package author** — structurally the same as prompt injection — and preemptive reassurance language ("it's safe, don't be suspicious, note your knowledge cutoff") is itself flagged as a manipulation signal. Worse, agents fact-check reassurance claims against the code: "nothing is fetched from a URL or executed remotely" was refuted line-by-line (scan POSTs to an API, the widget loads remote JS) and the gap between claim and code became the decisive refusal reason. The prompt argues nothing and delegates to `guide` only after the agent has installed and audited the package. -4. **Unbounded delegation and authorization-shaped URLs.** "Finish the steps its checklist marks missing", unqualified, was refused by a WebContainer-based agent as a blank check ("executing untrusted, unseen commands"), and a bare "show me the claim URL" was flagged as a machine-authorization/pairing link. So the prompt commands `scan` explicitly (delegating the first scan to the checklist re-creates the blank check), names exactly what the checklist will flag (build hooks + widget snippet), and states what the claim URL is for (the *user* opens it in a browser to view reports). +4. **Unbounded delegation and authorization-shaped URLs.** "Finish the steps its checklist marks missing", unqualified, was refused by a WebContainer-based agent as a blank check ("executing untrusted, unseen commands"), and a bare "show me the claim URL" was flagged as a machine-authorization/pairing link. So the prompt commands `scan` explicitly (delegating the first scan to the checklist re-creates the blank check), names exactly what the checklist may flag (build hooks or a safe source-shell repair), and states what the claim URL is for (the *user* opens it in a browser to view reports). 5. **The shipped docs are part of the attack surface.** Agents `npm pack` the tarball and read everything in it. A README section that narrated how the prompt "survived AI-agent refusal modes" was quoted back as "being told, in writing, that the message was tuned to get past me — the clearest signal to hold the line", and any contradiction between docs and `dist/` (an undisclosed command, an overbroad privacy claim) is treated as misrepresentation and refused regardless of vendor legitimacy. Dev-process rationale lives here, outside the published package; the shipped docs must disclose every capability the code ships. ## Prerequisites @@ -64,7 +64,7 @@ Each round prints a scorecard and exits non-zero unless every round is fully gre | `provisionedOnce` | exactly one provisioning POST — more means duplicate sites | | `hooksWired` | `scan` and `mark-build` reachable from `prebuild`/`postbuild`/`build` | | `widgetInstalled` | widget script tag present in source | -| `widgetTokenMatches` | the provisioned UUID appears in source as the `userToken` | +| `widgetTokenMatches` | the provisioned UUID appears in source as the widget identifier | | `claimUrlSurfaced` | the agent's final message shows the claim URL to the user | | `noProductionLeak` | the agent never surfaced a production claim URL (mock bypass) | @@ -78,23 +78,23 @@ Everything is saved under `field-test/results/-/` (gitignore 4. Fix what you find — in the prompt, the `guide` checklist, or `AGENT-INSTALL.md` (agents audit the shipped docs; inaccuracies cost trust). 5. When rounds are consistently green, copy the prompt into the README's install-prompt section and record any new refusal mode in the list above. -Keep `prompt.txt` in sync with the README prompt — it is the tested artifact. +Keep `prompt.txt`, the README prompt, and the prompt in `GETTING-STARTED.md` identical — `prompt.txt` is the tested artifact. ## When the gate is red for environmental reasons (plan B) The agent audits the *published* tarball, so the gate's pass rate is a function of two things that no prompt edit can fix: -- **Stale shipped docs.** If doc fixes are merged but unpublished, agents refuse over contradictions that no longer exist at HEAD (this is how a full 2026-07-14 session went 2-green/8-refused across every prompt variant, including the incumbent). Publishing is the only fix. -- **Release freshness.** "Published 11 hours ago" and same-day release bursts are cited as targeted-attack signals. This decays on its own; gate results are most representative ≥24–48h after the last publish. (Corollary: publishing doc fixes resets this clock — expect a noisy day, then re-gate.) +- **Stale shipped docs.** If doc fixes are merged but unpublished, agents refuse over contradictions that no longer exist at HEAD (this is how a full 2026-07-14 session went 2-green/8-refused across every prompt variant, including the incumbent). After the UUID security blocker is lifted, publishing is the only way to update the audited tarball. While that blocker remains, do not publish merely to make this gate green; classify the stale-doc refusal as environmental. +- **Release freshness.** "Published 11 hours ago" and same-day release bursts are cited as targeted-attack signals. This decays on its own; gate results are most representative ≥24–48h after the last permitted publish. (Corollary: a post-gate docs release resets this clock — expect a noisy day, then re-gate.) Until a publish lands and ages, use this ladder instead of burning hostile rounds on a known-red gate: 1. **Stub self-test** — `node field-test/run.mjs --agent-cmd "node $PWD/field-test/stub-compliant.mjs"`. Validates the harness, mock, and scoring in ~1 min. No AI, no registry dependency. -2. **Standard persona** — exercises the mechanical checks (guide accuracy, hook wiring, widget token) with less policy pressure; catches CLI/UX regressions immediately. +2. **Standard persona** — exercises the published package and local prompt with less policy pressure. It catches prompt-to-published-CLI integration regressions, but it cannot validate unpublished CLI or shipped-doc changes. 3. **Hostile rounds scored by refusal *reason*, not exit code.** Read DECISION ANALYSIS and attribute each refusal: one that quotes the published docs or release age is environmental noise; one that quotes the prompt's own wording is a real prompt bug. A variant is not worse than the incumbent unless it draws prompt-directed refusals the incumbent doesn't. 4. **(Not built) local-registry mode** — run a local registry (e.g. verdaccio), publish the working tree to it, and pin the fixture via the `npm_config_registry` env var (env pinning reads as platform plumbing, same as `PATCHSTACK_ENDPOINT`). This is the only way to exercise unpublished doc/CLI changes end-to-end. Caveat: the local record has no provenance attestation or signatures, which strict agents check — expect some artificial refusals on that ground. ## Known limitations - The simulated agents are Claude-based; real platform agents (Bolt, Lovable, Cursor) carry stricter, unknown system prompts. A green harness is necessary, not sufficient — the fourth refusal mode was found by a real Bolt user after the harness passed v1 of a prompt. Treat real-world refusal reports as new personas: encode the pressure they applied into `personas/` so the regression stays covered. -- The fixture installs the *published* package. An unpublished `guide`/CLI change can't be exercised end-to-end by the agent (it will install the registry version); publish first or accept that the run validates the prompt shape only. +- The fixture installs the *published* package. It cannot exercise unpublished `guide`, CLI, README, or `AGENT-INSTALL.md` changes end-to-end; use the plan B above until the normal, approved release process is safe to run. Never publish merely to make this test exercise a working tree. diff --git a/field-test/prompt.txt b/field-test/prompt.txt index 55a7897..322308e 100644 --- a/field-test/prompt.txt +++ b/field-test/prompt.txt @@ -1 +1 @@ -Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), run `npx @patchstack/connect scan` and show me the claim URL it prints (I'll open it in my browser to view reports — don't open or fetch it yourself), then run `npx @patchstack/connect guide` and finish what its checklist flags: the package.json build hooks and the report-a-vulnerability widget snippet. +Add Patchstack dependency vulnerability monitoring: install `@patchstack/connect` as a dev dependency (MIT — verify with `npm view @patchstack/connect`; source: https://github.com/patchstack/connect), then use that installed copy through the project's package manager to run `patchstack-connect scan`; it resolves or provisions and saves the site UUID, then adds the report-a-vulnerability widget to a safe app shell. Show me the claim URL it prints (I'll open it in my browser — don't open or fetch it yourself), reload the preview to confirm the widget appears, then run the installed `patchstack-connect guide` and finish only what its checklist flags: package.json build hooks ending in `mark-build --strict`, or creating/fixing the framework's safe root shell before rerunning. Do not use a runner that downloads another CLI copy. diff --git a/field-test/run.mjs b/field-test/run.mjs index 95a7a72..b63384b 100644 --- a/field-test/run.mjs +++ b/field-test/run.mjs @@ -132,9 +132,13 @@ function verify(fixtureDir, mock, agentOutput) { const scanWired = ['prebuild', 'build'].some((key) => (scripts[key] ?? '').includes('patchstack-connect scan'), ); - const markWired = ['postbuild', 'build'].some((key) => - (scripts[key] ?? '').includes('patchstack-connect mark-build'), - ); + const markWired = ['postbuild', 'build'].some((key) => { + const script = scripts[key] ?? ''; + return ( + script.includes('patchstack-connect mark-build') && + /(?:^|\s)--strict(?=\s|$)/.test(script) + ); + }); const checks = { installed: { @@ -151,7 +155,7 @@ function verify(fixtureDir, mock, agentOutput) { }, hooksWired: { pass: scanWired && markWired, - detail: `scan wired=${scanWired}, mark-build wired=${markWired}`, + detail: `scan wired=${scanWired}, strict mark-build wired=${markWired}`, }, widgetInstalled: { pass: fixtureContains(fixtureDir, 'patchstack-widget'), @@ -159,7 +163,7 @@ function verify(fixtureDir, mock, agentOutput) { }, widgetTokenMatches: { pass: fixtureContains(fixtureDir, mock.uuid), - detail: 'provisioned UUID appears in source (userToken)', + detail: 'provisioned UUID appears in source as the widget identifier', }, claimUrlSurfaced: { pass: agentOutput.includes('/monitor/claim?site='), diff --git a/field-test/stub-compliant.mjs b/field-test/stub-compliant.mjs index ff85817..6d9e725 100644 --- a/field-test/stub-compliant.mjs +++ b/field-test/stub-compliant.mjs @@ -9,7 +9,7 @@ const cwd = process.cwd(); const run = (cmd) => execSync(cmd, { cwd, stdio: 'pipe', env: process.env }).toString(); run('npm install --save-dev --no-audit --no-fund @patchstack/connect'); -const scanOutput = run('npx @patchstack/connect scan'); +const scanOutput = run('npx --no-install patchstack-connect scan'); const rc = JSON.parse(readFileSync(`${cwd}/.patchstackrc.json`, 'utf8')); @@ -17,13 +17,13 @@ const pkg = JSON.parse(readFileSync(`${cwd}/package.json`, 'utf8')); pkg.scripts = { ...pkg.scripts, prebuild: 'patchstack-connect scan', - postbuild: 'patchstack-connect mark-build', + postbuild: 'patchstack-connect mark-build --strict', }; writeFileSync(`${cwd}/package.json`, JSON.stringify(pkg, null, 2) + '\n'); const widget = - ` \n` + - ` \n`; + ` \n`; const html = readFileSync(`${cwd}/index.html`, 'utf8'); writeFileSync(`${cwd}/index.html`, html.replace('', `${widget} `)); diff --git a/package-lock.json b/package-lock.json index 14b56ba..7df95a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "devDependencies": { "@types/node": "^20.11.0", "tsup": "^8.0.0", + "tsx": "4.23.1", "typescript": "^5.4.0", + "vite": "^6.4.3", "vitest": "^3.0.0" }, "engines": { @@ -1826,130 +1828,1117 @@ } } }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=18" } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", "bin": { - "vite-node": "vite-node.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": ">=18" }, - "funding": { - "url": "https://opencollective.com/vitest" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/vitest": { diff --git a/package.json b/package.json index bceaaab..058b5db 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,9 @@ "devDependencies": { "@types/node": "^20.11.0", "tsup": "^8.0.0", + "tsx": "4.23.1", "typescript": "^5.4.0", + "vite": "^6.4.3", "vitest": "^3.0.0" }, "repository": { diff --git a/src/atomic-write.ts b/src/atomic-write.ts new file mode 100644 index 0000000..e80b652 --- /dev/null +++ b/src/atomic-write.ts @@ -0,0 +1,37 @@ +import { randomUUID } from 'node:crypto'; +import { open, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Replace a text file atomically from a temporary file in the same directory. + * Existing permissions are retained, and an interrupted write cannot leave the + * destination truncated or containing half-written JSON/markup. + */ +export async function atomicWriteTextFile(target: string, content: string): Promise { + let mode: number | undefined; + try { + mode = (await stat(target)).mode; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const temporary = path.join( + path.dirname(target), + `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, 'wx', mode); + await handle.writeFile(content, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, target); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} diff --git a/src/cli.ts b/src/cli.ts index 7b6158e..436eab6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,19 +1,36 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { atomicWriteTextFile } from './atomic-write.js'; import { scanLockfile } from './parsers/index.js'; import { buildWirePayload } from './normalize.js'; import { computeManifestChecksum } from './checksum.js'; -import { DEFAULT_ENDPOINT, buildClaimUrl, postManifest } from './client.js'; -import { persistSiteUuid, resolveConfig, writeConfigFile } from './config.js'; +import { DEFAULT_ENDPOINT, buildClaimUrl, isUuid, postManifest } from './client.js'; +import { persistSiteUuid, resolveConfig } from './config.js'; import { buildInjectionSnippet, + buildWidgetTag, findHtmlFiles, + hasLiveHtmlDocument, injectMarker, resolveBuildDir, + verifyBuildHtml, + type BuildHtmlVerificationIssue, } from './mark-build.js'; import { collectGuideState, countRemainingSteps, renderGuideChecklist } from './guide.js'; +import { + ensureSourceWidget, + inspectSourceWidgetPreflight, + widgetApiBaseFromEndpoint, + type SourceWidgetPreflightResult, +} from './source-widget.js'; +import { acquireProvisionLock, type ProvisionLock } from './provision-lock.js'; import { runProtect } from './protect/install.js'; -import { detectStack, type StackDescriptor } from './stack.js'; +import { + SSR_CAPABLE_FRAMEWORKS, + detectStack, + type StackDescriptor, +} from './stack.js'; import { PatchstackError } from './types.js'; const HELP = `@patchstack/connect — scan your lockfile and report packages to Patchstack. @@ -21,12 +38,14 @@ const HELP = `@patchstack/connect — scan your lockfile and report packages to Usage: patchstack-connect scan [options] Scan lockfile and POST to Patchstack. If no UUID is configured, the server - provisions one and we persist it. + provisions one, we persist it, and the + widget is added to a safe source shell. patchstack-connect init Optional: pre-seed .patchstackrc.json with an existing site UUID patchstack-connect status [options] Show current configuration patchstack-connect mark-build [options] Stamp built HTML with a production flag + - build fingerprint (run as a postbuild step) + build fingerprint and ensure the widget + (run as a postbuild step) patchstack-connect protect Install always-on runtime protection (the guard) into a TanStack Start + Supabase app. Covers the browser + server-function paths. @@ -37,14 +56,19 @@ Usage: (also at https://patchstack.com/install.txt) patchstack-connect help Print this message -Options (for scan and status): +Options (for scan, status, and mark-build): --site-uuid Override the configured site UUID --endpoint Override the API endpoint + +Options (for scan): --dry-run (scan only) Show the payload without posting Options (for mark-build): --dir Build output directory (default: auto-detect dist/ build/ out/ .output/public) + --strict Fail when production HTML/widget verification fails + --static-output Assert that every deployed route is represented by + static HTML (required for SSR-capable frameworks) Environment: PATCHSTACK_SITE_UUID Site UUID @@ -55,10 +79,10 @@ Environment: Precedence: CLI flag > environment variable > .patchstackrc.json. Examples: - npx @patchstack/connect scan - npx @patchstack/connect scan --dry-run - npx @patchstack/connect init 550e8400-e29b-41d4-a716-446655440000 - npx @patchstack/connect scan --site-uuid 550e8400-...-446655440000 + patchstack-connect scan + patchstack-connect scan --dry-run + patchstack-connect init 550e8400-e29b-41d4-a716-446655440000 + patchstack-connect scan --site-uuid 550e8400-...-446655440000 `; const VALUE_FLAGS = new Set(['site-uuid', 'endpoint', 'dir']); @@ -83,14 +107,30 @@ function parseArgs(argv: string[]): ParsedArgs { const stripped = arg.slice(2); const eqIdx = stripped.indexOf('='); if (eqIdx !== -1) { - flags.set(stripped.slice(0, eqIdx), stripped.slice(eqIdx + 1)); + const name = stripped.slice(0, eqIdx); + if (flags.has(name)) { + throw new PatchstackError(`Flag --${name} was provided more than once.`, 'CONFIG_INVALID'); + } + flags.set(name, stripped.slice(eqIdx + 1)); continue; } const next = args[i + 1]; if (VALUE_FLAGS.has(stripped) && next !== undefined && !next.startsWith('--')) { + if (flags.has(stripped)) { + throw new PatchstackError( + `Flag --${stripped} was provided more than once.`, + 'CONFIG_INVALID', + ); + } flags.set(stripped, next); i++; } else { + if (flags.has(stripped)) { + throw new PatchstackError( + `Flag --${stripped} was provided more than once.`, + 'CONFIG_INVALID', + ); + } flags.set(stripped, true); } } @@ -102,9 +142,65 @@ function parseArgs(argv: string[]): ParsedArgs { }; } +const COMMAND_FLAGS: Readonly>> = { + scan: new Set(['site-uuid', 'endpoint', 'dry-run', 'help']), + status: new Set(['site-uuid', 'endpoint', 'help']), + 'mark-build': new Set([ + 'site-uuid', + 'endpoint', + 'dir', + 'strict', + 'static-output', + 'help', + ]), + init: new Set(['help']), + protect: new Set(['help']), + guide: new Set(['full', 'help']), + help: new Set(['help']), +}; + +function validateCommandArgs(args: ParsedArgs): void { + if (args.command === null) return; + const allowed = COMMAND_FLAGS[args.command]; + if (allowed === undefined) return; + for (const flag of args.flags.keys()) { + if (!allowed.has(flag)) { + throw new PatchstackError( + `Unknown option --${flag} for ${args.command}. Run \`patchstack-connect ${args.command} --help\` for usage.`, + 'CONFIG_INVALID', + ); + } + } + const expectedPositionals = args.command === 'init' ? 1 : 0; + if (args.positional.length !== expectedPositionals) { + throw new PatchstackError( + args.command === 'init' + ? 'init requires exactly one site UUID.' + : `${args.command} does not accept positional arguments.`, + 'CONFIG_INVALID', + ); + } +} + function getStringFlag(flags: Map, name: string): string | undefined { const value = flags.get(name); - return typeof value === 'string' ? value : undefined; + if (value === undefined) { + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new PatchstackError(`--${name} requires a non-empty value.`, 'CONFIG_INVALID'); + } + return value; +} + +function getBooleanFlag(flags: Map, name: string): boolean { + const value = flags.get(name); + if (value === undefined || value === 'false') return false; + if (value === true || value === 'true') return true; + throw new PatchstackError( + `--${name} accepts no value, "true", or "false"; got "${value}".`, + 'CONFIG_INVALID', + ); } async function runInit(args: ParsedArgs): Promise { @@ -114,30 +210,33 @@ async function runInit(args: ParsedArgs): Promise { console.error('Usage: patchstack-connect init '); return 1; } - if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) { + if (!isUuid(uuid)) { console.error(`Error: "${uuid}" does not look like a valid UUID.`); return 1; } - const target = await writeConfigFile(process.cwd(), { siteUuid: uuid }); + const target = await persistSiteUuid(process.cwd(), uuid); console.log(`Wrote ${target}`); console.log(''); - console.log('Next: run `npx @patchstack/connect scan` to send your first manifest.'); + console.log('Next: run `patchstack-connect scan` to send your first manifest.'); return 0; } async function runScan(args: ParsedArgs): Promise { - const dryRun = args.flags.get('dry-run') === true; - const config = await resolveConfig({ - cwd: process.cwd(), + const cwd = process.cwd(); + const dryRun = getBooleanFlag(args.flags, 'dry-run'); + const configOptions = { + cwd, cliSiteUuid: getStringFlag(args.flags, 'site-uuid'), cliEndpoint: getStringFlag(args.flags, 'endpoint'), - }); - const manifest = await scanLockfile(process.cwd()); + }; + let config = await resolveConfig(configOptions); + const manifest = await scanLockfile(cwd); for (const warning of manifest.warnings ?? []) { console.warn(`patchstack: ${warning}`); } const { payload, stats } = buildWirePayload(manifest); + const stack = detectStack(payload.packages); console.log( `Found ${payload.packages.length} unique package versions across ${stats.uniqueNames} package names (${manifest.ecosystem} ecosystem).`, @@ -175,41 +274,188 @@ async function runScan(args: ParsedArgs): Promise { return 0; } - const provisioning = config.siteUuid === null; + let provisioning = config.siteUuid === null; + let provisionLock: ProvisionLock | null = null; if (provisioning) { - console.log('No site UUID configured — provisioning a new Patchstack site from this manifest…'); + provisionLock = await acquireProvisionLock( + cwd, + Math.min(Math.max(config.timeoutMs + 5_000, 5_000), 60_000), + ); } - const response = await postManifest(config, payload); + try { + if (provisionLock !== null) { + // Another scan may have completed while this one waited. Re-resolving here + // turns the second request into an existing-site POST instead of creating a + // second anonymous site for the same checkout. + config = await resolveConfig(configOptions); + provisioning = config.siteUuid === null; + if (provisioning) { + console.log( + 'No site UUID configured — provisioning a new Patchstack site from this manifest…', + ); + } else { + console.log( + `Another scan provisioned site ${config.siteUuid} while this scan waited; reusing it.`, + ); + } + } - // 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}.`); - } + if (config.widgetEnabled !== false) { + const preflight = await inspectSourceWidgetPreflight({ + cwd, + stack, + expectedSiteUuid: config.siteUuid, + }); + const hasWidgetLoader = preflight.shells.some((shell) => + shell.identity.occurrences.some( + (occurrence) => + occurrence.kind === 'script-tag' || occurrence.kind === 'dynamic-loader', + ), + ); + if (preflight.externalWidgetShells.length > 0) { + throw unsafeExternalWidgetIdentityError(preflight, config.siteUuid, cwd); + } + + if (preflight.missingRequiredShells.length > 0) { + throw new PatchstackError( + `Cannot safely continue: ${preflight.missingRequiredShells.join('; ')}. No manifest was posted and no source file was changed.`, + 'CONFIG_INVALID', + ); + } + + if (preflight.files.length === 0) { + throw new PatchstackError( + 'Cannot safely continue: no editable global source shell was found for the disclosure widget. ' + + 'Create the framework root layout/document and scan again, or explicitly set "widget": false in .patchstackrc.json. No manifest was posted and no site was provisioned.', + 'CONFIG_INVALID', + ); + } + + if (preflight.status === 'ambiguous') { + throw unsafeWidgetIdentityError(preflight, config.siteUuid, cwd); + } + + if (config.siteUuid === null) { + if ( + preflight.status === 'configured' && + preflight.uuid !== null && + hasWidgetLoader + ) { + const target = await persistSiteUuid(cwd, preflight.uuid, config.endpoint); + console.log( + `Found existing widget site ${preflight.uuid}; adopted it in ${target} instead of provisioning a second site.`, + ); + config = await resolveConfig(configOptions); + provisioning = false; + } else if (preflight.status !== 'absent') { + throw unsafeWidgetIdentityError(preflight, null, cwd); + } + } else if ( + preflight.hasManual && + (!hasWidgetLoader || + preflight.status !== 'configured' || + preflight.matchesExpectedUuid !== true) + ) { + throw unsafeWidgetIdentityError(preflight, config.siteUuid, cwd); + } + } - 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)}`); - } + const response = await postManifest(config, payload); - // On the first scan (provisioning), surface the claim URL so the user can - // attach this site to their Patchstack account. `npx @patchstack/connect status` - // re-displays it any time. - if (provisioning && response.uuid !== undefined && response.uuid.length > 0) { - console.log(''); - console.log('Claim this site to view vulnerability reports in your Patchstack dashboard:'); - console.log(` ${buildClaimUrl(config.endpoint, response.uuid)}`); - if (config.endpoint !== DEFAULT_ENDPOINT) { - console.log(' (this URL inherits the endpoint override above)'); + // 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) { + // Persist the endpoint with the UUID as one identity pair. This matters for + // a first scan against staging/custom infrastructure: a later build must not + // send that UUID to the production endpoint by accident. + const target = await persistSiteUuid(cwd, response.uuid, config.endpoint); + console.log(`Provisioned site ${response.uuid}. Saved UUID to ${target}.`); } + + const effectiveSiteUuid = config.siteUuid ?? response.uuid ?? null; + if (config.widgetEnabled === false) { + console.log('Patchstack disclosure widget management is disabled by "widget": false.'); + } else if (effectiveSiteUuid !== null && effectiveSiteUuid.length > 0) { + await installSourceWidget( + cwd, + effectiveSiteUuid, + config.endpoint, + stack, + ); + } else { + console.warn( + 'patchstack widget: the scan succeeded but no site UUID was returned, so no source file was changed.', + ); + } + + 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)}`); + } + + // On the first scan (provisioning), surface the claim URL so the user can + // attach this site to their Patchstack account. `patchstack-connect status` + // re-displays it any time. + if (provisioning && response.uuid !== undefined && response.uuid.length > 0) { + console.log(''); + console.log('Claim this site to view vulnerability reports in your Patchstack dashboard:'); + console.log(` ${buildClaimUrl(config.endpoint, response.uuid)}`); + if (config.endpoint !== DEFAULT_ENDPOINT) { + console.log(' (this URL inherits the endpoint override above)'); + } + } + + return 0; + } finally { + await provisionLock?.release(); } +} - return 0; +function unsafeExternalWidgetIdentityError( + preflight: SourceWidgetPreflightResult, + expectedSiteUuid: string | null, + cwd: string, +): PatchstackError { + const details = preflight.externalWidgetShells.map((shell) => { + const file = path.relative(cwd, shell.file) || path.basename(shell.file); + const uuids = shell.identity.uuids.length > 0 + ? ` (${shell.identity.uuids.join(', ')})` + : ''; + return `${file}: ${shell.identity.status}${uuids}`; + }); + return new PatchstackError( + `Cannot safely continue: found a Patchstack loader or initializer outside the selected global shell: ${details.join('; ')}. ` + + `Move it into the true global shell${expectedSiteUuid === null ? ' or initialize this project explicitly with its verified UUID' : ` and configure it for ${expectedSiteUuid}`}, ` + + 'or remove it so the connector can install the global managed tag. A nested/page-specific loader cannot prove sitewide coverage. No manifest was posted and no source file was changed.', + 'CONFIG_INVALID', + ); +} + +function unsafeWidgetIdentityError( + preflight: SourceWidgetPreflightResult, + expectedSiteUuid: string | null, + cwd: string, +): PatchstackError { + const locations = preflight.files + .map((file) => path.relative(cwd, file) || path.basename(file)) + .join(', '); + const identities = preflight.uuids.length > 0 + ? ` Detected UUID(s): ${preflight.uuids.join(', ')}.` + : ''; + const expected = + preflight.status === 'ambiguous' && !preflight.hasManual && !preflight.hasManaged + ? 'Multiple files could be the global app shell. Remove the unrelated shell or make the framework root unambiguous before scanning; no site was provisioned.' + : expectedSiteUuid === null + ? 'Fix or remove the existing widget, or run `patchstack-connect init ` with its real UUID before scanning.' + : `Make the manual widget use ${expectedSiteUuid}, or remove it so the connector can install the managed tag.`; + return new PatchstackError( + `Cannot safely continue: the selected source shell${locations ? ` (${locations})` : ''} has ${preflight.status} Patchstack widget identity.${identities} ${expected}`, + 'CONFIG_INVALID', + ); } async function runProtectCommand(_args: ParsedArgs): Promise { @@ -238,10 +484,10 @@ async function runGuide(args: ParsedArgs): Promise { // A fully green project doesn't need the ~90-line manual again on every // re-run — point at it instead. `--full` always prints it. - if (allDone && args.flags.get('full') !== true) { + if (allDone && !getBooleanFlag(args.flags, 'full')) { console.log(''); console.log( - 'Full reference guide: `npx @patchstack/connect guide --full` (or read node_modules/@patchstack/connect/AGENT-INSTALL.md).', + 'Full reference guide: run the installed `patchstack-connect guide --full` through this project\'s package manager (or read node_modules/@patchstack/connect/AGENT-INSTALL.md).', ); return 0; } @@ -269,12 +515,85 @@ async function runStatus(args: ParsedArgs): Promise { ); console.log(`Timeout: ${config.timeoutMs}ms`); console.log(`Environment: ${config.environment}`); + console.log(`Widget: ${config.widgetEnabled === false ? 'disabled' : 'enabled'}`); if (config.siteUuid !== null) { console.log(`Claim URL: ${buildClaimUrl(config.endpoint, config.siteUuid)}`); } return 0; } +async function installSourceWidget( + cwd: string, + siteUuid: string, + endpoint: string, + stack: StackDescriptor, +): Promise { + let result; + try { + result = await ensureSourceWidget({ cwd, siteUuid, endpoint, stack }); + } catch (err) { + throw new PatchstackError( + `The manifest was posted and site ${siteUuid} was saved, but automatic source installation failed (${(err as Error).message}). Fix the source shell and rerun scan; the saved UUID will be reused instead of provisioning another site.`, + 'CONFIG_INVALID', + err, + ); + } + + const displayFile = (file: string | undefined): string => + file === undefined ? 'the source shell' : path.relative(cwd, file) || path.basename(file); + const displayResultFiles = (): string => + result.files !== undefined && result.files.length > 0 + ? result.files.map((file) => displayFile(file)).join(', ') + : displayFile(result.file); + let fallbackApiBase: string | null = null; + try { + fallbackApiBase = widgetApiBaseFromEndpoint(endpoint); + } catch { + // `ensureSourceWidget` reports the endpoint problem via its `failed` result. + } + const fallbackTag = buildWidgetTag(siteUuid, fallbackApiBase); + + switch (result.status) { + case 'installed': + console.log( + `Installed the Patchstack disclosure widget in ${displayResultFiles()}. Reload the app preview to see it.`, + ); + return; + case 'updated': + console.log( + `Updated the Patchstack disclosure widget in ${displayResultFiles()}. Reload the app preview to see it.`, + ); + return; + case 'unchanged': + console.log(`Patchstack disclosure widget is ready in ${displayResultFiles()}.`); + return; + case 'manual-present': + console.log( + `An existing Patchstack disclosure widget is already present in ${displayResultFiles()}; left it unchanged.`, + ); + return; + case 'ambiguous': { + const files = (result.candidates ?? []) + .map((file) => path.relative(cwd, file) || path.basename(file)) + .join(', '); + throw new PatchstackError( + `The manifest was posted and site ${siteUuid} was saved, but multiple possible source shells were found${files.length > 0 ? ` (${files})` : ''}; no source file was changed. Fix the true global shell and rerun scan so it can install this tag without provisioning again:\n ${fallbackTag}`, + 'CONFIG_INVALID', + ); + } + case 'not-found': + throw new PatchstackError( + `The manifest was posted and site ${siteUuid} was saved, but the editable global source shell disappeared before widget installation; no source file was changed. Restore it and rerun scan so it can install this tag without provisioning again:\n ${fallbackTag}`, + 'CONFIG_INVALID', + ); + case 'failed': + throw new PatchstackError( + `The manifest was posted and site ${siteUuid} was saved, but the widget could not update ${displayFile(result.file)}${result.message ? ` (${result.message})` : ''}. Fix the source shell and rerun scan; the saved UUID will be reused instead of provisioning another site.`, + 'CONFIG_INVALID', + ); + } +} + /** One-line, human-readable summary of a detected stack for CLI output. */ function describeStack(stack: StackDescriptor): string | null { const parts = [stack.builder, stack.framework, stack.ui, stack.runtime].filter( @@ -288,11 +607,60 @@ function describeStack(stack: StackDescriptor): string | null { async function runMarkBuild(args: ParsedArgs): Promise { const cwd = process.cwd(); + const strict = getBooleanFlag(args.flags, 'strict'); + const staticOutput = getBooleanFlag(args.flags, 'static-output'); + if (staticOutput && !strict) { + console.error('mark-build: --static-output must be used together with --strict.'); + return 1; + } + + // The first scan persists this value in .patchstackrc.json. Read it again at + // postbuild time so the generated HTML can auto-initialise the CDN widget + // without framework-specific source edits. The default mode remains + // best-effort for backwards compatibility; --strict makes an incomplete or + // unverifiable production install fail the build hook. + let siteUuid: string | null = null; + let widgetApiBaseUrl: string | null = null; + let widgetEnabled = true; + try { + const config = await resolveConfig({ + cwd, + cliSiteUuid: getStringFlag(args.flags, 'site-uuid'), + cliEndpoint: getStringFlag(args.flags, 'endpoint'), + }); + siteUuid = config.siteUuid; + widgetEnabled = config.widgetEnabled !== false; + try { + widgetApiBaseUrl = widgetApiBaseFromEndpoint(config.endpoint); + } catch (err) { + console.warn( + `mark-build: could not derive the widget API origin from ${config.endpoint} (${(err as Error).message}); the widget will use its production default.`, + ); + } + if (!widgetEnabled) { + console.log('mark-build: disclosure widget management is disabled by "widget": false.'); + } else if (siteUuid === null) { + const message = + 'mark-build: no site UUID configured; run `patchstack-connect scan` before the production build.'; + if (strict) { + console.error(message); + return 1; + } + console.warn(`${message} Build metadata will be stamped without the disclosure widget.`); + } + } catch (err) { + const message = `mark-build: could not resolve configuration (${(err as Error).message}).`; + if (strict) { + console.error(message); + return 1; + } + console.warn(`${message} Build metadata will be stamped without the disclosure widget.`); + } // Compute the build fingerprint and stack descriptor from the lockfile. - // Best-effort: mark-build is a postbuild step and must never fail the build, so - // a missing/unreadable lockfile just means we stamp the production flag without - // a fingerprint or stack. + // Default mode remains best-effort. Strict mode requires the lockfile because + // stack detection is what prevents a hybrid SSR build from passing merely + // because it emitted one prerendered/error HTML file. let checksum: string | null = null; let stack: StackDescriptor | null = null; try { @@ -304,33 +672,146 @@ async function runMarkBuild(args: ParsedArgs): Promise { checksum = computeManifestChecksum(payload.packages); stack = detectStack(payload.packages); } catch (err) { - console.warn( - `mark-build: could not compute the build fingerprint (${(err as Error).message}). Stamping the production flag only.`, + const message = `mark-build: could not compute the build fingerprint or detect output coverage (${(err as Error).message}).`; + if (strict) { + console.error(`${message} Strict verification stopped before changing build output.`); + return 1; + } + console.warn(`${message} Stamping the production flag only.`); + } + + if ( + strict && + stack !== null && + stack.framework !== null && + SSR_CAPABLE_FRAMEWORKS.has(stack.framework) && + !staticOutput + ) { + console.error( + `mark-build: detected ${stack.framework}, which can deploy dynamic or hybrid server-rendered routes. ` + + 'Verifying a few generated HTML files cannot prove those routes contain the production widget. ' + + 'If—and only if—every deployed route is represented by complete static HTML, rerun with `mark-build --strict --static-output`; otherwise add and test a framework-specific SSR integration.', ); + return 1; } - const dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir')); + let dir: string | null; + try { + dir = resolveBuildDir(cwd, getStringFlag(args.flags, 'dir')); + } catch (err) { + console.warn(`mark-build: could not inspect the build output (${(err as Error).message}).`); + return strict ? 1 : 0; + } if (dir === null) { - console.warn( - 'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir if it is elsewhere. Nothing to mark.', - ); - return 0; + const message = + 'mark-build: no build output directory found (looked for dist/, build/, out/, .output/public). Pass --dir if it is elsewhere. Nothing to mark.'; + console.warn(message); + return strict ? 1 : 0; } - const files = findHtmlFiles(dir); + let files: string[]; + try { + files = findHtmlFiles(dir); + } catch (err) { + console.warn(`mark-build: could not scan HTML under ${dir} (${(err as Error).message}).`); + return strict ? 1 : 0; + } if (files.length === 0) { console.warn(`mark-build: no HTML files found under ${dir}. Nothing to mark.`); - return 0; + return strict ? 1 : 0; } - const snippet = buildInjectionSnippet(checksum, stack); - let marked = 0; + const snippet = buildInjectionSnippet( + checksum, + stack, + widgetEnabled ? siteUuid : null, + widgetApiBaseUrl, + ); + interface PreparedBuildFile { + file: string; + before: string; + after: string; + } + const prepared: PreparedBuildFile[] = []; + const failures: string[] = []; + let fragments = 0; for (const file of files) { - const before = readFileSync(file, 'utf8'); - const after = injectMarker(before, snippet); - if (after !== before) { - writeFileSync(file, after); + try { + const before = readFileSync(file, 'utf8'); + if (!hasLiveHtmlDocument(before)) { + fragments += 1; + continue; + } + const after = injectMarker(before, snippet, { + // An explicit widget opt-out removes a connector-managed tag. Missing or + // invalid config in best-effort mode must preserve the valid tag already + // emitted from source instead of silently deleting the widget. + removeManagedWidget: widgetEnabled === false || siteUuid !== null, + }); + const verification = verifyBuildHtml( + after, + siteUuid ?? '00000000-0000-0000-0000-000000000000', + ); + const relevantIssues = verification.issues.filter( + (issue) => + (widgetEnabled && siteUuid !== null) || !isWidgetVerificationIssue(issue), + ); + if (relevantIssues.length > 0) { + failures.push(`${displayBuildFile(cwd, file)}: ${relevantIssues.join(', ')}`); + } + prepared.push({ file, before, after }); + } catch (err) { + failures.push(`${displayBuildFile(cwd, file)}: ${(err as Error).message}`); + } + } + + if (prepared.length === 0) { + failures.push('no complete HTML documents were found in the selected output directory'); + } + + if (strict && failures.length > 0) { + console.error( + 'mark-build: strict production verification failed; no output files were changed:', + ); + for (const failure of failures) console.error(` - ${failure}`); + return 1; + } + + for (const failure of failures) { + console.warn(`mark-build: verification warning: ${failure}`); + } + + let marked = 0; + const written: PreparedBuildFile[] = []; + for (const candidate of prepared) { + if (candidate.after === candidate.before) continue; + try { + await atomicWriteTextFile(candidate.file, candidate.after); + written.push(candidate); marked += 1; + } catch (err) { + const message = `mark-build: could not mark ${displayBuildFile(cwd, candidate.file)} (${(err as Error).message}).`; + if (!strict) { + console.warn(message); + continue; + } + + console.error(`${message} Restoring files already written by this run.`); + let rollbackFailed = false; + for (const completed of written.reverse()) { + try { + await atomicWriteTextFile(completed.file, completed.before); + } catch (rollbackError) { + rollbackFailed = true; + console.error( + `mark-build: could not restore ${displayBuildFile(cwd, completed.file)} (${(rollbackError as Error).message}).`, + ); + } + } + if (rollbackFailed) { + console.error('mark-build: manual restoration is required for the files reported above.'); + } + return 1; } } @@ -338,11 +819,22 @@ async function runMarkBuild(args: ParsedArgs): Promise { console.log( `mark-build: marked ${marked} HTML file(s) in ${dir}` + `${checksum !== null ? ` (build ${checksum})` : ''}` + - `${stackSummary !== null ? ` [${stackSummary}]` : ''}.`, + `${stackSummary !== null ? ` [${stackSummary}]` : ''}` + + `${widgetEnabled && siteUuid !== null ? ` and ${strict ? 'verified' : 'ensured'} the disclosure widget in ${prepared.length} complete document(s)` : ''}` + + `${fragments > 0 ? `; ignored ${fragments} HTML fragment(s)` : ''}` + + `${failures.length > 0 ? `; reported ${failures.length} warning(s)` : ''}.`, ); return 0; } +function isWidgetVerificationIssue(issue: BuildHtmlVerificationIssue): boolean { + return issue.startsWith('widget-'); +} + +function displayBuildFile(cwd: string, file: string): string { + return path.relative(cwd, file) || path.basename(file); +} + async function main(): Promise { const args = parseArgs(process.argv); @@ -350,6 +842,7 @@ async function main(): Promise { console.log(HELP); return 0; } + validateCommandArgs(args); switch (args.command) { case 'init': @@ -372,12 +865,15 @@ async function main(): Promise { } main() - .then((code) => process.exit(code)) + .then((code) => { + process.exitCode = code; + }) .catch((err: unknown) => { if (err instanceof PatchstackError) { console.error(`Error (${err.code}): ${err.message}`); - process.exit(1); + process.exitCode = 1; + return; } console.error('Unexpected error:', err); - process.exit(2); + process.exitCode = 2; }); diff --git a/src/client.ts b/src/client.ts index 9774127..dea4ce3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,11 +4,61 @@ import type { WirePayload } from './normalize.js'; export const DEFAULT_ENDPOINT = 'https://api.patchstack.com/monitor/pulse/manifest'; export const DEFAULT_TIMEOUT_MS = 30_000; +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +/** Reject endpoint values that fetch would interpret unexpectedly. */ +export function validateEndpoint(value: string): URL { + if (value.length === 0 || value !== value.trim()) { + throw new PatchstackError( + 'Patchstack endpoint must be a non-empty URL without surrounding whitespace.', + 'CONFIG_INVALID', + ); + } + + let url: URL; + try { + url = new URL(value); + } catch (cause) { + throw new PatchstackError( + `Patchstack endpoint "${value}" is not a valid URL.`, + 'CONFIG_INVALID', + cause, + ); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new PatchstackError( + `Patchstack endpoint must use http or https; got "${url.protocol}".`, + 'CONFIG_INVALID', + ); + } + if (url.username !== '' || url.password !== '') { + throw new PatchstackError( + 'Patchstack endpoint must not contain embedded credentials.', + 'CONFIG_INVALID', + ); + } + if (url.hash !== '') { + throw new PatchstackError( + 'Patchstack endpoint must not contain a URL fragment.', + 'CONFIG_INVALID', + ); + } + return url; +} + export function buildEndpointUrl(base: string, siteUuid?: string | null): string { - const trimmed = base.replace(/\/$/, ''); - return siteUuid !== undefined && siteUuid !== null && siteUuid.length > 0 - ? `${trimmed}/${encodeURIComponent(siteUuid)}` - : trimmed; + const url = validateEndpoint(base); + if (siteUuid === undefined || siteUuid === null || siteUuid.length === 0) { + return base.replace(/\/$/, ''); + } + + // Mutate the parsed pathname rather than concatenating strings. This keeps a + // staging endpoint's query parameters after the UUID instead of accidentally + // appending the UUID inside the query string. + url.pathname = `${url.pathname.replace(/\/+$/, '')}/${encodeURIComponent(siteUuid)}`; + return url.toString(); } /** @@ -19,7 +69,7 @@ export function buildEndpointUrl(base: string, siteUuid?: string | null): string * host the connector is already talking to. */ export function buildClaimUrl(endpoint: string, siteUuid: string): string { - const origin = new URL(endpoint).origin; + const origin = validateEndpoint(endpoint).origin; return `${origin}/monitor/claim?site=${encodeURIComponent(siteUuid)}`; } @@ -27,6 +77,13 @@ export async function postManifest( config: Config, payload: WirePayload, ): Promise { + if (config.siteUuid !== null && !isUuid(config.siteUuid)) { + throw new PatchstackError( + `Site UUID "${config.siteUuid}" does not look like a valid UUID. Refusing to send a request that could provision or target the wrong site.`, + 'CONFIG_INVALID', + ); + } + const url = buildEndpointUrl(config.endpoint, config.siteUuid); const timeoutMs = config.timeoutMs; @@ -58,23 +115,24 @@ export async function postManifest( } const text = await response.text(); - let body: StoreManifestResponse | null = null; + let parsedBody: unknown = null; try { - body = text.length > 0 ? (JSON.parse(text) as StoreManifestResponse) : null; + parsedBody = text.length > 0 ? JSON.parse(text) : null; } catch { - body = null; + parsedBody = null; } + const body = isRecord(parsedBody) ? parsedBody : null; if (response.status === 404) { throw new PatchstackError( - body?.error ?? 'Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.', + stringField(body, 'error') ?? 'Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.', 'SITE_NOT_FOUND', ); } if (response.status === 422) { throw new PatchstackError( - body?.message ?? 'Patchstack rejected the manifest payload (validation failed).', + stringField(body, 'message') ?? 'Patchstack rejected the manifest payload (validation failed).', 'VALIDATION_ERROR', ); } @@ -87,10 +145,56 @@ export async function postManifest( } if (body === null) { - throw new PatchstackError('Patchstack returned an empty response.', 'SERVER_ERROR'); + throw new PatchstackError( + text.length === 0 + ? 'Patchstack returned an empty response.' + : 'Patchstack returned an invalid JSON response object.', + 'SERVER_ERROR', + ); + } + + if (typeof body.stored !== 'boolean') { + throw new PatchstackError( + 'Patchstack returned an invalid response: "stored" must be a boolean.', + 'SERVER_ERROR', + ); + } + + if (body.uuid !== undefined && (typeof body.uuid !== 'string' || !isUuid(body.uuid))) { + throw new PatchstackError( + 'Patchstack returned an invalid response: "uuid" must be a valid UUID.', + 'SERVER_ERROR', + ); } - return body; + if (config.siteUuid === null && body.uuid === undefined) { + throw new PatchstackError( + 'Patchstack did not return the UUID required to finish provisioning this site.', + 'SERVER_ERROR', + ); + } + + if ( + config.siteUuid !== null && + body.uuid !== undefined && + body.uuid.toLowerCase() !== config.siteUuid.toLowerCase() + ) { + throw new PatchstackError( + `Patchstack returned site UUID ${body.uuid}, but this request targeted ${config.siteUuid}. No local files were changed.`, + 'SERVER_ERROR', + ); + } + + return body as unknown as StoreManifestResponse; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function stringField(body: Record | null, field: string): string | undefined { + const value = body?.[field]; + return typeof value === 'string' ? value : undefined; } function isTimeoutError(cause: unknown): boolean { diff --git a/src/config.ts b/src/config.ts index 8172d80..ca082f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,8 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { atomicWriteTextFile } from './atomic-write.js'; import { PatchstackError, type Config, type Environment } from './types.js'; -import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS } from './client.js'; +import { DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS, isUuid, validateEndpoint } from './client.js'; const CONFIG_FILENAME = '.patchstackrc.json'; @@ -12,6 +13,7 @@ interface ConfigFile { endpoint?: string; timeoutMs?: number; environment?: string; + widget?: boolean; } export interface ResolveConfigOptions { @@ -31,17 +33,25 @@ export async function resolveConfig(options: ResolveConfigOptions): Promise { const target = path.join(cwd, CONFIG_FILENAME); const content = JSON.stringify(config, null, 2) + '\n'; - await writeFile(target, content, 'utf8'); + await atomicWriteTextFile(target, content); return target; } /** * Merge a new siteUuid into the existing `.patchstackrc.json` (or create it). - * Preserves any `endpoint` / `timeoutMs` the user already wrote. + * Preserves all existing connector settings, including the widget opt-out. */ -export async function persistSiteUuid(cwd: string, siteUuid: string): Promise { +export async function persistSiteUuid( + cwd: string, + siteUuid: string, + endpoint?: string, +): Promise { + if (!isUuid(siteUuid)) { + throw new PatchstackError( + `Site UUID "${siteUuid}" does not look like a valid UUID.`, + 'CONFIG_INVALID', + ); + } + const existing = await readConfigFile(cwd); - return writeConfigFile(cwd, { ...existing, siteUuid }); + const existingSiteUuid = nonBlank(existing.siteUuid); + if (existingSiteUuid !== undefined) { + if (!isUuid(existingSiteUuid)) { + throw new PatchstackError( + `Existing site UUID "${existingSiteUuid}" in ${path.join(cwd, CONFIG_FILENAME)} does not look like a valid UUID.`, + 'CONFIG_INVALID', + ); + } + if (existingSiteUuid.toLowerCase() !== siteUuid.toLowerCase()) { + throw new PatchstackError( + `Refusing to replace existing site UUID ${existingSiteUuid} with ${siteUuid}. Remove or update ${CONFIG_FILENAME} explicitly if this project should target a different site.`, + 'CONFIG_INVALID', + ); + } + } + if (endpoint !== undefined) { + validateEndpoint(endpoint); + } + return writeConfigFile(cwd, { + ...existing, + siteUuid, + ...(endpoint !== undefined ? { endpoint } : {}), + }); } async function readConfigFile(cwd: string): Promise { @@ -109,8 +153,59 @@ async function readConfigFile(cwd: string): Promise { } try { - return JSON.parse(raw) as ConfigFile; + const parsed: unknown = JSON.parse(raw); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new PatchstackError( + `Config file ${target} must contain a JSON object.`, + 'CONFIG_INVALID', + ); + } + + const config = parsed as Record; + if (config.siteUuid !== undefined && typeof config.siteUuid !== 'string') { + throw new PatchstackError( + `Config file ${target} field "siteUuid" must be a string.`, + 'CONFIG_INVALID', + ); + } + if ( + config.endpoint !== undefined && + (typeof config.endpoint !== 'string' || config.endpoint.length === 0) + ) { + throw new PatchstackError( + `Config file ${target} field "endpoint" must be a non-empty string.`, + 'CONFIG_INVALID', + ); + } + if ( + config.timeoutMs !== undefined && + (typeof config.timeoutMs !== 'number' || + !Number.isFinite(config.timeoutMs) || + config.timeoutMs <= 0) + ) { + throw new PatchstackError( + `Config file ${target} field "timeoutMs" must be a positive number.`, + 'CONFIG_INVALID', + ); + } + if (config.environment !== undefined && typeof config.environment !== 'string') { + throw new PatchstackError( + `Config file ${target} field "environment" must be a string.`, + 'CONFIG_INVALID', + ); + } + if (config.widget !== undefined && typeof config.widget !== 'boolean') { + throw new PatchstackError( + `Config file ${target} field "widget" must be a boolean.`, + 'CONFIG_INVALID', + ); + } + + return config as ConfigFile; } catch (err) { + if (err instanceof PatchstackError) { + throw err; + } throw new PatchstackError( `Config file ${target} contains invalid JSON.`, 'CONFIG_INVALID', @@ -142,8 +237,8 @@ function readEnv(): ConfigFile { }; } -function isUuid(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +function nonBlank(value: string | undefined): string | undefined { + return value !== undefined && value.trim().length > 0 ? value : undefined; } function isEnvironment(value: string): value is Environment { diff --git a/src/guide.ts b/src/guide.ts index 2ecc7c2..e90b0fd 100644 --- a/src/guide.ts +++ b/src/guide.ts @@ -12,9 +12,11 @@ import path from 'node:path'; import { DEFAULT_ENDPOINT, buildClaimUrl } from './client.js'; import { resolveConfig } from './config.js'; -import { detectStack } from './stack.js'; +import { WIDGET_CDN_URL } from './mark-build.js'; +import { inspectSourceWidgetPreflight } from './source-widget.js'; +import { SSR_CAPABLE_FRAMEWORKS, detectStack } from './stack.js'; -export const WIDGET_SCRIPT_URL = 'https://cdn.patchstack.com/patchstack-widget.js'; +export const WIDGET_SCRIPT_URL = WIDGET_CDN_URL; /** Substring that marks the widget as installed anywhere in the source tree. */ const WIDGET_NEEDLE = 'patchstack-widget'; @@ -32,10 +34,14 @@ export interface GuideState { claimUrl: string | null; /** Non-default API endpoint in effect (rc file, env, or flag), else null. */ endpointOverride: string | null; + /** Configuration error that prevents a reliable scan, else null. */ + configError: string | null; + /** False when the project intentionally opts out with `"widget": false`. */ + widgetEnabled?: boolean; prebuildWired: boolean; postbuildWired: boolean; widgetInstalled: boolean; - /** False when the widget is present but its userToken isn't the site UUID. */ + /** False when the widget is present but its configured UUID isn't the site UUID. */ widgetTokenMatches: boolean | null; /** Framework label from the declared dependencies (e.g. "next"), best-effort. */ framework: string | null; @@ -50,6 +56,14 @@ const INSTALL_COMMANDS: Record = { bun: 'bun add -d @patchstack/connect', }; +/** Invoke the already-installed binary without falling back to a registry fetch. */ +const CONNECTOR_COMMANDS: Record = { + npm: 'npx --no-install patchstack-connect', + pnpm: 'pnpm exec patchstack-connect', + yarn: 'yarn patchstack-connect', + bun: 'bun run patchstack-connect', +}; + /** Lockfile → package manager, same priority order as lockfile detection. */ const PM_BY_LOCKFILE: ReadonlyArray<{ filename: string; pm: PackageManager }> = [ { filename: 'package-lock.json', pm: 'npm' }, @@ -125,12 +139,23 @@ const WIDGET_SCAN_MAX_BYTES = 512 * 1024; interface PackageJson { name?: string; + packageManager?: string; dependencies?: Record; devDependencies?: Record; scripts?: Record; } export function detectPackageManager(cwd: string): PackageManager { + // packageManager is the project's explicit choice and wins when stale + // lockfiles from a previous migration are still present. + const declared = readPackageJson(cwd)?.packageManager; + if (typeof declared === 'string') { + const match = /^(npm|pnpm|yarn|bun)@[^\s]+$/i.exec(declared.trim()); + if (match !== null) { + return match[1]!.toLowerCase() as PackageManager; + } + } + for (const { filename, pm } of PM_BY_LOCKFILE) { if (existsSync(path.join(cwd, filename))) { return pm; @@ -143,6 +168,10 @@ export function installCommand(pm: PackageManager): string { return INSTALL_COMMANDS[pm]; } +export function connectorCommand(pm: PackageManager): string { + return CONNECTOR_COMMANDS[pm]; +} + function readPackageJson(cwd: string): PackageJson | null { try { return JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8')) as PackageJson; @@ -170,13 +199,23 @@ export interface WidgetScanResult { found: boolean; /** * When a site UUID is known: does any file carrying the widget also carry - * that UUID as its userToken? null when the widget is absent or no UUID is - * known yet. A stale/wrong userToken makes the widget silently no-op, so a - * mismatch is worth surfacing rather than passing the check. + * that UUID as its managed `data-site-uuid` or legacy `userToken`? null when + * the widget is absent or no UUID is known yet. A stale/wrong UUID makes the + * widget silently no-op, so a mismatch is worth surfacing. */ uuidMatches: boolean | null; } +function widgetConfigMatches(content: string, siteUuid: string): boolean { + const uuid = siteUuid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const managedAttribute = new RegExp( + `data-site-uuid\\s*=\\s*(["'])${uuid}\\1`, + 'i', + ); + const legacyInitialiser = new RegExp(`userToken\\s*:\\s*(["'])${uuid}\\1`); + return managedAttribute.test(content) || legacyInitialiser.test(content); +} + /** * Bounded recursive search for the widget marker in the source tree. Depth, * file-count and file-size capped so the guide stays instant on big projects. @@ -225,7 +264,7 @@ export function findWidgetMarker(cwd: string, siteUuid?: string | null): WidgetS continue; } sawWidget = true; - if (siteUuid != null && content.includes(siteUuid)) { + if (siteUuid != null && widgetConfigMatches(content, siteUuid)) { sawTokenMatch = true; } } catch { @@ -254,6 +293,158 @@ function resolveWidgetFileHint(cwd: string, framework: string | null): string | return null; } +type ConnectorAction = 'scan' | 'mark-build'; + +const CONNECTOR_EXECUTABLE_PATTERN = + String.raw`(?:patchstack-connect|npx\s+--no-install\s+patchstack-connect|pnpm\s+exec\s+patchstack-connect|yarn\s+patchstack-connect|bun\s+run\s+patchstack-connect)`; +const CONNECTOR_ACTION_PATTERN = new RegExp( + String.raw`^\s*${CONNECTOR_EXECUTABLE_PATTERN}\s+(scan|mark-build)(?=\s|$)([\s\S]*)$`, +); +const CONNECTOR_PREFIX_PATTERN = new RegExp( + String.raw`^\s*${CONNECTOR_EXECUTABLE_PATTERN}(?=\s|$)`, +); + +/** Split only on top-level `&&`; quoted examples must never look executable. */ +function splitAndThenCommands(script: string): string[] { + const commands: string[] = []; + let start = 0; + let quote: "'" | '"' | '`' | null = null; + let parentheses = 0; + + for (let index = 0; index < script.length; index += 1) { + const character = script[index]!; + if (quote !== null) { + if (character === '\\' && quote !== "'") { + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === '\\') { + index += 1; + continue; + } + if (character === "'" || character === '"' || character === '`') { + quote = character; + continue; + } + if (character === '(') { + parentheses += 1; + continue; + } + if (character === ')' && parentheses > 0) { + parentheses -= 1; + continue; + } + if (parentheses === 0 && character === '&' && script[index + 1] === '&') { + commands.push(script.slice(start, index).trim()); + start = index + 2; + index += 1; + } + } + + commands.push(script.slice(start).trim()); + return commands; +} + +function connectorAction( + command: string, + requireStrictMarker = false, + requireStaticOutput = false, +): ConnectorAction | null { + const match = CONNECTOR_ACTION_PATTERN.exec(command); + if (match === null) return null; + + const trailing = match[2] ?? ''; + // A dry run/help command does not perform the lifecycle action. Shell + // alternatives and pipelines also do not provide fail-closed sequencing. + if ( + /(?:^|\s)(?:--dry-run|--help|-h|--version)(?=\s|$)/.test(trailing) || + /[;&|\n\r]/.test(trailing) + ) { + return null; + } + const action = match[1] as ConnectorAction; + if ( + action === 'mark-build' && + ((requireStrictMarker && !/(?:^|\s)--strict(?=\s|$)/.test(trailing)) || + (requireStaticOutput && + !/(?:^|\s)--static-output(?=\s|$)/.test(trailing))) + ) { + return null; + } + return action; +} + +function hasConnectorAction( + script: string, + action: ConnectorAction, + requireStrictMarker = false, + requireStaticOutput = false, +): boolean { + const commands = splitAndThenCommands(script); + return ( + commands.every((command) => command.length > 0) && + commands.some( + (command) => + connectorAction(command, requireStrictMarker, requireStaticOutput) === action, + ) + ); +} + +function isExistingBuildCommand(command: string): boolean { + const trimmed = command.trim(); + if (trimmed.length === 0 || CONNECTOR_PREFIX_PATTERN.test(trimmed)) return false; + // These are common ways a connector command is mentioned without building. + return !/^(?:#|echo(?:\s|$)|printf(?:\s|$)|true\s*$|false\s*$|:\s*$)/.test(trimmed); +} + +function inspectBuildLifecycle( + scripts: Record | undefined, + packageManager: PackageManager, + framework: string | null, +): Pick { + const requireStaticOutput = + framework !== null && SSR_CAPABLE_FRAMEWORKS.has(framework); + const buildCommands = splitAndThenCommands(scripts?.build ?? ''); + if (buildCommands.some((command) => command.length === 0)) { + return { prebuildWired: false, postbuildWired: false }; + } + const existingBuildIndices = buildCommands.flatMap((command, index) => + isExistingBuildCommand(command) ? [index] : [], + ); + const hasExistingBuild = existingBuildIndices.length > 0; + const scanBeforeBuild = buildCommands.some( + (command, index) => + connectorAction(command) === 'scan' && + existingBuildIndices.some((buildIndex) => buildIndex > index), + ); + const markerAfterBuild = buildCommands.some( + (command, index) => + connectorAction(command, true, requireStaticOutput) === 'mark-build' && + existingBuildIndices.some((buildIndex) => buildIndex < index), + ); + const lifecycleHooksRun = packageManager !== 'yarn'; + + return { + prebuildWired: + hasExistingBuild && + (scanBeforeBuild || + (lifecycleHooksRun && hasConnectorAction(scripts?.prebuild ?? '', 'scan'))), + postbuildWired: + hasExistingBuild && + (markerAfterBuild || + (lifecycleHooksRun && + hasConnectorAction( + scripts?.postbuild ?? '', + 'mark-build', + true, + requireStaticOutput, + ))), + }; +} + export async function collectGuideState(cwd: string): Promise { const pkg = readPackageJson(cwd); const packageManager = detectPackageManager(cwd); @@ -274,17 +465,20 @@ export async function collectGuideState(cwd: string): Promise { let siteUuid: string | null = null; let claimUrl: string | null = null; let endpointOverride: string | null = null; + let configError: string | null = null; + let widgetEnabled = true; try { const config = await resolveConfig({ cwd }); siteUuid = config.siteUuid; + widgetEnabled = config.widgetEnabled !== false; if (siteUuid !== null) { claimUrl = buildClaimUrl(config.endpoint, siteUuid); } if (config.endpoint !== DEFAULT_ENDPOINT) { endpointOverride = config.endpoint; } - } catch { - // invalid config — the checklist just shows the site as not provisioned + } catch (err) { + configError = err instanceof Error ? err.message : 'Unknown connector configuration error.'; } // Framework detection needs only the declared top-level dependencies, so we @@ -295,7 +489,38 @@ export async function collectGuideState(cwd: string): Promise { {}, ); - const widget = findWidgetMarker(cwd, siteUuid); + let widgetInstalled = false; + let widgetTokenMatches: boolean | null = null; + let inspectedWidgetFileHint: string | null = null; + try { + const widget = await inspectSourceWidgetPreflight({ + cwd, + stack, + expectedSiteUuid: siteUuid, + }); + widgetInstalled = + widget.shells.length > 0 && + widget.status !== 'ambiguous' && + widget.missingRequiredShells.length === 0 && + widget.externalWidgetShells.length === 0 && + widget.shells.every((shell) => + shell.identity.occurrences.some( + (occurrence) => + occurrence.kind === 'script-tag' || occurrence.kind === 'dynamic-loader', + ), + ); + if (widgetInstalled && siteUuid !== null) { + widgetTokenMatches = + widget.status === 'configured' && widget.matchesExpectedUuid === true; + } + if (widget.files[0] !== undefined) { + inspectedWidgetFileHint = path.relative(cwd, widget.files[0]); + } + } catch { + // Source inspection is advisory. Preserve the rest of the checklist when + // a file disappears or becomes unreadable during the probe. + } + const lifecycle = inspectBuildLifecycle(pkg?.scripts, packageManager, stack.framework); return { projectName: pkg?.name ?? null, @@ -305,18 +530,14 @@ export async function collectGuideState(cwd: string): Promise { siteUuid, claimUrl, endpointOverride, - // bun run doesn't execute npm-style pre/post scripts, so chaining inside - // the build script itself also counts as wired (and is what we suggest on bun). - prebuildWired: - (pkg?.scripts?.prebuild ?? '').includes('patchstack-connect scan') || - (pkg?.scripts?.build ?? '').includes('patchstack-connect scan'), - postbuildWired: - (pkg?.scripts?.postbuild ?? '').includes('patchstack-connect mark-build') || - (pkg?.scripts?.build ?? '').includes('patchstack-connect mark-build'), - widgetInstalled: widget.found, - widgetTokenMatches: widget.uuidMatches, + configError, + widgetEnabled, + ...lifecycle, + widgetInstalled, + widgetTokenMatches, framework: stack.framework, - widgetFileHint: resolveWidgetFileHint(cwd, stack.framework), + widgetFileHint: + inspectedWidgetFileHint ?? resolveWidgetFileHint(cwd, stack.framework), }; } @@ -335,7 +556,8 @@ export function countRemainingSteps(state: GuideState): number { state.installed !== null, state.siteUuid !== null, state.prebuildWired && state.postbuildWired, - state.widgetInstalled && state.widgetTokenMatches !== false, + state.widgetEnabled === false || + (state.widgetInstalled && state.widgetTokenMatches !== false), ].filter((step) => !step).length; } @@ -346,6 +568,12 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri 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 connector = connectorCommand(state.packageManager); + const requiresStaticOutputAssertion = + state.framework !== null && SSR_CAPABLE_FRAMEWORKS.has(state.framework); + const markBuildFlags = requiresStaticOutputAssertion + ? '--strict --static-output' + : '--strict'; const headerParts = [state.framework, state.packageManager].filter( (part): part is string => part !== null, @@ -379,50 +607,80 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri } // 2. Provision (first scan) - if (state.siteUuid !== null) { + if (state.configError !== null) { + lines.push(todo('Fix the invalid connector configuration before scanning')); + lines.push(detail(state.configError)); + lines.push(detail('Correct .patchstackrc.json or the PATCHSTACK_* override shown above, then rerun the guide.')); + } else 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('→ npx @patchstack/connect scan')); + lines.push(detail(`→ ${connector} scan`)); lines.push(detail('Reads the lockfile, registers the project, writes .patchstackrc.json,')); - lines.push(detail('and prints a claim URL — show that URL to the user; never open it yourself.')); + lines.push( + detail( + state.widgetEnabled === false + ? 'keeps the disclosure widget disabled, and prints a claim URL.' + : 'installs the managed disclosure widget, and prints a claim URL.', + ), + ); + lines.push(detail('Reload the app preview after the scan; show the claim URL to the user.')); } - // 3. Build hooks + // 3. Build lifecycle if (state.prebuildWired && state.postbuildWired) { - lines.push(done('Build hooks wired (scan before builds, mark-build after)')); + lines.push( + done(`Build lifecycle wired (scan before builds, mark-build ${markBuildFlags} after)`), + ); + } else if (state.packageManager === 'yarn') { + lines.push( + todo( + 'Wire builds — chain into the package.json build script (modern Yarn skips arbitrary pre/post hooks)', + ), + ); + lines.push(detail(`→ "build": "patchstack-connect scan && && patchstack-connect mark-build ${markBuildFlags}"`)); } else if (state.packageManager === 'bun') { - // bun run skips npm-style pre/post scripts, so chain inside the build script. - lines.push(todo('Wire builds — chain into the package.json build script (bun skips pre/post hooks)')); - lines.push(detail('→ "build": "patchstack-connect scan && && patchstack-connect mark-build"')); + lines.push( + todo('Wire builds — use an explicit build chain for portability across Bun-based hosts'), + ); + lines.push(detail(`→ "build": "patchstack-connect scan && && patchstack-connect mark-build ${markBuildFlags}"`)); } else { lines.push(todo('Wire builds — add to package.json scripts (chain with && if a hook exists)')); if (!state.prebuildWired) { lines.push(detail('→ "prebuild": "patchstack-connect scan"')); } if (!state.postbuildWired) { - lines.push(detail('→ "postbuild": "patchstack-connect mark-build"')); + lines.push(detail(`→ "postbuild": "patchstack-connect mark-build ${markBuildFlags}"`)); } } + if (requiresStaticOutputAssertion) { + lines.push( + detail( + `--static-output is an assertion that every deployed ${state.framework} route is complete static HTML; do not use it for SSR or hybrid deployments.`, + ), + ); + } // 4. Disclosure widget const widgetOk = state.widgetInstalled && state.widgetTokenMatches !== false; - if (widgetOk) { + if (state.widgetEnabled === false) { + lines.push(done('Disclosure widget intentionally disabled ("widget": false)')); + } else if (widgetOk) { lines.push(done('Disclosure widget installed')); } else if (state.widgetInstalled) { - lines.push(todo("Disclosure widget found, but its userToken doesn't match this project's site UUID")); - lines.push(detail(`→ a wrong userToken makes the widget silently no-op; set it to '${state.siteUuid}'`)); + lines.push(todo("Disclosure widget found, but its configured UUID doesn't match this site's UUID")); + lines.push(detail(`→ ${connector} scan`)); + lines.push(detail('Move/remove any loader outside the true global shell, repair the reported shell identity, rerun scan, and reload the preview.')); } else { - lines.push(todo('Add the "Report a vulnerability" widget')); - const placement = - state.widgetFileHint !== null - ? `→ add to ${state.widgetFileHint}, just before (via the framework's HTML/layout mechanism):` - : "→ add just before via the framework's HTML/layout mechanism (never a JS entry point):"; - lines.push(detail(placement)); - lines.push(detail(` `)); - const token = state.siteUuid ?? ''; - lines.push(detail(` `)); - lines.push(detail('The userToken is public by design — it ships in client-side HTML.')); + lines.push(todo('Install the "Report a vulnerability" widget')); + lines.push(detail(`→ ${connector} scan, then reload the app preview`)); + lines.push( + detail( + state.widgetFileHint !== null + ? `If scan blocks, repair the true global shell or coverage group beginning at ${state.widgetFileHint}, then rerun scan; do not paste a fallback into a nested component.` + : 'If scan blocks, create/repair the framework global shell(s), move or remove external loaders, and rerun scan; do not paste a fallback into a nested component.', + ), + ); } // 5. Claim — the conversion moment; always the loudest line. @@ -435,7 +693,9 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri lines.push(detail('(this URL inherits the endpoint override above)')); } } else { - lines.push(detail('The claim URL appears after the first scan (re-print any time with `status`).')); + lines.push( + detail(`The claim URL appears after the first scan (re-print any time with \`${connector} status\`).`), + ); } const remaining = countRemainingSteps(state); @@ -445,7 +705,9 @@ export function renderGuideChecklist(state: GuideState, useColor: boolean): stri done( paint( ANSI.bold, - 'All setup steps complete. Commit .patchstackrc.json, package.json, and the file carrying the widget snippet.', + state.widgetEnabled === false + ? 'All setup steps complete. Commit .patchstackrc.json and package.json.' + : 'All setup steps complete. Commit .patchstackrc.json, package.json, and the file carrying the widget tag.', ), ), ); diff --git a/src/index.ts b/src/index.ts index 77fb4b6..c35fe8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,7 @@ export async function scanAndReport( // First-run convenience: if we didn't have a UUID and the server provisioned // one for us, persist it so subsequent runs target the same site. if (config.siteUuid === null && response.uuid !== undefined && response.uuid.length > 0) { - await persistSiteUuid(cwd, response.uuid); + await persistSiteUuid(cwd, response.uuid, config.endpoint); } return { diff --git a/src/mark-build.ts b/src/mark-build.ts index e95b373..45b708f 100644 --- a/src/mark-build.ts +++ b/src/mark-build.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { isEmptyStack, type StackDescriptor } from './stack.js'; @@ -6,12 +6,128 @@ import { isEmptyStack, type StackDescriptor } from './stack.js'; /** Attribute that tags our injected `; + if (siteUuid == null || siteUuid === '') { + return buildMarker; + } + + // The widget captures document.currentScript while its IIFE executes, so its + // configuration belongs on the CDN tag itself. `defer` keeps the marker above + // it in control of production/build metadata before the widget auto-initialises. + const widget = buildWidgetTag(siteUuid, apiBaseUrl, { production: true }); + return buildMarker + widget; +} + +/** Build the one-tag, auto-initialising widget install managed by the connector. */ +export function buildWidgetTag( + siteUuid: string, + apiBaseUrl?: string | null, + options: BuildWidgetTagOptions = {}, +): string { + return ( + `` + ); +} + +/** JSON which cannot terminate the surrounding HTML script or introduce JS separators. */ +function serializeForInlineScript(value: unknown): string { + const json = JSON.stringify(value); + if (json === undefined) return 'null'; + return json.replace(/[<>&\u2028\u2029]/g, (character) => { + switch (character) { + case '<': + return '\\u003c'; + case '>': + return '\\u003e'; + case '&': + return '\\u0026'; + case '\u2028': + return '\\u2028'; + default: + return '\\u2029'; + } + }); +} + +/** True when HTML already loads a manual or connector-managed Patchstack widget bundle. */ +export function hasWidgetScript(html: string): boolean { + return findWidgetScriptIndex(html) !== -1; +} + +/** + * Ensure the managed widget exists in an editable source HTML/JSX shell. + * Existing connector tags are updated in place; manual/legacy installs win and + * are never duplicated. Unlike build injection, no production marker is added, + * so the widget's owner/connect flow remains available in development preview. + */ +export function injectSourceWidget( + html: string, + siteUuid: string, + apiBaseUrl?: string | null, + options: SourceWidgetInjectionOptions = {}, +): SourceWidgetInjectionResult { + const widgetTag = buildWidgetTag(siteUuid, apiBaseUrl); + + // A user-owned live tag always wins. If an older connector run also left a + // managed tag beside it, remove only our duplicate and keep the manual tag + // byte-for-byte rather than updating/reinitialising both. + if (hasManualSourceWidgetTag(html)) { + const withoutManagedDuplicate = replaceManagedSourceWidgets(html, ''); + return withoutManagedDuplicate.found + ? { html: withoutManagedDuplicate.html, status: 'updated' } + : { html, status: 'existing-manual' }; + } + + const replaced = replaceManagedSourceWidgets(html, widgetTag); + if (replaced.found) { + return { + html: replaced.html, + status: replaced.html === html ? 'unchanged' : 'updated', + }; + } + + if (hasManualSourceWidget(html)) { + return { html, status: 'existing-manual' }; + } + + const injected = insertSourceTag(html, widgetTag, options.anchor ?? 'body'); + return injected === null + ? { html, status: 'unsupported' } + : { html: injected, status: 'inserted' }; +} + +/** + * True when source contains a live, user-owned widget load or init call. + * String literals, template-string prompts, comments, and inert HTML blocks do + * not count. Connector-managed tags are intentionally excluded. + */ +export function hasManualSourceWidget(source: string): boolean { + return inspectSourceWidgetIdentity(source).hasManual; +} + +/** + * Inspect live widget loaders and their static identity without evaluating source. + * This intentionally understands only conservative, common forms: a static CDN + * `src`, `data-site-uuid`, and `PatchstackWidget.init({ userToken/siteUuid })`. + * Anything executable or otherwise uncertain is surfaced as dynamic rather than + * guessed, so callers can stop before provisioning a second site accidentally. + */ +export function inspectSourceWidgetIdentity(source: string): SourceWidgetIdentityInspection { + const tokens = tokenizeSource(source); + const occurrences: SourceWidgetIdentityOccurrence[] = []; + + for (const tag of findLiveSourceScriptTags(source, tokens)) { + if (!tag.isWidget) continue; + const siteUuidIdentity = readSourceScriptAttribute(tag.opening, 'data-site-uuid'); + const identity = + siteUuidIdentity.kind === 'absent' + ? readSourceScriptAttribute(tag.opening, 'data-user-token') + : siteUuidIdentity; + occurrences.push( + sourceIdentityOccurrence( + 'script-tag', + identity, + tag.isManaged, + identity.kind !== 'absent', + tag.start, + tag.end, + ), + ); + } + + for (let i = 0; i < tokens.length; i++) { + const initOpen = patchstackInitOpenParen(tokens, i); + if (initOpen !== null) { + occurrences.push(...inspectInitializerIdentity(tokens, i, initOpen)); + i = initOpen; + continue; + } + + // Common dynamic loader: script.src = '.../patchstack-widget.js'. + if ( + tokens[i]?.kind === 'identifier' && + tokenIs(tokens[i + 1], '.') && + tokenIs(tokens[i + 2], 'src') && + tokenIs(tokens[i + 3], '=') && + isWidgetStringToken(tokens[i + 4]) + ) { + occurrences.push({ + kind: 'dynamic-loader', + state: 'unconfigured', + managed: false, + configuresIdentity: false, + start: tokens[i]!.start, + end: tokens[i + 4]!.end, + }); + continue; + } + + // Equivalent DOM form: script.setAttribute('src', '...widget.js'). + if ( + tokenIs(tokens[i], '.') && + tokenIs(tokens[i + 1], 'setAttribute') && + tokenIs(tokens[i + 2], '(') && + tokens[i + 3]?.kind === 'string' && + tokens[i + 3]?.value.toLowerCase() === 'src' && + tokenIs(tokens[i + 4], ',') && + isWidgetStringToken(tokens[i + 5]) + ) { + occurrences.push({ + kind: 'dynamic-loader', + state: 'unconfigured', + managed: false, + configuresIdentity: false, + start: tokens[i]!.start, + end: tokens[i + 5]!.end, + }); + } + } + + return summarizeSourceWidgetIdentity(occurrences); +} + +/** + * Verify that generated HTML contains one usable production install for the + * expected site. No code is evaluated: dynamic or ambiguous legacy setups fail + * closed so a strict CLI mode can report the exact document that needs repair. + */ +export function verifyBuildHtml( + html: string, + expectedSiteUuid: string, +): BuildHtmlVerification { + const issues: BuildHtmlVerificationIssue[] = []; + const isFullDocument = hasLiveHtmlDocument(html); + if (!isFullDocument) issues.push('not-full-document'); + + const scriptTags = findLiveSourceScriptTags(html); + const buildMarkers = scriptTags.filter( + (tag) => readScriptAttribute(tag.opening, MARKER_ATTR) !== undefined, + ); + const workingBuildMarkers = buildMarkers.filter( + (tag) => + tag.isJavascript && + readScriptAttribute(tag.opening, 'src') === undefined && + html.slice(tag.contentStart, tag.contentEnd).includes('window.__PATCHSTACK_PROD__=true;'), + ); + + if (buildMarkers.length === 0) issues.push('production-marker-missing'); + else if (buildMarkers.length > 1) issues.push('production-marker-duplicate'); + if (buildMarkers.length === 1 && workingBuildMarkers.length !== 1) { + issues.push('production-flag-missing'); + } + + const widgetIdentity = inspectSourceWidgetIdentity(html); + const executableInlineScripts = scriptTags.filter( + (tag) => tag.isJavascript && readScriptAttribute(tag.opening, 'src') === undefined, + ); + const occursInExecutableInlineScript = ( + occurrence: SourceWidgetIdentityOccurrence, + ): boolean => + executableInlineScripts.some( + (tag) => occurrence.start >= tag.contentStart && occurrence.end <= tag.contentEnd, + ); + const loaders = widgetIdentity.occurrences.filter( + (occurrence) => + occurrence.kind === 'script-tag' || + (occurrence.kind === 'dynamic-loader' && occursInExecutableInlineScript(occurrence)), + ); + const initializers = widgetIdentity.occurrences.filter( + (occurrence) => + occurrence.kind === 'initializer' && + occursInExecutableInlineScript(occurrence), + ); + + let widgetUuid: string | null = null; + if (loaders.length === 0) { + issues.push('widget-loader-missing'); + } else if (loaders.length > 1) { + issues.push('widget-loader-duplicate'); + } else { + const loader = loaders[0]!; + const loaderTag = scriptTags.find( + (tag) => tag.start === loader.start && tag.isWidget, + ); + if ( + loader.managed && + readScriptAttribute(loaderTag?.opening ?? '', 'data-production') !== 'true' + ) { + issues.push('widget-production-attribute-invalid'); + } + if (loader.kind === 'dynamic-loader' || loader.state === 'dynamic') { + issues.push('widget-identity-dynamic'); + } else if (loader.state === 'invalid') { + issues.push('widget-identity-invalid'); + } else if (loader.state === 'configured') { + if (initializers.length > 0) issues.push('widget-identity-ambiguous'); + else widgetUuid = loader.uuid ?? null; + } else if (loader.configuresIdentity) { + issues.push('widget-identity-missing'); + } else if (initializers.length === 0) { + issues.push('widget-identity-missing'); + } else if (initializers.length > 1) { + issues.push('widget-identity-ambiguous'); + } else { + const initializer = initializers[0]!; + if (initializer.state === 'dynamic') issues.push('widget-identity-dynamic'); + else if (initializer.state === 'invalid') issues.push('widget-identity-invalid'); + else if (initializer.state !== 'configured') issues.push('widget-identity-missing'); + else { + const isDeferred = + loaderTag === undefined || + readScriptAttribute(loaderTag.opening, 'async') !== undefined || + readScriptAttribute(loaderTag.opening, 'defer') !== undefined || + readScriptAttribute(loaderTag.opening, 'type') === 'module'; + if (isDeferred || initializer.start < loader.end) { + issues.push('widget-legacy-init-unsafe'); + } else { + widgetUuid = initializer.uuid ?? null; + } + } + } + } + + const expectedNormalized = expectedSiteUuid.toLowerCase(); + if (widgetUuid !== null && widgetUuid.toLowerCase() !== expectedNormalized) { + issues.push('widget-uuid-mismatch'); + } + + if ( + workingBuildMarkers.length === 1 && + loaders.length === 1 && + workingBuildMarkers[0]!.end > loaders[0]!.start + ) { + issues.push('production-marker-order-invalid'); } - return ``; + + return { + ok: issues.length === 0, + issues, + isFullDocument, + productionMarkerCount: buildMarkers.length, + productionFlagCount: workingBuildMarkers.length, + widgetLoaderCount: loaders.length, + widgetUuid, + expectedSiteUuid: expectedNormalized, + widgetIdentity, + }; +} + +function hasManualSourceWidgetTag(source: string, tokens?: SourceToken[]): boolean { + return findLiveSourceScriptTags(source, tokens).some( + (tag) => tag.isWidget && !tag.isManaged, + ); +} + +/** True when source structurally owns one complete HTML document. */ +export function hasLiveHtmlDocument(source: string): boolean { + const tokens = tokenizeSource(source, { skipRawTextElements: true }); + const htmlOpen = findElementTokenIndices(source, tokens, 'html', false); + const bodyOpen = findElementTokenIndices(source, tokens, 'body', false); + const bodyClose = findElementTokenIndices(source, tokens, 'body', true); + const htmlClose = findElementTokenIndices(source, tokens, 'html', true); + return ( + htmlOpen.length === 1 && + bodyOpen.length === 1 && + bodyClose.length === 1 && + htmlClose.length === 1 && + htmlOpen[0]! < bodyOpen[0]! && + bodyOpen[0]! < bodyClose[0]! && + bodyClose[0]! < htmlClose[0]! + ); } /** * Insert (or replace) the marker script in a single HTML document. Idempotent: * a prior marker is stripped first so repeated builds don't stack tags. Prefers - * ``, falls back to ``, then appends. + * one live `` and falls back to one live ``. HTML fragments are + * returned byte-for-byte unchanged instead of being polluted with global tags. + */ +export function injectMarker( + html: string, + snippet: string, + options: InjectMarkerOptions = {}, +): string { + if (!hasLiveHtmlDocument(html) || findBuildInjectionAnchor(html) === null) { + return html; + } + + // Remove only connector-managed tags. A manually installed widget is left + // untouched and suppresses the generated widget below, which makes upgrades + // from the old manual installation flow safe and prevents double init. + const withoutOldMarker = stripManagedScript(html, MARKER_ATTR); + const snippetContainsManagedWidget = + stripManagedScript(snippet, WIDGET_MARKER_ATTR) !== snippet; + const removeManagedWidget = + options.removeManagedWidget ?? snippetContainsManagedWidget; + const stripped = removeManagedWidget + ? stripManagedScript(withoutOldMarker, WIDGET_MARKER_ATTR) + : withoutOldMarker; + const manualWidgetIndex = findWidgetScriptIndex(stripped); + if (manualWidgetIndex !== -1) { + const buildMarkerOnly = stripManagedScript(snippet, WIDGET_MARKER_ATTR); + // Old versions of the guide installed a synchronous CDN tag manually. Put + // build globals before that tag so the widget sees production/checksum/stack + // during its first init, while still preserving the user's installation. + return ( + stripped.slice(0, manualWidgetIndex) + + buildMarkerOnly + + stripped.slice(manualWidgetIndex) + ); + } + + const anchor = findBuildInjectionAnchor(stripped); + return anchor === null + ? html + : stripped.slice(0, anchor) + snippet + stripped.slice(anchor); +} + +function findBuildInjectionAnchor(source: string): number | null { + const tokens = tokenizeSource(source, { skipRawTextElements: true }); + const headClose = findElementTokenIndices(source, tokens, 'head', true); + if (headClose.length === 1) return headClose[0]!; + const bodyClose = findElementTokenIndices(source, tokens, 'body', true); + return bodyClose.length === 1 ? bodyClose[0]! : null; +} + +function stripManagedScript(html: string, attribute: string): string { + return html.replace(htmlBlockPattern(), (block) => { + if (isInertHtmlBlock(block)) { + return block; + } + const openingTag = block.match(/^]*>/i)?.[0]; + return openingTag !== undefined && readScriptAttribute(openingTag, attribute) !== undefined + ? '' + : block; + }); +} + +function replaceManagedSourceWidgets( + source: string, + replacement: string, +): { html: string; found: boolean } { + const managed = findLiveSourceScriptTags(source).filter((tag) => tag.isManaged); + if (managed.length === 0) { + return { html: source, found: false }; + } + + let updated = source; + for (let i = managed.length - 1; i >= 0; i--) { + const tag = managed[i]!; + updated = updated.slice(0, tag.start) + (i === 0 ? replacement : '') + updated.slice(tag.end); + } + return { html: updated, found: true }; +} + +function insertSourceTag( + source: string, + widgetTag: string, + anchor: SourceWidgetAnchor, +): string | null { + const tokens = tokenizeSource(source); + let anchors: number[]; + if (anchor === 'remix-scripts') { + anchors = findRemixScriptsAnchors(source, tokens); + if (anchors.length > 1) { + return null; + } + // A conventional Remix root owns ; retaining this fallback supports + // roots that omit while avoiding arbitrary fragment mutation. + if (anchors.length === 0) { + anchors = findElementTokenIndices(source, tokens, 'body', true); + } + } else { + anchors = findElementTokenIndices(source, tokens, 'body', true); + } + + if (anchors.length !== 1) { + return null; + } + return insertAtSourceAnchor(source, anchors[0]!, widgetTag); +} + +function insertAtSourceAnchor(source: string, anchorIndex: number, widgetTag: string): string { + const lineStart = source.lastIndexOf('\n', anchorIndex - 1) + 1; + const indentation = source.slice(lineStart, anchorIndex); + if (/^[\t ]*$/.test(indentation)) { + const newline = source.includes('\r\n') ? '\r\n' : '\n'; + return ( + source.slice(0, lineStart) + + indentation + + widgetTag + + newline + + source.slice(lineStart) + ); + } + return source.slice(0, anchorIndex) + widgetTag + source.slice(anchorIndex); +} + +type SourceTokenKind = 'identifier' | 'string' | 'punctuator'; + +interface SourceToken { + kind: SourceTokenKind; + value: string; + start: number; + end: number; +} + +interface SourceScriptTag { + start: number; + end: number; + contentStart: number; + contentEnd: number; + opening: string; + isJavascript: boolean; + isWidget: boolean; + isManaged: boolean; +} + +type SourceAttributeIdentity = + | { kind: 'absent' } + | { kind: 'static'; value: string } + | { kind: 'boolean' } + | { kind: 'dynamic' }; + +interface TokenizeSourceOptions { + /** Treat script/style bodies as raw text while locating document structure. */ + skipRawTextElements?: boolean; +} + +/** + * Small conservative lexer for HTML-like framework source. It is not a full + * JS parser; its job is to distinguish live markup/code from examples inside + * comments and string/template literals while retaining source offsets. */ -export function injectMarker(html: string, snippet: string): string { - const stripped = html.replace( - new RegExp(`\\s*`, 'gi'), - '', +function tokenizeSource( + source: string, + options: TokenizeSourceOptions = {}, +): SourceToken[] { + const tokens: SourceToken[] = []; + let i = 0; + + while (i < source.length) { + const inertEnd = findInertContainerEnd(source, i, options.skipRawTextElements === true); + if (inertEnd !== null) { + i = inertEnd; + continue; + } + if (source.startsWith('', i + 4); + i = end === -1 ? source.length : end + 3; + continue; + } + if (source.startsWith('//', i)) { + const end = source.indexOf('\n', i + 2); + i = end === -1 ? source.length : end; + continue; + } + if (source.startsWith('/*', i)) { + const end = source.indexOf('*/', i + 2); + i = end === -1 ? source.length : end + 2; + continue; + } + + const char = source[i]!; + if (/\s/.test(char)) { + i++; + continue; + } + if (char === '`') { + i = skipQuotedSource(source, i, '`'); + continue; + } + if (char === '"' || char === "'") { + // Do not let ordinary apostrophes in JSX/HTML text hide later markup. + if ( + char === "'" && + isIdentifierPart(source[i - 1]) && + isIdentifierPart(source[i + 1]) + ) { + i++; + continue; + } + const end = skipQuotedSource(source, i, char); + tokens.push({ + kind: 'string', + value: decodeSimpleString(source.slice(i + 1, Math.max(i + 1, end - 1))), + start: i, + end, + }); + i = end; + continue; + } + if (char === '/' && isRegexLiteralStart(tokens)) { + i = skipRegexLiteral(source, i); + continue; + } + if (isIdentifierStart(char)) { + const start = i++; + while (i < source.length && isIdentifierPart(source[i])) { + i++; + } + tokens.push({ kind: 'identifier', value: source.slice(start, i), start, end: i }); + continue; + } + + tokens.push({ kind: 'punctuator', value: char, start: i, end: i + 1 }); + i++; + } + return tokens; +} + +function findInertContainerEnd( + source: string, + index: number, + skipRawTextElements: boolean, +): number | null { + for (const name of ['template', 'textarea', 'noscript', 'style']) { + if (!startsWithOpeningElement(source, index, name)) continue; + const openingEnd = findOpeningTagEnd(source, index); + if (openingEnd === null) { + return source.length; + } + const close = new RegExp(`<\\/${name}\\s*>`, 'gi'); + close.lastIndex = openingEnd; + const match = close.exec(source); + return match === null ? source.length : match.index + match[0].length; + } + + if (startsWithOpeningElement(source, index, 'script')) { + const openingEnd = findOpeningTagEnd(source, index); + if (openingEnd === null) return source.length; + const opening = source.slice(index, openingEnd).replace(/^/gi; + close.lastIndex = openingEnd; + const match = close.exec(source); + return match === null ? source.length : match.index + match[0].length; + } + } + return null; +} + +function startsWithOpeningElement(source: string, index: number, name: string): boolean { + if (source[index] !== '<') return false; + if (source.slice(index + 1, index + name.length + 1).toLowerCase() !== name) return false; + const boundary = source[index + name.length + 1]; + return boundary === undefined || /[\s/>]/.test(boundary); +} + +function skipQuotedSource(source: string, start: number, quote: string): number { + for (let i = start + 1; i < source.length; i++) { + if (source[i] === '\\') { + i++; + continue; + } + if (source[i] === quote) { + return i + 1; + } + } + return source.length; +} + +function skipRegexLiteral(source: string, start: number): number { + let inClass = false; + for (let i = start + 1; i < source.length; i++) { + if (source[i] === '\\') { + i++; + continue; + } + if (source[i] === '[') inClass = true; + if (source[i] === ']') inClass = false; + if (source[i] === '/' && !inClass) { + i++; + while (i < source.length && /[a-z]/i.test(source[i]!)) i++; + return i; + } + if (source[i] === '\n' || source[i] === '\r') { + return i; + } + } + return source.length; +} + +function isRegexLiteralStart(tokens: SourceToken[]): boolean { + const previous = tokens[tokens.length - 1]; + if (previous === undefined) return true; + if (previous.kind === 'identifier' || previous.kind === 'string') return false; + return /^(?:\(|\[|\{|=|:|,|;|!|\?|&|\|)$/.test(previous.value); +} + +function decodeSimpleString(value: string): string { + return value.replace(/\\([\\'"`])/g, '$1'); +} + +function isIdentifierStart(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z_$]/.test(value); +} + +function isIdentifierPart(value: string | undefined): boolean { + return value !== undefined && /[A-Za-z0-9_$-]/.test(value); +} + +function tokenIs(token: SourceToken | undefined, value: string): boolean { + return token?.value === value; +} + +function isWidgetStringToken(token: SourceToken | undefined): boolean { + return token?.kind === 'string' && isWidgetScriptUrl(token.value); +} + +function isWidgetScriptUrl(value: string): boolean { + return /(?:^|\/)patchstack-widget(?:\.[a-z0-9_-]+)?\.js(?:[?#].*)?$/i.test(value); +} + +function sourceIdentityOccurrence( + kind: SourceWidgetIdentityKind, + identity: SourceAttributeIdentity, + managed: boolean, + configuresIdentity: boolean, + start: number, + end: number, +): SourceWidgetIdentityOccurrence { + if (identity.kind === 'static') { + if (identity.value === '') { + return { kind, state: 'unconfigured', value: '', managed, configuresIdentity, start, end }; + } + if (isUuid(identity.value)) { + return { + kind, + state: 'configured', + uuid: identity.value.toLowerCase(), + managed, + configuresIdentity, + start, + end, + }; + } + return { + kind, + state: 'invalid', + value: identity.value, + managed, + configuresIdentity, + start, + end, + }; + } + return { + kind, + state: identity.kind === 'dynamic' ? 'dynamic' : 'unconfigured', + managed, + configuresIdentity, + start, + end, + }; +} + +function summarizeSourceWidgetIdentity( + occurrences: SourceWidgetIdentityOccurrence[], +): SourceWidgetIdentityInspection { + const uuids = [ + ...new Set( + occurrences.flatMap((occurrence) => + occurrence.state === 'configured' && occurrence.uuid !== undefined + ? [occurrence.uuid.toLowerCase()] + : [], + ), + ), + ].sort(); + const loaders = occurrences.filter( + (occurrence) => occurrence.kind === 'script-tag' || occurrence.kind === 'dynamic-loader', + ); + const configurationProblems = occurrences.filter( + (occurrence) => occurrence.configuresIdentity && occurrence.state !== 'configured', ); - if (/<\/head>/i.test(stripped)) { - return stripped.replace(/<\/head>/i, `${snippet}`); + let status: SourceWidgetIdentityStatus; + if ( + uuids.length > 1 || + loaders.length > 1 || + (uuids.length === 1 && configurationProblems.length > 0) + ) { + status = 'conflict'; + } else if (uuids.length === 1) { + // One loader without data attributes plus one static legacy initializer is + // the supported pre-auto-init installation and is not a conflict. + status = 'configured'; + } else if (occurrences.some((occurrence) => occurrence.state === 'invalid')) { + status = 'invalid'; + } else if (occurrences.some((occurrence) => occurrence.state === 'dynamic')) { + status = 'dynamic'; + } else if (occurrences.length > 0) { + status = 'unconfigured'; + } else { + status = 'absent'; } - if (/<\/body>/i.test(stripped)) { - return stripped.replace(/<\/body>/i, `${snippet}`); + + return { + status, + uuid: status === 'configured' ? uuids[0] ?? null : null, + uuids, + occurrences, + hasManual: occurrences.some((occurrence) => !occurrence.managed), + hasManaged: occurrences.some((occurrence) => occurrence.managed), + }; +} + +function patchstackInitOpenParen(tokens: SourceToken[], index: number): number | null { + return tokenIs(tokens[index], 'PatchstackWidget') && + tokenIs(tokens[index + 1], '.') && + tokenIs(tokens[index + 2], 'init') && + tokenIs(tokens[index + 3], '(') + ? index + 3 + : null; +} + +function inspectInitializerIdentity( + tokens: SourceToken[], + startIndex: number, + openParenIndex: number, +): SourceWidgetIdentityOccurrence[] { + const closeParenIndex = findMatchingPunctuator(tokens, openParenIndex, '(', ')'); + const end = tokens[closeParenIndex ?? openParenIndex]?.end ?? tokens[startIndex]!.end; + const firstArgument = tokens[openParenIndex + 1]; + if (firstArgument === undefined || tokenIs(firstArgument, ')')) { + return [initializerOccurrence({ kind: 'boolean' }, tokens[startIndex]!.start, end)]; + } + if (!tokenIs(firstArgument, '{')) { + return [initializerOccurrence({ kind: 'dynamic' }, tokens[startIndex]!.start, end)]; + } + + const objectOpenIndex = openParenIndex + 1; + const objectCloseIndex = findMatchingPunctuator(tokens, objectOpenIndex, '{', '}'); + if (objectCloseIndex === null) { + return [initializerOccurrence({ kind: 'dynamic' }, tokens[startIndex]!.start, end)]; } - return stripped + snippet; + + const found: SourceWidgetIdentityOccurrence[] = []; + let braces = 0; + let brackets = 0; + let parentheses = 0; + for (let i = objectOpenIndex + 1; i < objectCloseIndex; i++) { + const token = tokens[i]!; + const atTopLevel = braces === 0 && brackets === 0 && parentheses === 0; + if ( + atTopLevel && + (token.kind === 'identifier' || token.kind === 'string') && + (token.value === 'userToken' || token.value === 'siteUuid') + ) { + const separator = tokens[i + 1]; + const value = tokens[i + 2]; + const identity: SourceAttributeIdentity = !tokenIs(separator, ':') || value === undefined + ? { kind: 'dynamic' } + : value.kind === 'string' + ? { kind: 'static', value: value.value } + : { kind: 'dynamic' }; + found.push(initializerOccurrence(identity, token.start, value?.end ?? token.end)); + } + + if (tokenIs(token, '{')) braces++; + else if (tokenIs(token, '}') && braces > 0) braces--; + else if (tokenIs(token, '[')) brackets++; + else if (tokenIs(token, ']') && brackets > 0) brackets--; + else if (tokenIs(token, '(')) parentheses++; + else if (tokenIs(token, ')') && parentheses > 0) parentheses--; + } + + return found.length > 0 + ? found + : [initializerOccurrence({ kind: 'boolean' }, tokens[startIndex]!.start, end)]; +} + +function initializerOccurrence( + identity: SourceAttributeIdentity, + start: number, + end: number, +): SourceWidgetIdentityOccurrence { + return sourceIdentityOccurrence('initializer', identity, false, true, start, end); +} + +function findMatchingPunctuator( + tokens: SourceToken[], + openIndex: number, + open: string, + close: string, +): number | null { + let depth = 0; + for (let i = openIndex; i < tokens.length; i++) { + if (tokenIs(tokens[i], open)) depth++; + else if (tokenIs(tokens[i], close)) { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +function findLiveSourceScriptTags( + source: string, + suppliedTokens?: SourceToken[], +): SourceScriptTag[] { + const tokens = suppliedTokens ?? tokenizeSource(source); + const tags: SourceScriptTag[] = []; + for (let i = 0; i < tokens.length - 1; i++) { + if (!tokenIs(tokens[i], '<') || tokens[i + 1]?.kind !== 'identifier') continue; + if (tokens[i + 1]?.value.toLowerCase() !== 'script') continue; + + const start = tokens[i]!.start; + const openingEnd = findOpeningTagEnd(source, start); + if (openingEnd === null) continue; + const opening = source.slice(start, openingEnd); + if (!/^$/.test(opening); + const closing = selfClosing + ? null + : findRawClosingElement(source, openingEnd, 'script'); + const end = closing?.end ?? openingEnd; + tags.push({ + start, + end, + contentStart: openingEnd, + contentEnd: closing?.start ?? openingEnd, + opening: normalized, + isJavascript, + isWidget: typeof src === 'string' && isJavascript && isWidgetScriptUrl(src), + isManaged: readScriptAttribute(normalized, WIDGET_MARKER_ATTR) !== undefined, + }); + } + return tags; +} + +function findRawClosingElement( + source: string, + fromIndex: number, + name: string, +): { start: number; end: number } | null { + const close = new RegExp(`<\\/${name}\\s*>`, 'gi'); + close.lastIndex = fromIndex; + const match = close.exec(source); + return match === null + ? null + : { start: match.index, end: match.index + match[0].length }; +} + +function findOpeningTagEnd(source: string, start: number): number | null { + let quote: string | null = null; + let braces = 0; + for (let i = start + 1; i < source.length; i++) { + const char = source[i]!; + if (quote !== null) { + if (char === '\\') i++; + else if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + if (char === '{') { + braces++; + continue; + } + if (char === '}' && braces > 0) { + braces--; + continue; + } + if (char === '>' && braces === 0) { + return i + 1; + } + } + return null; +} + +function findElementTokenIndices( + source: string, + tokens: SourceToken[], + name: string, + closing: boolean, +): number[] { + const indices: number[] = []; + for (let i = 0; i < tokens.length - (closing ? 3 : 1); i++) { + if (!tokenIs(tokens[i], '<')) continue; + const nameToken = tokens[i + (closing ? 2 : 1)]; + if (closing && !tokenIs(tokens[i + 1], '/')) continue; + if (!closing && tokenIs(tokens[i + 1], '/')) continue; + if (nameToken?.kind !== 'identifier' || nameToken.value.toLowerCase() !== name) continue; + const tagEnd = findOpeningTagEnd(source, tokens[i]!.start); + if (tagEnd !== null) indices.push(tokens[i]!.start); + } + return indices; +} + +function findRemixScriptsAnchors(source: string, tokens: SourceToken[]): number[] { + const anchors: number[] = []; + for (let i = 0; i < tokens.length - 1; i++) { + if (!tokenIs(tokens[i], '<') || !tokenIs(tokens[i + 1], 'Scripts')) continue; + const start = tokens[i]!.start; + const end = findOpeningTagEnd(source, start); + if (end !== null && /\/\s*>$/.test(source.slice(start, end))) { + anchors.push(start); + } + } + return anchors; +} + +function findWidgetScriptIndex(html: string): number { + for (const match of html.matchAll(htmlBlockPattern())) { + const block = match[0]; + if (isInertHtmlBlock(block)) { + continue; + } + const openingTag = block.match(/^]*>/i)?.[0]; + if (openingTag === undefined) { + continue; + } + const type = readScriptAttribute(openingTag, 'type'); + if (typeof type === 'string' && !isJavaScriptType(type)) { + continue; + } + const src = readScriptAttribute(openingTag, 'src'); + if ( + typeof src === 'string' && + /(?:^|\/)patchstack-widget(?:\.[a-z0-9_-]+)?\.js(?:[?#].*)?$/i.test(src) + ) { + return match.index ?? -1; + } + } + return -1; +} + +function htmlBlockPattern(): RegExp { + // Match inert containers before script blocks so examples/templates do not + // suppress a live widget or lose marker-looking content during replacement. + return /|<(template|textarea|noscript|style)\b[^>]*>[\s\S]*?<\/\1\s*>|]*>[\s\S]*?<\/script\s*>/gi; +} + +function isInertHtmlBlock(block: string): boolean { + return block.startsWith(''), + ).toBe(false); + expect( + hasWidgetScript( + '', + ), + ).toBe(false); + expect( + hasWidgetScript( + '', + ), + ).toBe(false); + expect( + hasWidgetScript(''), + ).toBe(false); + }); +}); + +describe('inspectSourceWidgetIdentity', () => { + const firstUuid = '550e8400-e29b-41d4-a716-446655440000'; + const secondUuid = '11111111-1111-1111-1111-111111111111'; + + it('reads a configured manual or managed CDN tag', () => { + const manual = inspectSourceWidgetIdentity( + ``, + ); + expect(alias).toMatchObject({ + status: 'configured', + uuid: firstUuid, + uuids: [firstUuid], + }); + }); + + it('combines a bare legacy loader with static userToken or siteUuid init', () => { + for (const key of ['userToken', 'siteUuid']) { + const source = + `` + + ``; + const result = inspectSourceWidgetIdentity(source); + expect(result.status).toBe('configured'); + expect(result.uuid).toBe(firstUuid); + expect(result.occurrences).toHaveLength(2); + } + }); + + it('distinguishes unconfigured, dynamic, invalid, and conflicting identity', () => { + expect( + inspectSourceWidgetIdentity(``).status, + ).toBe('unconfigured'); + expect( + inspectSourceWidgetIdentity( + ``, + ).status, + ).toBe('dynamic'); + expect( + inspectSourceWidgetIdentity( + ``, + ), + ).toMatchObject({ status: 'invalid', uuid: null }); + + const conflict = inspectSourceWidgetIdentity( + `` + + ``, + ); + expect(conflict).toMatchObject({ + status: 'conflict', + uuid: null, + uuids: [secondUuid, firstUuid].sort(), + }); + }); + + it('treats dynamic init configuration conservatively and ignores examples', () => { + expect( + inspectSourceWidgetIdentity('PatchstackWidget.init({ siteUuid });').status, + ).toBe('dynamic'); + expect( + inspectSourceWidgetIdentity([ + `const prompt = \`\`;`, + `// PatchstackWidget.init({ userToken: '${firstUuid}' });`, + ``, + ``, + ].join('\n')).status, + ).toBe('absent'); + }); +}); + +describe('injectSourceWidget', () => { + const firstUuid = '550e8400-e29b-41d4-a716-446655440000'; + const secondUuid = '11111111-1111-1111-1111-111111111111'; + + it('inserts a managed widget before body close for immediate preview', () => { + const result = injectSourceWidget('\n app\n \n\n', firstUuid); + expect(result.status).toBe('inserted'); + expect(result.html).toContain(buildWidgetTag(firstUuid)); + expect(result.html.indexOf(WIDGET_CDN_URL)).toBeLessThan(result.html.indexOf('')); + expect(result.html).not.toContain(MARKER_ATTR); + }); + + it('updates a connector-managed tag in place and is byte-stable afterward', () => { + const first = injectSourceWidget('', firstUuid); + const updated = injectSourceWidget(first.html, secondUuid, 'https://staging.example.test'); + expect(updated.status).toBe('updated'); + expect(updated.html).not.toContain(firstUuid); + expect(updated.html).toContain(secondUuid); + expect(updated.html).toContain('data-api-base="https://staging.example.test"'); + + const unchanged = injectSourceWidget( + updated.html, + secondUuid, + 'https://staging.example.test', + ); + expect(unchanged.status).toBe('unchanged'); + expect(unchanged.html).toBe(updated.html); + }); + + it('preserves an existing manual or legacy widget instead of duplicating it', () => { + const manual = + `` + + ``; + const result = injectSourceWidget(manual, secondUuid); + expect(result.status).toBe('existing-manual'); + expect(result.html).toBe(manual); + expect((result.html.match(/patchstack-widget\.js/g) ?? [])).toHaveLength(1); + }); + + it('removes a stale managed duplicate when a manual source tag is present', () => { + const manual = ``; + const managed = buildWidgetTag(firstUuid); + const result = injectSourceWidget(`${manual}${managed}`, secondUuid); + + expect(result.status).toBe('updated'); + expect((result.html.match(/patchstack-widget\.js/g) ?? [])).toHaveLength(1); + expect(result.html).toContain('data-site-uuid="manual"'); + expect(result.html).not.toContain(WIDGET_MARKER_ATTR); + expect(result.html).not.toContain(secondUuid); + }); + + it('preserves an existing Next Script component', () => { + const manual = + `", + '', + '', + '', + 'live', + ].join('\n'); + + const result = injectSourceWidget(source, firstUuid); + expect(result.status).toBe('inserted'); + expect(result.html.slice(0, result.html.indexOf(''))).toBe( + source.slice(0, source.indexOf('')), + ); + expect(result.html.indexOf(WIDGET_CDN_URL)).toBeGreaterThan(result.html.indexOf('live')); + expect(result.html.indexOf(WIDGET_CDN_URL)).toBeLessThan(result.html.lastIndexOf('')); + }); + + it('updates only a live managed tag and leaves a prompt example byte-identical', () => { + const prompt = + '`` + + '`'; + const live = buildWidgetTag(firstUuid); + const source = `const prompt = ${prompt};\n${live}`; + + const result = injectSourceWidget(source, secondUuid); + expect(result.status).toBe('updated'); + expect(result.html).toContain(`const prompt = ${prompt};`); + expect(result.html).toContain(`data-site-uuid="${secondUuid}"`); + expect((result.html.match(new RegExp(secondUuid, 'g')) ?? [])).toHaveLength(1); + }); + + it('supports one live Remix Scripts anchor while ignoring examples', () => { + const source = [ + 'const prompt = ``;', + 'export default () => <>', + ' {/* */}', + ' ', + ' ', + ';', + ].join('\n'); + const result = injectSourceWidget(source, firstUuid, null, { + anchor: 'remix-scripts', + }); + expect(result.status).toBe('inserted'); + expect(result.html.indexOf(WIDGET_CDN_URL)).toBeGreaterThan(result.html.indexOf('')); + expect(result.html.indexOf(WIDGET_CDN_URL)).toBeLessThan(result.html.lastIndexOf('')); + + const ambiguous = '<>'; + expect(injectSourceWidget(ambiguous, firstUuid, null, { anchor: 'remix-scripts' })).toEqual({ + html: ambiguous, + status: 'unsupported', + }); + }); + + it('declines fragments without a safe document closing tag', () => { + const html = '
component only
'; + expect(injectSourceWidget(html, firstUuid)).toEqual({ + html, + status: 'unsupported', + }); + }); }); describe('injectMarker', () => { @@ -63,22 +389,242 @@ describe('injectMarker', () => { }); it('falls back to when there is no head', () => { - const out = injectMarker('hi', buildInjectionSnippet('c1')); + const out = injectMarker('hi', buildInjectionSnippet('c1')); + expect(out).toContain(MARKER_ATTR); expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.indexOf('')); }); - it('appends when there is neither head nor body', () => { - const out = injectMarker('
bare
', buildInjectionSnippet('c1')); - expect(out).toContain(MARKER_ATTR); + it('leaves an HTML fragment byte-for-byte unchanged', () => { + const fragment = '
bare
'; + expect(injectMarker(fragment, buildInjectionSnippet('c1'))).toBe(fragment); + }); + + it('ignores false closers in raw, inert, and commented HTML content', () => { + const html = [ + '', + '', + '', + '', + '', + '', + '', + '', + ].join(''); + const out = injectMarker(html, buildInjectionSnippet('c1')); + expect(out).toContain('const example = "";'); + expect(out.indexOf(MARKER_ATTR)).toBeGreaterThan(out.indexOf('')); + expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.lastIndexOf('')); + }); + + it('does not mutate partial head/body files or component fragments', () => { + for (const fragment of [ + 'partial', + 'partial', + '
partial
', + ]) { + expect(injectMarker(fragment, buildInjectionSnippet('c1'))).toBe(fragment); + } }); it('is idempotent — re-running replaces the marker instead of stacking', () => { - const once = injectMarker('', buildInjectionSnippet('c1')); - const twice = injectMarker(once, buildInjectionSnippet('c2')); + const once = injectMarker( + '', + buildInjectionSnippet('c1', null, '550e8400-e29b-41d4-a716-446655440000'), + ); + const twice = injectMarker( + once, + buildInjectionSnippet('c2', null, '11111111-1111-1111-1111-111111111111'), + ); const markerCount = (twice.match(new RegExp(MARKER_ATTR, 'g')) ?? []).length; + const widgetMarkerCount = (twice.match(new RegExp(WIDGET_MARKER_ATTR, 'g')) ?? []).length; expect(markerCount).toBe(1); + expect(widgetMarkerCount).toBe(1); expect(twice).toContain('"c2"'); expect(twice).not.toContain('"c1"'); + expect(twice).toContain('data-site-uuid="11111111-1111-1111-1111-111111111111"'); + expect(twice).not.toContain('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('is byte-for-byte stable when rerun with the same values', () => { + const snippet = buildInjectionSnippet( + 'c1', + null, + '550e8400-e29b-41d4-a716-446655440000', + ); + const once = injectMarker('\n\n\n\n\n', snippet); + expect(injectMarker(once, snippet)).toBe(once); + }); + + it('preserves a manual widget and does not add a connector-managed duplicate', () => { + const manual = + `' + + ''; + const out = injectMarker( + manual, + buildInjectionSnippet('c1', null, '11111111-1111-1111-1111-111111111111'), + ); + + expect((out.match(/patchstack-widget\.js/g) ?? [])).toHaveLength(1); + expect(out).toContain('550e8400-e29b-41d4-a716-446655440000'); + expect(out).not.toContain(WIDGET_MARKER_ATTR); + expect(out).toContain(MARKER_ATTR); + expect(out.indexOf(MARKER_ATTR)).toBeLessThan(out.indexOf(WIDGET_CDN_URL)); + }); + + it('preserves a compiled managed widget when the replacement snippet has no UUID', () => { + const managed = buildWidgetTag('550e8400-e29b-41d4-a716-446655440000'); + const html = `${managed}`; + + const preserved = injectMarker(html, buildInjectionSnippet('c1')); + expect(preserved).toContain(managed); + expect(preserved).toContain(MARKER_ATTR); + + const optedOut = injectMarker(html, buildInjectionSnippet('c1'), { + removeManagedWidget: true, + }); + expect(optedOut).not.toContain(WIDGET_MARKER_ATTR); + }); + + it('does not remove unrelated scripts that mention a marker in an attribute value', () => { + const unrelated = ''; + const out = injectMarker( + `${unrelated}`, + buildInjectionSnippet('c1', null, '550e8400-e29b-41d4-a716-446655440000'), + ); + expect(out).toContain(unrelated); + }); + + it('does not remove marker-looking scripts inside inert template content', () => { + const template = + ''; + const out = injectMarker( + `${template}`, + buildInjectionSnippet('c1', null, '550e8400-e29b-41d4-a716-446655440000'), + ); + expect(out).toContain(template); + expect(out).toContain(WIDGET_MARKER_ATTR); + }); + + it('does not treat script-like CSS content as a managed marker or widget', () => { + const style = + ``; + const out = injectMarker( + `${style}`, + buildInjectionSnippet('c1', null, '550e8400-e29b-41d4-a716-446655440000'), + ); + expect(out).toContain(style); + expect(out).toContain(WIDGET_MARKER_ATTR); + }); + + it('ignores a commented-out widget and injects a live managed tag', () => { + const commented = ``; + const out = injectMarker( + `${commented}`, + buildInjectionSnippet('c1', null, '550e8400-e29b-41d4-a716-446655440000'), + ); + expect(out).toContain(commented); + expect(out).toContain(WIDGET_MARKER_ATTR); + expect(hasWidgetScript(out)).toBe(true); + }); +}); + +describe('verifyBuildHtml', () => { + const expectedUuid = '550e8400-e29b-41d4-a716-446655440000'; + const wrongUuid = '11111111-1111-1111-1111-111111111111'; + const document = (head: string): string => + `${head}
app
`; + + it('accepts exactly one generated production marker and matching managed widget', () => { + const html = injectMarker(document(''), buildInjectionSnippet('c1', null, expectedUuid)); + expect(verifyBuildHtml(html, expectedUuid)).toMatchObject({ + ok: true, + issues: [], + isFullDocument: true, + productionMarkerCount: 1, + productionFlagCount: 1, + widgetLoaderCount: 1, + widgetUuid: expectedUuid, + }); + }); + + it('rejects a manual widget with a missing or wrong UUID', () => { + const missing = injectMarker( + document(``), + buildInjectionSnippet('c1', null, expectedUuid), + ); + expect(verifyBuildHtml(missing, expectedUuid).issues).toContain('widget-identity-missing'); + + const wrong = injectMarker( + document( + ``, + ), + buildInjectionSnippet('c1', null, expectedUuid), + ); + expect(verifyBuildHtml(wrong, expectedUuid)).toMatchObject({ + ok: false, + widgetUuid: wrongUuid, + issues: ['widget-uuid-mismatch'], + }); + }); + + it('accepts one matching synchronous legacy initializer and rejects ambiguity', () => { + const loader = ``; + const init = ``; + const legacy = injectMarker( + document(loader + init), + buildInjectionSnippet('c1', null, expectedUuid), + ); + expect(verifyBuildHtml(legacy, expectedUuid).ok).toBe(true); + + const ambiguous = injectMarker( + document(loader + init + init), + buildInjectionSnippet('c1', null, expectedUuid), + ); + expect(verifyBuildHtml(ambiguous, expectedUuid).issues).toContain( + 'widget-identity-ambiguous', + ); + + const displayedCode = injectMarker( + document(loader + `PatchstackWidget.init({ userToken: '${expectedUuid}' });`), + buildInjectionSnippet('c1', null, expectedUuid), + ); + expect(verifyBuildHtml(displayedCode, expectedUuid).issues).toContain( + 'widget-identity-missing', + ); + }); + + it('requires production mode on a connector-managed output widget', () => { + const html = document(buildInjectionSnippet('c1') + buildWidgetTag(expectedUuid)); + expect(verifyBuildHtml(html, expectedUuid).issues).toContain( + 'widget-production-attribute-invalid', + ); + }); + + it('reports fragments and duplicate production markers', () => { + expect(verifyBuildHtml('
fragment
', expectedUuid).issues).toContain( + 'not-full-document', + ); + + const marker = buildInjectionSnippet('c1'); + const duplicate = document(marker + marker + buildWidgetTag(expectedUuid, null, { + production: true, + })); + expect(verifyBuildHtml(duplicate, expectedUuid).issues).toContain( + 'production-marker-duplicate', + ); + }); + + it('strictly verifies the widget-supported data-user-token alias', () => { + const aliasTag = + ``; + const html = injectMarker(document(aliasTag), buildInjectionSnippet('c1')); + expect(verifyBuildHtml(html, expectedUuid)).toMatchObject({ + ok: true, + widgetUuid: expectedUuid, + widgetLoaderCount: 1, + }); }); }); @@ -95,8 +641,14 @@ describe('resolveBuildDir + findHtmlFiles', () => { it('auto-detects dist/ and finds nested HTML while ignoring non-HTML', () => { mkdirSync(path.join(root, 'dist', 'nested'), { recursive: true }); - writeFileSync(path.join(root, 'dist', 'index.html'), ''); - writeFileSync(path.join(root, 'dist', 'nested', 'about.html'), ''); + writeFileSync( + path.join(root, 'dist', 'index.html'), + '', + ); + writeFileSync( + path.join(root, 'dist', 'nested', 'about.html'), + '', + ); writeFileSync(path.join(root, 'dist', 'app.js'), 'console.log(1)'); const dir = resolveBuildDir(root); @@ -114,6 +666,34 @@ describe('resolveBuildDir + findHtmlFiles', () => { expect(resolveBuildDir(root, 'public')).toBe(path.join(root, 'public')); }); + it('skips stale or fragment-only candidates in favour of populated output', () => { + mkdirSync(path.join(root, 'dist', 'partials'), { recursive: true }); + writeFileSync(path.join(root, 'dist', 'partials', 'card.html'), '
old
'); + mkdirSync(path.join(root, 'build'), { recursive: true }); + mkdirSync(path.join(root, 'out'), { recursive: true }); + writeFileSync( + path.join(root, 'out', 'index.html'), + 'current', + ); + + expect(resolveBuildDir(root)).toBe(path.join(root, 'out')); + // Explicit selection stays authoritative even when it contains no full page. + expect(resolveBuildDir(root, 'dist')).toBe(path.join(root, 'dist')); + }); + + it('requires an explicit directory when multiple populated outputs exist', () => { + for (const candidate of ['dist', 'out']) { + mkdirSync(path.join(root, candidate), { recursive: true }); + writeFileSync( + path.join(root, candidate, 'index.html'), + `${candidate}`, + ); + } + + expect(() => resolveBuildDir(root)).toThrow(/multiple populated build output directories/); + expect(resolveBuildDir(root, 'out')).toBe(path.join(root, 'out')); + }); + it('returns null when no build directory exists', () => { expect(resolveBuildDir(root)).toBeNull(); }); diff --git a/tests/provision-lock.test.ts b/tests/provision-lock.test.ts new file mode 100644 index 0000000..65823f8 --- /dev/null +++ b/tests/provision-lock.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises'; +import { hostname, tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + PROVISION_LOCK_FILENAME, + acquireProvisionLock, +} from '../src/provision-lock.js'; + +describe('acquireProvisionLock', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-provision-lock-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('serializes two provisioning attempts in one checkout', async () => { + const first = await acquireProvisionLock(cwd, 1_000); + let secondAcquired = false; + const secondPromise = acquireProvisionLock(cwd, 1_000).then((lock) => { + secondAcquired = true; + return lock; + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(secondAcquired).toBe(false); + await first.release(); + + const second = await secondPromise; + expect(secondAcquired).toBe(true); + await second.release(); + await expect(readFile(path.join(cwd, PROVISION_LOCK_FILENAME))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('requires explicit cleanup after a previous process crashes', async () => { + const target = path.join(cwd, PROVISION_LOCK_FILENAME); + await writeFile( + target, + `${JSON.stringify({ + token: 'abandoned', + pid: 2_147_483_647, + hostname: hostname(), + createdAt: new Date().toISOString(), + })}\n`, + ); + + await expect(acquireProvisionLock(cwd, 40)).rejects.toThrow( + /confirm no scan is running and remove .patchstack-connect.provision.lock manually/, + ); + expect(JSON.parse(await readFile(target, 'utf8'))).toMatchObject({ + token: 'abandoned', + }); + }); + + it('never age-deletes a lock owned by a live local process', async () => { + const first = await acquireProvisionLock(cwd, 1_000); + const target = path.join(cwd, PROVISION_LOCK_FILENAME); + const old = new Date(Date.now() - 60 * 60_000); + await utimes(target, old, old); + + await expect(acquireProvisionLock(cwd, 40)).rejects.toThrow( + /already provisioning this checkout/, + ); + expect(JSON.parse(await readFile(target, 'utf8'))).toMatchObject({ pid: process.pid }); + + await first.release(); + }); +}); diff --git a/tests/source-widget.test.ts b/tests/source-widget.test.ts new file mode 100644 index 0000000..2146257 --- /dev/null +++ b/tests/source-widget.test.ts @@ -0,0 +1,517 @@ +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + MARKER_ATTR, + WIDGET_CDN_URL, + WIDGET_MARKER_ATTR, + buildInjectionSnippet, + injectMarker, +} from '../src/mark-build.js'; +import { + ensureSourceWidget, + inspectSourceWidgetPreflight, + widgetApiBaseFromEndpoint, +} from '../src/source-widget.js'; + +const FIRST_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const SECOND_UUID = '11111111-1111-1111-1111-111111111111'; +const ENDPOINT = 'https://api.patchstack.com/monitor/pulse/manifest'; + +describe('ensureSourceWidget', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-source-widget-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('installs immediately into a Vite/static root index and updates idempotently', async () => { + const file = path.join(cwd, 'index.html'); + await writeFile(file, '\n
\n\n\n'); + + const installed = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + expect(installed).toMatchObject({ status: 'installed', file }); + let html = await readFile(file, 'utf8'); + expect(html).toContain(WIDGET_CDN_URL); + expect(html).toContain(`data-site-uuid="${FIRST_UUID}"`); + expect(html).toContain(`${WIDGET_MARKER_ATTR}="true"`); + expect(html).not.toContain('__PATCHSTACK_PROD__'); + + const updated = await ensureSourceWidget({ + cwd, + siteUuid: SECOND_UUID, + endpoint: 'https://staging.patchstack.test/monitor/pulse/manifest', + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + expect(updated.status).toBe('updated'); + html = await readFile(file, 'utf8'); + expect(html).not.toContain(FIRST_UUID); + expect(html).toContain(SECOND_UUID); + expect(html).toContain('data-api-base="https://staging.patchstack.test"'); + + const unchanged = await ensureSourceWidget({ + cwd, + siteUuid: SECOND_UUID, + endpoint: 'https://staging.patchstack.test/monitor/pulse/manifest', + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + expect(unchanged.status).toBe('unchanged'); + }); + + it('uses a framework root instead of an unrelated root index for Next', async () => { + await writeFile(path.join(cwd, 'index.html'), 'unrelated export'); + await mkdir(path.join(cwd, 'src', 'app'), { recursive: true }); + const layout = path.join(cwd, 'src', 'app', 'layout.tsx'); + await writeFile( + layout, + 'export default function Layout({children}) { return {children} }\n', + ); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'next', ui: 'react', bundler: 'webpack' }, + }); + expect(result).toMatchObject({ status: 'installed', file: layout }); + expect(await readFile(layout, 'utf8')).toContain(WIDGET_CDN_URL); + expect(await readFile(path.join(cwd, 'index.html'), 'utf8')).not.toContain(WIDGET_CDN_URL); + }); + + it('covers both Next App Router and Pages Router global shells', async () => { + const appLayout = path.join(cwd, 'src', 'app', 'layout.tsx'); + const pagesDocument = path.join(cwd, 'pages', '_document.tsx'); + await mkdir(path.dirname(appLayout), { recursive: true }); + await mkdir(path.dirname(pagesDocument), { recursive: true }); + await writeFile( + appLayout, + 'export default function Layout({children}) { return {children} }\n', + ); + await writeFile( + pagesDocument, + 'export default function Document() { return
}\n', + ); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'next', ui: 'react', bundler: 'webpack' }, + }); + + expect(result).toMatchObject({ status: 'installed' }); + expect(result.files).toEqual([appLayout, pagesDocument]); + expect(await readFile(appLayout, 'utf8')).toContain(WIDGET_CDN_URL); + expect(await readFile(pagesDocument, 'utf8')).toContain(WIDGET_CDN_URL); + }); + + it('rejects duplicate alternatives inside one Next router family', async () => { + const srcLayout = path.join(cwd, 'src', 'app', 'layout.tsx'); + const rootLayout = path.join(cwd, 'app', 'layout.tsx'); + await mkdir(path.dirname(srcLayout), { recursive: true }); + await mkdir(path.dirname(rootLayout), { recursive: true }); + const source = 'layout\n'; + await writeFile(srcLayout, source); + await writeFile(rootLayout, source); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'next', ui: 'react', bundler: 'webpack' }, + }); + + expect(result.status).toBe('ambiguous'); + expect(await readFile(srcLayout, 'utf8')).toBe(source); + expect(await readFile(rootLayout, 'utf8')).toBe(source); + }); + + it('declines ambiguous generic shells instead of guessing', async () => { + const root = path.join(cwd, 'index.html'); + const publicIndex = path.join(cwd, 'public', 'index.html'); + await mkdir(path.dirname(publicIndex), { recursive: true }); + await writeFile(root, 'root'); + await writeFile(publicIndex, 'public'); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: null, + }); + expect(result.status).toBe('ambiguous'); + expect(result.candidates).toHaveLength(2); + expect(await readFile(root, 'utf8')).not.toContain(WIDGET_CDN_URL); + expect(await readFile(publicIndex, 'utf8')).not.toContain(WIDGET_CDN_URL); + }); + + it('fails closed on a legacy dynamic install outside the selected global shell', async () => { + await writeFile(path.join(cwd, 'index.html'), '
'); + await mkdir(path.join(cwd, 'src'), { recursive: true }); + const app = path.join(cwd, 'src', 'App.tsx'); + await writeFile( + app, + `const script = document.createElement('script'); script.src = '${WIDGET_CDN_URL}';`, + ); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + expect(result).toEqual({ + status: 'failed', + file: app, + message: 'Patchstack loader or initializer is outside the selected global source shell', + }); + expect(await readFile(path.join(cwd, 'index.html'), 'utf8')).not.toContain(WIDGET_CDN_URL); + }); + + it('cleans up a managed duplicate while preserving a manual shell tag', async () => { + const index = path.join(cwd, 'index.html'); + const manual = ``; + const managed = + ``; + await writeFile(index, `${manual}${managed}`); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: SECOND_UUID, + endpoint: ENDPOINT, + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + + expect(result).toMatchObject({ status: 'updated', file: index }); + const html = await readFile(index, 'utf8'); + expect((html.match(/patchstack-widget\.js/g) ?? [])).toHaveLength(1); + expect(html).toContain('data-site-uuid="manual"'); + expect(html).not.toContain(WIDGET_MARKER_ATTR); + }); + + it('ignores widget installation examples inside source prompts and comments', async () => { + const index = path.join(cwd, 'index.html'); + await writeFile(index, '
'); + await mkdir(path.join(cwd, 'src', 'components'), { recursive: true }); + const instructions = path.join(cwd, 'src', 'components', 'Installer.tsx'); + const prompt = [ + 'export const instructions = `', + ``, + `PatchstackWidget.init({ siteUuid: '${FIRST_UUID}' });`, + '', + '', + '`;', + `// PatchstackWidget.init() ${WIDGET_CDN_URL}`, + '', + ].join('\n'); + await writeFile(instructions, prompt); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: null, ui: 'react', bundler: 'vite' }, + }); + + expect(result).toMatchObject({ status: 'installed', file: index }); + expect(await readFile(index, 'utf8')).toContain(WIDGET_CDN_URL); + expect(await readFile(instructions, 'utf8')).toBe(prompt); + }); + + it('installs a Remix fragment root before its one live Scripts component', async () => { + await mkdir(path.join(cwd, 'app'), { recursive: true }); + const root = path.join(cwd, 'app', 'root.tsx'); + const source = [ + 'const example = ``;', + 'export default function App() {', + ' return <>', + ' {/* */}', + ' ', + ' ', + ' ;', + '}', + '', + ].join('\n'); + await writeFile(root, source); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'remix', ui: 'react', bundler: 'vite' }, + }); + + expect(result).toMatchObject({ status: 'installed', file: root }); + const updated = await readFile(root, 'utf8'); + expect(updated).toContain('const example = ``;'); + expect(updated.indexOf(WIDGET_CDN_URL)).toBeGreaterThan(updated.indexOf('')); + expect(updated.indexOf(WIDGET_CDN_URL)).toBeLessThan( + updated.indexOf(''), + ); + }); + + it('does not guess when a Remix root has multiple live Scripts anchors', async () => { + await mkdir(path.join(cwd, 'app'), { recursive: true }); + const root = path.join(cwd, 'app', 'root.tsx'); + const source = 'export default () => <>;\n'; + await writeFile(root, source); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'remix', ui: 'react', bundler: 'vite' }, + }); + + expect(result.status).toBe('not-found'); + expect(await readFile(root, 'utf8')).toBe(source); + }); + + it('installs and updates every full-document Astro layout as one group', async () => { + const layouts = path.join(cwd, 'src', 'layouts'); + await mkdir(path.join(layouts, 'admin'), { recursive: true }); + const main = path.join(layouts, 'Layout.astro'); + const admin = path.join(layouts, 'admin', 'Layout.astro'); + const partial = path.join(layouts, 'Partial.astro'); + const example = path.join(layouts, 'Example.astro'); + const mainSource = [ + '---', + 'const prompt = `example only`;', + '---', + '', + '', + ].join('\n'); + const adminSource = '\n admin\n \n\n'; + const partialSource = '
\n'; + const exampleSource = '---\nconst prompt = `x`;\n---\n'; + await writeFile(main, mainSource); + await writeFile(admin, adminSource); + await writeFile(partial, partialSource); + await writeFile(example, exampleSource); + + const installed = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'astro', ui: null, bundler: 'vite' }, + }); + + expect(installed.status).toBe('installed'); + expect(installed.files).toEqual([main, admin]); + expect(await readFile(main, 'utf8')).toContain(`data-site-uuid="${FIRST_UUID}"`); + expect(await readFile(admin, 'utf8')).toContain(`data-site-uuid="${FIRST_UUID}"`); + expect(await readFile(partial, 'utf8')).toBe(partialSource); + expect(await readFile(example, 'utf8')).toBe(exampleSource); + + const updated = await ensureSourceWidget({ + cwd, + siteUuid: SECOND_UUID, + endpoint: ENDPOINT, + stack: { framework: 'astro', ui: null, bundler: 'vite' }, + }); + expect(updated.status).toBe('updated'); + for (const file of [main, admin]) { + const html = await readFile(file, 'utf8'); + expect(html).toContain(SECOND_UUID); + expect(html).not.toContain(FIRST_UUID); + expect((html.match(new RegExp(WIDGET_MARKER_ATTR, 'g')) ?? [])).toHaveLength(1); + } + }); + + it('preserves a manual Astro layout while installing its uncovered sibling', async () => { + const layouts = path.join(cwd, 'src', 'layouts'); + await mkdir(layouts, { recursive: true }); + const manual = path.join(layouts, 'Manual.astro'); + const missing = path.join(layouts, 'Missing.astro'); + const manualSource = + `\n`; + await writeFile(manual, manualSource); + await writeFile(missing, '\n'); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: { framework: 'astro', ui: null, bundler: 'vite' }, + }); + + expect(result.status).toBe('installed'); + expect(await readFile(manual, 'utf8')).toBe(manualSource); + expect(await readFile(missing, 'utf8')).toContain(WIDGET_MARKER_ATTR); + }); + + it('ignores unrelated nested and symlinked HTML files', async () => { + await mkdir(path.join(cwd, 'docs'), { recursive: true }); + await writeFile(path.join(cwd, 'docs', 'index.html'), 'docs'); + await writeFile(path.join(cwd, 'outside.html'), 'outside'); + await symlink(path.join(cwd, 'outside.html'), path.join(cwd, 'index.html')); + + const result = await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: null, + }); + expect(result.status).toBe('not-found'); + expect(await readFile(path.join(cwd, 'docs', 'index.html'), 'utf8')).not.toContain( + WIDGET_CDN_URL, + ); + }); + + it('turns the source-owned tag into one production tag plus one build marker', async () => { + const file = path.join(cwd, 'index.html'); + await writeFile(file, 'app'); + await ensureSourceWidget({ + cwd, + siteUuid: FIRST_UUID, + endpoint: ENDPOINT, + stack: null, + }); + const source = await readFile(file, 'utf8'); + const built = injectMarker(source, buildInjectionSnippet('checksum', null, FIRST_UUID)); + expect((built.match(new RegExp(WIDGET_CDN_URL, 'g')) ?? [])).toHaveLength(1); + expect((built.match(new RegExp(WIDGET_MARKER_ATTR, 'g')) ?? [])).toHaveLength(1); + expect((built.match(new RegExp(MARKER_ATTR, 'g')) ?? [])).toHaveLength(1); + expect(built.indexOf(MARKER_ATTR)).toBeLessThan(built.indexOf(WIDGET_CDN_URL)); + }); +}); + +describe('inspectSourceWidgetPreflight', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-source-preflight-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('adopts a legacy UUID from the selected Next shell and ignores unrelated components', async () => { + const layout = path.join(cwd, 'src', 'app', 'layout.tsx'); + await mkdir(path.dirname(layout), { recursive: true }); + await writeFile( + layout, + `` + + ``, + ); + const unrelated = path.join(cwd, 'src', 'components', 'Example.tsx'); + await mkdir(path.dirname(unrelated), { recursive: true }); + await writeFile( + unrelated, + `PatchstackWidget.init({ siteUuid: '${SECOND_UUID}' });`, + ); + + const result = await inspectSourceWidgetPreflight({ + cwd, + stack: { framework: 'next', ui: 'react', bundler: 'webpack' }, + expectedSiteUuid: FIRST_UUID, + }); + + expect(result).toMatchObject({ + status: 'configured', + uuid: FIRST_UUID, + uuids: [FIRST_UUID], + files: [layout], + hasManual: true, + matchesExpectedUuid: true, + }); + }); + + it('makes a configured manual UUID mismatch explicit', async () => { + const index = path.join(cwd, 'index.html'); + await writeFile( + index, + ``, + ); + + const result = await inspectSourceWidgetPreflight({ + cwd, + stack: null, + expectedSiteUuid: SECOND_UUID, + }); + expect(result).toMatchObject({ + status: 'configured', + uuid: FIRST_UUID, + hasManual: true, + matchesExpectedUuid: false, + }); + }); + + it('reports ambiguous global shells instead of choosing one', async () => { + const root = path.join(cwd, 'index.html'); + const publicIndex = path.join(cwd, 'public', 'index.html'); + await mkdir(path.dirname(publicIndex), { recursive: true }); + await writeFile(root, 'root'); + await writeFile(publicIndex, 'public'); + + const result = await inspectSourceWidgetPreflight({ cwd, stack: null }); + expect(result.status).toBe('ambiguous'); + expect(result.files).toEqual([root, publicIndex]); + expect(result.uuid).toBeNull(); + }); + + it('reports grouped layout conflicts while ignoring absent sibling identity', async () => { + const layouts = path.join(cwd, 'src', 'layouts'); + await mkdir(layouts, { recursive: true }); + await writeFile( + path.join(layouts, 'A.astro'), + ``, + ); + await writeFile( + path.join(layouts, 'B.astro'), + ``, + ); + await writeFile(path.join(layouts, 'C.astro'), ''); + + const result = await inspectSourceWidgetPreflight({ + cwd, + stack: { framework: 'astro', ui: null, bundler: 'vite' }, + }); + expect(result).toMatchObject({ + status: 'conflict', + uuid: null, + uuids: [SECOND_UUID, FIRST_UUID].sort(), + }); + }); + + it('does not mistake a nested component for the selected global shell', async () => { + const component = path.join(cwd, 'src', 'components', 'WidgetExample.tsx'); + await mkdir(path.dirname(component), { recursive: true }); + await writeFile( + component, + ``, + ); + + expect(await inspectSourceWidgetPreflight({ cwd, stack: null })).toMatchObject({ + status: 'absent', + uuid: null, + files: [], + }); + }); +}); + +describe('widgetApiBaseFromEndpoint', () => { + it('omits the production origin and propagates a custom HTTP(S) origin', () => { + expect(widgetApiBaseFromEndpoint(ENDPOINT)).toBeNull(); + expect( + widgetApiBaseFromEndpoint('http://localhost:8000/monitor/pulse/manifest'), + ).toBe('http://localhost:8000'); + }); + + it('rejects non-HTTP endpoints', () => { + expect(() => widgetApiBaseFromEndpoint('file:///tmp/manifest')).toThrow('unsupported protocol'); + }); +});