Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Every command at a glance — what it does, whether it reads your source, what i
| Command | What it does | Reads your source? | Writes to your project | Sends over the network |
|---|---|---|---|---|
| `scan` | Provision (or reuse) the site and POST the dependency list for vulnerability matching. Also runs automatically via `setup` and the install/build hooks. | No source analysis — lockfile only; `node_modules/` is enumerated when no lockfile can be read (e.g. `bun.lockb`) or when the lockfiles present disagree. It also reads the `<title>` of the root `index.html` and the `name` in `package.json`, to report what the site is called. During `prebuild` only, it reads the scaffolded guard and its co-located rules JSON to remove a previous map stamp and record the build's checksum. | `.patchstackrc.json` (public: site UUID + settings, the link to connect the site, and whether it is connected yet); `.patchstackrc.local.json` (the API key, created owner-only) and a `.gitignore` entry for it — the CLI says so if it could not add one; during `prebuild`, removal of a previous `_patchstack.build_id` from the guard's own rules file so a later build cannot carry stale coordinates, and `_patchstack.manifest_checksum` in that file: the checksum of the package list the build is compiled from; the widget `<script>` tag in the root HTML shell — only after a successful post; the production marker in a code root shell — before the post, since it needs no site UUID | Package names + versions; this site's public address and name, where the project or build environment states them; the site UUID, to check whether the site is connected to an account yet (skipped once `.patchstackrc.json` records that it is) |
| `setup` | One bounded command: scan, widget (with `--widget`), install + verify guard, upload map, pull live rules, wire build hooks. In a folder of HTML pages with no `package.json` it first writes one with a build step; when `@patchstack/connect` is missing it adds it with the project's package manager. Never starts the app or runs a build. | Local integration and structural source analysis | `package.json` and `.gitignore` for a plain HTML folder, the dependency and lockfile when it adds itself, config, widget, guard/framework files, scripts, map identity in the guard rules JSON, git-ignored `.patchstack/` cache | Package inventory and site identity; structural map (routes, input names, packages, file:line, identity); authenticated Patchstack for JavaScript rules lookup. No source code or environment values |
| `setup` | One bounded command: scan, widget (with `--widget`), install + verify guard, upload map, pull live rules, wire build hooks. In a folder of HTML pages with no `package.json` it first writes one with a build step; when `@patchstack/connect` is missing it adds it with the project's package manager, falling back to the newest version the project accepts and finishing with that version's `setup`. Never starts the app or runs a build. | Local integration and structural source analysis | `package.json` and `.gitignore` for a plain HTML folder, the dependency and lockfile when it adds itself, config, widget, guard/framework files, scripts, map identity in the guard rules JSON, git-ignored `.patchstack/` cache | Package inventory and site identity; structural map (routes, input names, packages, file:line, identity); authenticated Patchstack for JavaScript rules lookup. No source code or environment values |
| `dev -- <command> [args]` | Runs only the explicitly selected dev-server command, with continuous map synchronization. In a Lovable or Replit project `setup` wraps a recognizable single-command dev script automatically; elsewhere `setup --dev-sync` opts in, and ordinary setup leaves dev scripts unchanged. | Polls source/config/lockfile metadata and parses source for changed maps; ignores outputs, hidden files, dependencies and symlinks | Guard rules JSON map stamp and local rule cache | Package inventories, structural maps and authenticated rule lookups; attempts coalesced to at most once per three minutes. App/network failures do not trigger automatic restarts. The child receives `PATCHSTACK_DEV_SYNC=1`, keeping map-specific rules detect-only during HMR; broad/hardening rules retain normal modes |
| `setup --runtime-url <URL>` / `protect --check --runtime-url <URL>` | Check activation at one explicitly selected running URL, without starting or restarting an app | Local map stamp and server-side credential | None beyond ordinary setup changes | One signed read-only OPTIONS challenge, using `x-patchstack-activation`; no API key or bearer token sent to that URL, no redirects, HTTPS except loopback HTTP. Response includes loaded site/map identity, last lookup time, source health, mode, readiness and rule counts; no rule bodies, secrets or source |
| `map` | Local attack-surface analysis; setup invokes it with upload, and wires prebuild uploads. | Server source parsed with TypeScript (app compiler or CLI dependency) | `--out` file if requested; an uploaded map is stamped into the guard rules JSON during setup or a pre-bundle build hook | Standalone command sends nothing unless `--upload`. Setup and its build hook upload structure only, never source text or environment values |
Expand Down Expand Up @@ -545,6 +545,9 @@ It is server-only. Never put it in the widget tag, client bundles, or public env
and `mark-build` only read and write files; `scan` and `mark-build` also report the dependency
manifest they read. One exception: when `@patchstack/connect` is not in the project yet, `setup` adds
it with the project's package manager, and like any install that runs the project's install scripts.
If the package manager refuses the version that is running (pnpm 11 and later skip versions published
in the last day), `setup` lets it add the newest version it allows, and then runs that installed
version's `setup` with the same arguments.

5. **Commit** `.patchstackrc.json`, the updated `package.json`, the guard/framework source changes, and the layout/HTML file carrying the widget tag (and the production marker, when `scan` wrote one into a JSX root or Astro layout), so every developer and CI run reports to the same site.

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,9 @@ The explicit `dev -- <command>` wrapper also runs your selected application comm
dependency manifest they read, or only its checksum from a production build that has no API key.
One exception to "only read and write files": when `@patchstack/connect` is not in the project yet,
`setup` adds it with your package manager (`npm install --save`, `pnpm add`, `yarn add` or `bun add`),
and like any install that runs the project's install scripts.
and like any install that runs the project's install scripts. If the package manager refuses the version
that is running (pnpm 11 and later skip versions published in the last day), `setup` lets it add the newest
version it allows, and then runs that installed version's `setup` with the same arguments.

## Configuration

Expand Down
36 changes: 29 additions & 7 deletions src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,43 @@ export function installArgs(pm: PackageManager, spec: string): string[] {

/**
* Add `@patchstack/connect` to the project with its own package manager, at the version that is running,
* so the project gets the code the person just approved. Like any install, the package manager runs the
* project's own install scripts. In a terminal its output goes straight there. Read by an assistant, the
* output is held back and its last lines are shown only if the install fails: a long install log can push
* setup's own summary out of what the assistant gets to read.
* so the project gets the code the person just approved. With no version, the package manager picks the
* newest one it allows. Like any install, the package manager runs the project's own install scripts. In a
* terminal its output goes straight there. Read by an assistant, the output is held back and its last lines
* are shown only if the install fails (and `showFailure` is not false): a long install log can push setup's
* own summary out of what the assistant gets to read.
*/
export function installSelf(cwd: string, pm: PackageManager, version: string, options: { quiet?: boolean } = {}): { ok: boolean; command: string } {
const spec = /^\d+\.\d+\.\d+/.test(version) ? `@patchstack/connect@${version}` : '@patchstack/connect';
export function installSelf(cwd: string, pm: PackageManager, version: string | null, options: { quiet?: boolean; showFailure?: boolean } = {}): { ok: boolean; command: string } {
const spec = version !== null && /^\d+\.\d+\.\d+/.test(version) ? `@patchstack/connect@${version}` : '@patchstack/connect';
const args = installArgs(pm, spec);
const quiet = options.quiet === true;
const result = spawnSync(pm, args, { cwd, stdio: quiet ? 'pipe' : 'inherit', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, shell: process.platform === 'win32' });
if (quiet && result.status !== 0) {
if (quiet && result.status !== 0 && options.showFailure !== false) {
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trimEnd().split('\n');
for (const line of output.slice(-INSTALL_FAILURE_LINES)) console.error(line);
}
return { ok: result.status === 0, command: [pm, ...args].join(' ') };
}

const INSTALL_FAILURE_LINES = 20;

/** The CLI of the `@patchstack/connect` installed in this project, or null when there is none to run. */
export function installedCli(cwd: string): string | null {
const dir = path.join(cwd, 'node_modules', '@patchstack', 'connect');
try {
const { bin } = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) as { bin?: unknown };
const entry = typeof bin === 'string' ? bin : (bin as Record<string, unknown> | undefined)?.['patchstack-connect'];
return typeof entry === 'string' ? path.join(dir, entry) : null;
} catch {
return null;
}
}

/**
* Finish setup with the version installed in the project rather than the one running, so the files setup
* writes match the package the app imports. Runs that package's own CLI with the same arguments, in the
* same directory, with this process's environment and output.
*/
export function runInstalledSetup(cli: string, args: string[], cwd: string): number {
return spawnSync(process.execPath, [cli, ...args], { cwd, stdio: 'inherit' }).status ?? 1;
}
22 changes: 19 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
} from './config.js';
import { painter, renderBox, renderFindings, renderLogo } from './art.js';
import { detectHost, HOST_NAMES, parseHost, renderGoLive } from './go-live.js';
import { createSiteProject, installSelf, isPlainHtmlSite } from './bootstrap.js';
import { createSiteProject, installedCli, installSelf, isPlainHtmlSite, runInstalledSetup } from './bootstrap.js';
import {
buildInjectionSnippet,
buildSandboxSnippet,
Expand Down Expand Up @@ -97,6 +97,7 @@ import {
renderStatus,
renderSummary,
SUMMARY_HEADING,
SETUP_NOT_FINISHED,
SUMMARY_INTRO,
summaryClosingLine,
type StatusReport,
Expand Down Expand Up @@ -1563,10 +1564,25 @@ async function runSetup(args: ParsedArgs): Promise<number> {
const addedSelf = before.installed === null;
if (addedSelf) {
console.log('Adding Patchstack to this project…');
const installed = installSelf(cwd, before.packageManager, packageVersion(), { quiet: process.stdout.isTTY !== true });
const quiet = process.stdout.isTTY !== true;
const running = packageVersion();
// Its failure is not shown: the install below is the one that settles it, and says why if it fails too.
let installed = installSelf(cwd, before.packageManager, running, { quiet, showFailure: false });
before = await collectGuideState(cwd);
if (!installed.ok || before.installed === null) {
console.error(`Could not add Patchstack to this project. Run this, then run setup again:\n ${installCommand(before.packageManager)}`);
// A package manager can refuse a version for being new: pnpm 11 and later skip anything published in the
// last day, which is often the release running here. Asked for no version, it picks the newest it allows.
installed = installSelf(cwd, before.packageManager, null, { quiet });
before = await collectGuideState(cwd);
const cli = installed.ok && before.installed !== null && before.installed.version !== running ? installedCli(cwd) : null;
if (cli !== null) {
console.log(`This project accepts @patchstack/connect ${before.installed!.version}, not ${running}, so setup continues with ${before.installed!.version}.`);
return runInstalledSetup(cli, process.argv.slice(2), cwd);
}
}
if (!installed.ok || before.installed === null) {
console.error(`Could not add Patchstack to this project, so setup did not finish. Run this, then run setup again:\n ${installCommand(before.packageManager)}`);
if (quiet) console.log(SETUP_NOT_FINISHED);
return 1;
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ export const SUMMARY_HEADING = '──── Summary ────';
/** The line printed just before the summary, saying what it is for. */
export const SUMMARY_INTRO = `Setup is finished. The person may not see this command's output, only your reply. The summary below is written for them: put it in your final message exactly as written, with every section. When they say "${NEXT_REPLY}", run npx @patchstack/connect next.`;

/**
* The line setup's output ends on when it stopped before doing anything, read by an assistant. An install
* that fails can scroll past in a long log; this keeps "it finished" from being the message the person gets.
*/
export const SETUP_NOT_FINISHED =
'Setup did not finish, so Patchstack is not set up and there is no link to connect it yet. Your reply to the person must say so, and what to do next.';

/**
* The line setup's output ends on, after the summary. It is the line an assistant is surest to read, so it
* repeats what the intro asks and carries the link the person needs: the one that connects their account
Expand Down
68 changes: 68 additions & 0 deletions tests/bin-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,74 @@ describe.skipIf(!built)('the packaged bin, invoked as npm invokes it', () => {
}
});

it('says plainly that setup did not finish when Patchstack cannot be added at all', () => {
// An assistant reads this output and tells the person how it went. The install error can scroll past
// in a long log, so the last line is the one that says it did not finish.
const project = mkdtempSync(path.join(tmpdir(), 'ps-bin-noinstall-'));
const fakeBin = mkdtempSync(path.join(tmpdir(), 'ps-bin-fake-pnpm-'));
try {
writeFileSync(path.join(project, 'package.json'), JSON.stringify({ name: 'shop', version: '1.0.0' }));
writeFileSync(path.join(project, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n");
writeFileSync(path.join(fakeBin, 'pnpm'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
const result = spawnSync(process.execPath, [bin, 'setup', '--widget'], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, PATCHSTACK_ENDPOINT: 'http://127.0.0.1:1/monitor/pulse/manifest' },
});

expect(result.status).toBe(1);
expect(result.stderr).toContain('setup did not finish');
expect(result.stdout.trimEnd().split('\n').at(-1)).toMatch(/^Setup did not finish, so Patchstack is not set up/);
expect(existsSync(path.join(project, '.patchstackrc.json'))).toBe(false);
} finally {
rmSync(project, { recursive: true, force: true });
rmSync(fakeBin, { recursive: true, force: true });
}
});

it('finishes with the newest version the project accepts when it refuses the one running', () => {
// pnpm 11 and later skip a version published in the last day, so the release that `npx` just fetched is
// often refused. Without a version the package manager picks one it allows, and that version's own setup
// takes over, so the files written match the package the app imports.
const project = mkdtempSync(path.join(tmpdir(), 'ps-bin-older-'));
const fakeBin = mkdtempSync(path.join(tmpdir(), 'ps-bin-fake-pnpm-'));
try {
writeFileSync(path.join(project, 'package.json'), JSON.stringify({ name: 'shop', version: '1.0.0' }));
writeFileSync(path.join(project, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n");
const installed = path.join(project, 'node_modules', '@patchstack', 'connect');
// Refuses `add @patchstack/connect@<version>`; `add @patchstack/connect` installs an older stand-in whose
// CLI records the arguments it was handed.
writeFileSync(
path.join(fakeBin, 'pnpm'),
[
'#!/bin/sh',
'case "$2" in *@patchstack/connect@*) echo "ERR_PNPM_NO_MATURE_MATCHING_VERSION" >&2; exit 1;; esac',
`mkdir -p '${installed}'`,
`printf '%s' '{"name":"@patchstack/connect","version":"0.0.1","bin":{"patchstack-connect":"./cli.js"}}' > '${installed}/package.json'`,
`printf '%s' 'require("fs").writeFileSync("handed.txt", process.argv.slice(2).join(" ")); console.log("older setup ran"); process.exit(0);' > '${installed}/cli.js'`,
`printf '%s' '{"name":"shop","version":"1.0.0","dependencies":{"@patchstack/connect":"^0.0.1"}}' > '${project}/package.json'`,
'exit 0',
].join('\n') + '\n',
{ mode: 0o755 },
);
const result = spawnSync(process.execPath, [bin, 'setup', '--widget'], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, PATCHSTACK_ENDPOINT: 'http://127.0.0.1:1/monitor/pulse/manifest' },
});

expect(result.status).toBe(0);
expect(result.stdout).toContain('setup continues with 0.0.1');
expect(result.stdout).toContain('older setup ran');
expect(readFileSync(path.join(project, 'handed.txt'), 'utf8')).toBe('setup --widget');
// The newer setup stopped at the hand-over: the older one is the only one that wrote anything.
expect(existsSync(path.join(project, '.patchstackrc.json'))).toBe(false);
} finally {
rmSync(project, { recursive: true, force: true });
rmSync(fakeBin, { recursive: true, force: true });
}
});

/**
* `--dry-run` is how someone finds out what a scan would send before sending it, so a field the preview
* omits is a field nobody gets to object to. The request body is built once and used for both, and this
Expand Down
4 changes: 2 additions & 2 deletions tests/execution-disclosure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,9 @@ const DECLARED: Array<{ file: string; call: string; times: number; executesProje
{
file: 'src/bootstrap.ts',
call: 'spawnSync',
times: 1,
times: 2,
executesProjectCode: true,
what: "`setup` adds `@patchstack/connect` with the project's package manager when it is missing; like any install, that runs the project's install scripts",
what: "`setup` adds `@patchstack/connect` with the project's package manager when it is missing; like any install, that runs the project's install scripts. When the project only accepts an older version, setup finishes by running that installed version's own `setup`",
},
{
file: 'src/protect/install/runtime/report-listeners.cjs',
Expand Down
Loading