diff --git a/src/__tests__/programs-cli.test.ts b/src/__tests__/programs-cli.test.ts index 3427dd98..7092a8da 100644 --- a/src/__tests__/programs-cli.test.ts +++ b/src/__tests__/programs-cli.test.ts @@ -1,11 +1,15 @@ -const { mockRunWizard, mockRunWizardCI } = vi.hoisted(() => ({ - mockRunWizard: vi.fn(), - mockRunWizardCI: vi.fn(), -})); +const { mockRunWizard, mockRunWizardCI, mockRunWizardHeadless } = vi.hoisted( + () => ({ + mockRunWizard: vi.fn(), + mockRunWizardCI: vi.fn(), + mockRunWizardHeadless: vi.fn(), + }), +); vi.mock('@lib/runners', () => ({ runWizard: mockRunWizard, runWizardCI: mockRunWizardCI, + runWizardHeadless: mockRunWizardHeadless, })); vi.mock('@lib/wizard-tools', async (importOriginal) => { @@ -226,6 +230,41 @@ describe('yargs parsing for the audit family', () => { expect(canonical.debug).toBe(true); expect(legacy.debug).toBe(true); }); + + test('parses the upload-source-maps headless selection flags', async () => { + const argv = await parseCommand( + uploadSourcemapsCommand, + 'upload-source-maps --headless-DONOTUSE-EXPERIMENTAL --selected-path apps/web --selected-variant nextjs', + ); + expect(argv['headless-DONOTUSE-EXPERIMENTAL']).toBe(true); + expect(argv.selectedPath).toBe('apps/web'); + expect(argv.selectedVariant).toBe('nextjs'); + }); + + test('routes headless upload-source-maps runs to runWizardHeadless', () => { + // This describe has no mock-clearing hook; earlier dispatch tests leave + // runner-mock calls behind, so start from a clean slate. + vi.clearAllMocks(); + uploadSourcemapsCommand.handler?.( + makeArgv({ + 'headless-DONOTUSE-EXPERIMENTAL': true, + selectedPath: '.', + selectedVariant: 'nextjs', + }), + ); + expect(mockRunWizardHeadless).toHaveBeenCalledTimes(1); + expect(mockRunWizard).not.toHaveBeenCalled(); + expect(mockRunWizardCI).not.toHaveBeenCalled(); + const [config, opts] = mockRunWizardHeadless.mock.calls[0] as [ + { id?: string }, + Record, + ]; + expect(config.id).toBe('error-tracking-upload-source-maps'); + expect(opts).toMatchObject({ + selectedPath: '.', + selectedVariant: 'nextjs', + }); + }); }); describe('pickerChildrenToShow (today: picker shows only the default leaf)', () => { diff --git a/src/commands/upload-sourcemaps.ts b/src/commands/upload-sourcemaps.ts index 7b8e03b3..e9880ce7 100644 --- a/src/commands/upload-sourcemaps.ts +++ b/src/commands/upload-sourcemaps.ts @@ -1,7 +1,7 @@ -import { runWizard, runWizardCI } from '@lib/runners'; +import { runWizard, runWizardCI, runWizardHeadless } from '@lib/runners'; import { errorTrackingUploadSourceMapsConfig } from '@lib/programs/error-tracking-upload-source-maps/index'; import { runDetectOnly } from '@lib/programs/error-tracking-upload-source-maps/detect-only'; -import { regionOption } from '@lib/headless-mode'; +import { headlessOption, isHeadless, regionOption } from '@lib/headless-mode'; import { runCommandHandler } from './factories/shared'; import { skillProgramOptions } from './skill-program-options'; import type { Command } from './command'; @@ -29,6 +29,22 @@ export const uploadSourcemapsCommand: Command = { type: 'string' as const, hidden: true, }, + // Project selection for non-interactive runs, passed verbatim from a + // stored detection report row (the project the user picked in the + // PostHog app). Hidden like the headless flag: the contract is unstable. + 'selected-path': { + describe: + "Project directory to instrument, relative to the repo root ('.' for the root). Non-interactive runs only.", + type: 'string' as const, + hidden: true, + }, + 'selected-variant': { + describe: + 'Source-maps skill variant of the selected project (e.g. nextjs). Non-interactive runs only.', + type: 'string' as const, + hidden: true, + }, + ...headlessOption, ...regionOption, }, handler: (argv) => { @@ -39,6 +55,8 @@ export const uploadSourcemapsCommand: Command = { const options = { ...argv, ...extras }; if (options.detectOnly) { runCommandHandler(() => runDetectOnly(options)); + } else if (isHeadless(options)) { + runWizardHeadless(errorTrackingUploadSourceMapsConfig, options); } else if (options.ci) { runWizardCI(errorTrackingUploadSourceMapsConfig, options); } else { diff --git a/src/lib/programs/__tests__/source-maps-non-interactive-selection.test.ts b/src/lib/programs/__tests__/source-maps-non-interactive-selection.test.ts new file mode 100644 index 00000000..8bc592f1 --- /dev/null +++ b/src/lib/programs/__tests__/source-maps-non-interactive-selection.test.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockWizardAbort } = vi.hoisted(() => ({ + mockWizardAbort: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@utils/wizard-abort', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, wizardAbort: mockWizardAbort }; +}); + +import { getUI, setUI } from '@ui'; +import { LoggingUI } from '@ui/logging-ui'; +import { buildSession, type WizardSession } from '@lib/wizard-session'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/detect'; +import { seedNonInteractiveSelection } from '@lib/programs/error-tracking-upload-source-maps/non-interactive-selection'; + +describe('seedNonInteractiveSelection', () => { + let installDir: string; + let session: WizardSession; + + beforeEach(() => { + installDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sourcemaps-select-')); + fs.mkdirSync(path.join(installDir, 'apps', 'web'), { recursive: true }); + session = buildSession({ installDir, ci: true }); + setUI(new LoggingUI()); + mockWizardAbort.mockClear(); + }); + + afterEach(() => { + fs.rmSync(installDir, { recursive: true, force: true }); + }); + + it.each([ + { selectedPath: '.', label: 'the repo root' }, + { selectedPath: 'apps/web', label: 'a monorepo subproject' }, + ])('seeds the selection for $label', async ({ selectedPath }) => { + await seedNonInteractiveSelection(session, { + selectedPath, + selectedVariant: 'nextjs', + }); + + expect(mockWizardAbort).not.toHaveBeenCalled(); + const ui = getUI(); + expect( + ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant), + ).toBe('nextjs'); + expect(ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath)).toBe( + selectedPath, + ); + expect( + ui.getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName), + ).toBe('Next.js'); + }); + + it.each([ + { + name: 'both flags missing', + options: {}, + messagePart: '--selected-path', + }, + { + name: 'variant missing', + options: { selectedPath: '.' }, + messagePart: '--selected-variant', + }, + { + name: 'unknown variant', + options: { selectedPath: '.', selectedVariant: 'cobol' }, + messagePart: 'Unknown --selected-variant "cobol"', + }, + { + name: 'path not in the repository', + options: { selectedPath: 'apps/missing', selectedVariant: 'nextjs' }, + messagePart: 'run a new scan', + }, + { + name: 'path escaping the install dir', + options: { selectedPath: '../outside', selectedVariant: 'nextjs' }, + messagePart: 'outside the install directory', + }, + ])('aborts on $name without seeding', async ({ options, messagePart }) => { + await seedNonInteractiveSelection(session, options); + + expect(mockWizardAbort).toHaveBeenCalledTimes(1); + expect(mockWizardAbort.mock.calls[0][0].message).toContain(messagePart); + expect( + getUI().getFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedVariant), + ).toBeUndefined(); + }); +}); diff --git a/src/lib/programs/__tests__/source-maps-prompt.test.ts b/src/lib/programs/__tests__/source-maps-prompt.test.ts index 18de645d..2a79b847 100644 --- a/src/lib/programs/__tests__/source-maps-prompt.test.ts +++ b/src/lib/programs/__tests__/source-maps-prompt.test.ts @@ -100,3 +100,63 @@ describe('buildSourceMapsUploadPrompt rust workspace scope', () => { expect(prompt).not.toContain('Cargo workspace exception'); }); }); + +describe('buildSourceMapsUploadPrompt non-interactive mode', () => { + const prompt = buildSourceMapsUploadPrompt({ + ...baseParams, + nonInteractive: true, + }); + + it('never references the ask tool or the API-key prompt', () => { + expect(prompt).not.toContain('wizard_ask'); + expect(prompt).not.toContain('secretRef'); + expect(prompt).not.toContain('Paste your PostHog personal API key'); + }); + + it('drops the local-test offer', () => { + expect(prompt).not.toContain('Test the local setup'); + expect(prompt).not.toContain('Want me to help you test'); + }); + + it('forbids real env files and env tools, allowing only committed examples', () => { + expect(prompt).toContain('NEVER read, create, or modify real env files'); + expect(prompt).toContain('never call check_env_keys or set_env_values'); + expect(prompt).toContain('committed env example file'); + expect(prompt).not.toContain('Then call set_env_values'); + }); + + it('routes dependency changes through the package manager', () => { + // A package.json edit without its lockfile fails npm ci in the PR. + expect(prompt).toContain( + 'Dependency changes go through the package manager', + ); + expect(prompt).toContain('lockfile'); + }); + + it('hands the API key off as a documented follow-up', () => { + expect(prompt).toContain('you\ncannot obtain one'); + expect(prompt).toContain('"What you still need to do"'); + expect(prompt).toContain(baseParams.settingsUrl); + }); + + it('keeps the monorepo scope rules', () => { + const monorepoPrompt = buildSourceMapsUploadPrompt({ + ...baseParams, + projectPath: 'backend', + nonInteractive: true, + }); + + expect(monorepoPrompt).toContain('scope your work to `backend`'); + expect(monorepoPrompt).toContain( + "Project directory (relative to the wizard's working directory): backend", + ); + }); + + it('leaves the interactive prompt untouched', () => { + const interactive = buildSourceMapsUploadPrompt(baseParams); + + expect(interactive).toContain('wizard_ask'); + expect(interactive).toContain('Test the local setup'); + expect(interactive).toContain('secretRef'); + }); +}); diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index 80db004f..00ec27e5 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -14,6 +14,7 @@ import { type SkillVariant, } from './detect.js'; import { getContentBlocks } from './content/index.js'; +import { seedNonInteractiveSelection } from './non-interactive-selection.js'; import { getUI } from '@ui'; import { installOrUpdatePostHogCli } from '@steps/install-cli-steering'; import { analytics } from '@utils/analytics'; @@ -59,8 +60,15 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { reportFile: REPORT_FILE, getContentBlocks, requires: ['posthog-integration'], + // Non-interactive runs have no detect+pick screen; the selection arrives as + // CLI flags and is seeded into framework context before the agent runs. + ciPreRun: seedNonInteractiveSelection, + + run: (session: WizardSession): Promise => { + // `ci` is set once by buildSession and never changes, so it is safe to + // read here even though the store forks the session reference. + const nonInteractive = session.ci === true; - run: (_session: WizardSession): Promise => { // Read the picked project LIVE at prompt-build time, not here: the picker // screen runs AFTER this run config is resolved (post-auth), and the store // forks the session reference, so the `session` passed in never sees the @@ -121,6 +129,7 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { settingsUrl: `${uiHost}/project/${ctx.projectId}/settings/user-api-keys`, uiHost, reportFile: REPORT_FILE, + nonInteractive, }); }, diff --git a/src/lib/programs/error-tracking-upload-source-maps/non-interactive-selection.ts b/src/lib/programs/error-tracking-upload-source-maps/non-interactive-selection.ts new file mode 100644 index 00000000..81e17e6d --- /dev/null +++ b/src/lib/programs/error-tracking-upload-source-maps/non-interactive-selection.ts @@ -0,0 +1,111 @@ +/** + * Project selection for non-interactive `upload-source-maps` runs. + * + * Interactive runs pick the project on the source-maps-detect screen, which + * writes the selection into framework context. Non-interactive runs have no + * screens, so the caller passes the selection explicitly — `--selected-path` + * and `--selected-variant`, verbatim from a stored detection report row (the + * project the user picked in the PostHog app after a `--detect-only` scan). + * This ciPreRun validates the flags and seeds the same framework-context keys + * the screen would have written, so the program's run config stays mode-blind. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { getUI } from '@ui'; +import type { WizardSession } from '@lib/wizard-session'; +import { wizardAbort, WizardError } from '@utils/wizard-abort'; +import { + SOURCE_MAPS_CONTEXT_KEYS, + VARIANT_DISPLAY_NAME, + type SkillVariant, +} from './detect.js'; + +function isSkillVariant(value: string): value is SkillVariant { + return value in VARIANT_DISPLAY_NAME; +} + +async function abortSelection(message: string, kind: string): Promise { + await wizardAbort({ + message, + error: new WizardError('upload-source-maps selection invalid', { + integration: 'error-tracking-upload-source-maps', + selection_error_kind: kind, + }), + }); +} + +/** + * `ciPreRun` for the source-maps program: resolve the project to instrument + * from the selection flags instead of the interactive detect+pick screen. + */ +export async function seedNonInteractiveSelection( + session: WizardSession, + options: Record = {}, +): Promise { + const selectedPath = + typeof options.selectedPath === 'string' && options.selectedPath !== '' + ? options.selectedPath + : undefined; + const selectedVariant = + typeof options.selectedVariant === 'string' && + options.selectedVariant !== '' + ? options.selectedVariant + : undefined; + + if (!selectedPath || !selectedVariant) { + await abortSelection( + 'Non-interactive upload-source-maps runs need the project to instrument: ' + + "pass --selected-path (project directory relative to the repo root, '.' for the root) " + + 'and --selected-variant, from a detection report.', + 'missing-flags', + ); + return; + } + + if (!isSkillVariant(selectedVariant)) { + await abortSelection( + `Unknown --selected-variant "${selectedVariant}". Known variants: ${Object.keys( + VARIANT_DISPLAY_NAME, + ).join(', ')}.`, + 'unknown-variant', + ); + return; + } + + // The path comes from a stored detection report; resolve it against the + // install dir and refuse anything that escapes it or no longer exists + // (the repository may have changed since the scan — rescanning is the fix). + const installDir = path.resolve(session.installDir); + const projectDir = path.resolve(installDir, selectedPath); + if ( + projectDir !== installDir && + !projectDir.startsWith(installDir + path.sep) + ) { + await abortSelection( + `--selected-path "${selectedPath}" points outside the install directory.`, + 'path-outside-install-dir', + ); + return; + } + if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) { + await abortSelection( + `--selected-path "${selectedPath}" does not exist in the repository. ` + + 'The repository may have changed since the detection scan — run a new scan and retry.', + 'path-not-found', + ); + return; + } + + const ui = getUI(); + ui.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, + selectedVariant, + ); + ui.setFrameworkContext(SOURCE_MAPS_CONTEXT_KEYS.selectedPath, selectedPath); + ui.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName, + VARIANT_DISPLAY_NAME[selectedVariant], + ); +} diff --git a/src/lib/programs/error-tracking-upload-source-maps/prompt.ts b/src/lib/programs/error-tracking-upload-source-maps/prompt.ts index 0b3c994e..68990e98 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/prompt.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/prompt.ts @@ -11,14 +11,130 @@ export type SourceMapsUploadPromptParams = { host: string; settingsUrl: string; uiHost: string; - /** Hand-off report the agent writes in STEP 9; the outro points the user at it. */ + /** Hand-off report the agent writes in the final step; the outro points the user at it. */ reportFile: string; + /** + * Non-interactive (headless / CI) run: no user is present and wizard_ask is + * disabled, so the prompt must not ask for the API key or offer the local + * test. The run's output becomes a pull request, so only committed files + * matter — the key itself is a documented follow-up, never a value. + */ + nonInteractive?: boolean; }; export const SOURCE_MAPS_DETECTION_FAILED_PROMPT = `Detection did not pick a source maps skill variant for this project. Emit: ${AgentSignals.ABORT} unsupported-platform Then halt.`; +// ── Shared fragments ───────────────────────────────────────────────── +// Text that appears in both the interactive and non-interactive prompts +// lives here once, so an edit propagates to both modes instead of +// silently drifting. Step numbers (and small mode-specific clauses) are +// parameters because the two flows number their steps differently. + +const opening = (platformLabel: string): string => + `You are wiring up PostHog Error Tracking source map upload for this ${platformLabel} project.`; + +const projectContextBlock = (p: { + projectId: number; + host: string; + platformLabel: string; + skillId: string; + projectLine: string; + settingsUrl: string; +}): string => `Project context: +- PostHog Project ID: ${p.projectId} +- PostHog Host: ${p.host} +- Detected platform: ${p.platformLabel} +- Skill to use: ${p.skillId} +${p.projectLine} +- Personal API keys settings page: ${p.settingsUrl}`; + +const skillSourceOfTruthBlock = (installStep: number): string => + `The skill you install in STEP ${installStep} is the source of truth for the HOW of every +step: its "## Steps" section has an overview, tips and per-technology +examples for each named step, and its reference files carry the exact +per-framework API. The STEPS below give the order, the conditionals, and the +wizard-specific mechanics (which MCP tool to call, signals to emit) — read +the matching skill step (named in parentheses) before doing the work, and do +not invent steps the skill doesn't describe.`; + +const FOLLOW_IN_ORDER = 'Follow these steps IN ORDER. Do not skip or reorder.'; + +const taskListIntro = (suffix: string): string => + `Use exactly these tasks, in this order — do not collapse, rename, or omit +any of them${suffix}`; + +const TASKUPDATE_RULE = `Drive the list with TaskUpdate — mark a task in_progress when you start it +and completed when done.`; + +const installSkillStep = ( + n: number, + skillId: string, + drivenSteps: string, +): string => `STEP ${n} — Install the skill. + Call install_skill (wizard-tools MCP server) with skillId "${skillId}". + Do NOT run shell commands to install skills. Then read the installed + SKILL.md and its reference files — they drive STEPS ${drivenSteps}. + If install fails, emit ${AgentSignals.ERROR_RESOURCE_MISSING} skill ${skillId} could not be installed.`; + +const buildConfigStep = ( + n: number, +): string => `STEP ${n} — Apply build-config changes. (skill: "Apply build-config changes") + Make the bundler / build-config changes the skill's step instructs. The + skill and its reference are the source of truth for this platform.`; + +const credsReadableStep = ( + n: number, + askClause: string, +): string => `STEP ${n} — Make the credentials readable at build time. (skill: "Make credentials available at build time") + Follow the skill's step. Wizard-specific: if it calls for a loader (e.g. + \`dotenv\`), install it SILENTLY${askClause}. + Skip this step entirely if the platform already auto-loads .env.`; + +const buildRunCommandsStep = ( + n: number, +): string => `STEP ${n} — Identify the build AND run commands. (skill: "Identify the build and run commands") + Per the skill, resolve the production BUILD command and the RUN command + for THIS project (use detect_package_manager for the package manager). Do + NOT run either yourself — the user runs them. If you cannot identify a + build command, emit ${AgentSignals.ABORT} build command not found.`; + +/** + * The CI step's shared skeleton. `credsIntro` finishes the "must carry" + * sentence (each mode references its own credentials step); `rules` is the + * mode's full "Wizard-specific rules on top" bullet list. + */ +const ciStep = ( + n: number, + credsIntro: string, + rules: string, +): string => `STEP ${n} — Set up CI for automatic uploads. (skill: "Set up CI for automatic uploads") + Source maps only upload when the production build runs, so the build's + CI/CD must carry ${credsIntro} Follow the + skill's "Set up CI for automatic uploads" step — it is the source of + truth for tracing where the production build runs and wiring the + credentials through every layer, whatever the CI provider. + Wizard-specific rules on top: +${rules}`; + +const handOffHeader = ( + n: number, + reportFile: string, +): string => `STEP ${n} — Summarise and hand off. (skill: "Verify and hand off") + Follow the skill's "Verify and hand off" step. Write the hand-off to + \`${reportFile}\` at the WIZARD'S WORKING DIRECTORY — pass exactly + \`${reportFile}\` as the file path, never prefixed with the selected + project directory; this file is the one exception to the project-scope + rule above.`; + +const symbolSetsFooter = ( + uiHost: string, + projectId: number, +): string => `The Symbol sets page for this project — where the user + confirms the upload landed — is: + ${uiHost}/project/${projectId}/error_tracking/configuration`; + export function buildSourceMapsUploadPrompt( params: SourceMapsUploadPromptParams, ): string { @@ -32,53 +148,14 @@ export function buildSourceMapsUploadPrompt( settingsUrl, uiHost, reportFile, + nonInteractive, } = params; const platformLabel = displayName ?? variant; const inSubproject = projectPath != null && projectPath !== '.'; const projectLine = inSubproject ? `- Project directory (relative to the wizard's working directory): ${projectPath}` : "- Project directory: the wizard's working directory (even when it sits inside a larger git repo, this directory is the project root)"; - const envFilePathGuidance = inSubproject - ? `Tool filePaths are relative to the wizard's working directory, not the selected project directory. Treat the skill's env path as relative to the selected project and prefix it with \`${projectPath}/\` when calling the tools: for example, pass \`${projectPath}/.env\`, not \`.env\` (which would target the wizard's working directory).${ - variant === 'rust' - ? ` Cargo workspace exception: when the skill places the env file at the workspace root instead, pass the path of the ROOT manifest's directory relative to the wizard's working directory (e.g. \`rust/.env\` for a workspace root at \`rust/\`, or plain \`.env\` when the workspace root IS the working directory) — not the member path.` - : '' - }` - : `Tool filePaths are relative to the wizard's working directory. For an env file at this project root, pass \`.env\`; never prefix it with this directory's path inside an ancestor repository.`; - - const credentialSteps = `STEP 4 — Make the credentials readable at build time. (skill: "Make credentials available at build time") - Follow the skill's step. Wizard-specific: if it calls for a loader (e.g. - \`dotenv\`), install it SILENTLY — do NOT ask the user or call wizard_ask. - Skip this step entirely if the platform already auto-loads .env. - -STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file") - Use the wizard-tools MCP server. Reuse the env file the skill tells you to - pick — the prerequisite PostHog integration usually already wrote - POSTHOG_* vars to one, so seed your keys alongside them. - - First call check_env_keys on that file (returns present/absent, never - values — don't read the file directly). - - Env tool path rule: ${envFilePathGuidance} - - Then call set_env_values, passing the STEP 1 secretRef as a value - object, not a literal string: - values: { - "POSTHOG_CLI_API_KEY": { secretRef: "" }, - "POSTHOG_CLI_PROJECT_ID": "${projectId}", - "POSTHOG_CLI_HOST": "${uiHost}" - } - Variable names follow the skill's per-uploader conventions. The wizard - resolves the ref locally before writing, so you never see the key value.`; - - return `You are wiring up PostHog Error Tracking source map upload for this ${platformLabel} project. - -Project context: -- PostHog Project ID: ${projectId} -- PostHog Host: ${host} -- Detected platform: ${platformLabel} -- Skill to use: ${skillId} -${projectLine} -- Personal API keys settings page: ${settingsUrl} - -All file changes, build/run commands, and config edits target the project directory above${ + const scopeBlock = `All file changes, build/run commands, and config edits target the project directory above${ inSubproject ? ` — this is a monorepo, so scope your work to \`${projectPath}\` and do not touch other packages` : '' @@ -93,17 +170,45 @@ root manifest are IN SCOPE — treat them as part of the selected project, and follow the skill's workspace guidance for which paths to use. Every other package stays off-limits.` : '' + }`; + const context = { + projectId, + host, + platformLabel, + skillId, + projectLine, + settingsUrl, + }; + + if (nonInteractive) { + return buildNonInteractivePrompt({ + context, + scopeBlock, + skillId, + projectId, + settingsUrl, + uiHost, + reportFile, + }); } -The skill you install in STEP 2 is the source of truth for the HOW of every -step: its "## Steps" section has an overview, tips and per-technology -examples for each named step, and its reference files carry the exact -per-framework API. The STEPS below give the order, the conditionals, and the -wizard-specific mechanics (which MCP tool to call, signals to emit) — read -the matching skill step (named in parentheses) before doing the work, and do -not invent steps the skill doesn't describe. + const envFilePathGuidance = inSubproject + ? `Tool filePaths are relative to the wizard's working directory, not the selected project directory. Treat the skill's env path as relative to the selected project and prefix it with \`${projectPath}/\` when calling the tools: for example, pass \`${projectPath}/.env\`, not \`.env\` (which would target the wizard's working directory).${ + variant === 'rust' + ? ` Cargo workspace exception: when the skill places the env file at the workspace root instead, pass the path of the ROOT manifest's directory relative to the wizard's working directory (e.g. \`rust/.env\` for a workspace root at \`rust/\`, or plain \`.env\` when the workspace root IS the working directory) — not the member path.` + : '' + }` + : `Tool filePaths are relative to the wizard's working directory. For an env file at this project root, pass \`.env\`; never prefix it with this directory's path inside an ancestor repository.`; -Follow these steps IN ORDER. Do not skip or reorder. + return `${opening(platformLabel)} + +${projectContextBlock(context)} + +${scopeBlock} + +${skillSourceOfTruthBlock(2)} + +${FOLLOW_IN_ORDER} Your FIRST message must contain ONLY parallel tool calls, in this order: - the STEP 1 wizard_ask call FIRST — tool calls execute as they stream, @@ -115,9 +220,8 @@ Your FIRST message must contain ONLY parallel tool calls, in this order: Do not read files, explore the project, or write any text first, and keep any thinking before the calls to a single short sentence. -Use exactly these tasks, in this order — do not collapse, rename, or omit -any of them. Getting the API key is NOT a task — its prompt is already on -screen by the time the list renders: +${taskListIntro(`. Getting the API key is NOT a task — its prompt is already on +screen by the time the list renders:`)} 1. Install source maps skill 2. Apply build-config changes (per skill) 3. Make credentials readable at build time @@ -126,8 +230,7 @@ screen by the time the list renders: 6. Set up CI for auto-upload 7. Test the local setup 8. Summarise & hand off -Drive the list with TaskUpdate — mark a task in_progress when you start it -and completed when done. ALWAYS keep task 7 ("Test the local setup") in the +${TASKUPDATE_RULE} ALWAYS keep task 7 ("Test the local setup") in the list even if the user declines it in STEP 8: mark it completed rather than deleting it, so the user can see it was offered. @@ -148,38 +251,40 @@ STEP 1 — Get a personal API key from the user. (skill: "Get a personal API key If wizard_ask is unavailable (CI / non-interactive), emit ${AgentSignals.ABORT} requires-interactive-mode and halt. -STEP 2 — Install the skill. - Call install_skill (wizard-tools MCP server) with skillId "${skillId}". - Do NOT run shell commands to install skills. Then read the installed - SKILL.md and its reference files — they drive STEPS 3-9. - If install fails, emit ${ - AgentSignals.ERROR_RESOURCE_MISSING - } skill ${skillId} could not be installed. +${installSkillStep(2, skillId, '3-9')} -STEP 3 — Apply build-config changes. (skill: "Apply build-config changes") - Make the bundler / build-config changes the skill's step instructs. The - skill and its reference are the source of truth for this platform. +${buildConfigStep(3)} -${credentialSteps} +${credsReadableStep(4, ' — do NOT ask the user or call wizard_ask')} -STEP 6 — Identify the build AND run commands. (skill: "Identify the build and run commands") - Per the skill, resolve the production BUILD command and the RUN command - for THIS project (use detect_package_manager for the package manager). Do - NOT run either yourself — the user runs them. If you cannot identify a - build command, emit ${AgentSignals.ABORT} build command not found. +STEP 5 — Write the credentials to the env file. (skill: "Write credentials to the env file") + Use the wizard-tools MCP server. Reuse the env file the skill tells you to + pick — the prerequisite PostHog integration usually already wrote + POSTHOG_* vars to one, so seed your keys alongside them. + - First call check_env_keys on that file (returns present/absent, never + values — don't read the file directly). + - Env tool path rule: ${envFilePathGuidance} + - Then call set_env_values, passing the STEP 1 secretRef as a value + object, not a literal string: + values: { + "POSTHOG_CLI_API_KEY": { secretRef: "" }, + "POSTHOG_CLI_PROJECT_ID": "${projectId}", + "POSTHOG_CLI_HOST": "${uiHost}" + } + Variable names follow the skill's per-uploader conventions. The wizard + resolves the ref locally before writing, so you never see the key value. -STEP 7 — Set up CI for automatic uploads. (skill: "Set up CI for automatic uploads") - Source maps only upload when the production build runs, so the build's - CI/CD must carry the same upload credentials you wrote in STEP 5. Do this - step without asking — there is no opt-in question for it. Follow the - skill's "Set up CI for automatic uploads" step — it is the source of - truth for tracing where the production build runs and wiring the - credentials through every layer, whatever the CI provider. - Wizard-specific rules on top: - - Trace the deploy path by reading the project's files — do NOT ask the +${buildRunCommandsStep(6)} + +${ciStep( + 7, + `the same upload credentials you wrote in STEP 5. Do this + step without asking — there is no opt-in question for it.`, + ` - Trace the deploy path by reading the project's files — do NOT ask the user, and do NOT invent config that isn't there. - Carry every manual follow-up the skill has you hand off (secrets the - user must create, an untraceable build path) into STEP 9. + user must create, an untraceable build path) into STEP 9.`, +)} STEP 8 — Offer to test the local setup. (skill: "Test the local setup") Call wizard_ask: @@ -222,17 +327,141 @@ STEP 8 — Offer to test the local setup. (skill: "Test the local setup") After the user continues, revert the test code per the skill's rules and surface any failure in STEP 9. -STEP 9 — Summarise and hand off. (skill: "Verify and hand off") - Follow the skill's "Verify and hand off" step. Write the hand-off to - \`${reportFile}\` at the WIZARD'S WORKING DIRECTORY — pass exactly - \`${reportFile}\` as the file path, never prefixed with the selected - project directory; this file is the one exception to the project-scope - rule above. Cover: the files you changed (paths only), the exact build +${handOffHeader( + 9, + reportFile, +)} Cover: the files you changed (paths only), the exact build and upload commands, every CI secret the user still has to create, and how to verify the upload — then give the same summary in chat. Never write secret values into the report, only variable names. The success - screen points the user at this file, so do not skip it. The Symbol sets page for this project — where the user - confirms the upload landed — is: - ${uiHost}/project/${projectId}/error_tracking/configuration + screen points the user at this file, so do not skip it. ${symbolSetsFooter( + uiHost, + projectId, + )} +`; +} + +type NonInteractivePromptParts = { + context: Parameters[0]; + scopeBlock: string; + skillId: string; + projectId: number; + settingsUrl: string; + uiHost: string; + reportFile: string; +}; + +/** + * The non-interactive (headless / CI) prompt: no API-key ask, no local-test + * offer, committed-files-only credential handling, and its own step + * numbering. Assembled from the shared fragments above plus the sections + * that only exist in this mode. + */ +function buildNonInteractivePrompt(parts: NonInteractivePromptParts): string { + const { + context, + scopeBlock, + skillId, + projectId, + settingsUrl, + uiHost, + reportFile, + } = parts; + + return `${opening(context.platformLabel)} + +This is a non-interactive run: no user is present and there is no way to +ask questions or pause for input — work straight through. Your changes +will be committed and opened as a pull request on the user's repository, so +only committed files matter. + +Source map upload needs a PostHog personal API key at build time, but you +cannot obtain one — the user creates it after this run. Hard rules for the +key: +- Never invent, request, or write an API key value anywhere — not even a + placeholder shaped like a real key. +- Refer to the key ONLY by the environment variable / CI secret name the + skill specifies. +- Creating the key is the user's follow-up work; STEP 7's hand-off report + documents exactly what they must do. + +Dependency changes go through the package manager, never hand-edits of +package.json (or the platform's manifest): run e.g. \`npm install --save-dev +\` so the lockfile updates alongside the manifest. A manifest edit +without its lockfile is a broken pull request — it fails clean installs in +the user's CI. + +${projectContextBlock(context)} + +${scopeBlock} + +${skillSourceOfTruthBlock(1)} Skill steps that gather input +from the user or pause for them do not apply to this run. + +${FOLLOW_IN_ORDER} + +Your FIRST message must contain ONLY parallel TaskCreate tool calls — one +call PER task below (the tool takes a single task per call). Keep every +description to a few words — never a sentence. Do not read files, explore +the project, or write any text first, and keep any thinking before the +calls to a single short sentence. + +${taskListIntro(':')} + 1. Install source maps skill + 2. Apply build-config changes (per skill) + 3. Make credentials readable at build time + 4. Write non-secret config + 5. Identify build & run commands + 6. Set up CI for auto-upload + 7. Summarise & hand off +${TASKUPDATE_RULE} + +${installSkillStep(1, skillId, '2-7')} + +${buildConfigStep(2)} + +${credsReadableStep(3, '')} + +STEP 4 — Write the non-secret config. (skill: "Write credentials to the env file") + The skill's step assumes an interactive run writing a real key into a + local env file; adapt it for this run: + - NEVER read, create, or modify real env files (.env, .env.local, ...), + and never call check_env_keys or set_env_values. + - If the project has a committed env example file (.env.example, + .env.sample, .env.template, .env.dist), add the skill's variable names + there with your normal file tools: the API key variable with an empty + value, and the non-secret values filled in (project ID "${projectId}", + host "${uiHost}"). + - Where the skill's build or CI config takes the non-secret values + directly, prefer literals there over depending on a local env file. + Variable names follow the skill's per-uploader conventions. + +${buildRunCommandsStep(5)} + +${ciStep( + 6, + 'the upload credentials from STEP 4.', + ` - Trace the deploy path by reading the project's files — do NOT invent + config that isn't there. + - Reference the API key strictly as a CI secret, named exactly per the + skill's convention. You cannot create the secret — the user does, so + carry it into STEP 7's report. + - Carry every other manual follow-up the skill has you hand off (an + untraceable build path, provider-side settings) into STEP 7 as well.`, +)} + +${handOffHeader(7, reportFile)} + START the report with a "What you still need to do" section — numbered, + copy-pasteable follow-ups: + 1. Create a personal API key with the 'Source map upload' preset: + ${settingsUrl} + 2. Add it as the CI secret referenced in STEP 6, named exactly as in the + workflow config. + 3. The exact env lines to set locally for local production builds, with + the key's value left blank for the user to fill in. + Then cover: the files you changed (paths only), the exact build and + upload commands, and how to verify the upload — then give the same + summary in chat. Never write secret values into the report, only + variable names. ${symbolSetsFooter(uiHost, projectId)} `; } diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 532ae74e..3a137aad 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -230,9 +230,15 @@ export interface ProgramConfig { * CI-mode pre-run strategy. When set, runWizardCI awaits this after building * the ci:true session and before the agent runs, instead of walking step * onReady hooks. Use for headless prerequisite work (e.g. framework - * detection) that the TUI performs via step onReady callbacks. + * detection) that the TUI performs via step onReady callbacks. Also receives + * the parsed CLI options bag, so program-specific flags (e.g. + * upload-source-maps' selection flags) can seed the session without + * threading new fields through buildSession. */ - ciPreRun?: (session: WizardSession) => Promise; + ciPreRun?: ( + session: WizardSession, + options?: Record, + ) => Promise; /** Prerequisites: other program ids that must have run first */ requires?: string[]; /** diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index 221e68d5..6e8fdcd2 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -181,7 +181,7 @@ export function runNonInteractive( try { if (config.ciPreRun) { - await config.ciPreRun(session); + await config.ciPreRun(session, options); } else { const readyCtx = { session, diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index a228cfe0..e759dee2 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -285,13 +285,18 @@ export class LoggingUI implements WizardUI { // No-op in CI mode } - setFrameworkContext(_key: string, _value: unknown): void { - // No-op in CI mode + // Non-interactive runs have no store, so framework context lives in a plain + // map here: a program's ciPreRun seeds it (e.g. upload-source-maps' project + // selection) and its run config reads it back through the same getUI() + // calls that reach the WizardStore in TUI mode. + private frameworkContext: Record = {}; + + setFrameworkContext(key: string, value: unknown): void { + this.frameworkContext[key] = value; } - getFrameworkContext(_key: string): unknown { - // No frameworkContext in CI mode - return undefined; + getFrameworkContext(key: string): unknown { + return this.frameworkContext[key]; } waitForGate(_stepId: string): Promise {