@contentful/experience-design-system-cli 2.7.5-dev-build-752dc9c.0 → 2.10.1-dev-build-90df010.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 (34) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/analyze/build-analyze-view-rows.d.ts +23 -0
  3. package/dist/src/analyze/build-analyze-view-rows.js +39 -0
  4. package/dist/src/analyze/command.js +13 -10
  5. package/dist/src/analyze/extract/validate.d.ts +13 -1
  6. package/dist/src/analyze/extract/validate.js +32 -5
  7. package/dist/src/analyze/select/command.d.ts +37 -0
  8. package/dist/src/analyze/select/command.js +200 -9
  9. package/dist/src/analyze/select/tui/App.js +1 -9
  10. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +1 -1
  11. package/dist/src/analyze/select/tui/components/Sidebar.js +26 -3
  12. package/dist/src/analyze/select-agent/command.js +22 -10
  13. package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
  14. package/dist/src/analyze/tui/AnalyzeView.js +5 -2
  15. package/dist/src/apply/api-client.d.ts +20 -0
  16. package/dist/src/apply/api-client.js +71 -5
  17. package/dist/src/generate/command.js +21 -2
  18. package/dist/src/import/orchestrator.d.ts +21 -0
  19. package/dist/src/import/orchestrator.js +95 -14
  20. package/dist/src/import/tui/WizardApp.d.ts +13 -0
  21. package/dist/src/import/tui/WizardApp.js +224 -52
  22. package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
  23. package/dist/src/import/tui/steps/GateStep.js +4 -2
  24. package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
  25. package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
  26. package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
  27. package/dist/src/import/tui/wizard-422-helpers.js +60 -0
  28. package/dist/src/program.d.ts +17 -0
  29. package/dist/src/program.js +27 -4
  30. package/dist/src/session/db.d.ts +13 -0
  31. package/dist/src/session/db.js +50 -0
  32. package/dist/src/types.d.ts +1 -1
  33. package/package.json +2 -2
  34. package/skills/generate-components.md +2 -0
@@ -6,7 +6,7 @@ import { parseSelectToolCallLines, runAgent } from '../../generate/agent-runner.
6
6
  import { OutputFormatter, c } from '../../output/format.js';
7
7
  import { buildRepoContextIndex, buildSelectionContext } from './context-builder.js';
8
8
  import { isAbsolute, resolve } from 'node:path';
9
- import { validateExtractedComponents, shouldExcludeDueToValidation } from '../extract/validate.js';
9
+ import { validateExtractedComponents, shouldExcludeDueToValidation, formatExclusionWarning, } from '../extract/validate.js';
10
10
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
11
11
  const DEFAULT_TIMEOUT_MS = Number(process.env.EDS_AGENT_TIMEOUT_MS ?? 3 * 60 * 1000);
12
12
  const DEFAULT_CONCURRENCY = 5;
@@ -178,7 +178,7 @@ export function registerAnalyzeSelectAgentCommand(program) {
178
178
  .option('--model <name>', 'Model to use (defaults to a small/fast model per agent)')
179
179
  .option('--verbose', 'Show full agent output including reasoning text')
180
180
  .option('--dry-run', 'Print the prompt for the first component without invoking the agent')
181
- .option('--exclude-invalid', 'Automatically reject components with validation errors (empty names, collisions)')
181
+ .option('--exclude-invalid', 'Auto-reject components with validation errors instead of failing loud (LLM cannot fix structural issues)')
182
182
  .action(async (opts) => {
183
183
  if (!VALID_AGENTS.has(opts.agent)) {
184
184
  process.stderr.write(`Error: unknown agent '${opts.agent}'. Accepted values: claude, codex, opencode, cursor\n`);
@@ -205,20 +205,32 @@ export function registerAnalyzeSelectAgentCommand(program) {
205
205
  }
206
206
  // Re-run validation (not persisted to DB, so always recompute).
207
207
  const validatedComponents = validateExtractedComponents(rawComponents);
208
- // If --exclude-invalid, split out errored components before sending to LLM.
209
- const invalidComponents = opts.excludeInvalid ? validatedComponents.filter(shouldExcludeDueToValidation) : [];
210
- const componentsForAgent = opts.excludeInvalid
211
- ? validatedComponents.filter((comp) => !shouldExcludeDueToValidation(comp))
212
- : validatedComponents;
213
- if (invalidComponents.length > 0) {
214
- process.stderr.write(c.yellow(`Auto-rejecting ${invalidComponents.length} component(s) with validation errors (--exclude-invalid):\n`));
208
+ // Fail-loud gate: if any component has validation errors, refuse to
209
+ // proceed unless --exclude-invalid is set. The LLM cannot fix
210
+ // structural issues (empty names, slot/prop collisions, etc.) — silent
211
+ // exclusion in this CI-style command would drop components without the
212
+ // caller noticing.
213
+ const invalidComponents = validatedComponents.filter(shouldExcludeDueToValidation);
214
+ if (invalidComponents.length > 0 && !opts.excludeInvalid) {
215
+ const lines = [
216
+ `Error: ${invalidComponents.length} component(s) failed validation; refusing select-agent without --exclude-invalid:`,
217
+ ];
215
218
  for (const comp of invalidComponents) {
216
219
  const codes = (comp.validationIssues ?? [])
217
220
  .filter((i) => i.severity === 'error')
218
221
  .map((i) => i.code)
219
222
  .join(', ');
220
- process.stderr.write(` ✗ ${comp.name} ${codes}\n`);
223
+ lines.push(` ✗ ${comp.name} ${codes}`);
221
224
  }
225
+ lines.push('');
226
+ lines.push('Re-run with --exclude-invalid to auto-reject these components, or fix them in source first.');
227
+ process.stderr.write(lines.join('\n') + '\n');
228
+ process.exit(1);
229
+ return;
230
+ }
231
+ const componentsForAgent = validatedComponents.filter((comp) => !shouldExcludeDueToValidation(comp));
232
+ if (invalidComponents.length > 0) {
233
+ process.stderr.write(c.yellow(formatExclusionWarning(invalidComponents)));
222
234
  }
223
235
  if (selectionRoot && scannedFiles.length > 0) {
224
236
  scannedFiles = scannedFiles.map((f) => (isAbsolute(f) ? f : resolve(selectionRoot, f)));
@@ -9,10 +9,18 @@ export type AnalyzeViewResult = {
9
9
  propCount: number;
10
10
  slotCount: number;
11
11
  warnings: string[];
12
+ /**
13
+ * Error-severity validation messages. Components with any errors will be
14
+ * auto-rejected by the next step (`analyze select --select-all`), so the
15
+ * extract TUI surfaces them prominently — both with a per-row ✗ badge
16
+ * and a dedicated Errors section below the component list.
17
+ */
18
+ errors: string[];
12
19
  extractionConfidence: number | null;
13
20
  needsReview: boolean;
14
21
  }>;
15
22
  totalWarnings: number;
23
+ totalErrors: number;
16
24
  };
17
25
  type AnalyzeViewProps = {
18
26
  result: AnalyzeViewResult;
@@ -53,8 +53,11 @@ export function AnalyzeView({ result, onExit }) {
53
53
  const conf = component.extractionConfidence;
54
54
  const confColor = conf === null ? 'gray' : component.needsReview ? 'red' : conf >= 4 ? 'white' : conf >= 3 ? 'yellow' : 'red';
55
55
  const confLabel = conf === null ? '—' : (component.needsReview ? '⚑ ' : '') + String(conf);
56
- return (_jsxs(Box, { children: [component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: (component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots')).padEnd(8) }), _jsx(Text, { color: confColor, children: confLabel }), component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ⚠ ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name));
57
- }), showScrollDown && _jsx(Text, { dimColor: true, children: " \u25BC scroll down" }), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
56
+ return (_jsxs(Box, { children: [component.errors.length > 0 && _jsx(Text, { color: "red", children: "\u2717 " }), component.errors.length === 0 && component.warnings.length > 0 && _jsx(Text, { color: "yellow", children: "\u26A0 " }), component.errors.length === 0 && component.warnings.length === 0 && _jsx(Text, { children: " " }), _jsx(Text, { children: truncateName(component.name).padEnd(20) }), _jsx(Text, { dimColor: true, children: component.framework.padEnd(10) }), _jsx(Text, { children: (component.propCount + ' props').padEnd(10) }), _jsx(Text, { children: (component.slotCount + ' ' + (component.slotCount === 1 ? 'slot' : 'slots')).padEnd(8) }), _jsx(Text, { color: confColor, children: confLabel }), component.errors.length > 0 && (_jsx(Text, { color: "red", children: ' ✗ ' + component.errors.length + ' error' + (component.errors.length === 1 ? '' : 's') })), component.errors.length === 0 && component.warnings.length > 0 && (_jsx(Text, { color: "yellow", children: ' ⚠ ' + component.warnings.length + ' warning' + (component.warnings.length === 1 ? '' : 's') }))] }, component.name));
57
+ }), showScrollDown && _jsx(Text, { dimColor: true, children: " \u25BC scroll down" }), result.totalErrors > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "red", children: 'Errors (' + result.totalErrors + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
58
+ .filter((c) => c.errors.length > 0)
59
+ .flatMap((c) => c.errors.map((e) => ({ component: c.name, error: e })))
60
+ .map((e, i) => (_jsx(Text, { color: "red", children: ' ✗ ' + e.component + ': ' + e.error }, i)))] })), result.totalWarnings > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { bold: true, color: "yellow", children: 'Warnings (' + result.totalWarnings + ')' }), _jsx(Text, { dimColor: true, children: '─'.repeat(70) }), _jsx(Text, { children: " " }), result.components
58
61
  .filter((c) => c.warnings.length > 0)
59
62
  .flatMap((c) => c.warnings.map((w) => ({ component: c.name, warning: w })))
60
63
  .map((w, i) => (_jsx(Text, { color: "yellow", children: ' ⚠ ' + w.component + ': ' + w.warning }, i)))] })), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: 'Run: analyze select --session ' + result.sessionId }), _jsx(Text, { children: " " })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or q to exit" }) })] }));
@@ -1,5 +1,8 @@
1
1
  import type { ManifestPayload, ServerPreviewResponse, ApplyOperationResponse } from '@contentful/experience-design-system-types';
2
2
  export declare const DEFAULT_HOST = "https://api.contentful.com";
3
+ export declare const PREVIEW_ERROR_PREFIX = "preview failed:";
4
+ export declare const APPLY_ERROR_PREFIX = "apply failed:";
5
+ export declare const VALIDATION_FAILED_CODE = "\"ValidationFailed\"";
3
6
  export interface ApiClientOptions {
4
7
  host?: string;
5
8
  cmaToken: string;
@@ -11,6 +14,23 @@ export declare class ApiError extends Error {
11
14
  readonly body: string;
12
15
  constructor(message: string, status: number, body: string);
13
16
  }
17
+ export interface PreviewValidationError {
18
+ componentName: string;
19
+ path: string;
20
+ message: string;
21
+ }
22
+ /**
23
+ * Parse the JSON body of a 422 from `previewImport()` into structured
24
+ * per-component validation errors. Returns [] for any malformed input
25
+ * so callers can fall back to the generic error path without try/catch.
26
+ *
27
+ * Path shape: `manifest:components/<Name>/$slots/<key>` or
28
+ * `manifest:components/<Name>/$properties/<key>`. Only the component
29
+ * name is extracted today; `path` and `message` are kept verbatim so
30
+ * future surfaces (debug logging, headless retry in SP-4) can render
31
+ * the field-level detail.
32
+ */
33
+ export declare function parsePreviewValidationErrors(body: string): PreviewValidationError[];
14
34
  export declare class ImportApiClient {
15
35
  private host;
16
36
  private token;
@@ -1,5 +1,26 @@
1
1
  import { DEFAULT_API_HOST, toApiHost } from '../host-utils.js';
2
2
  export const DEFAULT_HOST = DEFAULT_API_HOST;
3
+ // Phase-prefix constants used at the two ApiError throw sites below and
4
+ // imported by orchestrator.ts to identify preview-phase 422s for retry.
5
+ export const PREVIEW_ERROR_PREFIX = 'preview failed:';
6
+ export const APPLY_ERROR_PREFIX = 'apply failed:';
7
+ // Substring match the orchestrator uses to distinguish a parseable
8
+ // component-level validation failure from generic 422s. Quoted because the
9
+ // match runs against the raw JSON body (which contains `"code":"ValidationFailed"`).
10
+ // If the server ever changes the casing or naming, isPreviewValidationError
11
+ // silently returns false and the retry loop never fires — so this lives next
12
+ // to the prefixes as a deliberate, named contract rather than an inline
13
+ // magic string in the orchestrator.
14
+ export const VALIDATION_FAILED_CODE = '"ValidationFailed"';
15
+ // Cap on the body slice appended to ApiError.message. Bumped from 1000 →
16
+ // 16384 so realistic 422 ValidationFailed reports (which list every
17
+ // offending component, ~100 chars per error, easily exceeds 1KB once you
18
+ // cross ~10 components) survive intact through subprocess stderr. The
19
+ // orchestrator's parseOffendingComponentNames does JSON.parse on this slice
20
+ // and silently fails to recover any offenders if the JSON is mid-truncated.
21
+ // The cap stays in place to keep a runaway server response from blowing up
22
+ // log output.
23
+ const ERROR_BODY_LOG_CAP = 16384;
3
24
  export class ApiError extends Error {
4
25
  status;
5
26
  body;
@@ -8,13 +29,58 @@ export class ApiError extends Error {
8
29
  this.status = status;
9
30
  this.body = body;
10
31
  if (body) {
11
- // Append a trimmed version of the response body so callers that only
12
- // log e.message don't silently swallow the server's error detail.
13
- const trimmed = body.length > 1000 ? body.slice(0, 1000) + '…' : body;
32
+ // Append a (possibly trimmed) version of the response body so callers
33
+ // that only log e.message don't silently swallow the server's error
34
+ // detail.
35
+ const trimmed = body.length > ERROR_BODY_LOG_CAP ? body.slice(0, ERROR_BODY_LOG_CAP) + '…' : body;
14
36
  this.message = `${message}\n${trimmed}`;
15
37
  }
16
38
  }
17
39
  }
40
+ const COMPONENT_PATH_PREFIX = 'manifest:components/';
41
+ /**
42
+ * Parse the JSON body of a 422 from `previewImport()` into structured
43
+ * per-component validation errors. Returns [] for any malformed input
44
+ * so callers can fall back to the generic error path without try/catch.
45
+ *
46
+ * Path shape: `manifest:components/<Name>/$slots/<key>` or
47
+ * `manifest:components/<Name>/$properties/<key>`. Only the component
48
+ * name is extracted today; `path` and `message` are kept verbatim so
49
+ * future surfaces (debug logging, headless retry in SP-4) can render
50
+ * the field-level detail.
51
+ */
52
+ export function parsePreviewValidationErrors(body) {
53
+ if (!body)
54
+ return [];
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(body);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const details = parsed?.details;
63
+ const errors = details?.errors;
64
+ if (!Array.isArray(errors))
65
+ return [];
66
+ const out = [];
67
+ for (const raw of errors) {
68
+ if (typeof raw !== 'object' || raw === null)
69
+ continue;
70
+ const entry = raw;
71
+ if (typeof entry.path !== 'string' || typeof entry.message !== 'string')
72
+ continue;
73
+ if (!entry.path.startsWith(COMPONENT_PATH_PREFIX))
74
+ continue;
75
+ const tail = entry.path.slice(COMPONENT_PATH_PREFIX.length);
76
+ const slash = tail.indexOf('/');
77
+ const componentName = slash === -1 ? tail : tail.slice(0, slash);
78
+ if (!componentName)
79
+ continue;
80
+ out.push({ componentName, path: entry.path, message: entry.message });
81
+ }
82
+ return out;
83
+ }
18
84
  async function request(url, options) {
19
85
  const headers = {
20
86
  Authorization: `Bearer ${options.token}`,
@@ -68,7 +134,7 @@ export class ImportApiClient {
68
134
  body: JSON.stringify(manifest),
69
135
  });
70
136
  if (!res.ok) {
71
- throw new ApiError(`preview failed: ${res.status}`, res.status, await res.text());
137
+ throw new ApiError(`${PREVIEW_ERROR_PREFIX} ${res.status}`, res.status, await res.text());
72
138
  }
73
139
  return (await res.json());
74
140
  }
@@ -80,7 +146,7 @@ export class ImportApiClient {
80
146
  body: JSON.stringify({ ...manifest, acknowledgeBreakingChanges }),
81
147
  });
82
148
  if (!res.ok) {
83
- throw new ApiError(`apply failed: ${res.status}`, res.status, await res.text());
149
+ throw new ApiError(`${APPLY_ERROR_PREFIX} ${res.status}`, res.status, await res.text());
84
150
  }
85
151
  return (await res.json());
86
152
  }
@@ -9,7 +9,7 @@ import { OutputFormatter, c } from '../output/format.js';
9
9
  import { buildPrompt, resolveSkillPath } from './prompt-builder.js';
10
10
  import { GenerateView } from './tui/GenerateView.js';
11
11
  import { registerGenerateEditCommand } from './edit/command.js';
12
- import { openPipelineDb, loadRawComponents, applyToolCalls, applyTokenToolCalls, computeComponentInputHash, computeTokenInputHash, lookupCache, lookupCacheByEntity, storeCache, copyComponentFromCache, copyTokensFromCache, } from '../session/db.js';
12
+ import { openPipelineDb, loadRawComponents, applyToolCalls, applyTokenToolCalls, computeComponentInputHash, computeTokenInputHash, lookupCache, lookupCacheByEntity, storeCache, copyComponentFromCache, copyTokensFromCache, renameEmptySlots, } from '../session/db.js';
13
13
  import { getRefineArtifactsRoot, getRefineSessionPaths } from '../analyze/select/persistence.js';
14
14
  const execFileAsync = promisify(execFile);
15
15
  const VALID_AGENTS = new Set(['claude', 'codex', 'opencode', 'cursor']);
@@ -119,6 +119,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
119
119
  warnings: [],
120
120
  failed: false,
121
121
  cached: true,
122
+ renamedSlotsCount: 0,
122
123
  };
123
124
  }
124
125
  // Check for pinned (human-edited) entry with a different hash
@@ -134,16 +135,28 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
134
135
  warnings: [`${component.name}: source changed but human edits preserved`],
135
136
  failed: false,
136
137
  cached: true,
138
+ renamedSlotsCount: 0,
137
139
  };
138
140
  }
139
141
  }
142
+ // Rename empty-named slots in the DB and patch the in-memory component so the
143
+ // prompt sees the heuristic names. applyToolCalls matches by name — a row with
144
+ // name="" would never be reachable by a classify_slot call otherwise.
145
+ const { renames, warnings: renameWarnings } = renameEmptySlots(db, sessionId, component.component_id, component.name, component.slots.length);
146
+ let effectiveSlots = component.slots;
147
+ if (renames.length > 0) {
148
+ const renameMap = new Map(renames.map((r) => [r.oldName, r.newName]));
149
+ effectiveSlots = component.slots.map((s) => (renameMap.has(s.name) ? { ...s, name: renameMap.get(s.name) } : s));
150
+ for (const w of renameWarnings)
151
+ process.stderr.write(` ${c.yellow('⚠')} ${w}\n`);
152
+ }
140
153
  const rawComponentsInline = JSON.stringify([
141
154
  {
142
155
  name: component.name,
143
156
  source: component.source,
144
157
  framework: component.framework,
145
158
  props: component.props,
146
- slots: component.slots,
159
+ slots: effectiveSlots,
147
160
  },
148
161
  ], null, 2);
149
162
  const prompt = await buildPrompt({
@@ -186,6 +199,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
186
199
  warnings: [],
187
200
  failed: true,
188
201
  error: `timed out after ${DEFAULT_TIMEOUT_MS / 60000} minutes`,
202
+ renamedSlotsCount: renames.length,
189
203
  };
190
204
  }
191
205
  if (result.exitCode !== 0) {
@@ -209,6 +223,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
209
223
  slots: applied.slots,
210
224
  warnings: applied.warnings,
211
225
  failed: false,
226
+ renamedSlotsCount: renames.length,
212
227
  };
213
228
  }
214
229
  return {
@@ -219,6 +234,7 @@ async function runOneComponent(agent, model, db, sessionId, component, tokensInl
219
234
  warnings: [],
220
235
  failed: true,
221
236
  error: lastError,
237
+ renamedSlotsCount: renames.length,
222
238
  };
223
239
  }
224
240
  async function runAllComponents(agent, model, db, sessionId, components, tokensInline, tokenMapInline, verbose, noCache) {
@@ -390,6 +406,7 @@ async function runGenerateSkill(skill, opts, verbose = false) {
390
406
  }
391
407
  const totalClassified = generated.reduce((s, r) => s + r.classified, 0);
392
408
  const totalExcluded = generated.reduce((s, r) => s + r.excluded, 0);
409
+ const totalRenamedSlots = componentResults.reduce((s, r) => s + r.renamedSlotsCount, 0);
393
410
  const allOk = failed.length === 0;
394
411
  const cachedNote = cachedResults.length > 0 ? c.dim(` (${cachedResults.length} cached)`) : '';
395
412
  process.stderr.write((allOk ? c.green('✓') : c.yellow('⚠')) +
@@ -397,6 +414,8 @@ async function runGenerateSkill(skill, opts, verbose = false) {
397
414
  cachedNote +
398
415
  c.dim(` ${totalClassified} classified, ${totalExcluded} excluded`) +
399
416
  '\n');
417
+ // Machine-parseable summary on stdout for the wizard / orchestrator.
418
+ process.stdout.write(`renamed-slots: ${totalRenamedSlots}\n`);
400
419
  if (generated.length === 0 && cachedResults.length === 0) {
401
420
  die('Error: all components failed to generate — check agent output above');
402
421
  }
@@ -35,4 +35,25 @@ export interface PipelineResult {
35
35
  project: string;
36
36
  steps: StepResult[];
37
37
  }
38
+ export declare function isPreviewValidationError(result: {
39
+ exitCode: number;
40
+ stderr: string;
41
+ }): boolean;
42
+ export declare function parseOffendingComponentNames(output: string): string[];
43
+ /**
44
+ * Build the apply-push StepResult record. Centralizes the success/failure
45
+ * shape so excludedByValidationRetry is recorded consistently in both
46
+ * branches — previously the total-failure branch dropped it, leaving users
47
+ * with a failed pipeline and no audit trail of what was auto-excluded
48
+ * before the retry loop gave up.
49
+ */
50
+ export declare function buildPushStepResult(args: {
51
+ created: number;
52
+ updated: number;
53
+ failed: number;
54
+ durationMs: number;
55
+ stderr: string;
56
+ excludedByRetry: string[];
57
+ totalFailure?: boolean;
58
+ }): StepResult;
38
59
  export declare function runPipeline(opts: PipelineOptions, progressWriter: (line: string) => void, cliPathOverride?: string): Promise<PipelineResult>;
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { openPipelineDb, getOrCreateSession, createStep, updateStep, findLatestSessionForCommand, } from '../session/db.js';
6
+ import { PREVIEW_ERROR_PREFIX, VALIDATION_FAILED_CODE, parsePreviewValidationErrors } from '../apply/api-client.js';
6
7
  function findCliPath() {
7
8
  return join(fileURLToPath(import.meta.url), '..', '..', '..', '..', 'bin', 'cli.js');
8
9
  }
@@ -27,6 +28,49 @@ async function runStep(args, cliPath, env = {}, streamStderr = false) {
27
28
  });
28
29
  });
29
30
  }
31
+ const MAX_VALIDATION_RETRIES = Number(process.env['EDS_MAX_VALIDATION_RETRIES'] ?? 2);
32
+ export function isPreviewValidationError(result) {
33
+ return (result.exitCode !== 0 &&
34
+ result.stderr.includes(`${PREVIEW_ERROR_PREFIX} 422`) &&
35
+ result.stderr.includes(VALIDATION_FAILED_CODE));
36
+ }
37
+ export function parseOffendingComponentNames(output) {
38
+ // The 422 body is appended to the ApiError message by the constructor and
39
+ // written to stderr by die(). Extract the JSON portion by finding the first '{'.
40
+ const jsonStart = output.indexOf('{');
41
+ if (jsonStart === -1)
42
+ return [];
43
+ const errors = parsePreviewValidationErrors(output.slice(jsonStart));
44
+ return [...new Set(errors.map((e) => e.componentName))];
45
+ }
46
+ /**
47
+ * Build the apply-push StepResult record. Centralizes the success/failure
48
+ * shape so excludedByValidationRetry is recorded consistently in both
49
+ * branches — previously the total-failure branch dropped it, leaving users
50
+ * with a failed pipeline and no audit trail of what was auto-excluded
51
+ * before the retry loop gave up.
52
+ */
53
+ export function buildPushStepResult(args) {
54
+ const { created, updated, failed, durationMs, stderr, excludedByRetry, totalFailure } = args;
55
+ const excludedDetail = excludedByRetry.length > 0 ? { excludedByValidationRetry: excludedByRetry } : {};
56
+ if (totalFailure) {
57
+ const detail = excludedByRetry.length > 0 ? { ...excludedDetail } : undefined;
58
+ return {
59
+ step: 'apply push',
60
+ status: 'failed',
61
+ durationMs,
62
+ error: stderr,
63
+ ...(detail ? { detail } : {}),
64
+ };
65
+ }
66
+ const status = failed > 0 ? 'failed' : 'complete';
67
+ return {
68
+ step: 'apply push',
69
+ status,
70
+ durationMs,
71
+ detail: { created, updated, failed, ...excludedDetail },
72
+ };
73
+ }
30
74
  export async function runPipeline(opts, progressWriter, cliPathOverride) {
31
75
  const projectRoot = resolve(opts.project);
32
76
  const outDir = resolve(opts.out);
@@ -322,7 +366,37 @@ export async function runPipeline(opts, progressWriter, cliPathOverride) {
322
366
  components: componentsPath,
323
367
  });
324
368
  const t0 = Date.now();
325
- const r = await runStep(pushArgs, cliPath, { FORCE_COLOR: '1' }, true);
369
+ let r = await runStep(pushArgs, cliPath, { FORCE_COLOR: '1' }, true);
370
+ // Bounded retry loop: on a preview-phase 422 with a parseable ValidationFailed
371
+ // body, exclude the offending components and re-run apply push.
372
+ const excludedByRetry = [];
373
+ let validationRetryCount = 0;
374
+ while (validationRetryCount < MAX_VALIDATION_RETRIES && isPreviewValidationError(r) && extractSessionId) {
375
+ const offenders = parseOffendingComponentNames(r.stderr + r.stdout);
376
+ if (offenders.length === 0)
377
+ break; // unparseable body — give up and surface original error
378
+ process.stderr.write(`[retry ${validationRetryCount + 1}/${MAX_VALIDATION_RETRIES}] Preview validation failed — excluding ${offenders.join(', ')} and retrying\n`);
379
+ excludedByRetry.push(...offenders);
380
+ // No --select-all here: that would route through runNonInteractive's
381
+ // rebuild path, which DELETEs all rows and re-inserts with default
382
+ // status='extracted' — wiping the post-`generate components` state
383
+ // (status='generated' + raw_props.cdf_type) the next apply push reads.
384
+ // The bare --exclude-components form takes the rejectComponentsByName
385
+ // early-return: pure UPDATE, no rebuild.
386
+ const rejectArgs = [
387
+ 'analyze',
388
+ 'select',
389
+ '--session',
390
+ extractSessionId,
391
+ '--exclude-components',
392
+ offenders.join(','),
393
+ ];
394
+ const rejectResult = await runStep(rejectArgs, cliPath);
395
+ if (rejectResult.exitCode !== 0)
396
+ break; // selection step failed — give up
397
+ r = await runStep(pushArgs, cliPath, { FORCE_COLOR: '1' }, true);
398
+ validationRetryCount++;
399
+ }
326
400
  const durationMs = Date.now() - t0;
327
401
  let pushResult = null;
328
402
  try {
@@ -345,25 +419,32 @@ export async function runPipeline(opts, progressWriter, cliPathOverride) {
345
419
  // Total failure — nothing was pushed
346
420
  updateStep(db, pushStepId, 'failed', {}, r.stderr);
347
421
  progressWriter(`${pushLabel}✗ failed (${(durationMs / 1000).toFixed(1)}s)`);
348
- steps.push({
349
- step: 'apply push',
350
- status: 'failed',
422
+ steps.push(buildPushStepResult({
423
+ created,
424
+ updated,
425
+ failed,
351
426
  durationMs,
352
- error: r.stderr,
353
- });
427
+ stderr: r.stderr,
428
+ excludedByRetry,
429
+ totalFailure: true,
430
+ }));
354
431
  db.close();
355
432
  return { session: sessionId, project: projectRoot, steps };
356
433
  }
357
- const stepStatus = failed > 0 ? 'failed' : 'complete';
358
- updateStep(db, pushStepId, stepStatus === 'complete' ? 'complete' : 'failed', { components: componentsPath });
359
- const statusIcon = failed > 0 ? '⚠' : '✓';
360
- progressWriter(`${pushLabel}${statusIcon} ${created} created, ${updated} updated, ${failed} failed (${(durationMs / 1000).toFixed(1)}s)`);
361
- steps.push({
362
- step: 'apply push',
363
- status: stepStatus,
434
+ const stepResult = buildPushStepResult({
435
+ created,
436
+ updated,
437
+ failed,
364
438
  durationMs,
365
- detail: { created, updated, failed },
439
+ stderr: r.stderr,
440
+ excludedByRetry,
366
441
  });
442
+ updateStep(db, pushStepId, stepResult.status === 'complete' ? 'complete' : 'failed', {
443
+ components: componentsPath,
444
+ });
445
+ const statusIcon = failed > 0 ? '⚠' : '✓';
446
+ progressWriter(`${pushLabel}${statusIcon} ${created} created, ${updated} updated, ${failed} failed (${(durationMs / 1000).toFixed(1)}s)`);
447
+ steps.push(stepResult);
367
448
  }
368
449
  progressWriter('');
369
450
  progressWriter(`Pipeline complete. Session: ${sessionId}`);
@@ -1,4 +1,17 @@
1
1
  import React from 'react';
2
+ export declare function buildAnalyzeSelectArgs(opts: {
3
+ sessionId: string;
4
+ acceptAll: boolean;
5
+ }): string[];
6
+ export declare function formatAcceptanceSummary(opts: {
7
+ accepted: number;
8
+ autoRejected: number;
9
+ }): string;
10
+ export declare function formatGeneratedSummary(opts: {
11
+ generated: number;
12
+ renamedSlots: number;
13
+ autoRejected: number;
14
+ }): string;
2
15
  export type WizardAppProps = {
3
16
  initialSpaceId?: string;
4
17
  initialEnvironmentId?: string;