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