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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,48 @@ The following CLI arguments are available:
| `--integration` | Integration to set up | string | | "nextjs", "astro", "react", "svelte", "react-native" | |
| `--force-install` | Force install packages even if peer dependency checks fail | boolean | `false` | | `POSTHOG_WIZARD_FORCE_INSTALL` |
| `--install-dir` | Directory to install PostHog in | string | | | `POSTHOG_WIZARD_INSTALL_DIR` |
| `--ci` | Enable CI mode for non-interactive execution | boolean | `false` | | `POSTHOG_WIZARD_CI` |
| `--api-key` | PostHog personal API key (phx_xxx) for authentication | string | | | `POSTHOG_WIZARD_API_KEY` |

> Note: A large amount of the scaffolding for this came from the amazing Sentry
> wizard, which you can find [here](https://github.com/getsentry/sentry-wizard)
> 💖

# CI Mode

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

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

When running in CI mode (`--ci`):

- Bypasses OAuth login flow (uses personal API key directly)
- Auto-selects defaults for all prompts
- Skips MCP server installation
- Auto-continues on git warnings (uncommitted/untracked files)
- Auto-consents to AI usage

The CLI args override environment variables in CI mode.

### Required Flags for CI Mode

- `--region`: Cloud region (`us` or `eu`)
- `--api-key`: Personal API key (`phx_xxx`) from your [PostHog settings](https://app.posthog.com/settings/user-api-keys)
- `--install-dir`: Directory to install PostHog in (e.g., `.` for current directory)

### Required API Key Scopes

When creating your personal API key, ensure it has the following scopes enabled:

- `user:read` - Required to fetch user information
- `project:read` - Required to fetch project details and API token
- `introspection` - Required for API introspection
- `llm_gateway:read` - Required for LLM gateway access
- `dashboard:write` - Required to create dashboards
- `insight:write` - Required to create insights

# Steal this code

While the wizard works great on its own, we also find the approach used by this
Expand Down
58 changes: 47 additions & 11 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,6 @@ import { runWizard } from './src/run';
import { isNonInteractiveEnvironment } from './src/utils/environment';
import clack from './src/utils/clack';

if (isNonInteractiveEnvironment()) {
clack.intro(chalk.inverse(`PostHog Wizard`));

clack.log.error(
'This installer requires an interactive terminal (TTY) to run.\n' +
'It appears you are running in a non-interactive environment.\n' +
'Please run the wizard in an interactive terminal.',
);
process.exit(1);
}

if (process.env.NODE_ENV === 'test') {
void (async () => {
try {
Expand Down Expand Up @@ -79,6 +68,17 @@ yargs(hideBin(process.argv))
'Use local MCP server at http://localhost:8787/mcp\nenv: POSTHOG_WIZARD_LOCAL_MCP',
type: 'boolean',
},
ci: {
default: false,
describe:
'Enable CI mode for non-interactive execution\nenv: POSTHOG_WIZARD_CI',
type: 'boolean',
},
'api-key': {
describe:
'PostHog personal API key (phx_xxx) for authentication\nenv: POSTHOG_WIZARD_API_KEY',
type: 'string',
},
})
.command(
['$0'],
Expand All @@ -105,6 +105,42 @@ yargs(hideBin(process.argv))
},
(argv) => {
const options = { ...argv };

// CI mode validation and TTY check
if (options.ci) {
// Validate required CI flags
if (!options.region) {
clack.intro(chalk.inverse(`PostHog Wizard`));
clack.log.error('CI mode requires --region (us or eu)');
process.exit(1);
}
if (!options.apiKey) {
clack.intro(chalk.inverse(`PostHog Wizard`));
clack.log.error(
'CI mode requires --api-key (personal API key phx_xxx)',
);
process.exit(1);
}
if (!options.installDir) {
clack.intro(chalk.inverse(`PostHog Wizard`));
clack.log.error(
'CI mode requires --install-dir (directory to install PostHog in)',
);
process.exit(1);
}
} else if (isNonInteractiveEnvironment()) {
// Original TTY error for non-CI mode
clack.intro(chalk.inverse(`PostHog Wizard`));
clack.log.error(
'This installer requires an interactive terminal (TTY) to run.\n' +
'It appears you are running in a non-interactive environment.\n' +
'Please run the wizard in an interactive terminal.\n\n' +
'For CI/CD environments, use --ci mode:\n' +
' npx @posthog/wizard --ci --region us --api-key phx_xxx',
);
process.exit(1);
}

void runWizard(options as unknown as WizardOptions);
},
)
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ describe('CLI argument parsing', () => {
process.env = { ...originalEnv };
delete process.env.POSTHOG_WIZARD_REGION;
delete process.env.POSTHOG_WIZARD_DEFAULT;
delete process.env.POSTHOG_WIZARD_CI;
delete process.env.POSTHOG_WIZARD_API_KEY;
delete process.env.POSTHOG_WIZARD_INSTALL_DIR;

// Mock process.exit to prevent test runner from exiting
process.exit = jest.fn() as any;
Expand Down Expand Up @@ -187,4 +190,113 @@ describe('CLI argument parsing', () => {
expect(args.debug).toBe(true);
});
});

describe('--ci flag', () => {
test('defaults to false when not specified', async () => {
await runCLI([]);

const args = getLastCallArgs(mockRunWizard);
expect(args.ci).toBe(false);
});

test('can be set to true', async () => {
await runCLI([
'--ci',
'--region',
'us',
'--api-key',
'phx_test',
'--install-dir',
'/tmp/test',
]);

const args = getLastCallArgs(mockRunWizard);
expect(args.ci).toBe(true);
});

test('requires --region when --ci is set', async () => {
await runCLI([
'--ci',
'--api-key',
'phx_test',
'--install-dir',
'/tmp/test',
]);

expect(process.exit).toHaveBeenCalledWith(1);
});

test('requires --api-key when --ci is set', async () => {
await runCLI(['--ci', '--region', 'us', '--install-dir', '/tmp/test']);

expect(process.exit).toHaveBeenCalledWith(1);
});

test('requires --install-dir when --ci is set', async () => {
await runCLI(['--ci', '--region', 'us', '--api-key', 'phx_test']);

expect(process.exit).toHaveBeenCalledWith(1);
});

test('passes --api-key to runWizard', async () => {
await runCLI([
'--ci',
'--region',
'us',
'--api-key',
'phx_test_key',
'--install-dir',
'/tmp/test',
]);

const args = getLastCallArgs(mockRunWizard);
expect(args.apiKey).toBe('phx_test_key');
});
});

describe('CI environment variables', () => {
test('respects POSTHOG_WIZARD_CI', async () => {
process.env.POSTHOG_WIZARD_CI = 'true';
process.env.POSTHOG_WIZARD_REGION = 'us';
process.env.POSTHOG_WIZARD_API_KEY = 'phx_env_key';
process.env.POSTHOG_WIZARD_INSTALL_DIR = '/tmp/test';

await runCLI([]);

const args = getLastCallArgs(mockRunWizard);
expect(args.ci).toBe(true);
});

test('respects POSTHOG_WIZARD_API_KEY', async () => {
process.env.POSTHOG_WIZARD_CI = 'true';
process.env.POSTHOG_WIZARD_REGION = 'eu';
process.env.POSTHOG_WIZARD_API_KEY = 'phx_env_key';
process.env.POSTHOG_WIZARD_INSTALL_DIR = '/tmp/test';

await runCLI([]);

const args = getLastCallArgs(mockRunWizard);
expect(args.apiKey).toBe('phx_env_key');
});

test('CLI args override CI environment variables', async () => {
process.env.POSTHOG_WIZARD_CI = 'true';
process.env.POSTHOG_WIZARD_REGION = 'us';
process.env.POSTHOG_WIZARD_API_KEY = 'phx_env_key';
process.env.POSTHOG_WIZARD_INSTALL_DIR = '/tmp/test';

await runCLI([
'--region',
'eu',
'--api-key',
'phx_cli_key',
'--install-dir',
'/other/path',
]);

const args = getLastCallArgs(mockRunWizard);
expect(args.region).toBe('eu');
expect(args.apiKey).toBe('phx_cli_key');
});
});
});
1 change: 1 addition & 0 deletions src/astro/astro-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export async function runAstroWizard(options: WizardOptions): Promise<void> {
await addMCPServerToClientsStep({
cloudRegion,
integration: Integration.astro,
ci: options.ci,
});

const outroMessage = getOutroMessage({
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ ${chalk.cyan(config.metadata.docsUrl)}`;
await addMCPServerToClientsStep({
cloudRegion,
integration: config.metadata.integration,
ci: options.ci,
});

// Build outro message
Expand Down
5 changes: 2 additions & 3 deletions src/react-native/react-native-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,10 @@ export async function runReactNativeWizard(
await addMCPServerToClientsStep({
cloudRegion,
integration: Integration.reactNative,
ci: options.ci,
});

const packageManagerForOutro = await getPackageManager({
installDir: options.installDir,
});
const packageManagerForOutro = await getPackageManager(options);

const outroMessage = getOutroMessage({
options,
Expand Down
1 change: 1 addition & 0 deletions src/react/react-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export async function runReactWizard(options: WizardOptions): Promise<void> {
await addMCPServerToClientsStep({
cloudRegion,
integration: Integration.react,
ci: options.ci,
});

const outroMessage = getOutroMessage({
Expand Down
8 changes: 8 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type Args = {
default?: boolean;
signup?: boolean;
localMcp?: boolean;
ci?: boolean;
apiKey?: string;
};

export async function runWizard(argv: Args) {
Expand Down Expand Up @@ -55,10 +57,16 @@ export async function runWizard(argv: Args) {
default: finalArgs.default ?? false,
signup: finalArgs.signup ?? false,
localMcp: finalArgs.localMcp ?? false,
ci: finalArgs.ci ?? false,
apiKey: finalArgs.apiKey,
};

clack.intro(`Welcome to the PostHog setup wizard ✨`);

if (wizardOptions.ci) {
clack.log.info(chalk.dim('Running in CI mode'));
}

const integration =
finalArgs.integration ?? (await getIntegrationForSetup(wizardOptions));

Expand Down
8 changes: 8 additions & 0 deletions src/steps/add-mcp-server-to-clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,20 @@ export const addMCPServerToClientsStep = async ({
cloudRegion,
askPermission = true,
local = false,
ci = false,
}: {
integration?: Integration;
cloudRegion?: CloudRegion;
askPermission?: boolean;
local?: boolean;
ci?: boolean;
}): Promise<string[]> => {
// CI mode: skip MCP installation entirely (default to No)
if (ci) {
clack.log.info('Skipping MCP installation (CI mode)');
return [];
}

const region = cloudRegion ?? (await askForCloudRegion());

const hasPermission = askPermission
Expand Down
1 change: 1 addition & 0 deletions src/svelte/svelte-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export async function runSvelteWizard(options: WizardOptions): Promise<void> {
await addMCPServerToClientsStep({
cloudRegion,
integration: Integration.svelte,
ci: options.ci,
});

const outroMessage = getOutroMessage({
Expand Down
Loading
Loading