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
46 changes: 23 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The PostHog wizard helps you quickly add PostHog to your project using AI.
To use the wizard, you can run it directly using:

```bash
npx @posthog/wizard
npx @posthog/wizard@latest
```

Currently the wizard can be used for over 16+ frameworks for frontend, backend, and mobile applications. If you have other integrations you would like the wizard to
Expand All @@ -43,10 +43,10 @@ Protocol) servers:

```bash
# Install PostHog MCP server to supported clients
npx @posthog/wizard mcp add
npx @posthog/wizard@latest mcp add

# Remove PostHog MCP server from supported clients
npx @posthog/wizard mcp remove
npx @posthog/wizard@latest mcp remove
```

## Wizard programs
Expand All @@ -58,7 +58,7 @@ The wizard's commands are grouped into **programs** — self-contained agentic j
Running the wizard with no arguments installs PostHog into your project. It detects your framework, wires up initialization, instruments a starter set of events, and walks you through a first dashboard:

```bash
npx @posthog/wizard
npx @posthog/wizard@latest
```

Powered by the `posthog-integration` program. Most other programs below build on it (they declare `requires: ['posthog-integration']`) and will offer to run it first if PostHog isn't already set up.
Expand All @@ -68,7 +68,7 @@ Powered by the `posthog-integration` program. Most other programs below build on
Autonomously sets up PostHog self-driving end-to-end. It connects GitHub, enables Session Replay and Error Tracking, wires up signal sources, and configures a Signals scout troop that watches your project for you.

```bash
npx @posthog/wizard self-driving
npx @posthog/wizard@latest self-driving
```

If PostHog isn't already installed, the wizard runs the default integration first (composed run) before starting the self-driving setup.
Expand All @@ -81,16 +81,16 @@ audit (the default); pass a subcommand to run a specific one:

```bash
# Runs the events audit (the default) — no subcommand needed
npx @posthog/wizard audit
npx @posthog/wizard@latest audit

# Or run a specific audit directly
npx @posthog/wizard audit events # event capture quality + cost (default)
npx @posthog/wizard audit all # comprehensive audit across every area
npx @posthog/wizard audit autocapture # autocapture setup + cost
npx @posthog/wizard audit feature-flags # feature flag usage + cost
npx @posthog/wizard audit identify # your $identify implementation
npx @posthog/wizard audit session-replay # session replay setup
npx @posthog/wizard audit web-analytics # web analytics setup
npx @posthog/wizard@latest audit events # event capture quality + cost (default)
npx @posthog/wizard@latest audit all # comprehensive audit across every area
npx @posthog/wizard@latest audit autocapture # autocapture setup + cost
npx @posthog/wizard@latest audit feature-flags # feature flag usage + cost
npx @posthog/wizard@latest audit identify # your $identify implementation
npx @posthog/wizard@latest audit session-replay # session replay setup
npx @posthog/wizard@latest audit web-analytics # web analytics setup
```

Most audit subcommands resolve at runtime from the published skill registry, so
Expand All @@ -108,7 +108,7 @@ new audits appear without a wizard release (`web-analytics` is wizard-native).
Wire up an existing PostHog + Stripe project for revenue analytics:

```bash
npx @posthog/wizard revenue-analytics
npx @posthog/wizard@latest revenue-analytics
```

Requires PostHog and Stripe SDKs already installed. Supports `--ci` with the
Expand All @@ -120,7 +120,7 @@ Detect data sources your project already uses (Postgres, MySQL, MongoDB,
Snowflake, BigQuery, Stripe, …) and connect them to PostHog's data warehouse:

```bash
npx @posthog/wizard warehouse
npx @posthog/wizard@latest warehouse
```

The wizard scans your dependencies and `.env` key names (never the values) to
Expand All @@ -132,7 +132,7 @@ OAuth sources open the PostHog app's new-source flow in your browser.
Upload JavaScript source maps to PostHog error tracking so stack traces are symbolicated back to your original code:

```bash
npx @posthog/wizard upload-source-maps
npx @posthog/wizard@latest upload-source-maps
```

### Run skill
Expand All @@ -141,8 +141,8 @@ Run any context-mill skill directly by name, even if it isn't exposed as its own
command:

```bash
npx @posthog/wizard skill list # list every available skill
npx @posthog/wizard skill <skill-name> # run one by name
npx @posthog/wizard@latest skill list # list every available skill
npx @posthog/wizard@latest skill <skill-name> # run one by name
```

## Wizard ownership
Expand Down Expand Up @@ -181,7 +181,7 @@ account, uses the returned personal API key to run the normal CI install,
and wires PostHog into the project at `--install-dir`:

```bash
npx @posthog/wizard --ci --signup \
npx @posthog/wizard@latest --ci --signup \
--email you@example.com \
--install-dir .
```
Expand All @@ -197,10 +197,10 @@ PostHog yourself — use the `provision` subcommand, which emits a structured

```bash
# Human-readable (when stdout is a TTY)
npx @posthog/wizard provision --email user@example.com --region us
npx @posthog/wizard@latest provision --email user@example.com --region us

# Machine-readable — auto when stdout is piped, or force with --json
npx @posthog/wizard provision --email user@example.com --region eu --json
npx @posthog/wizard@latest provision --email user@example.com --region eu --json
```

Success prints the full `ProvisioningResult` (`projectApiKey`, `host`,
Expand Down Expand Up @@ -235,13 +235,13 @@ The following CLI arguments are available:
> gateway doesn't yet grant the scopes the wizard needs to personal API keys
> for most users, so non-interactive `--ci` runs fail at the gateway. The flag
> is disabled in the published package and exits with an error — run the wizard
> in an interactive terminal instead (`npx @posthog/wizard`). The notes below
> in an interactive terminal instead (`npx @posthog/wizard@latest`). The notes below
> describe CI mode as it works in development builds.

Run the wizard non-interactive executions with `--ci`:

```bash
npx @posthog/wizard --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir .
npx @posthog/wizard@latest --ci --api-key $POSTHOG_PERSONAL_API_KEY --install-dir .
```

When running in CI mode (`--ci`):
Expand Down
4 changes: 3 additions & 1 deletion docs/error-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ as an API: backends may branch on it.

New codes follow the pattern `PHW_<GROUP>_<NAME>` (see `ERROR_CODE_PATTERN` in
`codes.ts`). Groups are lowercase module prefixes (`cli`, `args`, `auth`, `env`,
`detect`, `skill`, `agent`, `settings`, `internal`).
`detect`, `skill`, `agent`, `settings`, `gateway`, `internal`).

## How codes propagate

Expand Down Expand Up @@ -107,6 +107,8 @@ screen, debug log) keep the full detail.
| `PHW_AGENT_ORCHESTRATOR_SINK_INVARIANT` | agent | orchestrator plan violates sink coverage invariant | no |
| `PHW_SETTINGS_UNFIXABLE_CONFLICT` | settings | Claude settings conflict that cannot be auto-neutralized (managed/unwritable) | no |
| `PHW_INTERNAL_UNHANDLED` | internal | catch-all: an unexpected error escaped the pipeline | yes |
| `PHW_GATEWAY_MINT_REFUSED` | gateway | the gateway-token mint refused this run (blocked, throttled, unlisted program, rollout off); the server's reason is shown | no |
| `PHW_GATEWAY_MINT_FAILED` | gateway | the gateway-token mint could not be reached or answered unusably | yes |

Retry advice is guidance for automated hosts (sandbox re-run policies), not a
guarantee.
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/wizard-abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
runCleanups,
} from '@utils/wizard-abort';
import { analytics } from '@utils/analytics';
import { ErrorCodes } from '@lib/errors';
import { getUI } from '../ui';

vi.mock('../utils/analytics');
Expand Down Expand Up @@ -132,6 +133,23 @@ describe('wizardAbort', () => {
});
});

it('resolves the code from a coded WizardError when the caller passes none', async () => {
// A mint refusal reaches wizardAbort as the error alone; its code must
// still land on the captured exception.
const error = new WizardError(
'refused',
{ status: 403 },
ErrorCodes.GatewayMintRefused,
);

await expect(wizardAbort({ error })).rejects.toThrow('process.exit called');

expect(mockAnalytics.captureException).toHaveBeenCalledWith(error, {
status: 403,
error_code: ErrorCodes.GatewayMintRefused,
});
});

it('runs registered cleanup functions before analytics and display', async () => {
const callOrder: string[] = [];

Expand Down
149 changes: 149 additions & 0 deletions src/lib/__tests__/gateway-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ import {
resetGatewaySession,
} from '@lib/gateway-session';
import type { HostResolution } from '@lib/host-resolution';
import { ErrorCodes } from '@lib/errors';
import { WizardError } from '@utils/wizard-abort';
import { analytics } from '@utils/analytics';
import { logToFile } from '@utils/debug';

vi.mock('@utils/analytics', () => ({
analytics: { wizardCapture: vi.fn(), captureException: vi.fn() },
}));

vi.mock('@utils/debug', () => ({ logToFile: vi.fn() }));

// logToFile is variadic, so a leak in any argument is a leak. Rendered every way the
Expand Down Expand Up @@ -37,6 +44,7 @@ describe('gatewayAuth', () => {
beforeEach(() => {
resetGatewaySession();
fetchMock.mockReset();
vi.mocked(analytics.wizardCapture).mockClear();
vi.mocked(logToFile).mockClear();
vi.stubGlobal('fetch', fetchMock);
});
Expand Down Expand Up @@ -288,6 +296,147 @@ describe('gatewayAuth', () => {
},
);

it('reads the outcome from the DRF body code and shows its detail', async () => {
// The exact shape the backend's exception handler writes for a refusal.
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () =>
Promise.resolve({
type: 'permission_denied',
code: 'blocked',
detail: 'This account is blocked. Contact wizard@posthog.com.',
attr: null,
}),
});
const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(GatewayMintRefused);
expect((err as GatewayMintRefused).outcome).toBe('blocked');
expect((err as GatewayMintRefused).message).toContain(
'Contact wizard@posthog.com',
);
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'gateway mint refused',
{ status: 403, outcome: 'blocked', program: 'audit' },
);
});

it.each([
[
'code wins over outcome',
{ code: 'blocked', outcome: 'throttled' },
'blocked',
],
[
'outcome carries it when code is absent',
{ outcome: 'throttled' },
'throttled',
],
[
'an empty code does not shadow outcome',
{ code: ' ', outcome: 'throttled' },
'throttled',
],
[
'a control-only code does not shadow outcome',
{ code: '\u0007', outcome: 'throttled' },
'throttled',
],
])('resolves the outcome when %s', async (_label, body, want) => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve(body),
});
const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch(
(e: unknown) => e,
);
expect((err as GatewayMintRefused).outcome).toBe(want);
});

it('captures a refusal with its status, outcome and program', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 403,
json: () =>
Promise.resolve({
detail: 'This account is blocked.',
outcome: 'blocked',
}),
});
// The backend's own denial event has no run id, so this client event is
// what joins a refusal to the session.
const err: unknown = await gatewayAuth(host, 'pha_oauth', 'audit').catch(
(e: unknown) => e,
);
expect(analytics.wizardCapture).toHaveBeenCalledTimes(1);
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'gateway mint refused',
{ status: 403, outcome: 'blocked', program: 'audit' },
);
expect((err as GatewayMintRefused).outcome).toBe('blocked');
});

it.each([
['absent', () => Promise.resolve({ detail: 'Limit reached.' })],
['not a string', () => Promise.resolve({ outcome: 429 })],
['a non-string code', () => Promise.resolve({ code: 403 })],
['oversized', () => Promise.resolve({ outcome: 'x'.repeat(65) })],
['unparseable', () => Promise.reject(new SyntaxError('bad json'))],
])(
'captures a refusal with no outcome when the body has one that is %s',
async (_label, json) => {
fetchMock.mockResolvedValue({ ok: false, status: 429, json });
await expect(
gatewayAuth(host, 'pha_oauth', 'integration'),
).rejects.toBeInstanceOf(GatewayMintRefused);
expect(analytics.wizardCapture).toHaveBeenCalledWith(
'gateway mint refused',
{ status: 429, outcome: undefined, program: 'integration' },
);
},
);

it('does not capture a mint failure as a refusal', async () => {
// A 5xx is the mint being unavailable, not a decision about this run.
fetchMock.mockResolvedValue({ ok: false, status: 503 });
await expect(
gatewayAuth(host, 'pha_oauth', 'integration'),
).rejects.toBeInstanceOf(GatewayMintFailed);
expect(analytics.wizardCapture).not.toHaveBeenCalled();
});

it('throws coded WizardErrors so the runners can name the failure', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 403,
json: () => Promise.resolve({ outcome: 'blocked' }),
});
const refused: unknown = await gatewayAuth(
host,
'pha_oauth',
'integration',
).catch((e: unknown) => e);
expect(refused).toBeInstanceOf(WizardError);
expect((refused as WizardError).code).toBe(ErrorCodes.GatewayMintRefused);
// The context is what wizardAbort attaches to the captured exception.
expect((refused as WizardError).context).toEqual({
status: 403,
outcome: 'blocked',
});

fetchMock.mockResolvedValueOnce({ ok: false, status: 503 });
const failed: unknown = await gatewayAuth(
host,
'pha_oauth',
'integration',
).catch((e: unknown) => e);
expect(failed).toBeInstanceOf(WizardError);
expect((failed as WizardError).code).toBe(ErrorCodes.GatewayMintFailed);
});

it('surfaces a refusal through the transport catch', async () => {
// The refusal is thrown from inside the try that wraps fetch, so a catch that
// treats every throw as a transport failure would silently restore fallback.
Expand Down
13 changes: 13 additions & 0 deletions src/lib/errors/__tests__/codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ describe('error catalog', () => {
}
});

it('files the mint codes under the gateway group', () => {
// A refusal is a per-run decision the user can sometimes act on; a failure
// is the mint being unavailable and clears on its own.
expect(ERROR_CATALOG[ErrorCodes.GatewayMintRefused]).toMatchObject({
group: 'gateway',
retry: 'case-by-case',
});
expect(ERROR_CATALOG[ErrorCodes.GatewayMintFailed]).toMatchObject({
group: 'gateway',
retry: 'yes',
});
});

it('every entry carries a group, retry advice, and a description', () => {
for (const [code, entry] of Object.entries(ERROR_CATALOG)) {
expect(entry.group, `${code} group`).toBeTruthy();
Expand Down
Loading
Loading