@contentful/experience-design-system-cli 2.12.0 → 2.12.1-dev-build-bae15c7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +214 -357
  2. package/dist/package.json +1 -1
  3. package/dist/src/analyze/extract/astro.js +9 -0
  4. package/dist/src/analyze/extract/react.js +23 -0
  5. package/dist/src/analyze/extract/stencil.js +3 -0
  6. package/dist/src/analyze/extract/vue.js +6 -0
  7. package/dist/src/analyze/extract/web-components.js +10 -0
  8. package/dist/src/analyze/pre-classify.d.ts +1 -1
  9. package/dist/src/analyze/pre-classify.js +3 -3
  10. package/dist/src/analyze/select/persistence.d.ts +13 -0
  11. package/dist/src/analyze/select/persistence.js +38 -0
  12. package/dist/src/analyze/select/preview-annotations.d.ts +26 -0
  13. package/dist/src/analyze/select/preview-annotations.js +62 -0
  14. package/dist/src/analyze/select/tui/App.js +5 -2
  15. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +30 -0
  16. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +137 -0
  17. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +58 -1
  18. package/dist/src/analyze/select/tui/components/FieldEditor.js +637 -161
  19. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +1 -1
  20. package/dist/src/analyze/select/tui/components/JsonPanel.js +5 -1
  21. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +34 -0
  22. package/dist/src/analyze/select/tui/components/RationalePanel.js +92 -0
  23. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +13 -1
  24. package/dist/src/analyze/select/tui/components/Sidebar.js +27 -3
  25. package/dist/src/analyze/select/tui/hooks/scroll-offset.d.ts +29 -0
  26. package/dist/src/analyze/select/tui/hooks/scroll-offset.js +26 -0
  27. package/dist/src/analyze/select-agent/command.d.ts +2 -0
  28. package/dist/src/analyze/select-agent/command.js +260 -45
  29. package/dist/src/analyze/select-agent/show-rationale.d.ts +35 -0
  30. package/dist/src/analyze/select-agent/show-rationale.js +92 -0
  31. package/dist/src/apply/command.js +7 -3
  32. package/dist/src/apply/tui/ServerApplyView.d.ts +3 -1
  33. package/dist/src/apply/tui/ServerApplyView.js +3 -2
  34. package/dist/src/credentials-store.d.ts +5 -0
  35. package/dist/src/credentials-store.js +7 -1
  36. package/dist/src/generate/agent-runner.d.ts +14 -0
  37. package/dist/src/generate/agent-runner.js +16 -0
  38. package/dist/src/generate/command.d.ts +6 -0
  39. package/dist/src/generate/command.js +45 -10
  40. package/dist/src/generate/progress.d.ts +9 -0
  41. package/dist/src/generate/progress.js +11 -0
  42. package/dist/src/generate/prompt-builder.d.ts +8 -0
  43. package/dist/src/generate/prompt-builder.js +22 -11
  44. package/dist/src/import/agent-model-resolve.d.ts +23 -0
  45. package/dist/src/import/agent-model-resolve.js +35 -0
  46. package/dist/src/import/auto-filter-resolve.d.ts +12 -0
  47. package/dist/src/import/auto-filter-resolve.js +16 -0
  48. package/dist/src/import/command.js +248 -8
  49. package/dist/src/import/orchestrator.d.ts +2 -0
  50. package/dist/src/import/orchestrator.js +11 -3
  51. package/dist/src/import/print-prompt.d.ts +35 -0
  52. package/dist/src/import/print-prompt.js +31 -0
  53. package/dist/src/import/tui/CustomPromptBanner.d.ts +11 -0
  54. package/dist/src/import/tui/CustomPromptBanner.js +7 -0
  55. package/dist/src/import/tui/WizardApp.d.ts +112 -8
  56. package/dist/src/import/tui/WizardApp.js +820 -333
  57. package/dist/src/import/tui/auto-filter-error.d.ts +2 -0
  58. package/dist/src/import/tui/auto-filter-error.js +29 -0
  59. package/dist/src/import/tui/final-review-host.d.ts +15 -0
  60. package/dist/src/import/tui/final-review-host.js +20 -0
  61. package/dist/src/import/tui/merge-ai-decisions.d.ts +5 -0
  62. package/dist/src/import/tui/merge-ai-decisions.js +22 -0
  63. package/dist/src/import/tui/push-decision-gate-helpers.d.ts +8 -0
  64. package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
  65. package/dist/src/import/tui/push-progress.d.ts +23 -0
  66. package/dist/src/import/tui/push-progress.js +14 -0
  67. package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
  68. package/dist/src/import/tui/run-print-files-helpers.js +8 -0
  69. package/dist/src/import/tui/run-teaser.d.ts +5 -0
  70. package/dist/src/import/tui/run-teaser.js +9 -0
  71. package/dist/src/import/tui/runLivePreview.d.ts +35 -0
  72. package/dist/src/import/tui/runLivePreview.js +73 -0
  73. package/dist/src/import/tui/runScopeGate.d.ts +22 -0
  74. package/dist/src/import/tui/runScopeGate.js +28 -0
  75. package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
  76. package/dist/src/import/tui/scope-gate-host.js +20 -0
  77. package/dist/src/import/tui/spawn-generate.d.ts +33 -0
  78. package/dist/src/import/tui/spawn-generate.js +53 -0
  79. package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
  80. package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
  81. package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
  82. package/dist/src/import/tui/steps/DoneStep.js +10 -4
  83. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
  84. package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
  85. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
  86. package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
  87. package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
  88. package/dist/src/import/tui/steps/PushingStep.js +40 -0
  89. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
  90. package/dist/src/import/tui/steps/ScopeGateStep.js +250 -0
  91. package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
  92. package/dist/src/import/tui/useLivePreview.d.ts +35 -0
  93. package/dist/src/import/tui/useLivePreview.js +120 -0
  94. package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
  95. package/dist/src/import/tui/wizard-generate-progress.js +25 -0
  96. package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
  97. package/dist/src/import/tui/wizard-save-flow.js +20 -0
  98. package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
  99. package/dist/src/import/tui/wizard-state-transitions.js +79 -0
  100. package/dist/src/lib/contentful-urls.d.ts +34 -0
  101. package/dist/src/lib/contentful-urls.js +41 -0
  102. package/dist/src/program.js +2 -0
  103. package/dist/src/runs/export-helpers.d.ts +30 -0
  104. package/dist/src/runs/export-helpers.js +59 -0
  105. package/dist/src/runs/fingerprint.d.ts +47 -0
  106. package/dist/src/runs/fingerprint.js +75 -0
  107. package/dist/src/runs/ls-command.d.ts +12 -0
  108. package/dist/src/runs/ls-command.js +137 -0
  109. package/dist/src/runs/modify-launcher.d.ts +24 -0
  110. package/dist/src/runs/modify-launcher.js +36 -0
  111. package/dist/src/runs/path-prompt.d.ts +8 -0
  112. package/dist/src/runs/path-prompt.js +72 -0
  113. package/dist/src/runs/push-creds-prompt.d.ts +25 -0
  114. package/dist/src/runs/push-creds-prompt.js +39 -0
  115. package/dist/src/runs/push-helpers.d.ts +29 -0
  116. package/dist/src/runs/push-helpers.js +69 -0
  117. package/dist/src/runs/replay-helpers.d.ts +67 -0
  118. package/dist/src/runs/replay-helpers.js +148 -0
  119. package/dist/src/runs/resolve-run-target.d.ts +12 -0
  120. package/dist/src/runs/resolve-run-target.js +45 -0
  121. package/dist/src/runs/run-picker-mount.d.ts +46 -0
  122. package/dist/src/runs/run-picker-mount.js +72 -0
  123. package/dist/src/runs/run-picker.d.ts +17 -0
  124. package/dist/src/runs/run-picker.js +145 -0
  125. package/dist/src/runs/save-conflict.d.ts +8 -0
  126. package/dist/src/runs/save-conflict.js +56 -0
  127. package/dist/src/runs/save-path-resolver.d.ts +55 -0
  128. package/dist/src/runs/save-path-resolver.js +71 -0
  129. package/dist/src/runs/staleness.d.ts +38 -0
  130. package/dist/src/runs/staleness.js +140 -0
  131. package/dist/src/runs/store.d.ts +78 -0
  132. package/dist/src/runs/store.js +136 -0
  133. package/dist/src/session/cache-keys.d.ts +15 -0
  134. package/dist/src/session/cache-keys.js +38 -0
  135. package/dist/src/session/db.d.ts +89 -3
  136. package/dist/src/session/db.js +447 -104
  137. package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
  138. package/dist/src/setup/auto-filter-prompt.js +24 -0
  139. package/dist/src/setup/command.d.ts +7 -0
  140. package/dist/src/setup/command.js +58 -2
  141. package/dist/src/types.d.ts +6 -0
  142. package/package.json +2 -2
  143. package/skills/generate-components.md +54 -6
  144. package/skills/select-components.md +13 -4
@@ -4,10 +4,19 @@ import { Box, Text, useStdout } from 'ink';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { appendFileSync, writeFileSync } from 'node:fs';
6
6
  import { access, readFile, stat } from 'node:fs/promises';
7
- import { homedir, tmpdir } from 'node:os';
7
+ import { tmpdir } from 'node:os';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { execFile, spawn } from 'node:child_process';
10
+ import { mkdir } from 'node:fs/promises';
11
+ import { buildRunTeaserLine } from './run-teaser.js';
12
+ import { PathPrompt } from '../../runs/path-prompt.js';
13
+ import { RunPicker } from '../../runs/run-picker.js';
14
+ import { SaveConflictGate } from '../../runs/save-conflict.js';
15
+ import { detectSaveConflict, buildTimestampedSubdir, resolveSavePath, } from '../../runs/save-path-resolver.js';
16
+ import { appendRun } from '../../runs/store.js';
17
+ import { buildSourceFingerprint, buildSavedFingerprint } from '../../runs/fingerprint.js';
10
18
  import { TopBar } from '../../analyze/select/tui/components/TopBar.js';
19
+ import { CustomPromptBanner } from './CustomPromptBanner.js';
11
20
  import { WelcomeStep } from './steps/WelcomeStep.js';
12
21
  import { PathValidationStep } from './steps/PathValidationStep.js';
13
22
  import { RunningStep } from './steps/RunningStep.js';
@@ -17,30 +26,98 @@ import { WizardPreviewStep } from './steps/WizardPreviewStep.js';
17
26
  import { DoneStep } from './steps/DoneStep.js';
18
27
  import { ErrorStep } from './steps/ErrorStep.js';
19
28
  import { TokenInputStep } from './steps/TokenInputStep.js';
20
- import { GenerateReviewStep } from './steps/GenerateReviewStep.js';
21
29
  import { PreviewValidationErrorStep } from './steps/PreviewValidationErrorStep.js';
30
+ import { PushingStep } from './steps/PushingStep.js';
31
+ import { computePushExpected } from './push-progress.js';
32
+ import { nextStateAfterPrint } from './run-print-files-helpers.js';
33
+ import { PushDecisionGateStep } from './steps/PushDecisionGateStep.js';
34
+ import { chooseGateAction } from './push-decision-gate-helpers.js';
22
35
  import { ImportApiClient, ApiError } from '../../apply/api-client.js';
23
36
  import { handlePreview422, applySkipValidationErrors, clearedValidationErrorState } from './wizard-422-helpers.js';
37
+ import { parseGenerateStderrChunk } from './wizard-generate-progress.js';
38
+ import { spawnGenerateChild } from './spawn-generate.js';
24
39
  import { readTokensFromPath, hasBreakingChangesWithImpact } from '../../apply/manifest.js';
25
40
  import { buildManifest } from '@contentful/experience-design-system-types';
26
- import { openPipelineDb, loadCDFComponents, seedCDFFromPreviewResponse, seedDefaultsFromChangedItems, backfillUnclassifiedProps, } from '../../session/db.js';
41
+ import { openPipelineDb, loadCDFComponents, loadScopeComponents, seedCDFFromPreviewResponse, seedDefaultsFromChangedItems, backfillUnclassifiedProps, } from '../../session/db.js';
42
+ import { ScopeGateHost } from './scope-gate-host.js';
43
+ import { mergeAiDecisions } from './merge-ai-decisions.js';
44
+ import { FinalReviewHost } from './final-review-host.js';
45
+ import { runScopeGate } from './runScopeGate.js';
46
+ import { buildAutoFilterErrorTail } from './auto-filter-error.js';
27
47
  import { checkAgentAuth } from '../../generate/agent-runner.js';
28
48
  import { normalizePath } from '../path-utils.js';
29
49
  import { DEFAULT_CONFIGURED_HOST, toConfiguredHost } from '../../host-utils.js';
30
50
  import { writeExperiencesCredentials } from '../../credentials-store.js';
51
+ import { nextStepAfterScopeGate, nextStepAfterCredentialsValidated, shouldBypassPreview, buildSkippedPreviewTransition, shouldRefusePush, buildSkippedPushTransition, } from './wizard-state-transitions.js';
31
52
  function findCliPath() {
32
53
  return join(fileURLToPath(import.meta.url), '..', '..', '..', '..', '..', 'bin', 'cli.js');
33
54
  }
34
- export function buildAnalyzeSelectArgs(opts) {
35
- const args = ['analyze', 'select', '--session', opts.sessionId];
36
- if (opts.acceptAll) {
37
- // The wizard pre-shows validation errors in the analyze-extract TUI before
38
- // reaching this gate, so the user has already seen what will be excluded.
39
- // Pass --exclude-invalid to bypass the headless fail-loud gate (which is
40
- // for CI / scripted callers that need to fail loud) — the wizard surfaces
41
- // the auto-rejection via formatAcceptanceSummary on the next screen.
42
- args.push('--select-all', '--exclude-invalid');
55
+ export function buildSelectAgentArgs(opts) {
56
+ // Feature 3: the wizard auto-filter run should never fail-loud on validation
57
+ // errors — those components surface in the AI-excluded section with a
58
+ // synthesized reason, not an exit-1 abort. So we always pass --exclude-invalid.
59
+ const args = ['analyze', 'select-agent', '--agent', opts.agent, '--session', opts.sessionId, '--exclude-invalid'];
60
+ if (opts.model)
61
+ args.push('--model', opts.model);
62
+ if (opts.selectPromptPath)
63
+ args.push('--select-prompt-path', opts.selectPromptPath);
64
+ if (opts.noCache)
65
+ args.push('--no-cache');
66
+ return args;
67
+ }
68
+ export function parseAutoFilterProgressLine(line) {
69
+ // Format: progress=select-agent:N/M:<decision>:<name>:<url-encoded-reason>
70
+ // Name and reason CANNOT contain `:` raw (name comes from component identifier
71
+ // which forbids colons; reason is URL-encoded). We split with a limit so any
72
+ // stray colon inside the URL-encoded reason wouldn't matter — but the encoder
73
+ // also handles `:` as `%3A` so this is defensive.
74
+ const prefix = 'progress=select-agent:';
75
+ if (!line.startsWith(prefix))
76
+ return null;
77
+ const rest = line.slice(prefix.length);
78
+ const parts = rest.split(':');
79
+ if (parts.length < 4)
80
+ return null;
81
+ const [counter, decision, name, ...reasonParts] = parts;
82
+ if (!counter)
83
+ return null;
84
+ const counterMatch = /^(\d+)\/(\d+)$/.exec(counter);
85
+ if (!counterMatch)
86
+ return null;
87
+ if (decision !== 'accepted' && decision !== 'rejected' && decision !== 'failed')
88
+ return null;
89
+ if (!name)
90
+ return null;
91
+ const encodedReason = reasonParts.join(':');
92
+ let reason = '';
93
+ try {
94
+ reason = decodeURIComponent(encodedReason);
43
95
  }
96
+ catch {
97
+ reason = encodedReason;
98
+ }
99
+ return {
100
+ n: Number(counterMatch[1]),
101
+ total: Number(counterMatch[2]),
102
+ decision,
103
+ name,
104
+ reason,
105
+ };
106
+ }
107
+ export function buildGenerateComponentsArgs(opts) {
108
+ // Default behavior preserves the SHA cache (re-runs only re-classify
109
+ // changed components). The operator opts into a full re-classify pass via
110
+ // `experiences import --no-cache`, which forwards through to the spawned
111
+ // `generate components` subprocess. See wizard-cache.test.ts.
112
+ const args = ['generate', 'components', '--agent', opts.agent, '--session', opts.sessionId];
113
+ if (opts.tokensPath)
114
+ args.push('--tokens', opts.tokensPath);
115
+ if (opts.model)
116
+ args.push('--model', opts.model);
117
+ if (opts.noCache)
118
+ args.push('--no-cache');
119
+ if (opts.generatePromptPath)
120
+ args.push('--generate-prompt-path', opts.generatePromptPath);
44
121
  return args;
45
122
  }
46
123
  export function formatAcceptanceSummary(opts) {
@@ -49,16 +126,6 @@ export function formatAcceptanceSummary(opts) {
49
126
  return `${acceptedClause}.`;
50
127
  return `${acceptedClause}, ${opts.autoRejected} excluded due to validation errors.`;
51
128
  }
52
- export function formatGeneratedSummary(opts) {
53
- const generatedClause = `Generated definitions for ${opts.generated} component${opts.generated === 1 ? '' : 's'}.`;
54
- const renamedClause = opts.renamedSlots > 0
55
- ? ` ${opts.renamedSlots} unnamed slot${opts.renamedSlots === 1 ? '' : 's'} renamed (children / slot_<n>) so the LLM could classify them.`
56
- : '';
57
- const exclusionClause = opts.autoRejected > 0
58
- ? ` ${opts.autoRejected} component${opts.autoRejected === 1 ? '' : 's'} excluded earlier due to validation errors.`
59
- : '';
60
- return `${generatedClause}${renamedClause}${exclusionClause}`;
61
- }
62
129
  function runCli(args) {
63
130
  return new Promise((res) => {
64
131
  execFile('node', [findCliPath(), ...args], (error, stdout, stderr) => {
@@ -66,12 +133,22 @@ function runCli(args) {
66
133
  });
67
134
  });
68
135
  }
136
+ /**
137
+ * Parse the `wrote tokens.json (N tokens)` confirmation line emitted by
138
+ * `experiences print tokens`. Returns 0 when the line cannot be parsed —
139
+ * the wizard still records the run; downstream consumers should treat 0 as
140
+ * "unknown" rather than "no tokens".
141
+ */
142
+ export function parsePrintTokensCount(stdout) {
143
+ const m = /\((\d+)\s+token/.exec(stdout);
144
+ return m ? Number(m[1]) : 0;
145
+ }
69
146
  const WIZARD_LOG = join(tmpdir(), 'experiences-import-wizard.log');
70
147
  function logStep(entry) {
71
148
  const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
72
149
  appendFileSync(WIZARD_LOG, line);
73
150
  }
74
- export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', initialHost, initialAgent, initialProjectPath, host, } = {}) {
151
+ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master', initialCmaToken = '', initialHost, initialAgent, initialModel, initialProjectPath, host, autoAcceptScope = false, noCache = false, autoFilter = true, livePreview = true, noPush = false, noSave = false, outDirOverride, onConflictMode, selectPromptPath, generatePromptPath, seedExtractSessionId, seedGenerateSessionId, seedTokenSessionId, initialStep, initialRawTokensPath, initialRuns, onRunPicked, } = {}) {
75
152
  const defaultConfiguredHost = toConfiguredHost(host || process.env['EDS_HOST']) ?? DEFAULT_CONFIGURED_HOST;
76
153
  const resolveWizardHost = (hostValue) => hostValue || defaultConfiguredHost;
77
154
  const { stdout } = useStdout();
@@ -86,18 +163,57 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
86
163
  extractSessionId: null,
87
164
  tokensPath: '',
88
165
  });
166
+ // Feature 3: holds the spawned `analyze select-agent` subprocess so the
167
+ // scope-gate's `q` (during running) can SIGTERM it for cancellation.
168
+ const autoFilterChildRef = useRef(null);
169
+ // Promise that resolves when the auto-filter subprocess fully exits. Used
170
+ // by `cancelAutoFilterAndWait` so scope-gate confirm can guarantee its
171
+ // snapshot write goes AFTER the subprocess's last write.
172
+ const autoFilterDonePromiseRef = useRef(null);
173
+ // Background `generate components` subprocess spawned from scope-gate
174
+ // confirm so its LLM call overlaps with the operator's credential entry.
175
+ // Held in refs so we can SIGTERM on credential failure / quit, and await
176
+ // the promise from inside `validateCredentials` once creds come back OK.
177
+ const generateChildRef = useRef(null);
178
+ const generatePromiseRef = useRef(null);
179
+ // Modify-entry short-circuit: when the launcher passes seed session IDs
180
+ // and `initialStep: 'final-review'`, skip the welcome/token-input bootstrap
181
+ // and land directly on the post-generate review screen. The DB-backed
182
+ // GenerateReviewStep loads its data off `state.extractSessionId`, so all
183
+ // we need to do here is seed the IDs and the step.
184
+ const modifyEntryReady = !!seedExtractSessionId && initialStep === 'final-review';
185
+ // Headless raw-tokens entry: when the operator passed `--raw-tokens <path>`
186
+ // the CLI seeds this prop. Skip welcome + token-input and land on the
187
+ // `generating-tokens` step which already drives the token-classification
188
+ // subprocess off `state.rawTokensPath`. Modify-entry wins if both are set.
189
+ const rawTokensEntryReady = !modifyEntryReady && !!initialRawTokensPath;
190
+ const initialStepResolved = modifyEntryReady
191
+ ? 'final-review'
192
+ : rawTokensEntryReady
193
+ ? 'generating-tokens'
194
+ : initialProjectPath
195
+ ? 'token-input'
196
+ : 'welcome';
197
+ const initialOutDir = initialProjectPath ? join(resolve(initialProjectPath), '.contentful') : '';
198
+ const initialTokensPath = modifyEntryReady && initialOutDir ? join(initialOutDir, 'tokens.json') : '';
89
199
  const [state, setState] = useState({
90
- step: initialProjectPath ? 'token-input' : 'welcome',
200
+ step: modifyEntryReady || rawTokensEntryReady
201
+ ? initialStepResolved
202
+ : initialRuns && initialRuns.length > 0
203
+ ? 'run-picker'
204
+ : initialStepResolved,
91
205
  agent: initialAgent ?? 'claude',
206
+ ...(initialModel ? { agentModel: initialModel } : {}),
92
207
  projectPath: initialProjectPath ?? '',
93
- outDir: initialProjectPath ? join(resolve(initialProjectPath), '.contentful') : '',
94
- rawTokensPath: '',
95
- tokensPath: '',
208
+ outDir: initialOutDir,
209
+ rawTokensPath: rawTokensEntryReady ? initialRawTokensPath : '',
210
+ tokensPath: initialTokensPath,
96
211
  tokenSourceChanged: null,
97
212
  skipComponents: false,
98
- tokenSessionId: null,
99
- extractSessionId: null,
100
- generateSessionId: null,
213
+ tokenSessionId: seedTokenSessionId ?? null,
214
+ tokenCount: 0,
215
+ extractSessionId: seedExtractSessionId ?? null,
216
+ generateSessionId: seedGenerateSessionId ?? null,
101
217
  extractedCount: 0,
102
218
  acceptedCount: 0,
103
219
  autoRejectedCount: 0,
@@ -115,9 +231,10 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
115
231
  serverPreview: null,
116
232
  manifest: null,
117
233
  pushProgress: null,
234
+ pushExpected: null,
118
235
  pushResult: {
119
- componentTypes: { created: 0, updated: 0, failed: 0 },
120
- designTokens: { created: 0, updated: 0, failed: 0 },
236
+ componentTypes: { created: 0, updated: 0, removed: 0, failed: 0 },
237
+ designTokens: { created: 0, updated: 0, removed: 0, failed: 0 },
121
238
  },
122
239
  errorStep: '',
123
240
  errorMessage: '',
@@ -125,6 +242,15 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
125
242
  authCheckStepNumber: 1,
126
243
  previewValidationErrors: [],
127
244
  previewValidationMissingNames: [],
245
+ aiFilterStatus: 'idle',
246
+ aiFilterProgress: null,
247
+ aiDecisions: {},
248
+ aiFilterError: null,
249
+ credentialsValidating: false,
250
+ generatePrefetchStatus: 'idle',
251
+ generatePrefetchError: null,
252
+ credentialsSkipped: false,
253
+ lastRunId: null,
128
254
  });
129
255
  useEffect(() => {
130
256
  sessionRef.current = {
@@ -209,15 +335,10 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
209
335
  // ── Step runners ────────────────────────────────────────────────────────
210
336
  const runGenerateTokens = async (rawTokensPath, outDir) => {
211
337
  const result = await new Promise((res) => {
212
- const child = spawn('node', [
213
- findCliPath(),
214
- 'generate',
215
- 'tokens',
216
- '--agent',
217
- state.agent,
218
- '--raw-tokens',
219
- rawTokensPath,
220
- ]);
338
+ const tokenArgs = [findCliPath(), 'generate', 'tokens', '--agent', state.agent, '--raw-tokens', rawTokensPath];
339
+ if (state.agentModel)
340
+ tokenArgs.push('--model', state.agentModel);
341
+ const child = spawn('node', tokenArgs);
221
342
  let stdout = '';
222
343
  let stderr = '';
223
344
  child.stdout.on('data', (d) => {
@@ -251,7 +372,8 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
251
372
  });
252
373
  return;
253
374
  }
254
- update({ step: 'path-validation', tokensPath, tokenSessionId });
375
+ const tokenCount = parsePrintTokensCount(r.stdout);
376
+ update({ step: 'path-validation', tokensPath, tokenSessionId, tokenCount });
255
377
  };
256
378
  const runExtract = async (projectPath) => {
257
379
  const outDir = join(resolve(projectPath), '.contentful');
@@ -321,93 +443,249 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
321
443
  return;
322
444
  }
323
445
  update({
324
- step: 'review-extraction-gate',
446
+ step: 'scope-gate',
325
447
  extractSessionId,
326
448
  extractedCount,
449
+ aiFilterStatus: autoFilter ? 'running' : 'idle',
450
+ aiFilterProgress: autoFilter ? { done: 0, total: extractedCount } : null,
451
+ aiDecisions: {},
452
+ aiFilterError: null,
327
453
  });
454
+ if (autoFilter && extractSessionId) {
455
+ autoFilterDonePromiseRef.current = runAutoFilter(extractSessionId);
456
+ }
328
457
  };
329
- const runAnalyzeSelect = async (sessionId, extractedCount, tokensPath, acceptAll) => {
330
- logStep({
331
- fn: 'runAnalyzeSelect:enter',
332
- sessionId,
333
- extractedCount,
334
- acceptAll,
458
+ // Feature 3: spawn `analyze select-agent` after extract and stream decisions
459
+ // into wizard state via stderr progress lines. The subprocess writes
460
+ // `raw_components.status` + `reject_reason` itself (see Task 2), so the
461
+ // scope-gate UI re-loads via `loadScopeComponents` to get fresh data on every
462
+ // render — but we also keep a memory-side `aiDecisions` map for streaming UX.
463
+ const runAutoFilter = (sessionId) => {
464
+ return new Promise((res) => {
465
+ const args = buildSelectAgentArgs({
466
+ sessionId,
467
+ agent: state.agent,
468
+ ...(state.agentModel ? { model: state.agentModel } : {}),
469
+ selectPromptPath,
470
+ noCache,
471
+ });
472
+ const child = spawn('node', [findCliPath(), ...args]);
473
+ autoFilterChildRef.current = child;
474
+ let stderr = '';
475
+ child.stderr.on('data', (d) => {
476
+ const chunk = String(d);
477
+ stderr += chunk;
478
+ for (const line of chunk.split('\n')) {
479
+ const trimmed = line.trim();
480
+ if (!trimmed)
481
+ continue;
482
+ const parsed = parseAutoFilterProgressLine(trimmed);
483
+ if (!parsed)
484
+ continue;
485
+ setState((prev) => ({
486
+ ...prev,
487
+ aiFilterProgress: { done: parsed.n, total: parsed.total },
488
+ aiDecisions: {
489
+ ...prev.aiDecisions,
490
+ [parsed.name]: { decision: parsed.decision, reason: parsed.reason },
491
+ },
492
+ }));
493
+ }
494
+ });
495
+ // Use 'close' (not 'exit') so the status flip happens after the child's
496
+ // stdio AND its inherited SQLite WAL/SHM file handles have been fully
497
+ // released by the OS. 'exit' fires the instant the process terminates;
498
+ // setting state then triggers a wizard re-render that re-opens
499
+ // pipeline.db from the scope-gate step, and the lock from the dead
500
+ // child's still-mapped WAL handle surfaces as "database is locked".
501
+ child.on('close', (code, signal) => {
502
+ autoFilterChildRef.current = null;
503
+ if (signal === 'SIGTERM') {
504
+ setState((prev) => ({ ...prev, aiFilterStatus: 'cancelled' }));
505
+ }
506
+ else if ((code ?? 0) !== 0) {
507
+ const tail = buildAutoFilterErrorTail(stderr);
508
+ setState((prev) => ({
509
+ ...prev,
510
+ aiFilterStatus: 'failed',
511
+ aiFilterError: tail || `exit ${code}`,
512
+ }));
513
+ }
514
+ else {
515
+ setState((prev) => ({ ...prev, aiFilterStatus: 'complete' }));
516
+ }
517
+ res();
518
+ });
519
+ child.on('error', (err) => {
520
+ autoFilterChildRef.current = null;
521
+ setState((prev) => ({
522
+ ...prev,
523
+ aiFilterStatus: 'failed',
524
+ aiFilterError: err.message,
525
+ }));
526
+ res();
527
+ });
335
528
  });
336
- let acceptedCount;
337
- if (!acceptAll && state.serverPreview && !process.env['EDS_PREVIEW_ANNOTATIONS']) {
338
- process.env['EDS_PREVIEW_ANNOTATIONS'] = JSON.stringify(buildPreviewAnnotations(state.serverPreview));
339
- }
340
- if (!acceptAll) {
341
- update({ step: 'analyze-select' });
529
+ };
530
+ const cancelAutoFilter = () => {
531
+ const child = autoFilterChildRef.current;
532
+ if (child && !child.killed) {
533
+ try {
534
+ child.kill('SIGTERM');
535
+ }
536
+ catch {
537
+ // best-effort
538
+ }
342
539
  }
343
- const args = buildAnalyzeSelectArgs({ sessionId, acceptAll });
344
- const r = acceptAll ? await runCli(args) : await runCliInteractive(args);
345
- logStep({ fn: 'runAnalyzeSelect:post-spawn', exitCode: r.exitCode, args });
346
- if (!acceptAll)
347
- clearPreviewEnvVars();
348
- if (r.exitCode !== 0) {
349
- update({
350
- step: 'error',
351
- errorStep: 'analyze select',
352
- errorMessage: r.stderr.trim() || 'Unknown error',
353
- });
540
+ };
541
+ // Variant that returns a Promise resolving when the subprocess has fully
542
+ // exited. Used by scope-gate confirm to guarantee operator-write-last
543
+ // ordering on the review-state snapshot (PR #43 race fix).
544
+ const cancelAutoFilterAndWait = async () => {
545
+ const child = autoFilterChildRef.current;
546
+ const donePromise = autoFilterDonePromiseRef.current;
547
+ if (!child || child.killed) {
548
+ // Subprocess already gone (or never started). If the Promise is still
549
+ // pending — e.g. exit handler hasn't fired yet — await it; otherwise
550
+ // resolve immediately.
551
+ if (donePromise)
552
+ await donePromise;
354
553
  return;
355
554
  }
356
- // Read accepted count from the review state file (since TUI subprocess inherits stdio
357
- // in the manual review path; in the bulk path we also persist to the same file).
358
- // Also extract auto-rejected count (components with error-severity validation issues
359
- // dropped by the gate) so the user sees what was excluded on the next step.
360
- const artifactsRoot = process.env['EDS_REVIEW_ARTIFACTS_DIR']
361
- ? resolve(process.env['EDS_REVIEW_ARTIFACTS_DIR'])
362
- : resolve(homedir(), '.contentful', 'experience-design-system-cli', 'reviews');
363
- const reviewStatePath = resolve(artifactsRoot, sessionId, 'current-review-state.json');
364
- let autoRejectedCount = 0;
365
555
  try {
366
- const reviewState = JSON.parse(await readFile(reviewStatePath, 'utf8'));
367
- acceptedCount = reviewState.components.filter((c) => c.status === 'accepted').length;
368
- autoRejectedCount = reviewState.components.filter((c) => c.status === 'rejected' && (c.originalProposal.validationIssues ?? []).some((i) => i.severity === 'error')).length;
556
+ child.kill('SIGTERM');
369
557
  }
370
558
  catch {
371
- acceptedCount = extractedCount;
559
+ // best-effort
372
560
  }
373
- update({ acceptedCount, autoRejectedCount });
374
- if (acceptedCount > 0) {
375
- if (await runAgentAuthCheck('generating')) {
376
- void runGenerate(sessionId, tokensPath, acceptedCount);
561
+ if (donePromise)
562
+ await donePromise;
563
+ };
564
+ // Cancel a running generation prefetch (SIGTERM the child + clear refs +
565
+ // reset state). Best-effort — if the child has already exited, this is a
566
+ // no-op aside from clearing the prefetch status.
567
+ const cancelGeneratePrefetch = () => {
568
+ const child = generateChildRef.current;
569
+ if (child && !child.killed) {
570
+ try {
571
+ child.kill('SIGTERM');
572
+ }
573
+ catch {
574
+ // best-effort
377
575
  }
378
576
  }
379
- else {
380
- advanceToPushFlow(0);
381
- }
577
+ generateChildRef.current = null;
578
+ generatePromiseRef.current = null;
579
+ setState((prev) => ({
580
+ ...prev,
581
+ generatePrefetchStatus: 'idle',
582
+ generatePrefetchError: null,
583
+ generateProgress: null,
584
+ }));
585
+ };
586
+ // Spawn `generate components` in the background. Wires up the same
587
+ // stderr-progress streaming as `runGenerate` but stores the in-flight
588
+ // promise in a ref so the post-credentials path can await it.
589
+ const startGeneratePrefetch = (extractSessionId, tokensPath) => {
590
+ const args = [
591
+ findCliPath(),
592
+ ...buildGenerateComponentsArgs({
593
+ sessionId: extractSessionId,
594
+ tokensPath,
595
+ agent: state.agent,
596
+ ...(state.agentModel ? { model: state.agentModel } : {}),
597
+ noCache,
598
+ }),
599
+ ];
600
+ let progressCursor = null;
601
+ const { child, donePromise } = spawnGenerateChild({
602
+ command: 'node',
603
+ args,
604
+ onStderr: (chunk) => {
605
+ const nextProgress = parseGenerateStderrChunk(chunk, progressCursor);
606
+ if (nextProgress !== progressCursor) {
607
+ progressCursor = nextProgress;
608
+ setState((prev) => ({ ...prev, generateProgress: nextProgress }));
609
+ }
610
+ },
611
+ });
612
+ generateChildRef.current = child;
613
+ generatePromiseRef.current = donePromise;
614
+ setState((prev) => ({
615
+ ...prev,
616
+ generatePrefetchStatus: 'running',
617
+ generatePrefetchError: null,
618
+ }));
619
+ donePromise
620
+ .then((result) => {
621
+ // Clear the child ref regardless of how it ended.
622
+ generateChildRef.current = null;
623
+ if (result.signal === 'SIGTERM') {
624
+ // Caller already reset state via cancelGeneratePrefetch.
625
+ return;
626
+ }
627
+ if (result.exitCode !== 0) {
628
+ const tail = result.stderr.trim().split('\n').slice(-3).join('\n') || `exit ${result.exitCode}`;
629
+ setState((prev) => ({
630
+ ...prev,
631
+ generatePrefetchStatus: 'failed',
632
+ generatePrefetchError: tail,
633
+ generateProgress: null,
634
+ }));
635
+ return;
636
+ }
637
+ const sessionMatch = /^session=(.+)$/m.exec(result.stdout);
638
+ const generateSessionId = sessionMatch ? sessionMatch[1].trim() : null;
639
+ const countMatch = /(\d+) components?/.exec(result.stderr);
640
+ const generatedCount = countMatch ? Number(countMatch[1]) : 0;
641
+ const renamedMatch = /^renamed-slots:\s*(\d+)$/m.exec(result.stdout);
642
+ const renamedSlotsCount = renamedMatch ? Number(renamedMatch[1]) : 0;
643
+ // Persist generated state but DO NOT auto-advance — the credentials
644
+ // path will pick this up via `advanceAfterCredentialsValidated`.
645
+ setState((prev) => ({
646
+ ...prev,
647
+ generateSessionId,
648
+ generatedCount,
649
+ renamedSlotsCount,
650
+ generateProgress: null,
651
+ generatePrefetchStatus: 'complete',
652
+ }));
653
+ })
654
+ .catch(() => {
655
+ generateChildRef.current = null;
656
+ setState((prev) => ({
657
+ ...prev,
658
+ generatePrefetchStatus: 'failed',
659
+ generatePrefetchError: 'subprocess error',
660
+ }));
661
+ });
662
+ return donePromise;
382
663
  };
383
664
  const runGenerate = async (extractSessionId, tokensPath, acceptedCount) => {
384
- const result = await new Promise((res) => {
385
- const args = [findCliPath(), 'generate', 'components', '--agent', state.agent, '--session', extractSessionId];
386
- if (tokensPath)
387
- args.push('--tokens', tokensPath);
388
- const child = spawn('node', args);
389
- let stdout = '';
390
- let stderr = '';
391
- child.stdout.on('data', (d) => {
392
- stdout += String(d);
393
- });
394
- child.stderr.on('data', (d) => {
395
- const chunk = String(d);
396
- stderr += chunk;
397
- for (const line of chunk.split('\n')) {
398
- const m = /\[(\d+)\/(\d+)\]\s+(.+)/.exec(line);
399
- if (m)
400
- update({
401
- generateProgress: {
402
- done: Number(m[1]),
403
- total: Number(m[2]),
404
- current: m[3].trim(),
405
- },
406
- });
665
+ const args = [
666
+ findCliPath(),
667
+ ...buildGenerateComponentsArgs({
668
+ sessionId: extractSessionId,
669
+ tokensPath,
670
+ agent: state.agent,
671
+ ...(state.agentModel ? { model: state.agentModel } : {}),
672
+ noCache,
673
+ generatePromptPath,
674
+ }),
675
+ ];
676
+ let progressCursor = state.generateProgress;
677
+ const { donePromise } = spawnGenerateChild({
678
+ command: 'node',
679
+ args,
680
+ onStderr: (chunk) => {
681
+ const nextProgress = parseGenerateStderrChunk(chunk, progressCursor);
682
+ if (nextProgress !== progressCursor) {
683
+ progressCursor = nextProgress;
684
+ update({ generateProgress: nextProgress });
407
685
  }
408
- });
409
- child.on('exit', (code) => res({ exitCode: code ?? 0, stdout, stderr }));
686
+ },
410
687
  });
688
+ const result = await donePromise;
411
689
  if (result.exitCode !== 0) {
412
690
  update({
413
691
  step: 'error',
@@ -423,7 +701,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
423
701
  const renamedMatch = /^renamed-slots:\s*(\d+)$/m.exec(result.stdout);
424
702
  const renamedSlotsCount = renamedMatch ? Number(renamedMatch[1]) : 0;
425
703
  update({
426
- step: 'review-generated-gate',
704
+ step: 'final-review',
427
705
  generateSessionId,
428
706
  generatedCount,
429
707
  renamedSlotsCount,
@@ -433,97 +711,9 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
433
711
  const advanceToPushFlow = (generatedAcceptedCount) => {
434
712
  update({ generatedAcceptedCount, step: 'credentials' });
435
713
  };
436
- const runGenerateEdit = async (sessionId, generatedCount, acceptAll, returnToPreview = false) => {
437
- let generatedAcceptedCount;
438
- if (acceptAll) {
439
- generatedAcceptedCount = generatedCount;
440
- }
441
- else {
442
- update({ step: 'generate-edit' });
443
- if (sessionId) {
444
- const r = await runCliInteractive(['generate', 'components', 'edit', '--session', sessionId]);
445
- if (r.exitCode !== 0) {
446
- update({
447
- step: 'error',
448
- errorStep: 'generate edit',
449
- errorMessage: r.stderr.trim() || 'Unknown error',
450
- });
451
- return;
452
- }
453
- const acceptedMatch = /Accepted: (\d+)/.exec(r.stderr);
454
- generatedAcceptedCount = acceptedMatch ? Number(acceptedMatch[1]) : generatedCount;
455
- }
456
- else {
457
- generatedAcceptedCount = generatedCount;
458
- }
459
- }
460
- if (returnToPreview) {
461
- const { extractSessionId, tokensPath } = sessionRef.current;
462
- void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
463
- }
464
- else {
465
- advanceToPushFlow(generatedAcceptedCount);
466
- }
467
- };
468
- const setPreviewEnvVars = () => {
469
- const creds = credentialsRef.current;
470
- const cmaToken = creds?.cmaToken || state.cmaToken;
471
- const spaceId = creds?.spaceId || state.spaceId;
472
- const environmentId = creds?.environmentId || state.environmentId;
473
- if (cmaToken)
474
- process.env['EDS_CMA_TOKEN'] = cmaToken;
475
- if (spaceId)
476
- process.env['EDS_SPACE_ID'] = spaceId;
477
- if (environmentId)
478
- process.env['EDS_ENVIRONMENT_ID'] = environmentId;
479
- process.env['EDS_TOKENS_PATH'] = state.tokensPath || '';
480
- };
481
- const clearPreviewEnvVars = () => {
482
- delete process.env['EDS_PREVIEW_ANNOTATIONS'];
483
- delete process.env['EDS_PREVIEW_COUNTS'];
484
- delete process.env['EDS_CMA_TOKEN'];
485
- delete process.env['EDS_SPACE_ID'];
486
- delete process.env['EDS_ENVIRONMENT_ID'];
487
- delete process.env['EDS_TOKENS_PATH'];
488
- };
489
- const runEditFromPreview = async (preview) => {
490
- const sessionId = state.extractSessionId;
491
- if (!sessionId) {
492
- update({
493
- step: 'error',
494
- errorStep: 'edit definitions',
495
- errorMessage: 'No session available for editing',
496
- });
497
- return;
498
- }
499
- process.env['EDS_PREVIEW_ANNOTATIONS'] = JSON.stringify(buildPreviewAnnotations(preview));
500
- if (preview) {
501
- process.env['EDS_PREVIEW_COUNTS'] = JSON.stringify({
502
- compNew: preview.components.new.length,
503
- compChanged: preview.components.changed.length,
504
- compRemoved: preview.components.removed.length,
505
- compUnchanged: preview.components.unchanged.length,
506
- tokNew: preview.tokens.new.length,
507
- tokChanged: preview.tokens.changed.length,
508
- tokRemoved: preview.tokens.removed.length,
509
- tokUnchanged: preview.tokens.unchanged.length,
510
- });
511
- }
512
- setPreviewEnvVars();
513
- update({ step: 'analyze-select' });
514
- const r = await runCliInteractive(['analyze', 'select', '--session', sessionId]);
515
- clearPreviewEnvVars();
516
- if (r.exitCode !== 0) {
517
- update({
518
- step: 'error',
519
- errorStep: 'edit definitions',
520
- errorMessage: 'Editor exited with an error',
521
- });
522
- return;
523
- }
524
- // Re-preview with updated definitions
525
- const { extractSessionId: sid, tokensPath: tp } = sessionRef.current;
526
- void runPreview(sid, tp, state.spaceId, state.environmentId, state.cmaToken, state.host);
714
+ const runEditFromPreview = async () => {
715
+ // Post-preview edits land in the unified final-review screen.
716
+ update({ step: 'final-review' });
527
717
  };
528
718
  const runSkipValidationErrorsAndRetry = async (errors) => {
529
719
  await applySkipValidationErrors(state.extractSessionId, errors);
@@ -533,14 +723,12 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
533
723
  const advanceWithCredentials = (spaceId, environmentId, cmaToken, host) => {
534
724
  const resolvedHost = resolveWizardHost(host);
535
725
  credentialsRef.current = { spaceId, environmentId, cmaToken };
536
- update({
537
- spaceId,
538
- environmentId,
539
- cmaToken,
540
- host: resolvedHost,
541
- credentialsError: '',
542
- step: 'credential-test-gate',
543
- });
726
+ // The dedicated `credential-test-gate` screen was dropped — credentials are
727
+ // now always validated immediately after they're persisted. The inline
728
+ // `credentialsValidating` status (PR #54) handles the visual feedback while
729
+ // the API ping is in flight. The literal `'credential-test-gate'` is kept
730
+ // in the WizardStep union for back-compat, but is never set as a step.
731
+ void validateCredentials(spaceId, environmentId, cmaToken, resolvedHost);
544
732
  };
545
733
  const confirmCredentials = async (spaceId, environmentId, cmaToken, host) => {
546
734
  const resolvedHost = resolveWizardHost(host);
@@ -566,7 +754,9 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
566
754
  }
567
755
  };
568
756
  const validateCredentials = async (spaceId, environmentId, cmaToken, host) => {
569
- update({ step: 'validating-credentials' });
757
+ // Inline validation: stay on the credentials step, flip the validating
758
+ // boolean so CredentialsStep locks input + renders an inline status.
759
+ update({ step: 'credentials', credentialsValidating: true, credentialsError: '' });
570
760
  try {
571
761
  const resolvedHost = resolveWizardHost(host);
572
762
  const client = new ImportApiClient({
@@ -576,24 +766,89 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
576
766
  host: resolvedHost,
577
767
  });
578
768
  await client.validateToken();
579
- const { extractSessionId, tokensPath } = sessionRef.current;
580
- void runPreview(extractSessionId, tokensPath, spaceId, environmentId, cmaToken, resolvedHost);
769
+ update({ credentialsValidating: false });
770
+ await advanceAfterCredentialsValidated();
581
771
  }
582
772
  catch (e) {
583
773
  if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
584
- update({ step: 'credentials', credentialsError: e.message });
774
+ // Validation failure: cancel any in-flight generate prefetch so we
775
+ // don't leave an orphaned subprocess running after the operator backs
776
+ // out (Risk #1 in the spec).
777
+ cancelGeneratePrefetch();
778
+ update({ step: 'credentials', credentialsValidating: false, credentialsError: e.message });
585
779
  return;
586
780
  }
587
781
  const msg = e instanceof Error ? e.message : 'Credential check failed';
782
+ cancelGeneratePrefetch();
588
783
  update({
589
784
  step: 'error',
590
785
  errorStep: 'validating-credentials',
591
786
  errorMessage: msg,
592
787
  errorAllowCredentialRetry: false,
788
+ credentialsValidating: false,
593
789
  });
594
790
  }
595
791
  };
792
+ // Branch after credentials are known good (validated, or skipped via the
793
+ // credential-test-gate skip path). Either runs the generator (if there are
794
+ // accepted components) or jumps straight to push-decision-gate (if scope
795
+ // rejected everything but tokens/removals still need to be pushed).
796
+ const advanceAfterCredentialsValidated = async () => {
797
+ // If we already ran the generator (re-entering credentials from a late
798
+ // 401/403 raised by runPreview), skip back to push-decision-gate rather
799
+ // than re-running the LLM.
800
+ if (state.generateSessionId) {
801
+ update({ step: 'push-decision-gate' });
802
+ return;
803
+ }
804
+ const next = nextStepAfterCredentialsValidated({ acceptedCount: state.acceptedCount });
805
+ if (next === 'generating') {
806
+ const sid = sessionRef.current.extractSessionId;
807
+ if (!sid) {
808
+ update({
809
+ step: 'error',
810
+ errorStep: 'post-credentials',
811
+ errorMessage: 'Internal error: extract session ID missing after credential validation.',
812
+ });
813
+ return;
814
+ }
815
+ // Prefetch path: if a background generate is already running or has
816
+ // already completed, await it (or use its result) instead of spawning
817
+ // a second LLM call. Transition to 'generating' first so the operator
818
+ // sees the familiar RunningStep progress screen while we wait —
819
+ // matching the no-prefetch flow's visual.
820
+ const inflight = generatePromiseRef.current;
821
+ if (inflight) {
822
+ update({ step: 'generating' });
823
+ const result = await inflight;
824
+ generatePromiseRef.current = null;
825
+ if (result.exitCode === 0 && result.signal !== 'SIGTERM') {
826
+ // The donePromise.then() handler already populated
827
+ // generateSessionId / generatedCount in state. Advance to
828
+ // final-review using the latest values via functional setState so
829
+ // we read whichever update landed last.
830
+ setState((prev) => ({ ...prev, step: 'final-review' }));
831
+ return;
832
+ }
833
+ // Prefetch failed — fall through to retry via a fresh runGenerate.
834
+ }
835
+ if (await runAgentAuthCheck('generating')) {
836
+ void runGenerate(sid, state.tokensPath, state.acceptedCount);
837
+ }
838
+ return;
839
+ }
840
+ update({ step: 'push-decision-gate' });
841
+ };
596
842
  const runPreview = async (extractSessionId, tokensPath, spaceId, environmentId, cmaToken, host) => {
843
+ // Skip-credentials short-circuit. When the operator pressed `s` on the
844
+ // credentials screen, we never got a working token — calling
845
+ // previewImport would 401/403 (or worse, send a half-formed manifest
846
+ // somewhere). Jump straight to the push-decision-gate; Task 3 disables
847
+ // the push options downstream.
848
+ if (shouldBypassPreview(state)) {
849
+ update(buildSkippedPreviewTransition());
850
+ return;
851
+ }
597
852
  update({ step: 'previewing' });
598
853
  const resolvedHost = resolveWizardHost(host);
599
854
  try {
@@ -721,6 +976,16 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
721
976
  }
722
977
  };
723
978
  const runPush = async (manifest, spaceId, environmentId, cmaToken, host, acknowledgeBreakingChanges, preview) => {
979
+ // Skip-credentials defensive guard. The push-decision-gate disables
980
+ // push-emitting choices when `credentialsSkipped` is true, so we
981
+ // should never get here in practice. But if a state-machine bug or
982
+ // future regression ever did, refuse to issue the API call — there
983
+ // is no validated token and the operator explicitly opted out of
984
+ // push. Route back to the local-save (print-gate) path instead.
985
+ if (shouldRefusePush(state)) {
986
+ update(buildSkippedPushTransition());
987
+ return;
988
+ }
724
989
  if (preview) {
725
990
  const hasComponentChanges = preview.components.new.length > 0 ||
726
991
  preview.components.changed.length > 0 ||
@@ -730,14 +995,15 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
730
995
  update({
731
996
  step: 'done',
732
997
  pushResult: {
733
- componentTypes: { created: 0, updated: 0, failed: 0 },
734
- designTokens: { created: 0, updated: 0, failed: 0 },
998
+ componentTypes: { created: 0, updated: 0, removed: 0, failed: 0 },
999
+ designTokens: { created: 0, updated: 0, removed: 0, failed: 0 },
735
1000
  },
736
1001
  });
737
1002
  return;
738
1003
  }
739
1004
  }
740
- update({ step: 'pushing' });
1005
+ const pushExpected = preview ? computePushExpected(preview) : null;
1006
+ update({ step: 'pushing', pushExpected, pushProgress: null });
741
1007
  try {
742
1008
  const resolvedHost = resolveWizardHost(host);
743
1009
  const client = new ImportApiClient({
@@ -760,7 +1026,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
760
1026
  process.stderr.write(`[eds] log write failed: ${err instanceof Error ? err.message : String(err)}\n`);
761
1027
  }
762
1028
  update({
763
- pushProgress: `Queued (operation ${operation.sys.id.slice(0, 8)}...)`,
1029
+ pushProgress: { kind: 'queued', operationId: operation.sys.id },
764
1030
  });
765
1031
  let pollCount = 0;
766
1032
  operation = await client.pollOperation(operation.sys.id, {
@@ -769,7 +1035,15 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
769
1035
  const s = op.summary;
770
1036
  if (s) {
771
1037
  const done = s.total - s.pending;
772
- update({ pushProgress: `${done}/${s.total} entities processed` });
1038
+ const items = op.items ?? [];
1039
+ // Best-effort fresh signal: pick the most recently succeeded item
1040
+ // (the API does not surface an in-progress status today). Falls
1041
+ // back to null and the PushingStep hides the line.
1042
+ const lastDone = items.length > 0 ? items[items.length - 1] : null;
1043
+ const current = lastDone && lastDone.status === 'succeeded' ? lastDone.id : null;
1044
+ update({
1045
+ pushProgress: { kind: 'progress', processed: done, total: s.total, current },
1046
+ });
773
1047
  }
774
1048
  try {
775
1049
  logStep({
@@ -806,11 +1080,13 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
806
1080
  componentTypes: {
807
1081
  created: items.filter((i) => i.entityType === 'ComponentType' && i.action === 'create' && i.status === 'succeeded').length,
808
1082
  updated: items.filter((i) => i.entityType === 'ComponentType' && i.action === 'update' && i.status === 'succeeded').length,
1083
+ removed: items.filter((i) => i.entityType === 'ComponentType' && i.action === 'delete' && i.status === 'succeeded').length,
809
1084
  failed: items.filter((i) => i.entityType === 'ComponentType' && i.status === 'failed').length,
810
1085
  },
811
1086
  designTokens: {
812
1087
  created: items.filter((i) => i.entityType === 'DesignToken' && i.action === 'create' && i.status === 'succeeded').length,
813
1088
  updated: items.filter((i) => i.entityType === 'DesignToken' && i.action === 'update' && i.status === 'succeeded').length,
1089
+ removed: items.filter((i) => i.entityType === 'DesignToken' && i.action === 'delete' && i.status === 'succeeded').length,
814
1090
  failed: items.filter((i) => i.entityType === 'DesignToken' && i.status === 'failed').length,
815
1091
  },
816
1092
  summary: operation.summary,
@@ -822,11 +1098,13 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
822
1098
  componentTypes: {
823
1099
  created: preview?.components.new.length ?? 0,
824
1100
  updated: preview?.components.changed.length ?? 0,
1101
+ removed: preview?.components.removed.length ?? 0,
825
1102
  failed: 0,
826
1103
  },
827
1104
  designTokens: {
828
1105
  created: preview?.tokens.new.length ?? 0,
829
1106
  updated: preview?.tokens.changed.length ?? 0,
1107
+ removed: preview?.tokens.removed.length ?? 0,
830
1108
  failed: 0,
831
1109
  },
832
1110
  summary: operation.summary,
@@ -844,7 +1122,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
844
1122
  });
845
1123
  }
846
1124
  };
847
- const runPrintFiles = async (extractSessionId, outDir) => {
1125
+ const runPrintFiles = async (extractSessionId, outDir, opts = {}) => {
848
1126
  update({ step: 'printing' });
849
1127
  const componentsPath = join(outDir, 'components.json');
850
1128
  const printArgs = ['print', 'components', '--out', componentsPath];
@@ -857,10 +1135,142 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
857
1135
  errorStep: 'print components',
858
1136
  errorMessage: r.stderr.trim() || 'Unknown error',
859
1137
  });
1138
+ return { ok: false };
1139
+ }
1140
+ // Co-locate tokens.json with components.json. Without this, `--out-dir`
1141
+ // would move components.json to the operator's chosen path while leaving
1142
+ // tokens.json behind at <projectPath>/.contentful/tokens.json.
1143
+ let emittedTokensPath;
1144
+ let emittedTokenCount;
1145
+ if (opts.tokenSessionId) {
1146
+ const tokensOut = join(outDir, 'tokens.json');
1147
+ const tokenArgs = ['print', 'tokens', '--out', tokensOut, '--session', opts.tokenSessionId];
1148
+ const tr = await runCli(tokenArgs);
1149
+ if (tr.exitCode !== 0) {
1150
+ update({
1151
+ step: 'error',
1152
+ errorStep: 'print tokens',
1153
+ errorMessage: tr.stderr.trim() || 'Unknown error',
1154
+ });
1155
+ return { ok: false };
1156
+ }
1157
+ emittedTokensPath = tokensOut;
1158
+ emittedTokenCount = parsePrintTokensCount(tr.stdout);
1159
+ }
1160
+ update(nextStateAfterPrint({ skipGate: opts.skipGate, componentsPath }));
1161
+ return {
1162
+ ok: true,
1163
+ ...(emittedTokensPath ? { tokensPath: emittedTokensPath } : {}),
1164
+ ...(typeof emittedTokenCount === 'number' ? { tokenCount: emittedTokenCount } : {}),
1165
+ };
1166
+ };
1167
+ const runSaveAndPush = async () => {
1168
+ await startSaveFlow({ skipGate: true, andPush: true });
1169
+ };
1170
+ // ── Task 4: save-path orchestration ────────────────────────────────────────
1171
+ // Wraps every `runPrintFiles` site. When `--out-dir` is set we skip the
1172
+ // inline prompt and conflict gate entirely. Otherwise the wizard transitions
1173
+ // to the path-prompt step; the operator's submit handler calls back into
1174
+ // `proceedToWrite` (which may surface the conflict gate).
1175
+ const pendingSaveOptionsRef = useRef({});
1176
+ const startSaveFlow = async (opts = {}) => {
1177
+ pendingSaveOptionsRef.current = opts;
1178
+ if (outDirOverride) {
1179
+ await mkdir(outDirOverride, { recursive: true });
1180
+ if (onConflictMode) {
1181
+ const resolved = await resolveSavePath(outDirOverride, { onConflict: onConflictMode });
1182
+ if (resolved.kind === 'fail') {
1183
+ const files = resolved.conflict.files.join(', ');
1184
+ process.stderr.write(`Error: --on-conflict fail — refusing to overwrite ${files} at ${resolved.conflict.path}.\n`);
1185
+ process.exit(1);
1186
+ return;
1187
+ }
1188
+ if (resolved.kind === 'write') {
1189
+ await mkdir(resolved.path, { recursive: true });
1190
+ await proceedToWrite(resolved.path);
1191
+ return;
1192
+ }
1193
+ }
1194
+ await proceedToWrite(outDirOverride);
860
1195
  return;
861
1196
  }
862
- // tokensPath is already on disk from generate-tokens step; just record it
863
- update({ step: 'print-gate', componentsPath });
1197
+ setState((prev) => ({ ...prev, step: 'path-prompt' }));
1198
+ };
1199
+ const proceedToWrite = async (path) => {
1200
+ setState((prev) => ({ ...prev, outDir: path }));
1201
+ const { extractSessionId, tokensPath } = sessionRef.current;
1202
+ const { skipGate, andPush } = pendingSaveOptionsRef.current;
1203
+ const result = await runPrintFiles(extractSessionId, path, {
1204
+ ...(skipGate ? { skipGate: true } : {}),
1205
+ ...(state.tokenSessionId ? { tokenSessionId: state.tokenSessionId } : {}),
1206
+ });
1207
+ if (!result.ok)
1208
+ return;
1209
+ // Prefer the freshly emitted path/count (covers --out-dir); fall back to
1210
+ // the values captured during the original generate-tokens step.
1211
+ const recordedTokensPath = result.tokensPath ?? (state.tokenSessionId ? tokensPath || null : null);
1212
+ const recordedTokenCount = result.tokenCount ?? state.tokenCount;
1213
+ if (result.ok) {
1214
+ // Append a run record on every successful write. Best-effort: append
1215
+ // failures must not break the wizard flow (they surface on stderr).
1216
+ try {
1217
+ // Build the v3 fingerprints. Both are best-effort: if any step
1218
+ // throws (missing source file, hash failure, db lookup error) we
1219
+ // fall back to null fingerprints rather than aborting the save.
1220
+ let sourceFingerprint = null;
1221
+ let savedFingerprint = null;
1222
+ try {
1223
+ if (state.extractSessionId) {
1224
+ const db = openPipelineDb();
1225
+ try {
1226
+ sourceFingerprint = await buildSourceFingerprint({
1227
+ db,
1228
+ extractSessionId: state.extractSessionId,
1229
+ rawTokensPath: state.rawTokensPath || null,
1230
+ });
1231
+ }
1232
+ finally {
1233
+ db.close();
1234
+ }
1235
+ }
1236
+ }
1237
+ catch (err) {
1238
+ process.stderr.write(`Warning: failed to compute source fingerprint: ${err instanceof Error ? err.message : String(err)}\n`);
1239
+ }
1240
+ try {
1241
+ const componentsBuf = await readFile(join(path, 'components.json')).catch(() => null);
1242
+ const tokensBuf = recordedTokensPath ? await readFile(recordedTokensPath).catch(() => null) : null;
1243
+ savedFingerprint = buildSavedFingerprint({
1244
+ componentsJson: componentsBuf,
1245
+ tokensJson: tokensBuf,
1246
+ });
1247
+ }
1248
+ catch (err) {
1249
+ process.stderr.write(`Warning: failed to compute saved fingerprint: ${err instanceof Error ? err.message : String(err)}\n`);
1250
+ }
1251
+ const record = await appendRun({
1252
+ projectPath: state.projectPath,
1253
+ savePath: path,
1254
+ componentCount: state.generatedAcceptedCount || state.generatedCount,
1255
+ tokenCount: recordedTokenCount,
1256
+ tokensPath: recordedTokensPath || null,
1257
+ tokenSessionId: state.tokenSessionId,
1258
+ agent: state.agent,
1259
+ pushedTo: null,
1260
+ extractSessionId: state.extractSessionId ?? '',
1261
+ generateSessionId: state.generateSessionId,
1262
+ sourceFingerprint,
1263
+ savedFingerprint,
1264
+ });
1265
+ setState((prev) => ({ ...prev, lastRunId: record.id }));
1266
+ }
1267
+ catch (err) {
1268
+ process.stderr.write(`Warning: failed to record run: ${err instanceof Error ? err.message : String(err)}\n`);
1269
+ }
1270
+ }
1271
+ if (andPush) {
1272
+ void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
1273
+ }
864
1274
  };
865
1275
  // ── Effect: kick off automatic steps ───────────────────────────────────────────────
866
1276
  const tokenReuseChecked = useRef(false);
@@ -895,6 +1305,7 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
895
1305
  }, [state.step]); // intentional: only re-run when step changes
896
1306
  // ── Render ────────────────────────────────────────────────────────────────────────────
897
1307
  const noQuitSteps = [
1308
+ 'run-picker',
898
1309
  'checking-claude-auth',
899
1310
  'validating-credentials',
900
1311
  'generating-tokens',
@@ -911,6 +1322,18 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
911
1322
  const totalSteps = 3 + (hasTokens ? 1 : 0) + (hasComponents ? 2 : 0);
912
1323
  const stepContent = (() => {
913
1324
  switch (state.step) {
1325
+ case 'run-picker':
1326
+ return (_jsx(RunPicker, { runs: initialRuns ?? [], onSelect: (selection) => {
1327
+ if (selection.action === 'new') {
1328
+ update({ step: 'welcome' });
1329
+ return;
1330
+ }
1331
+ // Push / modify routing exits the wizard back into the CLI
1332
+ // surface so `replayRun` / `modifyRun` (which spin their own
1333
+ // UI / spawn their own Ink trees) can take over. The CLI
1334
+ // entry point in `command.ts` provides `onRunPicked`.
1335
+ onRunPicked?.(selection);
1336
+ }, onCancel: () => process.exit(0) }));
914
1337
  case 'welcome':
915
1338
  return (_jsx(WelcomeStep, { onContinue: (path) => {
916
1339
  const projectPath = normalizePath(path);
@@ -939,7 +1362,17 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
939
1362
  case 'path-validation':
940
1363
  return (_jsx(PathValidationStep, { projectPath: state.projectPath, onConfirm: (path) => {
941
1364
  void runExtract(path);
942
- }, onSkipComponents: () => update({ step: 'push-decision-gate', skipComponents: true }), onChangePath: () => update({ step: 'welcome' }), onQuit: () => process.exit(0) }));
1365
+ }, onSkipComponents: () => {
1366
+ // No components — only design tokens to push. Still gather creds
1367
+ // first (unless --no-push, in which case there is nothing to do
1368
+ // and we save files instead).
1369
+ if (noPush) {
1370
+ update({ skipComponents: true, acceptedCount: 0 });
1371
+ void startSaveFlow();
1372
+ return;
1373
+ }
1374
+ update({ step: 'credentials', skipComponents: true, acceptedCount: 0 });
1375
+ }, onChangePath: () => update({ step: 'welcome' }), onQuit: () => process.exit(0) }));
943
1376
  case 'extracting': {
944
1377
  const ep = state.extractProgress;
945
1378
  let extractDetail;
@@ -954,32 +1387,64 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
954
1387
  }
955
1388
  return (_jsx(RunningStep, { stepNumber: hasTokens ? 2 : 1, totalSteps: totalSteps, title: "Extracting components", description: "I'm scanning your files and figuring out what components exist, what props they have, and how they're structured. This is fully automatic \u2014 sit tight.", detail: extractDetail }));
956
1389
  }
957
- case 'review-extraction-gate': {
958
- const stepNum = hasTokens ? 2 : 1;
959
- return (_jsx(GateStep, { successMessage: `Step ${stepNum} complete`, summary: `Found ${state.extractedCount} component${state.extractedCount === 1 ? '' : 's'}.`, context: "Ready to review what was extracted? You can correct any props the extractor got wrong, or approve everything and move on.", continueLabel: "Review components", skipLabel: "Approve all and skip", onContinue: () => {
960
- if (!state.extractSessionId) {
961
- update({
962
- step: 'error',
963
- errorStep: 'analyze extract',
964
- errorMessage: 'Extract session ID missing — please re-run.',
965
- });
966
- return;
967
- }
968
- void runAnalyzeSelect(state.extractSessionId, state.extractedCount, state.tokensPath, false);
969
- }, onSkip: () => {
970
- if (!state.extractSessionId) {
971
- update({
972
- step: 'error',
973
- errorStep: 'analyze extract',
974
- errorMessage: 'Extract session ID missing — please re-run.',
975
- });
976
- return;
977
- }
978
- void runAnalyzeSelect(state.extractSessionId, state.extractedCount, state.tokensPath, true);
1390
+ case 'scope-gate': {
1391
+ if (!state.extractSessionId) {
1392
+ return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { color: "red", children: "Error: extract session ID missing \u2014 please re-run." }) }));
1393
+ }
1394
+ const sessionId = state.extractSessionId;
1395
+ const db = openPipelineDb();
1396
+ let components;
1397
+ try {
1398
+ components = loadScopeComponents(db, sessionId);
1399
+ }
1400
+ finally {
1401
+ db.close();
1402
+ }
1403
+ // INTEG-4318: overlay the streamed auto-filter decisions (from
1404
+ // stderr progress lines) onto DB-loaded rows so 'failed' components
1405
+ // (LLM omitted a tool call in a batch) surface in the scope-gate
1406
+ // instead of silently defaulting to included.
1407
+ components = mergeAiDecisions(components, state.aiDecisions);
1408
+ return (_jsx(ScopeGateHost, { components: components, autoAccept: autoAcceptScope, aiFilterStatus: state.aiFilterStatus, aiFilterProgress: state.aiFilterProgress, aiFilterError: state.aiFilterError, onCancelAutoFilter: cancelAutoFilter, onConfirm: (decisions) => {
1409
+ void runScopeGate({
1410
+ sessionId,
1411
+ decisions,
1412
+ cancelAutoFilter: state.aiFilterStatus === 'running' ? cancelAutoFilterAndWait : undefined,
1413
+ onAdvanceToGenerate: async ({ sessionId: sid, acceptedCount }) => {
1414
+ update({ acceptedCount, autoRejectedCount: 0 });
1415
+ const next = nextStepAfterScopeGate({ acceptedCount, noPush });
1416
+ if (next === 'generating') {
1417
+ if (await runAgentAuthCheck('generating')) {
1418
+ void runGenerate(sid, state.tokensPath, acceptedCount);
1419
+ }
1420
+ return;
1421
+ }
1422
+ // next === 'credentials' (push enabled). Kick off the
1423
+ // generate child in the background so the LLM call overlaps
1424
+ // with the operator's credential entry. We only prefetch
1425
+ // when we have accepted components to classify AND push is
1426
+ // enabled (noPush path is already excluded — the scope-gate
1427
+ // helper would have returned 'generating' there).
1428
+ if (acceptedCount > 0 && !noPush) {
1429
+ if (await runAgentAuthCheck('credentials')) {
1430
+ startGeneratePrefetch(sid, state.tokensPath);
1431
+ }
1432
+ }
1433
+ update({ step: 'credentials' });
1434
+ },
1435
+ onAdvanceToPushFlow: (count) => {
1436
+ update({ acceptedCount: count, autoRejectedCount: 0 });
1437
+ const next = nextStepAfterScopeGate({ acceptedCount: count, noPush });
1438
+ if (next === 'print-gate') {
1439
+ void startSaveFlow();
1440
+ return;
1441
+ }
1442
+ // 'credentials' — still need creds for tokens/removals push.
1443
+ advanceToPushFlow(count);
1444
+ },
1445
+ });
979
1446
  }, onQuit: () => process.exit(0) }));
980
1447
  }
981
- case 'analyze-select':
982
- return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "Launching component review... (the TUI will appear shortly)" }) }));
983
1448
  case 'generating': {
984
1449
  const p = state.generateProgress;
985
1450
  const stepNum = hasTokens ? 4 : 3;
@@ -988,34 +1453,32 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
988
1453
  : `Starting up ${state.agent}... (this can take 10–30 minutes for large libraries — grab a coffee)`;
989
1454
  return (_jsx(RunningStep, { stepNumber: stepNum, totalSteps: totalSteps, title: "Generating definitions", description: `${formatAcceptanceSummary({ accepted: state.acceptedCount, autoRejected: state.autoRejectedCount })} ${state.agent} is mapping your TypeScript types to Contentful's CDF format.${hasTokens ? ' Using your design tokens for prop resolution.' : ''}`, detail: progressDetail }));
990
1455
  }
991
- case 'review-generated-gate': {
992
- const stepNum = hasTokens ? 4 : 3;
993
- return (_jsx(GateStep, { successMessage: `Step ${stepNum} complete — definitions generated`, summary: formatGeneratedSummary({
994
- generated: state.generatedCount,
995
- renamedSlots: state.renamedSlotsCount,
996
- autoRejected: state.autoRejectedCount,
997
- }), context: "Take a final look before pushing to Contentful. You can accept, reject, or inspect each component's generated definition.", continueLabel: "Review definitions", skipLabel: "Approve all and skip", onContinue: () => {
998
- update({ step: 'generate-review' });
999
- }, onSkip: () => {
1000
- void runGenerateEdit(state.generateSessionId, state.generatedCount, true);
1001
- }, onQuit: () => process.exit(0) }));
1002
- }
1003
- case 'generate-review': {
1004
- if (!state.extractSessionId) {
1005
- return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { color: "red", children: "Error: no session ID \u2014 cannot load generated definitions." }) }));
1006
- }
1007
- return (_jsx(GenerateReviewStep, { extractSessionId: state.extractSessionId, onFinalize: (accepted, rejected) => {
1008
- update({
1009
- generatedAcceptedCount: accepted,
1010
- step: 'push-decision-gate',
1011
- });
1012
- void Promise.resolve();
1013
- // log so orchestrator can read it
1014
- process.stderr.write(`Accepted: ${accepted} Rejected: ${rejected}\n`);
1456
+ case 'final-review': {
1457
+ return (_jsx(FinalReviewHost, { extractSessionId: state.extractSessionId, generatedCount: state.generatedCount, autoAccept: autoAcceptScope, livePreview: livePreview, spaceId: state.spaceId, environmentId: state.environmentId, cmaToken: state.cmaToken, host: state.host, tokensPath: state.tokensPath, onFinalize: (accepted, rejected, unresolved) => {
1458
+ process.stderr.write(`Accepted: ${accepted} Rejected: ${rejected} Unresolved: ${unresolved}\n`);
1459
+ if (noPush) {
1460
+ update({ generatedAcceptedCount: accepted });
1461
+ void startSaveFlow();
1462
+ return;
1463
+ }
1464
+ if (noSave) {
1465
+ update({ generatedAcceptedCount: accepted });
1466
+ const { extractSessionId, tokensPath } = sessionRef.current;
1467
+ void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
1468
+ return;
1469
+ }
1470
+ if (autoAcceptScope) {
1471
+ // Headless run with neither --no-save nor --no-push: pick the
1472
+ // default "both" path automatically to preserve scripted
1473
+ // (auto-accept-scope) UX from before the gate gained a third
1474
+ // option. Operators who want push-only must pass --no-save.
1475
+ update({ generatedAcceptedCount: accepted });
1476
+ void runSaveAndPush();
1477
+ return;
1478
+ }
1479
+ update({ generatedAcceptedCount: accepted, step: 'push-decision-gate' });
1015
1480
  }, onQuit: () => process.exit(0) }));
1016
1481
  }
1017
- case 'generate-edit':
1018
- return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { dimColor: true, children: "Launching definition review... (the TUI will appear shortly)" }) }));
1019
1482
  case 'push-decision-gate': {
1020
1483
  const tokenDesc = hasTokens ? 'tokens.json' : null;
1021
1484
  const compDesc = hasComponents ? 'components.json' : null;
@@ -1026,96 +1489,120 @@ export function WizardApp({ initialSpaceId = '', initialEnvironmentId = 'master'
1026
1489
  : hasTokens
1027
1490
  ? 'Design tokens ready.'
1028
1491
  : 'Ready to continue.';
1029
- return (_jsx(GateStep, { successMessage: "Generation complete", summary: summary, context: `Push directly to your Contentful space now, or save ${files || 'the output files'} to disk first.`, continueLabel: "Push to Contentful", skipLabel: `Save ${files || 'files'} to disk`, onContinue: () => {
1030
- update({ step: 'credentials' });
1031
- }, onSkip: () => {
1032
- void runPrintFiles(state.extractSessionId, state.outDir);
1492
+ return (_jsx(PushDecisionGateStep, { summary: summary, context: `Save ${files || 'output files'} to disk, push to your Contentful space, or both.`, fileList: files || 'files', pushDisabled: state.credentialsSkipped, onChoice: (choice) => {
1493
+ const action = chooseGateAction(choice);
1494
+ if (action === 'save-and-push') {
1495
+ void runSaveAndPush();
1496
+ return;
1497
+ }
1498
+ if (action === 'push-only') {
1499
+ const { extractSessionId, tokensPath } = sessionRef.current;
1500
+ void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
1501
+ return;
1502
+ }
1503
+ // save-only
1504
+ void startSaveFlow();
1033
1505
  }, onQuit: () => process.exit(0) }));
1034
1506
  }
1035
1507
  case 'credentials':
1036
- return (_jsx(CredentialsStep, { initialSpaceId: state.spaceId, initialEnvironmentId: state.environmentId, initialCmaToken: state.cmaToken, initialHost: state.host, error: state.credentialsError || undefined, onConfirm: (spaceId, environmentId, cmaToken, host) => {
1508
+ return (_jsx(CredentialsStep, { initialSpaceId: state.spaceId, initialEnvironmentId: state.environmentId, initialCmaToken: state.cmaToken, initialHost: state.host, error: state.credentialsError || undefined, validating: state.credentialsValidating, generatePrefetchStatus: state.generatePrefetchStatus, generatePrefetchError: state.generatePrefetchError, onConfirm: (spaceId, environmentId, cmaToken, host) => {
1037
1509
  void confirmCredentials(spaceId, environmentId, cmaToken, host);
1038
- }, onContinue: advanceWithCredentials, onQuit: () => process.exit(0) }));
1039
- case 'credential-test-gate':
1040
- return (_jsx(GateStep, { successMessage: "Credentials entered", summary: `Space: ${state.spaceId} · Environment: ${state.environmentId}`, context: "Verify your credentials work before running the import, or skip and find out during the push step.", continueLabel: "Test credentials", skipLabel: "Skip and continue", showSkip: true, onContinue: () => {
1041
- void validateCredentials(state.spaceId, state.environmentId, state.cmaToken, state.host);
1042
- }, onSkip: () => {
1043
- const { extractSessionId, tokensPath } = sessionRef.current;
1044
- void runPreview(extractSessionId, tokensPath, state.spaceId, state.environmentId, state.cmaToken, state.host);
1045
- }, onQuit: () => process.exit(0) }));
1046
- case 'validating-credentials':
1047
- return (_jsx(RunningStep, { stepNumber: totalSteps - 1, totalSteps: totalSteps, title: "Validating credentials", description: "Checking that your space ID and CMA token are valid..." }));
1510
+ }, onContinue: advanceWithCredentials, onRetryPrefetch: state.generatePrefetchStatus === 'failed' && sessionRef.current.extractSessionId
1511
+ ? () => {
1512
+ const sid = sessionRef.current.extractSessionId;
1513
+ void startGeneratePrefetch(sid, state.tokensPath);
1514
+ }
1515
+ : undefined, onSkip: () => {
1516
+ // Skip-credentials: mark the wizard as skipped, clear any
1517
+ // stale credentialsError banner, and advance through the
1518
+ // normal post-credentials branch. The in-flight generate
1519
+ // prefetch (PR #54) is intentionally NOT cancelled here
1520
+ // the operator still wants to see classifications.
1521
+ update({ credentialsSkipped: true, credentialsError: '' });
1522
+ void advanceAfterCredentialsValidated();
1523
+ }, onQuit: () => {
1524
+ cancelGeneratePrefetch();
1525
+ process.exit(0);
1526
+ } }));
1527
+ // 'credential-test-gate' is intentionally NOT rendered as a dedicated
1528
+ // screen any more (the post-#54 inline credentials-validating status made
1529
+ // the gate redundant). The step value is kept in the union for back-compat
1530
+ // with existing imports/tests but is never actually set as a step —
1531
+ // `advanceWithCredentials` calls `validateCredentials` directly.
1532
+ //
1533
+ // 'validating-credentials' is intentionally NOT rendered as a dedicated
1534
+ // screen any more (see CredentialsStep `validating` prop). The step
1535
+ // value is kept in the union for back-compat with existing imports/tests
1536
+ // but should never actually be set — `validateCredentials` keeps the
1537
+ // step on 'credentials' and toggles `credentialsValidating` instead.
1048
1538
  case 'previewing':
1049
1539
  return (_jsx(RunningStep, { stepNumber: totalSteps, totalSteps: totalSteps, title: "Computing diff", description: "Computing diff against your Contentful space..." }));
1050
1540
  case 'preview-gate':
1051
1541
  return (_jsx(WizardPreviewStep, { preview: state.serverPreview, spaceId: state.spaceId, environmentId: state.environmentId, stepNumber: totalSteps, totalSteps: totalSteps, onConfirm: (acknowledge) => {
1052
1542
  void runPush(state.manifest, state.spaceId, state.environmentId, state.cmaToken, state.host, acknowledge, state.serverPreview);
1053
1543
  }, onEdit: () => {
1054
- void runEditFromPreview(state.serverPreview);
1544
+ void runEditFromPreview();
1055
1545
  }, onSaveFiles: () => {
1056
- void runPrintFiles(state.extractSessionId, state.outDir);
1546
+ void startSaveFlow();
1057
1547
  }, onQuit: () => process.exit(0) }));
1058
1548
  case 'pushing':
1059
- return (_jsx(RunningStep, { stepNumber: totalSteps, totalSteps: totalSteps, title: "Push to Contentful", description: "Writing component types and design tokens to your Contentful space...", detail: state.pushProgress ?? undefined }));
1549
+ return (_jsx(PushingStep, { stepNumber: totalSteps, totalSteps: totalSteps, expected: state.pushExpected, progress: state.pushProgress }));
1550
+ case 'path-prompt':
1551
+ return (_jsx(PathPrompt, { defaultPath: state.outDir, onSubmit: (submitted) => {
1552
+ void (async () => {
1553
+ await mkdir(submitted, { recursive: true });
1554
+ const hasConflict = await detectSaveConflict(submitted);
1555
+ if (hasConflict) {
1556
+ setState((prev) => ({ ...prev, step: 'save-conflict-gate', outDir: submitted }));
1557
+ return;
1558
+ }
1559
+ await proceedToWrite(submitted);
1560
+ })();
1561
+ }, onCancel: () => process.exit(0) }));
1562
+ case 'save-conflict-gate':
1563
+ return (_jsx(SaveConflictGate, { path: state.outDir, onOverwrite: () => {
1564
+ void proceedToWrite(state.outDir);
1565
+ }, onNew: () => {
1566
+ const subdir = buildTimestampedSubdir(state.outDir);
1567
+ void (async () => {
1568
+ await mkdir(subdir, { recursive: true });
1569
+ await proceedToWrite(subdir);
1570
+ })();
1571
+ }, onCancel: () => setState((prev) => ({ ...prev, step: 'path-prompt' })) }));
1060
1572
  case 'printing':
1061
1573
  return (_jsx(RunningStep, { stepNumber: totalSteps, totalSteps: totalSteps, title: "Writing files", description: "Writing output files to disk..." }));
1062
- case 'print-gate':
1574
+ case 'print-gate': {
1575
+ const teaser = buildRunTeaserLine(state.lastRunId);
1063
1576
  return (_jsx(GateStep, { successMessage: "Files saved", summary: [
1064
1577
  hasComponents && state.componentsPath ? `components.json → ${state.componentsPath}` : null,
1065
1578
  hasTokens && state.tokensPath ? `tokens.json → ${state.tokensPath}` : null,
1066
1579
  ]
1067
1580
  .filter(Boolean)
1068
- .join('\n'), context: "Your files are saved to disk. Run `experiences import` again when you're ready to push to Contentful.", continueLabel: "Exit", showSkip: false, onContinue: () => process.exit(0), onQuit: () => process.exit(0) }));
1581
+ .join('\n'), context: teaser
1582
+ ? `Your files are saved to disk. ${teaser}`
1583
+ : "Your files are saved to disk. Run `experiences import` again when you're ready to push to Contentful.", continueLabel: "Exit", showSkip: false, onContinue: () => process.exit(0), onQuit: () => process.exit(0) }));
1584
+ }
1069
1585
  case 'done': {
1070
1586
  const totalFailed = state.pushResult.componentTypes.failed + state.pushResult.designTokens.failed;
1071
- return (_jsx(DoneStep, { componentTypes: state.pushResult.componentTypes, designTokens: state.pushResult.designTokens, summary: state.pushResult.summary, spaceId: state.spaceId, environmentId: state.environmentId, onExit: () => process.exit(totalFailed > 0 ? 1 : 0) }));
1587
+ const teaser = buildRunTeaserLine(state.lastRunId);
1588
+ return (_jsx(DoneStep, { componentTypes: state.pushResult.componentTypes, designTokens: state.pushResult.designTokens, summary: state.pushResult.summary, spaceId: state.spaceId, environmentId: state.environmentId, host: state.host, ...(teaser ? { runTeaser: teaser } : {}), onExit: () => process.exit(totalFailed > 0 ? 1 : 0) }));
1072
1589
  }
1073
1590
  case 'preview-validation-error': {
1074
1591
  return (_jsx(PreviewValidationErrorStep, { errors: state.previewValidationErrors, missingNames: state.previewValidationMissingNames, onEdit: () => {
1075
- void runEditFromPreview(null);
1592
+ void runEditFromPreview();
1076
1593
  }, onSkip: () => {
1077
1594
  void runSkipValidationErrorsAndRetry(state.previewValidationErrors);
1078
1595
  }, onQuit: () => process.exit(0) }));
1079
1596
  }
1080
1597
  case 'error':
1081
1598
  return (_jsx(ErrorStep, { stepName: state.errorStep, message: state.errorMessage, onExit: () => process.exit(1), onRetryCredentials: state.errorAllowCredentialRetry ? () => update({ step: 'credentials', credentialsError: '' }) : undefined }));
1599
+ // 'validating-credentials' kept in the WizardStep union for back-compat
1600
+ // but is no longer reachable as a step (validateCredentials toggles the
1601
+ // `credentialsValidating` boolean while leaving step on 'credentials').
1602
+ // Default-return null so the switch is exhaustive.
1603
+ default:
1604
+ return null;
1082
1605
  }
1083
1606
  })();
1084
- return (_jsxs(Box, { flexDirection: "column", width: terminalWidth, children: [_jsx(TopBar, { subcommand: "import", hints: hints }), stepContent] }));
1085
- }
1086
- function buildPreviewAnnotations(preview) {
1087
- const annotations = {};
1088
- if (!preview)
1089
- return annotations;
1090
- for (const item of preview.components.new) {
1091
- const key = item.key ?? '';
1092
- if (key)
1093
- annotations[key] = 'new';
1094
- }
1095
- for (const item of preview.components.removed) {
1096
- annotations[item.name] = 'removed';
1097
- }
1098
- for (const item of preview.components.changed) {
1099
- if (item.changeClassification?.classification === 'breaking') {
1100
- annotations[item.current.name] = 'breaking';
1101
- }
1102
- else {
1103
- annotations[item.current.name] = 'changed';
1104
- }
1105
- }
1106
- return annotations;
1107
- }
1108
- // Interactive subprocess helper — uses spawn with inherited stdio so child isTTY is true
1109
- function runCliInteractive(args) {
1110
- logStep({
1111
- fn: 'runCliInteractive:spawn',
1112
- args,
1113
- });
1114
- return new Promise((res) => {
1115
- const child = spawn('node', [findCliPath(), ...args], { stdio: 'inherit' });
1116
- child.on('exit', (code) => {
1117
- logStep({ fn: 'runCliInteractive:exit', args, exitCode: code ?? 0 });
1118
- res({ exitCode: code ?? 0, stdout: '', stderr: '' });
1119
- });
1120
- });
1607
+ return (_jsxs(Box, { flexDirection: "column", width: terminalWidth, children: [_jsx(TopBar, { subcommand: "import", hints: hints }), _jsx(CustomPromptBanner, { selectPromptPath: selectPromptPath, generatePromptPath: generatePromptPath }), stepContent] }));
1121
1608
  }