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
7 changes: 5 additions & 2 deletions scripts/smoke-test-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
# wizard-workbench, and run in CI mode.
#
# Prerequisites:
# - POSTHOG_PERSONAL_API_KEY env var (or in .env)
# - POSTHOG_PERSONAL_API_KEY env var (or in .env): a personal API key (phx_)
# or a wizard-app OAuth access token (pha_), which is what the CI bot uses.
# The env name stays the same for either so the secret rotates in place.
# - A wizard-workbench repo checked out (for the test app), pointed to by:
# - WIZARD_WORKBENCH_ROOT=/path/to/wizard-workbench
# or
Expand All @@ -15,8 +17,9 @@
# ./scripts/smoke-test-ci.sh basic-integration/next-js/15-pages-router-saas
#
# Examples:
# # With API key inline:
# # With the key inline (a phx_ personal key or a pha_ wizard-app token):
# POSTHOG_PERSONAL_API_KEY=phx_your_key_here ./scripts/smoke-test-ci.sh
# POSTHOG_PERSONAL_API_KEY=pha_wizard_app_token ./scripts/smoke-test-ci.sh
#
# # With project ID override:
# POSTHOG_PERSONAL_API_KEY=phx_your_key_here POSTHOG_PROJECT_ID=12345 ./scripts/smoke-test-ci.sh
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,36 @@ describe('CLI argument parsing', () => {

expect(mockStreamAttach).not.toHaveBeenCalled();
});

// The CI bot authenticates with a wizard-app pha_ token, the same
// credential headless takes. Either key reaches buildSession untouched and
// neither draws the unexpected-prefix warning.
test.each(['phx_ci_key', 'pha_ci_bot_token'])(
'accepts %s without a prefix warning',
async (apiKey) => {
const log = vi
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await runCLI([
'--ci',
'--api-key',
apiKey,
'--install-dir',
'/tmp/test',
]);

expect(process.exit).not.toHaveBeenCalledWith(1);
expect(getLastBuildSessionArgs().apiKey).toBe(apiKey);
const lines = log.mock.calls.map((c) => c.map(String).join(' '));
expect(lines.some((l) => l.includes('does not start with'))).toBe(
false,
);
} finally {
log.mockRestore();
}
},
);
});

// The experimental headless flag is the published-build sibling of --ci: it
Expand Down
35 changes: 17 additions & 18 deletions src/commands/basic-integration/__tests__/ci-install.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import { keyPrefixWarning } from '../ci-install';

/**
* `keyPrefixWarning` is the one behavioral fork between `--ci` and headless
* mode: headless accepts a `pha_` OAuth access token as first-class, CI does
* not. Everything else about the two modes is shared.
* `--ci` and headless accept the same credentials: a personal API key and a
* wizard-app OAuth access token (the CI bot's). Only unknown prefixes warn.
*/
describe('keyPrefixWarning', () => {
describe.each([false, true])('headless=%s', (headless) => {
test('a personal API key (phx_) is always accepted', () => {
expect(keyPrefixWarning('phx_abc', headless)).toBeNull();
});
test('a personal API key (phx_) is accepted', () => {
expect(keyPrefixWarning('phx_abc')).toBeNull();
});

test('no key returns no warning', () => {
expect(keyPrefixWarning(undefined, headless)).toBeNull();
});
test('a wizard-app OAuth access token (pha_) is accepted', () => {
// The CI bot authenticates the mint with one of these; a warning here
// would name the sanctioned credential as a mistake on every CI run.
expect(keyPrefixWarning('pha_abc')).toBeNull();
});

test('a project/client key (phc_) always warns', () => {
expect(keyPrefixWarning('phc_abc', headless)).toMatch(/phc_/);
});
test('no key returns no warning', () => {
expect(keyPrefixWarning(undefined)).toBeNull();
});

test('headless accepts a pha_ OAuth access token without warning', () => {
expect(keyPrefixWarning('pha_abc', true)).toBeNull();
test('a project/client key (phc_) warns and names both accepted kinds', () => {
expect(keyPrefixWarning('phc_abc')).toMatch(/phc_/);
expect(keyPrefixWarning('phc_abc')).toMatch(/"phx_" or "pha_"/);
});

test('CI mode warns on a pha_ OAuth access token', () => {
const warning = keyPrefixWarning('pha_abc', false);
expect(warning).toMatch(/OAuth access token/);
test('an unknown prefix warns', () => {
expect(keyPrefixWarning('sk-abc')).toMatch(/does not start with/);
});
});
55 changes: 21 additions & 34 deletions src/commands/basic-integration/ci-install.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Arguments } from 'yargs';
import { getUI, setUI } from '@ui';
import { LoggingUI } from '@ui/logging-ui';
import { runWizardCI, runWizardHeadless } from '@lib/runners';
import { API_KEY_HINT, runWizardCI, runWizardHeadless } from '@lib/runners';
import type { NonInteractiveMode } from '@lib/runners';
import { provisionNewAccount } from '@utils/provisioning';
import { posthogIntegrationConfig } from '@lib/programs/posthog-integration/index';
Expand Down Expand Up @@ -40,8 +40,8 @@ export function runHeadlessInstall(argv: Arguments): void {
/**
* Non-interactive install shared by CI and headless. Validates signup flags,
* optionally provisions an account, then installs. `mode` only changes
* user-facing labels, which api-key prefixes are accepted, and which runner is
* invoked — the install itself is identical (see runNonInteractive).
* user-facing labels and which runner is invoked; the accepted keys and the
* install itself are identical (see runNonInteractive).
*/
function runNonInteractiveInstall(
argv: Arguments,
Expand All @@ -55,11 +55,8 @@ function runNonInteractiveInstall(
// Base validation (region/install-dir/api-key) is owned by the runner.
// This layer only adds the signup branch on top.
if (!options.apiKey && !options.signup) {
const keyHint = headless
? 'personal API key phx_xxx or pha_ OAuth access token'
: 'personal API key phx_xxx';
return failCI(
`${label} mode requires --api-key (${keyHint}). ` +
`${label} mode requires --api-key (${API_KEY_HINT}). ` +
'To create a new account instead, use --signup --email you@example.com.',
ErrorCodes.ArgsMissingApiKey,
);
Expand All @@ -70,7 +67,7 @@ function runNonInteractiveInstall(
ErrorCodes.ArgsMissingEmail,
);
}
warnOnUnexpectedKeyPrefix(options.apiKey, headless);
warnOnUnexpectedKeyPrefix(options.apiKey);

void (async () => {
if (!options.apiKey && options.signup) {
Expand Down Expand Up @@ -105,37 +102,27 @@ function failCI(message: string, code?: ErrorCode): void {

/**
* Decide whether to warn about an unexpected `--api-key` prefix, and with what
* message. Returns `null` when the key is acceptable for the mode.
* message. Returns `null` when the key is acceptable.
*
* This is the one behavioral fork between `--ci` and headless mode: the LLM
* Gateway accepts a personal API key (`phx_`) in either mode, but in headless
* a `pha_` OAuth access token is *also* first-class — PostHog mints one under
* the wizard's own OAuth application for cloud runs and passes it as the
* api-key. Outside headless that token is unexpected and still warns.
* CI and headless accept the same two credentials: a personal API key (`phx_`)
* and a `pha_` OAuth access token minted under the wizard's own OAuth
* application (cloud runs, and the CI bot). Both authenticate the mint the
* same way. Anything else is unexpected and warns.
*
* Extracted as a pure predicate so the fork can be unit-tested without a UI.
* Extracted as a pure predicate so it can be unit-tested without a UI.
*/
export function keyPrefixWarning(
apiKey: string | undefined,
headless: boolean,
): string | null {
if (!apiKey || apiKey.startsWith('phx_')) return null;
if (headless && apiKey.startsWith('pha_')) return null;
const prefix = apiKey.slice(0, 4);
const hint =
prefix === 'pha_'
? ' (pha_ is an OAuth access token — CI mode expects a personal API key)'
: prefix === 'phc_'
? ' (phc_ is a project/client key — expected a personal API key)'
: '';
return `--api-key does not start with "phx_"${hint}. Continuing anyway, but the LLM Gateway may reject it with a 401.`;
export function keyPrefixWarning(apiKey: string | undefined): string | null {
if (!apiKey || apiKey.startsWith('phx_') || apiKey.startsWith('pha_')) {
return null;
}
const hint = apiKey.startsWith('phc_')
? ' (phc_ is a project/client key; expected a personal API key or a wizard-app token)'
: '';
return `--api-key does not start with "phx_" or "pha_"${hint}. Continuing anyway, but the LLM Gateway may reject it with a 401.`;
}

function warnOnUnexpectedKeyPrefix(
apiKey: string | undefined,
headless: boolean,
): void {
const message = keyPrefixWarning(apiKey, headless);
function warnOnUnexpectedKeyPrefix(apiKey: string | undefined): void {
const message = keyPrefixWarning(apiKey);
if (!message) return;
setUI(new LoggingUI());
getUI().intro('PostHog Wizard');
Expand Down
Loading
Loading