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
9 changes: 5 additions & 4 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,11 @@ Remove only the pieces that are actually present — check for each first. If no
2. **Remove the widget snippets** from the layout/template: the `<script src="https://cdn.patchstack.com/patchstack-widget.js">` tag and any `PatchstackWidget.init(...)` call (which may live in a separate client component/plugin/effect). Afterwards, grep the repo for `patchstack-widget` and `PatchstackWidget` to confirm nothing remains.
3. **Remove runtime protection before uninstalling the package.** Delete the connector-managed guard/rules files and remove only their managed imports, middleware registrations, tunnel code, and `#region patchstack…` blocks from the framework/server files. Preserve unrelated middleware and application code. Run `rg "patchstack|x-ps-target"` (or the available equivalent) afterwards and inspect every remaining source hit.
4. **Remove the hooks from `package.json` scripts.** If a hook was chained (e.g. `"postbuild": "existing-command && patchstack-connect mark-build"`), remove only the `patchstack-connect …` part and keep the rest; if removal leaves a script empty, delete the key.
5. **Uninstall the package** with the manager matching the lockfile: `npm uninstall` / `pnpm remove` / `yarn remove` / `bun remove` `@patchstack/connect`. Don't hand-edit `node_modules` or the lockfile.
6. **Delete `.patchstackrc.json`** and remove `PATCHSTACK_SITE_UUID`, `PATCHSTACK_API_KEY` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
7. **Commit** the changes. Reporting stops immediately. The `window.__PATCHSTACK_PROD__` flag that `mark-build` injected lives only in build output, never in source — the next build simply won't contain it (rebuild if build output is committed).
5. **Signal Patchstack that the package is being removed**: run `npx @patchstack/connect uninstall` (while the package is still installed and `.patchstackrc.json` still exists). If the site was never claimed, this deletes its anonymous record on Patchstack; if the site is claimed, it is only flagged — the record stays until its owner removes it in the dashboard. A failed signal must not stop the uninstall; continue with the remaining steps.
6. **Uninstall the package** with the manager matching the lockfile: `npm uninstall` / `pnpm remove` / `yarn remove` / `bun remove` `@patchstack/connect`. Don't hand-edit `node_modules` or the lockfile.
7. **Delete `.patchstackrc.json`** and remove `PATCHSTACK_SITE_UUID`, `PATCHSTACK_API_KEY` (and public-prefixed variants like `NEXT_PUBLIC_PATCHSTACK_SITE_UUID`) from env files and CI variables.
8. **Commit** the changes. Reporting stops immediately. The `window.__PATCHSTACK_PROD__` flag that `mark-build` injected lives only in build output, never in source — the next build simply won't contain it (rebuild if build output is committed).

Local removal does not delete the site record on Patchstack's side. An unclaimed site is an anonymous record that stops receiving reports; a claimed site is removed by the user in their dashboard at https://app.patchstack.com. There is no CLI command for account-side deletion — do not invent one, and never attempt to authenticate or remove the site on the user's behalf.
The `uninstall` signal is the only account-side effect local removal can have: it deletes an *unclaimed* (anonymous) record and merely flags a *claimed* one. A claimed site keeps using a site slot until its owner removes it in the dashboard at https://app.patchstack.com — end your report by telling the user this, alongside the site UUID from step 1. Never attempt to authenticate or remove a claimed site on the user's behalf.

The reverse also holds: removing the site on Patchstack's side (dashboard delete or the widget's uninstall flow) does not touch these local files — they must still be removed with the steps above. `npx @patchstack/connect status` shows `Site status: removed from Patchstack` in that state.
58 changes: 56 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { scanLockfile } from './parsers/index.js';
import { buildWirePayload } from './normalize.js';
import { computeManifestChecksum } from './checksum.js';
import { DEFAULT_ENDPOINT, buildClaimUrl, fetchSiteStatus, postManifest } from './client.js';
import {
DEFAULT_ENDPOINT,
buildClaimUrl,
fetchSiteStatus,
postManifest,
postPackageRemoved,
} from './client.js';
import {
assertDemoDependency,
assertPersistedSiteUuid,
Expand Down Expand Up @@ -55,6 +61,12 @@ Usage:
patchstack-connect status [options] Show current configuration and whether the
site still exists on Patchstack (active /
removed)
patchstack-connect uninstall [options] Signal Patchstack that this package is being
removed from the project. An unclaimed site
record is deleted; a claimed site is flagged
for its owner to remove in the dashboard.
Does NOT touch local files — see the
"Uninstalling" steps in AGENT-INSTALL.md
patchstack-connect mark-build [options] Stamp built HTML with a production flag +
build fingerprint, and ensure the widget
tag in built pages (run as a postbuild step)
Expand All @@ -80,7 +92,7 @@ Usage:
guide even when setup is complete
patchstack-connect help Print this message

Options (for scan, setup, and status):
Options (for scan, setup, status, and uninstall):
--site-uuid <uuid> Override the configured site UUID
--endpoint <url> Override the API endpoint
--dry-run (scan only) Show the payload without posting
Expand Down Expand Up @@ -594,6 +606,46 @@ async function runStatus(args: ParsedArgs): Promise<number> {
return 0;
}

async function runUninstall(args: ParsedArgs): Promise<number> {
const config = await resolveConfig({
cwd: process.cwd(),
cliSiteUuid: getStringFlag(args.flags, 'site-uuid'),
cliEndpoint: getStringFlag(args.flags, 'endpoint'),
});

if (config.siteUuid === null) {
console.log('No site UUID configured — there is no site record to signal about.');
console.log('Continue with the local removal steps in AGENT-INSTALL.md ("Uninstalling").');
return 0;
}

console.log(`Signalling Patchstack that @patchstack/connect is being removed (site ${config.siteUuid})…`);
const outcome = await postPackageRemoved(config);

switch (outcome.result) {
case 'deleted':
console.log('Site record removed from Patchstack (the site was unclaimed).');
break;
case 'flagged':
console.log('This site is claimed by a Patchstack account, so its record was kept and flagged.');
console.log('Its owner can remove it at https://app.patchstack.com to free the site slot.');
break;
case 'gone':
console.log('The site record no longer exists on Patchstack — nothing to signal.');
break;
case 'failed':
console.warn(`Could not signal Patchstack${outcome.message !== null ? ` (${outcome.message})` : ''}.`);
console.warn('The site record may remain — it can always be removed from the dashboard at https://app.patchstack.com.');
break;
}

console.log('');
console.log('This command only signals Patchstack. The local integration files must still be');
console.log('removed — follow the "Uninstalling" steps in AGENT-INSTALL.md.');
// Never fail the uninstall flow over the signal: local removal must proceed.
return 0;
}

/** 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(
Expand Down Expand Up @@ -705,6 +757,8 @@ async function main(): Promise<number> {
return runScan(args);
case 'status':
return runStatus(args);
case 'uninstall':
return runUninstall(args);
case 'mark-build':
return runMarkBuild(args);
case 'protect':
Expand Down
62 changes: 62 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,68 @@ export function buildSettingsUrl(endpoint: string, siteUuid: string): string {
return `${origin}/monitor/widget/settings/${encodeURIComponent(siteUuid)}`;
}

/** Build the package-removed signal URL corresponding to a manifest endpoint override. */
export function buildPackageRemovedUrl(manifestEndpoint: string, siteUuid: string): string {
const url = new URL(manifestEndpoint);
const path = url.pathname.replace(/\/$/, '');
url.pathname = path.endsWith('/manifest')
? `${path.slice(0, -'/manifest'.length)}/package-removed/${encodeURIComponent(siteUuid)}`
: `/monitor/pulse/package-removed/${encodeURIComponent(siteUuid)}`;
url.search = '';
url.hash = '';
return url.toString();
}

/**
* Outcome of the package-removed signal. 'deleted' — the site was unclaimed
* and its record was removed; 'flagged' — the site is claimed, so it was only
* marked for its owner to confirm removal in the dashboard; 'gone' — the
* record no longer existed; 'failed' — Patchstack could not be reached or
* returned an unexpected response.
*/
export interface PackageRemovedOutcome {
result: 'deleted' | 'flagged' | 'gone' | 'failed';
message: string | null;
}

/**
* Tell Patchstack the @patchstack/connect package is being uninstalled from
* this project. The site UUID is the only credential, so the server deletes
* only unclaimed (anonymous) sites; claimed sites are merely flagged for
* their owner. Never throws — an unreachable server must not block the local
* uninstall.
*/
export async function postPackageRemoved(config: Config): Promise<PackageRemovedOutcome> {
if (config.siteUuid === null) {
return { result: 'failed', message: 'No site UUID configured.' };
}

const url = buildPackageRemovedUrl(config.endpoint, config.siteUuid);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'User-Agent': '@patchstack/connect',
},
signal: AbortSignal.timeout(config.timeoutMs),
});
if (response.status === 404) {
return { result: 'gone', message: null };
}
if (!response.ok) {
return { result: 'failed', message: `Patchstack returned ${response.status}.` };
}
const body = (await response.json()) as { status?: string; message?: string };
if (body.status === 'deleted' || body.status === 'flagged') {
return { result: body.status, message: body.message ?? null };
}
return { result: 'failed', message: 'Patchstack returned an unexpected response.' };
} catch {
return { result: 'failed', message: `Could not reach Patchstack at ${url}.` };
}
}

/**
* Whether the site record still exists on Patchstack's side. Removing a site
* (dashboard delete or the widget's uninstall flow) only deletes the remote
Expand Down
102 changes: 102 additions & 0 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildClaimUrl,
buildEndpointUrl,
buildPackageRemovedUrl,
buildRulesUrl,
buildSettingsUrl,
fetchSiteStatus,
postManifest,
postPackageRemoved,
} from '../src/client.js';
import { PatchstackError } from '../src/types.js';

Expand Down Expand Up @@ -167,6 +169,106 @@ describe('fetchSiteStatus', () => {
});
});

describe('buildPackageRemovedUrl', () => {
it('maps the production manifest endpoint to the per-site package-removed endpoint', () => {
expect(
buildPackageRemovedUrl(
'https://api.patchstack.com/monitor/pulse/manifest',
'550e8400-e29b-41d4-a716-446655440000',
),
).toBe(
'https://api.patchstack.com/monitor/pulse/package-removed/550e8400-e29b-41d4-a716-446655440000',
);
});

it('falls back to the canonical path for a non-manifest endpoint override', () => {
expect(buildPackageRemovedUrl('http://localhost:8000/custom/endpoint?x=1#test', 'site/id')).toBe(
'http://localhost:8000/monitor/pulse/package-removed/site%2Fid',
);
});
});

describe('postPackageRemoved', () => {
const config = {
siteUuid: 'uuid',
endpoint: 'https://example.com/monitor/pulse/manifest',
timeoutMs: 30_000,
widget: true,
environment: 'production',
} as const;

afterEach(() => {
vi.unstubAllGlobals();
});

it('returns deleted with the server message for an unclaimed site', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ status: 'deleted', message: 'The site record was removed from Patchstack.' }),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);

await expect(postPackageRemoved(config)).resolves.toEqual({
result: 'deleted',
message: 'The site record was removed from Patchstack.',
});
const [calledUrl, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(calledUrl).toBe('https://example.com/monitor/pulse/package-removed/uuid');
expect(init.method).toBe('POST');
});

it('returns flagged for a claimed site', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: 'flagged', message: 'Claimed.' }), { status: 200 }),
),
);

await expect(postPackageRemoved(config)).resolves.toEqual({
result: 'flagged',
message: 'Claimed.',
});
});

it('returns gone on 404', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: 'Site not found.' }), { status: 404 }),
),
);

await expect(postPackageRemoved(config)).resolves.toEqual({ result: 'gone', message: null });
});

it('returns failed on server errors, bad bodies, and network failures — never throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
await expect(postPackageRemoved(config)).resolves.toMatchObject({ result: 'failed' });

vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })),
);
await expect(postPackageRemoved(config)).resolves.toMatchObject({ result: 'failed' });

vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('boom')));
await expect(postPackageRemoved(config)).resolves.toMatchObject({ result: 'failed' });
});

it('returns failed without a request when no siteUuid is configured', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

await expect(postPackageRemoved({ ...config, siteUuid: null })).resolves.toMatchObject({
result: 'failed',
});
expect(fetchMock).not.toHaveBeenCalled();
});
});

describe('postManifest', () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
Loading