diff --git a/bin.ts b/bin.ts index 4a5eaacf..9da65731 100644 --- a/bin.ts +++ b/bin.ts @@ -599,9 +599,15 @@ for (const wfConfig of getSubcommandWorkflows()) { cli.command( wfConfig.command!, wfConfig.description, - (y) => y.options(skillSubcommandOptions), + (y) => + y.options({ + ...skillSubcommandOptions, + ...(wfConfig.cliOptions ?? {}), + }), (argv) => { - const options = { ...argv }; + const extras = + wfConfig.mapCliOptions?.(argv as Record) ?? {}; + const options = { ...argv, ...extras }; if (options.ci) { runWizardCI(wfConfig, options); } else { diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 7fdb2265..1ae224b8 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -971,14 +971,29 @@ export async function runAgent( '//tmp/**', '//private/tmp', '//private/tmp/**', - // Package manager stores — allow writes so pnpm/npm can - // install packages without breaking the user's existing setup - '~/Library/pnpm/store/**', // pnpm global store (macOS) - '~/.local/share/pnpm/store/**', // pnpm global store (Linux) + // Package manager stores and toolchain installs — allow writes + // so pnpm/npm/yarn/bun and version managers (corepack, volta) + // can install packages and self-update without breaking the + // user's existing setup. + '~/Library/pnpm/**', // pnpm root (macOS) — store + .tools/ for packageManager pinning + '~/.local/share/pnpm/**', // pnpm root (Linux) '~/.pnpm-store/**', // pnpm alternate store - '~/.npm/**', // npm cache - '~/.yarn/**', // yarn classic cache - '~/.yarn/berry/**', // yarn berry cache + '~/.npm/**', // npm cache (covers _npx too) + '~/.yarn/**', // yarn classic + berry cache + '~/.bun/install/**', // bun cache + global installs + '~/.cache/node/corepack/**', // corepack version downloads (Linux/macOS) + '~/Library/Caches/node/corepack/**', // corepack on older macOS layouts + '~/.volta/**', // Volta toolchain (referenced by workbench package.json) + // Python — used by django/flask/fastapi wizards + '~/.cache/pip/**', + '~/Library/Caches/pip/**', + '~/.cache/uv/**', + '~/Library/Caches/uv/**', + '~/.cache/pypoetry/**', + '~/Library/Caches/pypoetry/**', + // Ruby — used by rails wizard + '~/.bundle/**', + '~/.gem/**', ], }, network: { diff --git a/src/lib/workflows/migration/content/free-tier.tsx b/src/lib/workflows/migration/content/free-tier.tsx new file mode 100644 index 00000000..50801ca2 --- /dev/null +++ b/src/lib/workflows/migration/content/free-tier.tsx @@ -0,0 +1,58 @@ +/** + * PostHog free-tier highlights — the numbers a migrating team gets back when + * they consolidate. Sourced from posthog.com/pricing.md. + */ + +import { Text } from 'ink'; +import { Colors } from '../../../../ui/tui/styles.js'; +import type { ContentBlock } from '../../../../ui/tui/primitives/content-types.js'; + +export const FREE_TIER_BLOCK: ContentBlock = { + type: 'lines', + interval: 400, + pause: 9000, + lines: [ + {' Free every month, on every product'}, + , + + {' 1,000,000 '} + events + product analytics + , + + {' 1,000,000 '} + requests + feature flags + experiments + , + + {' 5,000 '} + recordings + session replay + , + + {' 100,000 '} + exceptions + error tracking + , + + {' 100,000 '} + events + LLM analytics + , + + {' 50 GB '} + logs + logs + , + + {' 1,500 '} + responses + surveys + , + + {' 1,000,000 '} + rows + data warehouse + , + ], +}; diff --git a/src/lib/workflows/migration/content/index.tsx b/src/lib/workflows/migration/content/index.tsx new file mode 100644 index 00000000..45d842e1 --- /dev/null +++ b/src/lib/workflows/migration/content/index.tsx @@ -0,0 +1,258 @@ +/** + * Migration learn deck (statsig variant). Statsig is the only `migrate` + * variant today, so this deck plays as-is when the wizard runs + * `migrate --product=statsig`. Three movements: + * + * 1. Welcome and reassure. + * 2. What to expect — the migration is replacement-only, takes a few + * minutes, leaves the build green. + * 3. What's a little different — how flags and experiments work in + * PostHog, presented as right-way guidance rather than gotchas. + * + * FF/experiments guidance paraphrased from PostHog public docs: + * - posthog.com/docs/feature-flags/best-practices + * - posthog.com/docs/feature-flags/common-questions + * - posthog.com/docs/experiments/best-practices + */ + +import { Text } from 'ink'; +import type { WizardStore } from '../../../../ui/tui/store.js'; +import { Colors } from '../../../../ui/tui/styles.js'; +import { TextRevealMode } from '../../../../ui/tui/primitives/TextBlock.js'; +import type { ContentBlock } from '../../../../ui/tui/primitives/content-types.js'; +import { StatusPeekTrigger } from '../../../../ui/tui/components/StatusPeekTrigger.js'; +import { PRODUCT_SUITE_BLOCK } from '../../posthog-integration/content/product-suite.js'; +import { LINE_CHART_BLOCK } from '../../posthog-integration/content/line-chart.js'; +import { FUNNEL_BLOCK } from '../../posthog-integration/content/funnel.js'; +import { VENDOR_STACK_BLOCK } from './vendor-stack.js'; +import { FREE_TIER_BLOCK } from './free-tier.js'; +import { PRICING_STRUCTURE_BLOCK } from './pricing-structure.js'; + +export const getContentBlocks = (store?: WizardStore): ContentBlock[] => [ + // ── Welcome ──────────────────────────────────────────────────────────── + { + content: 'Hello.', + pause: 3000, + mode: TextRevealMode.Typewriter, + animationInterval: 160, + }, + + { content: 'The Wizard is an agent.', pause: 4000 }, + + { + content: + 'As we speak, it’s making a plan to migrate from Statsig to PostHog.', + pause: 6000, + }, + + { + content: 'PostHog covers the cost of running this agent.', + pause: 4000, + }, + + { type: 'clear', pause: 2000 }, + + { + pause: 5000, + persist: true, + content: , + }, + + { + pause: 6000, + persist: true, + content: ( + + Press{' '} + + S + {' '} + to expand or collapse the status. + + ), + }, + + { type: 'clear', pause: 2000 }, + + // ── What to expect ───────────────────────────────────────────────────── + { content: 'Here’s what to expect.', pause: 3000 }, + + { content: 'The migration takes about ten minutes.', pause: 3000 }, + + { + content: + 'Every Statsig call gets replaced in place with its PostHog equivalent.', + pause: 5500, + }, + + { + content: + 'Nothing new gets added. No extra captures, no surprise instrumentation.', + pause: 5500, + }, + + { + content: + 'The Statsig package gets removed at the end. We’ll run build and lint to clean up after ourselves.', + pause: 6500, + }, + + { type: 'clear', pause: 2000 }, + + // ── What's a little different ───────────────────────────────────────── + { + content: 'A few things work a little differently in PostHog.', + pause: 4500, + }, + + { + content: ( + + Flags evaluate against a stable user. Call{' '} + + identify() + {' '} + first, then check the flag. + + ), + pause: 6000, + persist: true, + }, + + { + content: + 'For anything in the first paint, evaluate server-side and bootstrap the values into the client.', + pause: 6500, + }, + + { + content: ( + + In production, route requests through a reverse proxy to avoid ad + blockers breaking your flags.{'\n'} + https://posthog.com/docs/advanced/proxy + + ), + pause: 6500, + persist: true, + }, + + { + content: + 'When a flag reaches 100% rollout, retire it. Flags are signals, not switches.', + pause: 5500, + }, + + { + content: ( + + Name flags descriptively. No double negatives. Reflect the return type.{' '} + For example + show-new-checkout + . + + ), + pause: 6500, + persist: true, + }, + + { type: 'clear', pause: 1500 }, + + // ── Experiments ──────────────────────────────────────────────────────── + { + content: ( + + Experiments + + ), + pause: 2500, + persist: true, + }, + + { + content: + 'Change one thing per variant. Multiple changes in one variant blur the result.', + pause: 5500, + }, + + { + content: + 'Decide the running time up front. PostHog includes a sample-size and duration calculator in the setup flow.', + pause: 6500, + }, + + { + content: 'Roll out to 5–10% first. Watch the metrics. Then increase.', + pause: 5000, + }, + + { + content: + 'Exclude users who already completed the flow. They can’t be affected by the test.', + pause: 5500, + }, + + { type: 'clear', pause: 1500 }, + + // ── Close ────────────────────────────────────────────────────────────── + { + content: 'Flags and experiments live alongside the rest of your data.', + pause: 4500, + }, + + { + content: 'Ship behind a flag, watch replays, check analytics for impact.', + pause: 4500, + }, + + { type: 'clear', pause: 1500 }, + + { + content: + 'PostHog also provides every other analytics and AI tool to build your product.', + pause: 4500, + }, + + PRODUCT_SUITE_BLOCK, + + { type: 'clear', pause: 1500 }, + + { + content: 'And consolidating onto one platform saves real money.', + pause: 4500, + }, + + { content: 'Here’s the math.', pause: 1500 }, + + VENDOR_STACK_BLOCK, + + { type: 'clear', pause: 1500 }, + + { + content: 'Pricing is usage-based, with a generous free tier.', + pause: 4000, + }, + + FREE_TIER_BLOCK, + + { type: 'clear', pause: 1500 }, + + PRICING_STRUCTURE_BLOCK, + + { type: 'clear', pause: 1500 }, + + { + content: 'Gain clarity and really understand your users.', + pause: 4000, + }, + + { content: 'Use trends to measure growth.', pause: 2500 }, + + LINE_CHART_BLOCK, + + { type: 'clear', pause: 500 }, + + { content: 'Use funnels to reveal bottlenecks.', pause: 2500 }, + + FUNNEL_BLOCK, +]; diff --git a/src/lib/workflows/migration/content/pricing-structure.tsx b/src/lib/workflows/migration/content/pricing-structure.tsx new file mode 100644 index 00000000..e8dc75b7 --- /dev/null +++ b/src/lib/workflows/migration/content/pricing-structure.tsx @@ -0,0 +1,41 @@ +/** + * Pricing structure block — what happens after the free tier. + */ + +import { Text } from 'ink'; +import { Colors } from '../../../../ui/tui/styles.js'; +import type { ContentBlock } from '../../../../ui/tui/primitives/content-types.js'; + +export const PRICING_STRUCTURE_BLOCK: ContentBlock = { + type: 'lines', + interval: 500, + pause: 8000, + lines: [ + {' After the free tier'}, + , + + {' $0 '} + base price · pay only for what you use + , + + {' ◆ '} + per-event prices decrease with volume + , + + {' ◆ '} + no per-seat charges — your whole team is included + , + + {' ◆ '} + web analytics bundled with product analytics + , + + {' ◆ '} + experiments bundled with feature flags + , + + {' ◆ '} + revenue analytics bundled with data warehouse + , + ], +}; diff --git a/src/lib/workflows/migration/content/vendor-stack.tsx b/src/lib/workflows/migration/content/vendor-stack.tsx new file mode 100644 index 00000000..d4723eae --- /dev/null +++ b/src/lib/workflows/migration/content/vendor-stack.tsx @@ -0,0 +1,47 @@ +/** + * Vendor cost stack — the multi-tool baseline a typical migration target has + * before consolidating onto PostHog. Numbers from each vendor's published + * starter pricing. + */ + +import { Text } from 'ink'; +import type { ContentBlock } from '../../../../ui/tui/primitives/content-types.js'; + +export const VENDOR_STACK_BLOCK: ContentBlock = { + type: 'lines', + interval: 600, + pause: 9000, + lines: [ + {' Typical pre-migration stack'}, + , + + {' Sentry'} + {' error tracking '} + {'$26/mo+'} + , + + {' LaunchDarkly'} + {' feature flags '} + {'$8.33/mo+'} + , + + {' Amplitude'} + {' product analytics '} + {'$49/mo+'} + , + + {' Braintrust'} + {' LLM analytics '} + {'$50/mo+'} + , + {' ─────────────────────────────────────'}, + + {' Total'} + {' '} + + {'$133/mo+'} + + , + {' plus ~450KB of JavaScript SDKs'}, + ], +}; diff --git a/src/lib/workflows/migration/index.ts b/src/lib/workflows/migration/index.ts new file mode 100644 index 00000000..3fe437df --- /dev/null +++ b/src/lib/workflows/migration/index.ts @@ -0,0 +1,71 @@ +import type { WorkflowConfig } from '../workflow-step.js'; +import type { AbortCase } from '../../agent/agent-runner.js'; +import { MIGRATION_WORKFLOW } from './steps.js'; +import { getContentBlocks } from './content/index.js'; + +const MIGRATION_REPORT_FILE = 'migration-report.md'; + +const MIGRATION_ABORT_CASES: AbortCase[] = [ + { + match: /^no source-sdk calls found$/i, + message: 'No source-SDK calls found', + body: + 'The migration needs an existing third-party SDK to migrate from. No ' + + 'calls to the source SDK appear anywhere in this project. If you ' + + "haven't installed PostHog yet, you don't need this command — run " + + '`npx @posthog/wizard@latest` to add PostHog from scratch.', + }, +]; + +/** + * Map each `--product=` choice to the context-mill skill ID that handles + * it. Adding a variant: drop a new row here. The CLI `choices` and the + * runtime lookup both read from this map, so the two stay in sync. + */ +const PRODUCT_TO_SKILL_ID = { + statsig: 'migrate-statsig', +} as const; + +type MigrateProduct = keyof typeof PRODUCT_TO_SKILL_ID; +const MIGRATE_PRODUCTS = Object.keys(PRODUCT_TO_SKILL_ID) as MigrateProduct[]; + +export const migrationConfig: WorkflowConfig = { + command: 'migrate', + description: 'Migrate to PostHog from another analytics provider', + flowKey: 'migration', + skillId: PRODUCT_TO_SKILL_ID.statsig, + steps: MIGRATION_WORKFLOW, + reportFile: MIGRATION_REPORT_FILE, + getContentBlocks, + cliOptions: { + product: { + describe: 'Source SDK to migrate from', + type: 'string', + choices: MIGRATE_PRODUCTS, + demandOption: true, + }, + }, + mapCliOptions: (argv) => ({ + skillId: PRODUCT_TO_SKILL_ID[argv.product as MigrateProduct], + }), + run: { + skillId: PRODUCT_TO_SKILL_ID.statsig, + integrationLabel: 'migration', + customPrompt: () => + 'Migrate this project from its existing third-party analytics, ' + + 'feature-flag, and observability tools to PostHog. Run the `migrate` ' + + 'skill end-to-end: follow the step chain starting at ' + + 'references/1-presence.md. Only replace existing source-SDK call sites ' + + 'with PostHog equivalents — make zero unrelated changes and no ' + + `net-new instrumentation. The final report is written to ./${MIGRATION_REPORT_FILE}.`, + successMessage: `Migration complete! View the report at ./${MIGRATION_REPORT_FILE}`, + reportFile: MIGRATION_REPORT_FILE, + docsUrl: '', + spinnerMessage: 'Migrating to PostHog...', + estimatedDurationMinutes: 8, + abortCases: MIGRATION_ABORT_CASES, + }, + requires: ['posthog-integration'], +}; + +export { MIGRATION_WORKFLOW } from './steps.js'; diff --git a/src/lib/workflows/migration/steps.ts b/src/lib/workflows/migration/steps.ts new file mode 100644 index 00000000..7ee5ab47 --- /dev/null +++ b/src/lib/workflows/migration/steps.ts @@ -0,0 +1,36 @@ +import type { Workflow } from '../workflow-step.js'; +import { RunPhase } from '../../wizard-session.js'; + +export const MIGRATION_WORKFLOW: Workflow = [ + { + id: 'intro', + label: 'Welcome', + screen: 'migration-intro', + gate: (session) => session.setupConfirmed, + }, + { + id: 'auth', + label: 'Authentication', + screen: 'auth', + isComplete: (session) => session.credentials !== null, + }, + { + id: 'run', + label: 'Migration', + screen: 'run', + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, + }, + { + id: 'outro', + label: 'Done', + screen: 'outro', + isComplete: (session) => session.outroDismissed, + }, + { + id: 'skills', + label: 'Skills', + screen: 'keep-skills', + }, +]; diff --git a/src/lib/workflows/posthog-integration/content/product-suite.tsx b/src/lib/workflows/posthog-integration/content/product-suite.tsx index e9f37422..2ab19921 100644 --- a/src/lib/workflows/posthog-integration/content/product-suite.tsx +++ b/src/lib/workflows/posthog-integration/content/product-suite.tsx @@ -60,7 +60,9 @@ export const PRODUCT_SUITE_BLOCK: ContentBlock = { , {' ◆ '} - {'Customer Analytics'} + {'Customer Analytics '} + {'◆ '} + {'PostHog Code'} , ], }; diff --git a/src/lib/workflows/workflow-registry.ts b/src/lib/workflows/workflow-registry.ts index 3f02e5c9..e8aa3b68 100644 --- a/src/lib/workflows/workflow-registry.ts +++ b/src/lib/workflows/workflow-registry.ts @@ -18,6 +18,7 @@ import { auditConfig } from './audit/index.js'; import { eventsAuditConfig } from './events-audit/index.js'; import { audit3000Config } from './audit-3000/index.js'; import { posthogDoctorConfig } from './posthog-doctor/index.js'; +import { migrationConfig } from './migration/index.js'; export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ posthogIntegrationConfig, @@ -26,6 +27,7 @@ export const WORKFLOW_REGISTRY: WorkflowConfig[] = [ eventsAuditConfig, audit3000Config, posthogDoctorConfig, + migrationConfig, ]; /** Look up a workflow config by its flowKey. */ diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index 8016ff4a..ddc5d2cb 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -145,6 +145,20 @@ export interface WorkflowConfig { * or skip the run step (posthog-doctor) leave this unset. */ getContentBlocks?: (store?: WizardStore) => ContentBlock[]; + /** + * Subcommand-specific CLI options. Spread into yargs `.options(...)` when the + * workflow's subcommand is registered. Workflow-specific knowledge stays in + * the workflow config, not in bin.ts. Typed as `unknown` to avoid pulling a + * yargs dependency into this module. + */ + cliOptions?: Record; + /** + * Translate parsed CLI argv into extra options the runner consumes. Runs + * after yargs validation, before runWizard/runWizardCI. Use this when a flag + * needs to derive another field (e.g. `--product=statsig` → `skillId: + * 'migrate-statsig'`). + */ + mapCliOptions?: (argv: Record) => Record; } /** diff --git a/src/ui/tui/components/StatusPeekTrigger.tsx b/src/ui/tui/components/StatusPeekTrigger.tsx index ce228e88..882cd5b8 100644 --- a/src/ui/tui/components/StatusPeekTrigger.tsx +++ b/src/ui/tui/components/StatusPeekTrigger.tsx @@ -13,6 +13,7 @@ let peekedOnce = false; interface StatusPeekTriggerProps { store?: WizardStore; + /** How long the status bar stays expanded, in ms. */ duration?: number; } diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index 93727f73..09cc7753 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -23,6 +23,7 @@ import { AGENT_SKILL_STEPS } from '../../lib/workflows/agent-skill/index.js'; export enum Screen { Intro = 'intro', RevenueIntro = 'revenue-intro', + MigrationIntro = 'migration-intro', AgentSkillIntro = 'agent-skill-intro', AuditIntro = 'audit-intro', AuditRun = 'audit-run', @@ -48,6 +49,7 @@ export enum Screen { export enum Flow { PostHogIntegration = 'posthog-integration', RevenueAnalyticsSetup = 'revenue-analytics-setup', + Migration = 'migration', Audit = 'audit', EventsAudit = 'events-audit', Audit3000 = 'audit-3000', diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 5eb87511..2e315a89 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -19,6 +19,7 @@ import { ModalDemo } from './demos/ModalDemo.js'; import { McpDemo } from './demos/McpDemo.js'; import { KeyboardHintsDemo } from './demos/KeyboardHintsDemo.js'; import { AuditChecksDemo } from './demos/AuditChecksDemo.js'; +import { LearnDeckDemo } from './demos/LearnDeckDemo.js'; interface PlaygroundAppProps { store: WizardStore; @@ -65,6 +66,11 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { label: 'Audit checks', component: , }, + { + id: 'learn-deck', + label: 'Learn deck', + component: , + }, ]; return ( diff --git a/src/ui/tui/playground/demos/LearnDeckDemo.tsx b/src/ui/tui/playground/demos/LearnDeckDemo.tsx new file mode 100644 index 00000000..f26f0a62 --- /dev/null +++ b/src/ui/tui/playground/demos/LearnDeckDemo.tsx @@ -0,0 +1,258 @@ +/** + * LearnDeckDemo — flip through every workflow's content deck one block at + * a time so wording, pauses, and visual blocks can be reviewed without + * waiting for the auto-advance timer. + * + * n / p step block (next / previous) + * [ / ] switch deck + * r replay current block (re-runs the reveal animation) + * + * Arrow keys are reserved for the playground's tab switcher, so this demo + * uses letter keys. + * + * Decks are pulled from `WORKFLOW_REGISTRY` so every workflow that ships a + * deck is reviewable here. Migration also gets per-variant entries (one + * per `--product=` choice) so the variant composer in + * `migration/content/index.tsx` can be exercised side-by-side with the + * generic deck. + */ + +import { Box, Text, useInput } from 'ink'; +import { useMemo, useState } from 'react'; +import { + ContentSequencer, + ProgressList, + SplitView, + TextRevealMode, +} from '../../primitives/index.js'; +import type { ContentBlock, ProgressItem } from '../../primitives/index.js'; +import { Colors } from '../../styles.js'; +import type { WizardStore } from '../../store.js'; +import { WORKFLOW_REGISTRY } from '../../../../lib/workflows/workflow-registry.js'; +import { AUDIT_AREA_SLIDES } from '../../screens/audit/slides/index.js'; +import { AUDIT_3000_AREA_SLIDES } from '../../screens/audit-3000/slides/index.js'; +import type { AreaSlide } from '../../screens/audit/slides/shared.js'; + +interface Deck { + id: string; + label: string; + blocks: ContentBlock[]; +} + +/** + * Fake task list to fill the right-hand pane so the SplitView layout matches + * what operators actually see during a real run. Mix of statuses so the + * spinner glyph, the in-progress row, and completed rows all render. + */ +const MOCK_TASKS: ProgressItem[] = [ + { + label: 'Confirm Statsig is in use', + activeForm: 'Confirming Statsig is in use', + status: 'completed', + }, + { + label: 'Install PostHog', + activeForm: 'Installing PostHog', + status: 'completed', + }, + { + label: 'Plan call site replacements', + activeForm: 'Planning call site replacements', + status: 'in_progress', + }, + { + label: 'Rewrite call sites', + activeForm: 'Rewriting call sites', + status: 'pending', + }, + { + label: 'Remove Statsig', + activeForm: 'Removing Statsig', + status: 'pending', + }, + { + label: 'Verify the project still builds', + activeForm: 'Verifying the build', + status: 'pending', + }, + { + label: 'Write migration report', + activeForm: 'Writing migration report', + status: 'pending', + }, +]; + +interface LearnDeckDemoProps { + store: WizardStore; +} + +export const LearnDeckDemo = ({ store }: LearnDeckDemoProps) => { + const decks: Deck[] = useMemo(() => { + const all: Deck[] = []; + + // Every workflow in the registry that ships a deck. Seed the store's + // skillId from the workflow config so decks that template the skill + // name (e.g. agent-skill's "Running the skill...") render the + // real value instead of "unknown". + for (const wf of WORKFLOW_REGISTRY) { + if (!wf.getContentBlocks) continue; + const stub = wf.skillId + ? withSessionOverride(store, { skillId: wf.skillId }) + : store; + all.push({ + id: `workflow:${wf.flowKey}`, + label: `${wf.flowKey} (${wf.command ?? 'default'})${ + wf.skillId ? ` · skill: ${wf.skillId}` : '' + }`, + blocks: wf.getContentBlocks(stub), + }); + } + + // Audit + audit-3000 ship their own per-area slide model (not the + // ContentBlock deck most workflows use). Adapt each AreaSlide into a + // flat ContentBlock list so the flipper can review them the same way. + all.push({ + id: 'audit:area-slides', + label: 'audit · area slides', + blocks: areaSlidesToBlocks(AUDIT_AREA_SLIDES), + }); + all.push({ + id: 'audit-3000:area-slides', + label: 'audit-3000 · area slides', + blocks: areaSlidesToBlocks(AUDIT_3000_AREA_SLIDES), + }); + + return all; + }, [store]); + + const [deckIdx, setDeckIdx] = useState(0); + const [blockIdx, setBlockIdx] = useState(0); + const [replayKey, setReplayKey] = useState(0); + + const deck = decks[deckIdx]; + const block = deck.blocks[blockIdx]; + + useInput((input) => { + if (input === 'p') { + setBlockIdx((i) => Math.max(0, i - 1)); + } else if (input === 'n') { + setBlockIdx((i) => Math.min(deck.blocks.length - 1, i + 1)); + } else if (input === '[') { + setDeckIdx((i) => (i - 1 + decks.length) % decks.length); + setBlockIdx(0); + } else if (input === ']') { + setDeckIdx((i) => (i + 1) % decks.length); + setBlockIdx(0); + } else if (input === 'r') { + setReplayKey((k) => k + 1); + } + }); + + const pauseMs = + typeof block === 'object' && 'pause' in block ? block.pause : '—'; + const blockKind = describeBlockKind(block); + + return ( + + + Learn deck flipper + + n/p step block · [ ] switch deck · r replay + + + + Deck: {deck.label}{' '} + + ({deckIdx + 1}/{decks.length}) + + + + Block: {blockIdx + 1}/{deck.blocks.length}{' '} + + · kind: {blockKind} · pause: {String(pauseMs)}ms + + + + + + + } + right={} + /> + + + ); +}; + +/** + * Build a store proxy that exposes an overridden `session` while keeping + * every prototype method (e.g. `setStatusExpanded`) and atom reference + * intact. A plain `{...store, session: ...}` spread would drop the + * prototype, so anything that called a store method on the result would + * crash at render time. + */ +function withSessionOverride( + store: WizardStore, + patch: Partial, +): WizardStore { + const stub = Object.create(Object.getPrototypeOf(store)) as WizardStore; + Object.assign(stub, store); + Object.defineProperty(stub, 'session', { + value: { ...store.session, ...patch }, + writable: false, + configurable: true, + }); + return stub; +} + +/** + * Adapter: turn each audit AreaSlide into a sequence of ContentBlocks so it + * fits the flipper's renderer. One block per intro paragraph, one for the + * visual when present. Each slide is preceded by a heading block naming the + * area so flipping between areas is obvious. + */ +function areaSlidesToBlocks(slides: AreaSlide[]): ContentBlock[] { + const out: ContentBlock[] = []; + for (const slide of slides) { + out.push({ + content: ( + + {slide.area} + + ), + pause: 3000, + }); + for (const paragraph of slide.intro) { + out.push({ content: paragraph, pause: 5000 }); + } + if (slide.visual) { + out.push({ + content: slide.visual, + pause: 8000, + persist: true, + }); + } + out.push({ type: 'clear', pause: 1000 }); + } + return out; +} + +function describeBlockKind(block: ContentBlock): string { + if (typeof block === 'string') return 'string'; + if (typeof block === 'object' && block !== null) { + if ('type' in block && block.type === 'clear') return 'clear'; + if ('type' in block && block.type === 'lines') return 'lines'; + if ('content' in block) { + return typeof block.content === 'string' ? 'text' : 'jsx'; + } + } + return 'unknown'; +} diff --git a/src/ui/tui/playground/demos/RunScreenDemo.tsx b/src/ui/tui/playground/demos/RunScreenDemo.tsx index 8655cd10..0c454b38 100644 --- a/src/ui/tui/playground/demos/RunScreenDemo.tsx +++ b/src/ui/tui/playground/demos/RunScreenDemo.tsx @@ -18,8 +18,8 @@ import { import type { ProgressItem } from '../../primitives/index.js'; import { LearnCard } from '../../components/LearnCard.js'; import { TipsCard } from '../../components/TipsCard.js'; +import { getContentBlocks as getMigrationContentBlocks } from '../../../../lib/workflows/migration/content/index.js'; import { WIZARD_LOG_FILE } from '../../../../utils/paths.js'; -import { getContentBlocks as getIntegrationContentBlocks } from '../../../../lib/workflows/posthog-integration/content/index.js'; const MOCK_TASKS = [ { @@ -162,6 +162,8 @@ export const RunScreenDemo = ({ store }: RunScreenDemoProps) => { const statuses = store.statusMessages.length > 0 ? store.statusMessages : undefined; + const learnBlocks = getMigrationContentBlocks(store); + const tabs = [ { id: 'status', @@ -174,7 +176,7 @@ export const RunScreenDemo = ({ store }: RunScreenDemoProps) => { ) : ( store.setLearnCardComplete()} /> ) diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 7bea2952..31234240 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -20,6 +20,7 @@ import { ManagedSettingsScreen } from './screens/ManagedSettingsScreen.js'; import { PortConflictScreen } from './screens/PortConflictScreen.js'; import { PostHogIntegrationIntroScreen } from './screens/PostHogIntegrationIntroScreen.js'; import { RevenueIntroScreen } from './screens/RevenueIntroScreen.js'; +import { MigrationIntroScreen } from './screens/MigrationIntroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { AuditIntroScreen } from './screens/audit/AuditIntroScreen.js'; import { AuditRunScreen } from './screens/audit/AuditRunScreen.js'; @@ -64,6 +65,7 @@ export function createScreens( // Wizard flow [Screen.Intro]: , [Screen.RevenueIntro]: , + [Screen.MigrationIntro]: , [Screen.AgentSkillIntro]: , [Screen.AuditIntro]: , [Screen.AuditRun]: , diff --git a/src/ui/tui/screens/MigrationIntroScreen.tsx b/src/ui/tui/screens/MigrationIntroScreen.tsx new file mode 100644 index 00000000..0fa1b695 --- /dev/null +++ b/src/ui/tui/screens/MigrationIntroScreen.tsx @@ -0,0 +1,43 @@ +import { Box, Text } from 'ink'; +import { useSyncExternalStore } from 'react'; +import type { WizardStore } from '../store.js'; +import { IntroScreenLayout } from './IntroScreenLayout.js'; + +interface MigrationIntroScreenProps { + store: WizardStore; +} + +export const MigrationIntroScreen = ({ store }: MigrationIntroScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const { session } = store; + + const body = ( + + Let's migrate this project to PostHog. + + ); + + return ( + { + if (value === 'cancel') { + process.exit(0); + } else { + store.completeSetup(); + } + }} + /> + ); +};