Skip to content

Commit 6a4a48d

Browse files
feat: add experimental headless mode for published, non-interactive runs (#732)
Adds a non-interactive run path that works in the *published* package, for PostHog's cloud setup runs and CI/CD. Until now --ci was the only headless path and it is stripped from published builds. Flag & routing - New global flag --headless-DONOTUSE-EXPERIMENTAL: hidden from --help and declared unconditionally so it survives in the shipped package (unlike --ci). Intentionally ugly-named and undocumented — the contract is still unstable. Its name + detection live in one place, src/lib/headless-mode.ts (HEADLESS_FLAG + isHeadless). - basic-integration dispatch routes the flag to its own entry point with a plain `if (isHeadless(argv))` check, separate from the `if (argv.ci)` check. CI / headless division (shared now, easy to diverge later) - runHeadlessInstall / runWizardHeadless are siblings of runCIInstall / runWizardCI. Both delegate to one shared pipeline, runNonInteractive(config, options, mode), parameterized by an explicit NonInteractiveMode ('ci' | 'headless'). To diverge: branch on mode, or give the headless functions their own body — no CI-path or caller changes needed. Auth - Headless accepts a pha_ OAuth access token as first-class (PostHog mints one under the wizard's OAuth app for cloud runs) in addition to a phx_ personal key; CI still warns on pha_. keyPrefixWarning extracted as a pure predicate. - The token is passed via POSTHOG_WIZARD_API_KEY (already wired through yargs .env), never on the command line. Analytics / instrumentation - runNonInteractive tags the analytics build type by mode ('headless' vs 'ci'). That tag rides on every analytics event and on LLM-gateway trace tags (via buildRunTags reading analytics.build), so headless cloud runs segment cleanly from prod / dev / ci everywhere. Other - --ci stays dev/test-only; its published-build rejection is unchanged and failNonInteractive still points at --ci (headless is not advertised). - README untouched (flag is undocumented); smoke test verifies the flag is accepted in published builds and reaches the headless install path. - Tests: keyPrefixWarning unit tests, headless routing + build-tag tests, flag parsing tests, and a gateway-trace build-tag case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4036947 commit 6a4a48d

14 files changed

Lines changed: 542 additions & 183 deletions

File tree

scripts/smoke-test.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
# with the tailored "CI mode is not currently supported" error and a
88
# non-zero exit. Guards against a future change that re-enables --ci in
99
# published builds without anyone noticing.
10+
# 3. In production builds, the experimental headless flag IS accepted (the
11+
# non-interactive published-build path) — it must not be rejected as an
12+
# unknown argument. It is intentionally undocumented; this check only keeps
13+
# the published binary from silently dropping the flag the cloud runs need.
1014
#
1115
# Runs from the wizard repo root via `pnpm test:smoke` (postbuild hook).
1216
set -e
@@ -76,3 +80,24 @@ if ! echo "$output" | grep -qi 'CI mode is not currently supported'; then
7680
echo "$output" >&2
7781
exit 1
7882
fi
83+
84+
# ── 4. Experimental headless flag accepted in production builds ──────────────
85+
# The non-interactive path for published builds (cloud / CI runs). yargs must
86+
# not reject the flag, and it must not fall through to the --ci rejection. With
87+
# no api-key the run exits fast on "Headless mode requires --api-key" — all this
88+
# asserts is that the flag is recognized and live in the published binary. The
89+
# flag name is intentionally undocumented; keep it in sync with @lib/headless-mode.
90+
HEADLESS_FLAG='--headless-DONOTUSE-EXPERIMENTAL'
91+
hl_output=$(node "$DIST_BIN" "$HEADLESS_FLAG" --install-dir /tmp/wizard-smoke-probe 2>&1) || true
92+
if echo "$hl_output" | grep -qiE 'unknown argument|not currently supported'; then
93+
echo 'Smoke test failed: headless flag not accepted in production build' >&2
94+
echo "Output was:" >&2
95+
echo "$hl_output" | head -5 >&2
96+
exit 1
97+
fi
98+
if ! echo "$hl_output" | grep -qi 'Headless mode requires --api-key'; then
99+
echo 'Smoke test failed: headless flag did not reach the headless install path' >&2
100+
echo "Output was:" >&2
101+
echo "$hl_output" | head -5 >&2
102+
exit 1
103+
fi
Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { basicIntegrationCommand } from '../commands/basic-integration';
2+
import { HEADLESS_FLAG } from '../lib/headless-mode';
23
import { parseCommand } from './helpers/parse-command.no-jest';
34

45
describe('basic-integration parsing (end-to-end yargs)', () => {
@@ -11,20 +12,34 @@ describe('basic-integration parsing (end-to-end yargs)', () => {
1112
expect(argv.installDir).toBe('/tmp/app');
1213
});
1314

14-
test('rejects --playground with --ci', async () => {
15-
await expect(
16-
parseCommand(basicIntegrationCommand, '--ci --playground'),
17-
).rejects.toThrow(/--playground cannot be combined/i);
15+
test('parses the experimental headless flag under its declared key', async () => {
16+
const argv = await parseCommand(
17+
basicIntegrationCommand,
18+
`--${HEADLESS_FLAG} --api-key pha_x --install-dir /tmp/app`,
19+
);
20+
// yargs always sets the value under the declared (kebab) key.
21+
expect(argv[HEADLESS_FLAG]).toBe(true);
1822
});
1923

20-
// Default boolean values (ci/playground default false) must not register as
21-
// a spurious conflict when only one mode flag is actually passed.
22-
test.each(['', '--ci --api-key phx_x --install-dir /tmp', '--playground'])(
23-
'accepts a single mode: "%s"',
24+
test.each(['--ci --playground', `--${HEADLESS_FLAG} --playground`])(
25+
'rejects --playground with a non-interactive flag: "%s"',
2426
async (args) => {
25-
await expect(
26-
parseCommand(basicIntegrationCommand, args),
27-
).resolves.toBeDefined();
27+
await expect(parseCommand(basicIntegrationCommand, args)).rejects.toThrow(
28+
/--playground cannot be combined/i,
29+
);
2830
},
2931
);
32+
33+
// Default boolean values (ci/headless/playground default false) must not
34+
// register as a spurious conflict when only one mode flag is actually passed.
35+
test.each([
36+
'',
37+
'--ci --api-key phx_x --install-dir /tmp',
38+
`--${HEADLESS_FLAG} --api-key pha_x --install-dir /tmp`,
39+
'--playground',
40+
])('accepts a single mode: "%s"', async (args) => {
41+
await expect(
42+
parseCommand(basicIntegrationCommand, args),
43+
).resolves.toBeDefined();
44+
});
3045
});

src/__tests__/cli.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,93 @@ describe('CLI argument parsing', () => {
272272
const args = getLastBuildSessionArgs();
273273
expect(args.apiKey).toBe('phx_test_key');
274274
});
275+
276+
test("tags the build as 'ci'", async () => {
277+
await runCLI([
278+
'--ci',
279+
'--api-key',
280+
'phx_test',
281+
'--install-dir',
282+
'/tmp/test',
283+
]);
284+
285+
const { analytics } = await import('../utils/analytics');
286+
expect(analytics.setTag).toHaveBeenCalledWith('build', 'ci');
287+
});
288+
});
289+
290+
// The experimental headless flag is the published-build sibling of --ci: it
291+
// routes through the same non-interactive runner (session.ci === true), but
292+
// is its own flag and tags the build distinctly so the two modes segment in
293+
// analytics. Its CLI name is intentionally ugly/undocumented — sourced from
294+
// @lib/headless-mode so this test never has to spell it out.
295+
describe('headless flag', () => {
296+
// Source of truth: HEADLESS_FLAG in src/lib/headless-mode.ts. Hardcoded
297+
// here (not imported) to keep this file free of top-level imports — see the
298+
// note at the top of the file.
299+
const headlessFlag = '--headless-DONOTUSE-EXPERIMENTAL';
300+
301+
test('routes through the CI runner (builds a ci session)', async () => {
302+
await runCLI([
303+
headlessFlag,
304+
'--api-key',
305+
'pha_test',
306+
'--install-dir',
307+
'/tmp/test',
308+
]);
309+
310+
const args = getLastBuildSessionArgs();
311+
expect(args.ci).toBe(true);
312+
});
313+
314+
test("tags the build as 'headless' (not 'ci')", async () => {
315+
await runCLI([
316+
headlessFlag,
317+
'--api-key',
318+
'pha_test',
319+
'--install-dir',
320+
'/tmp/test',
321+
]);
322+
323+
const { analytics } = await import('../utils/analytics');
324+
expect(analytics.setTag).toHaveBeenCalledWith('build', 'headless');
325+
expect(analytics.setTag).not.toHaveBeenCalledWith('build', 'ci');
326+
});
327+
328+
// The dispatch checks the headless flag before --ci, so headless wins when
329+
// both are passed. Not a supported combination, but pin the precedence.
330+
test('takes precedence over --ci when both are passed', async () => {
331+
await runCLI([
332+
'--ci',
333+
headlessFlag,
334+
'--api-key',
335+
'pha_test',
336+
'--install-dir',
337+
'/tmp/test',
338+
]);
339+
340+
const { analytics } = await import('../utils/analytics');
341+
expect(analytics.setTag).toHaveBeenCalledWith('build', 'headless');
342+
expect(analytics.setTag).not.toHaveBeenCalledWith('build', 'ci');
343+
});
344+
345+
test('does not require --region when headless is set', async () => {
346+
await runCLI([
347+
headlessFlag,
348+
'--api-key',
349+
'pha_test',
350+
'--install-dir',
351+
'/tmp/test',
352+
]);
353+
354+
expect(process.exit).not.toHaveBeenCalledWith(1);
355+
});
356+
357+
test('requires --api-key when headless is set', async () => {
358+
await runCLI([headlessFlag, '--install-dir', '/tmp/test']);
359+
360+
expect(process.exit).toHaveBeenCalledWith(1);
361+
});
275362
});
276363

277364
describe('CI environment variables', () => {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { keyPrefixWarning } from '../ci-install';
2+
3+
/**
4+
* `keyPrefixWarning` is the one behavioral fork between `--ci` and headless
5+
* mode: headless accepts a `pha_` OAuth access token as first-class, CI does
6+
* not. Everything else about the two modes is shared.
7+
*/
8+
describe('keyPrefixWarning', () => {
9+
describe.each([false, true])('headless=%s', (headless) => {
10+
test('a personal API key (phx_) is always accepted', () => {
11+
expect(keyPrefixWarning('phx_abc', headless)).toBeNull();
12+
});
13+
14+
test('no key returns no warning', () => {
15+
expect(keyPrefixWarning(undefined, headless)).toBeNull();
16+
});
17+
18+
test('a project/client key (phc_) always warns', () => {
19+
expect(keyPrefixWarning('phc_abc', headless)).toMatch(/phc_/);
20+
});
21+
});
22+
23+
test('headless accepts a pha_ OAuth access token without warning', () => {
24+
expect(keyPrefixWarning('pha_abc', true)).toBeNull();
25+
});
26+
27+
test('CI mode warns on a pha_ OAuth access token', () => {
28+
const warning = keyPrefixWarning('pha_abc', false);
29+
expect(warning).toMatch(/OAuth access token/);
30+
});
31+
});

src/commands/basic-integration/ci-install.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Arguments } from 'yargs';
22
import { getUI, setUI } from '@ui';
33
import { LoggingUI } from '@ui/logging-ui';
4-
import { runWizardCI } from '@lib/runners';
4+
import { runWizardCI, runWizardHeadless } from '@lib/runners';
5+
import type { NonInteractiveMode } from '@lib/runners';
56
import { provisionNewAccount } from '@utils/provisioning';
67
import { posthogIntegrationConfig } from '@lib/programs/posthog-integration/index';
78

@@ -15,36 +16,70 @@ type Options = Arguments & {
1516
projectId?: string;
1617
};
1718

18-
/** CI-mode entry point: validate signup flags, optionally provision an account, then install. */
19+
/**
20+
* CI-mode install entry point (`--ci`, dev/test builds). Thin shell over the
21+
* shared non-interactive install — see `runNonInteractiveInstall`.
22+
*/
1923
export function runCIInstall(argv: Arguments): void {
24+
runNonInteractiveInstall(argv, 'ci');
25+
}
26+
27+
/**
28+
* Headless install entry point (the experimental published-build run path; see
29+
* @lib/headless-mode). Thin shell over the shared non-interactive install.
30+
* Today it behaves exactly like `runCIInstall`; it is a separate function so
31+
* headless can diverge later (auth, prompts, …) without touching the CI path.
32+
*/
33+
export function runHeadlessInstall(argv: Arguments): void {
34+
runNonInteractiveInstall(argv, 'headless');
35+
}
36+
37+
/**
38+
* Non-interactive install shared by CI and headless. Validates signup flags,
39+
* optionally provisions an account, then installs. `mode` only changes
40+
* user-facing labels, which api-key prefixes are accepted, and which runner is
41+
* invoked — the install itself is identical (see runNonInteractive).
42+
*/
43+
function runNonInteractiveInstall(
44+
argv: Arguments,
45+
mode: NonInteractiveMode,
46+
): void {
2047
const options = { ...argv } as Options;
48+
const headless = mode === 'headless';
49+
const label = headless ? 'Headless' : 'CI';
50+
const runWizard = headless ? runWizardHeadless : runWizardCI;
2151

22-
// Base CI validation (region/install-dir/api-key) is owned by runWizardCI.
23-
// runCIInstall only layers the signup branch on top.
52+
// Base validation (region/install-dir/api-key) is owned by the runner.
53+
// This layer only adds the signup branch on top.
2454
if (!options.apiKey && !options.signup) {
55+
const keyHint = headless
56+
? 'personal API key phx_xxx or pha_ OAuth access token'
57+
: 'personal API key phx_xxx';
2558
return failCI(
26-
'CI mode requires --api-key (personal API key phx_xxx). ' +
59+
`${label} mode requires --api-key (${keyHint}). ` +
2760
'To create a new account instead, use --signup --email you@example.com.',
2861
);
2962
}
3063
if (!options.apiKey && options.signup && !options.email) {
31-
return failCI('CI --signup requires --email to create a new account.');
64+
return failCI(
65+
`${label} --signup requires --email to create a new account.`,
66+
);
3267
}
33-
warnOnUnexpectedKeyPrefix(options.apiKey);
68+
warnOnUnexpectedKeyPrefix(options.apiKey, headless);
3469

3570
void (async () => {
3671
if (!options.apiKey && options.signup) {
3772
// Fail before the irreversible provisioning step rather than after it.
3873
if (!options.installDir) {
3974
return failCI(
40-
'CI mode requires --install-dir (directory to install in)',
75+
`${label} mode requires --install-dir (directory to install in)`,
4176
);
4277
}
4378
const provisioned = await provisionForSignup(options);
4479
options.apiKey = provisioned.personalApiKey;
4580
if (options.projectId == null) options.projectId = provisioned.projectId;
4681
}
47-
runWizardCI(posthogIntegrationConfig, options);
82+
runWizard(posthogIntegrationConfig, options);
4883
})().catch(() => {
4984
process.exit(1);
5085
});
@@ -57,21 +92,43 @@ function failCI(message: string): void {
5792
process.exit(1);
5893
}
5994

60-
/** `phx_` is the personal-API-key prefix the LLM Gateway expects. */
61-
function warnOnUnexpectedKeyPrefix(apiKey: string | undefined): void {
62-
if (!apiKey || apiKey.startsWith('phx_')) return;
63-
setUI(new LoggingUI());
64-
getUI().intro('PostHog Wizard');
95+
/**
96+
* Decide whether to warn about an unexpected `--api-key` prefix, and with what
97+
* message. Returns `null` when the key is acceptable for the mode.
98+
*
99+
* This is the one behavioral fork between `--ci` and headless mode: the LLM
100+
* Gateway accepts a personal API key (`phx_`) in either mode, but in headless
101+
* a `pha_` OAuth access token is *also* first-class — PostHog mints one under
102+
* the wizard's own OAuth application for cloud runs and passes it as the
103+
* api-key. Outside headless that token is unexpected and still warns.
104+
*
105+
* Extracted as a pure predicate so the fork can be unit-tested without a UI.
106+
*/
107+
export function keyPrefixWarning(
108+
apiKey: string | undefined,
109+
headless: boolean,
110+
): string | null {
111+
if (!apiKey || apiKey.startsWith('phx_')) return null;
112+
if (headless && apiKey.startsWith('pha_')) return null;
65113
const prefix = apiKey.slice(0, 4);
66114
const hint =
67115
prefix === 'pha_'
68116
? ' (pha_ is an OAuth access token — CI mode expects a personal API key)'
69117
: prefix === 'phc_'
70-
? ' (phc_ is a project/client key — CI mode expects a personal API key)'
118+
? ' (phc_ is a project/client key — expected a personal API key)'
71119
: '';
72-
getUI().log.warn(
73-
`--api-key does not start with "phx_"${hint}. Continuing anyway, but the LLM Gateway may reject it with a 401.`,
74-
);
120+
return `--api-key does not start with "phx_"${hint}. Continuing anyway, but the LLM Gateway may reject it with a 401.`;
121+
}
122+
123+
function warnOnUnexpectedKeyPrefix(
124+
apiKey: string | undefined,
125+
headless: boolean,
126+
): void {
127+
const message = keyPrefixWarning(apiKey, headless);
128+
if (!message) return;
129+
setUI(new LoggingUI());
130+
getUI().intro('PostHog Wizard');
131+
getUI().log.warn(message);
75132
}
76133

77134
/**

src/commands/basic-integration/index.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isNonInteractiveEnvironment } from '@utils/environment';
22
import { setEntryCommand } from '@utils/links';
3+
import { isHeadless } from '@lib/headless-mode';
34
import { provisionCommand } from '../provision';
45
import type { Command } from '../command';
56

@@ -30,9 +31,10 @@ export const basicIntegrationCommand: Command = {
3031
},
3132
},
3233
check: (argv) => {
33-
// --playground is the standalone TUI demo; it can't combine with --ci.
34-
if (argv.playground && argv.ci) {
35-
throw new Error('--playground cannot be combined with --ci.');
34+
// --playground is the standalone TUI demo; it can't combine with a
35+
// non-interactive run (either --ci or the experimental headless flag).
36+
if (argv.playground && (argv.ci || isHeadless(argv))) {
37+
throw new Error('--playground cannot be combined with a headless run.');
3638
}
3739
return true;
3840
},
@@ -42,6 +44,18 @@ export const basicIntegrationCommand: Command = {
4244
// Each mode file is loaded only when its branch is taken, so a plain
4345
// `npx @posthog/wizard` never pulls in the CI or playground paths.
4446
void (async () => {
47+
// ── The CI / headless division ───────────────────────────────────
48+
// --ci (dev/test only) and the experimental headless flag (the
49+
// published-build, non-interactive path; see @lib/headless-mode) both
50+
// request a non-interactive install, but route to dedicated entry points
51+
// — runHeadlessInstall vs runCIInstall (and below them runWizardHeadless
52+
// vs runWizardCI). Both share one pipeline today but are separate
53+
// functions end-to-end, so headless can diverge later without touching
54+
// the CI path or its callers.
55+
if (isHeadless(argv)) {
56+
const { runHeadlessInstall } = await import('./ci-install');
57+
return runHeadlessInstall(argv);
58+
}
4559
if (argv.ci) {
4660
const { runCIInstall } = await import('./ci-install');
4761
return runCIInstall(argv);

0 commit comments

Comments
 (0)