@contentful/experience-design-system-cli 2.7.4 → 2.11.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 (38) 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 +16 -11
  5. package/dist/src/analyze/extract/validate.d.ts +16 -0
  6. package/dist/src/analyze/extract/validate.js +94 -0
  7. package/dist/src/analyze/select/command.d.ts +47 -0
  8. package/dist/src/analyze/select/command.js +238 -14
  9. package/dist/src/analyze/select/tui/App.js +15 -17
  10. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +4 -1
  11. package/dist/src/analyze/select/tui/components/Sidebar.js +42 -6
  12. package/dist/src/analyze/select/types.d.ts +6 -0
  13. package/dist/src/analyze/select/types.js +24 -7
  14. package/dist/src/analyze/select-agent/command.js +43 -3
  15. package/dist/src/analyze/tui/AnalyzeView.d.ts +8 -0
  16. package/dist/src/analyze/tui/AnalyzeView.js +5 -2
  17. package/dist/src/apply/api-client.d.ts +20 -0
  18. package/dist/src/apply/api-client.js +71 -5
  19. package/dist/src/generate/command.js +21 -2
  20. package/dist/src/import/command.js +2 -0
  21. package/dist/src/import/orchestrator.d.ts +22 -0
  22. package/dist/src/import/orchestrator.js +97 -14
  23. package/dist/src/import/tui/WizardApp.d.ts +13 -0
  24. package/dist/src/import/tui/WizardApp.js +224 -52
  25. package/dist/src/import/tui/steps/GateStep.d.ts +2 -1
  26. package/dist/src/import/tui/steps/GateStep.js +4 -2
  27. package/dist/src/import/tui/steps/GenerateReviewStep.js +2 -0
  28. package/dist/src/import/tui/steps/PreviewValidationErrorStep.d.ts +11 -0
  29. package/dist/src/import/tui/steps/PreviewValidationErrorStep.js +14 -0
  30. package/dist/src/import/tui/wizard-422-helpers.d.ts +48 -0
  31. package/dist/src/import/tui/wizard-422-helpers.js +60 -0
  32. package/dist/src/program.d.ts +17 -0
  33. package/dist/src/program.js +27 -4
  34. package/dist/src/session/db.d.ts +13 -0
  35. package/dist/src/session/db.js +50 -0
  36. package/dist/src/types.d.ts +8 -0
  37. package/package.json +2 -2
  38. package/skills/generate-components.md +2 -0
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { useImmediateInput } from '../../../analyze/select/tui/hooks/useImmediateInput.js';
4
- export function GateStep({ successMessage, summary, context, continueLabel = 'Continue', skipLabel = 'Approve all and skip', onContinue, onSkip, onQuit, showSkip = true, }) {
4
+ export function GateStep({ successMessage, summary, context, continueLabel = 'Continue', skipLabel = 'Approve all and skip', onContinue, onSkip, onQuit, showSkip = true, intent = 'success', }) {
5
5
  useImmediateInput((input, key) => {
6
6
  if (key.return) {
7
7
  onContinue();
@@ -16,5 +16,7 @@ export function GateStep({ successMessage, summary, context, continueLabel = 'Co
16
16
  return;
17
17
  }
18
18
  });
19
- return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { color: "green", children: ["\u2713 ", successMessage] }), summary && _jsx(Text, { dimColor: true, children: summary }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: context }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[Enter] ", continueLabel] }), showSkip && onSkip && _jsxs(Text, { dimColor: true, children: ["[a] ", skipLabel] }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
19
+ const headerColor = intent === 'error' ? 'red' : 'green';
20
+ const headerIcon = intent === 'error' ? '✗' : '✓';
21
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { color: headerColor, children: [headerIcon, " ", successMessage] }), summary && _jsx(Text, { dimColor: true, children: summary }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: context }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["[Enter] ", continueLabel] }), showSkip && onSkip && _jsxs(Text, { dimColor: true, children: ["[a] ", skipLabel] }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
20
22
  }
@@ -187,6 +187,8 @@ export function GenerateReviewStep({ extractSessionId, onFinalize, onQuit, }) {
187
187
  status: c.status,
188
188
  extractionConfidence: null,
189
189
  needsReview: false,
190
+ validationErrorCount: 0,
191
+ validationWarningCount: 0,
190
192
  }));
191
193
  const longestName = components.reduce((m, c) => Math.max(m, c.key.length), 0);
192
194
  const sidebarWidth = Math.min(Math.max(longestName + 4, 14), 22);
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ import type { PreviewValidationError } from '../../../apply/api-client.js';
3
+ type PreviewValidationErrorStepProps = {
4
+ errors: PreviewValidationError[];
5
+ missingNames: string[];
6
+ onEdit: () => void;
7
+ onSkip: () => void;
8
+ onQuit: () => void;
9
+ };
10
+ export declare function PreviewValidationErrorStep({ errors, missingNames, onEdit, onSkip, onQuit, }: PreviewValidationErrorStepProps): React.ReactElement;
11
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { GateStep } from './GateStep.js';
3
+ export function PreviewValidationErrorStep({ errors, missingNames, onEdit, onSkip, onQuit, }) {
4
+ const uniqueNames = [...new Set(errors.map((e) => e.componentName))];
5
+ const matchedNames = uniqueNames.filter((n) => !missingNames.includes(n));
6
+ const errorLines = errors.map((e) => ` ${e.componentName}: ${e.message}`).join('\n');
7
+ const missingNote = missingNames.length > 0
8
+ ? `\n\nNote: ${missingNames.length} component name${missingNames.length === 1 ? '' : 's'} from the server (${missingNames.join(', ')}) ${missingNames.length === 1 ? 'does' : 'do'} not match anything in this session — they cannot be edited or skipped from here.`
9
+ : '';
10
+ const skipLabel = matchedNames.length === 0
11
+ ? 'No matching components to skip'
12
+ : `Skip ${matchedNames.length === 1 ? matchedNames[0] : `${matchedNames.length} components`} and retry`;
13
+ return (_jsx(GateStep, { intent: "error", successMessage: "Preview validation failed", summary: errorLines, context: `${uniqueNames.length} component${uniqueNames.length === 1 ? '' : 's'} failed server validation. Edit their definitions in the review TUI, or skip them and retry preview without them.${missingNote}`, continueLabel: "Edit definitions", skipLabel: skipLabel, showSkip: matchedNames.length > 0, onContinue: onEdit, onSkip: onSkip, onQuit: onQuit }));
14
+ }
@@ -0,0 +1,48 @@
1
+ import { ApiError, type PreviewValidationError } from '../../apply/api-client.js';
2
+ import { patchReviewStateWithValidationErrors, rejectComponentsByName } from '../../analyze/select/command.js';
3
+ export type Preview422Outcome = {
4
+ kind: 'validation-error';
5
+ errors: PreviewValidationError[];
6
+ missingNames: string[];
7
+ } | {
8
+ kind: 'unparseable';
9
+ message: string;
10
+ } | {
11
+ kind: 'not-422';
12
+ };
13
+ /**
14
+ * Decide what to do with an `ApiError` raised by `previewImport()`.
15
+ * Returns `not-422` if the caller should fall through to its other branches
16
+ * (404 / 401 / 403 / generic), `unparseable` if the body cannot be parsed
17
+ * into structured component-level errors (caller should route to the generic
18
+ * error step), or `validation-error` with the patched-state side effects
19
+ * already applied.
20
+ *
21
+ * Pure decision logic + a single delegated side effect to
22
+ * `patchReviewStateWithValidationErrors`. Extracted from `WizardApp.runPreview`
23
+ * so the routing is unit-testable without driving the full TUI.
24
+ */
25
+ export declare function handlePreview422(err: ApiError, extractSessionId: string | null, patchFn?: typeof patchReviewStateWithValidationErrors): Promise<Preview422Outcome>;
26
+ /**
27
+ * The WizardState patch the wizard should spread into any update that
28
+ * transitions OUT of the preview-validation-error step (e.g. into
29
+ * preview-gate after a successful retry). Without this, the validation-error
30
+ * arrays linger in state and any future code path that reads them sees stale
31
+ * data from a prior failed attempt.
32
+ */
33
+ export declare function clearedValidationErrorState(): {
34
+ previewValidationErrors: PreviewValidationError[];
35
+ previewValidationMissingNames: string[];
36
+ };
37
+ /**
38
+ * Run the "skip and retry" side effect: dedup the offending component
39
+ * names from a list of errors and call `rejectComponentsByName`. Returns
40
+ * the deduped names so the caller can log them or include them in test
41
+ * assertions. No-op when sessionId is null or errors are empty.
42
+ *
43
+ * The DB update inside `rejectComponentsByName` is what makes the next
44
+ * preview see a manifest with the offenders excluded — see SP-3 retro
45
+ * Bug 1: writing to the JSON state file alone is not enough because
46
+ * `loadCDFComponents` reads from the pipeline DB.
47
+ */
48
+ export declare function applySkipValidationErrors(sessionId: string | null, errors: PreviewValidationError[], rejectFn?: typeof rejectComponentsByName): Promise<string[]>;
@@ -0,0 +1,60 @@
1
+ import { parsePreviewValidationErrors } from '../../apply/api-client.js';
2
+ import { patchReviewStateWithValidationErrors, rejectComponentsByName } from '../../analyze/select/command.js';
3
+ /**
4
+ * Decide what to do with an `ApiError` raised by `previewImport()`.
5
+ * Returns `not-422` if the caller should fall through to its other branches
6
+ * (404 / 401 / 403 / generic), `unparseable` if the body cannot be parsed
7
+ * into structured component-level errors (caller should route to the generic
8
+ * error step), or `validation-error` with the patched-state side effects
9
+ * already applied.
10
+ *
11
+ * Pure decision logic + a single delegated side effect to
12
+ * `patchReviewStateWithValidationErrors`. Extracted from `WizardApp.runPreview`
13
+ * so the routing is unit-testable without driving the full TUI.
14
+ */
15
+ export async function handlePreview422(err, extractSessionId, patchFn = patchReviewStateWithValidationErrors) {
16
+ if (err.status !== 422)
17
+ return { kind: 'not-422' };
18
+ const errors = parsePreviewValidationErrors(err.body);
19
+ if (errors.length === 0)
20
+ return { kind: 'unparseable', message: err.message };
21
+ let missingNames = [];
22
+ if (extractSessionId) {
23
+ const result = await patchFn(extractSessionId, errors);
24
+ missingNames = result.missingNames;
25
+ }
26
+ return { kind: 'validation-error', errors, missingNames };
27
+ }
28
+ /**
29
+ * The WizardState patch the wizard should spread into any update that
30
+ * transitions OUT of the preview-validation-error step (e.g. into
31
+ * preview-gate after a successful retry). Without this, the validation-error
32
+ * arrays linger in state and any future code path that reads them sees stale
33
+ * data from a prior failed attempt.
34
+ */
35
+ export function clearedValidationErrorState() {
36
+ return {
37
+ previewValidationErrors: [],
38
+ previewValidationMissingNames: [],
39
+ };
40
+ }
41
+ /**
42
+ * Run the "skip and retry" side effect: dedup the offending component
43
+ * names from a list of errors and call `rejectComponentsByName`. Returns
44
+ * the deduped names so the caller can log them or include them in test
45
+ * assertions. No-op when sessionId is null or errors are empty.
46
+ *
47
+ * The DB update inside `rejectComponentsByName` is what makes the next
48
+ * preview see a manifest with the offenders excluded — see SP-3 retro
49
+ * Bug 1: writing to the JSON state file alone is not enough because
50
+ * `loadCDFComponents` reads from the pipeline DB.
51
+ */
52
+ export async function applySkipValidationErrors(sessionId, errors, rejectFn = rejectComponentsByName) {
53
+ if (!sessionId)
54
+ return [];
55
+ const names = [...new Set(errors.map((e) => e.componentName))];
56
+ if (names.length === 0)
57
+ return [];
58
+ await rejectFn(sessionId, names);
59
+ return names;
60
+ }
@@ -1,2 +1,19 @@
1
1
  import { Command } from 'commander';
2
+ type SpawnedChild = {
3
+ on(event: 'error', listener: (err: unknown) => void): unknown;
4
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
5
+ };
6
+ /**
7
+ * Run the spawn lifecycle for the build command. Extracted so the
8
+ * error-surfacing path is testable: previously `child.on('error', ...)`
9
+ * silently returned exit code 1 with no context, leaving the user
10
+ * staring at a generic non-zero exit when `pnpm` wasn't on PATH.
11
+ */
12
+ export declare function runBuild(opts: {
13
+ spawnFn: () => SpawnedChild;
14
+ stderrWrite: (chunk: string) => void;
15
+ }): Promise<{
16
+ exitCode: number;
17
+ }>;
2
18
  export declare function createProgram(): Command;
19
+ export {};
@@ -12,6 +12,30 @@ import { registerImportCommand } from './import/command.js';
12
12
  import { registerSetupCommand } from './setup/command.js';
13
13
  const require = createRequire(import.meta.url);
14
14
  const pkg = require('../package.json');
15
+ /**
16
+ * Run the spawn lifecycle for the build command. Extracted so the
17
+ * error-surfacing path is testable: previously `child.on('error', ...)`
18
+ * silently returned exit code 1 with no context, leaving the user
19
+ * staring at a generic non-zero exit when `pnpm` wasn't on PATH.
20
+ */
21
+ export async function runBuild(opts) {
22
+ return new Promise((resolvePromise) => {
23
+ const child = opts.spawnFn();
24
+ let settled = false;
25
+ const settle = (exitCode) => {
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
+ resolvePromise({ exitCode });
30
+ };
31
+ child.on('error', (err) => {
32
+ const message = err instanceof Error ? err.message : String(err);
33
+ opts.stderrWrite(`Error: failed to spawn build subprocess: ${message}\n`);
34
+ settle(1);
35
+ });
36
+ child.on('exit', (code) => settle(code ?? 1));
37
+ });
38
+ }
15
39
  function registerBuildCommand(program) {
16
40
  program
17
41
  .command('build')
@@ -19,10 +43,9 @@ function registerBuildCommand(program) {
19
43
  .action(async () => {
20
44
  const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
21
45
  process.stderr.write('⚙ Building from source...\n');
22
- const exitCode = await new Promise((resolvePromise) => {
23
- const child = spawn('pnpm', ['build'], { cwd: pkgRoot, stdio: 'inherit' });
24
- child.on('error', () => resolvePromise(1));
25
- child.on('exit', (code) => resolvePromise(code ?? 1));
46
+ const { exitCode } = await runBuild({
47
+ spawnFn: () => spawn('pnpm', ['build'], { cwd: pkgRoot, stdio: 'inherit' }),
48
+ stderrWrite: (s) => process.stderr.write(s),
26
49
  });
27
50
  process.exit(exitCode);
28
51
  });
@@ -53,6 +53,19 @@ export type RawComponentWithId = RawComponentDefinition & {
53
53
  component_id: string;
54
54
  };
55
55
  export declare function loadRawComponents(db: DatabaseSync, sessionId: string, allowedNames?: Set<string>): RawComponentWithId[];
56
+ /**
57
+ * Rename empty-named slots in the DB to heuristic names before generation.
58
+ * The LLM classify_slot tool call is matched back to the DB row by name, so a
59
+ * row with name="" can never be updated by the LLM — renaming it here makes it
60
+ * classifiable. Returns one warning string per renamed slot.
61
+ */
62
+ export declare function renameEmptySlots(db: DatabaseSync, sessionId: string, componentId: string, componentName: string, slotCount: number): {
63
+ renames: Array<{
64
+ oldName: string;
65
+ newName: string;
66
+ }>;
67
+ warnings: string[];
68
+ };
56
69
  export declare function storeCDFComponents(db: DatabaseSync, sessionId: string, components: Array<{
57
70
  key: string;
58
71
  entry: CDFComponentEntry;
@@ -563,6 +563,45 @@ export function loadRawComponents(db, sessionId, allowedNames) {
563
563
  }),
564
564
  }));
565
565
  }
566
+ /**
567
+ * Rename empty-named slots in the DB to heuristic names before generation.
568
+ * The LLM classify_slot tool call is matched back to the DB row by name, so a
569
+ * row with name="" can never be updated by the LLM — renaming it here makes it
570
+ * classifiable. Returns one warning string per renamed slot.
571
+ */
572
+ export function renameEmptySlots(db, sessionId, componentId, componentName, slotCount) {
573
+ const emptySlots = db
574
+ .prepare(`SELECT name, position FROM raw_slots
575
+ WHERE session_id = ? AND component_id = ? AND trim(name) = ''
576
+ ORDER BY position`)
577
+ .all(sessionId, componentId);
578
+ if (emptySlots.length === 0)
579
+ return { renames: [], warnings: [] };
580
+ const renames = [];
581
+ const warnings = [];
582
+ const rename = db.prepare(`UPDATE raw_slots SET name = ? WHERE session_id = ? AND component_id = ? AND name = ? AND position = ?`);
583
+ // Multi-statement write — wrap in a transaction so a SIGINT or driver error
584
+ // mid-loop leaves raw_slots either fully renamed or fully untouched, never
585
+ // half-renamed (which would corrupt the prompt-vs-DB invariant the LLM
586
+ // relies on for classify_slot). Matches the existing BEGIN/COMMIT pattern
587
+ // throughout this file (see storeRawComponents, createStep, etc.).
588
+ db.exec('BEGIN');
589
+ try {
590
+ for (const slot of emptySlots) {
591
+ // Single unnamed slot → "children". Multiple → positional names.
592
+ const newName = slotCount === 1 ? 'children' : `slot_${slot.position}`;
593
+ rename.run(newName, sessionId, componentId, slot.name, slot.position);
594
+ renames.push({ oldName: slot.name, newName });
595
+ warnings.push(`${componentName}: slot at position ${slot.position} had empty name — renamed to "${newName}" for classification`);
596
+ }
597
+ db.exec('COMMIT');
598
+ }
599
+ catch (e) {
600
+ db.exec('ROLLBACK');
601
+ throw e;
602
+ }
603
+ return { renames, warnings };
604
+ }
566
605
  function groupBy(items, key) {
567
606
  const map = new Map();
568
607
  for (const item of items) {
@@ -676,6 +715,12 @@ export function loadCDFComponents(db, sessionId) {
676
715
  return null;
677
716
  const $properties = {};
678
717
  for (const p of compProps) {
718
+ // Hallucination insurance: drop any prop whose name didn't survive trim.
719
+ // The pre-generate rename guard catches empty-named slots, but if the LLM
720
+ // hallucinated a classify_prop with an empty name into the DB, surface
721
+ // nothing to the manifest builder rather than emitting "$properties: { '': ... }".
722
+ if (!p.name.trim())
723
+ continue;
679
724
  const av = allowedValuesByProp.get(`${component_id}::${p.name}`);
680
725
  const propDef = {
681
726
  $type: p.cdf_type,
@@ -702,6 +747,11 @@ export function loadCDFComponents(db, sessionId) {
702
747
  const compSlots = slotsByComponent.get(component_id) ?? [];
703
748
  const $slots = {};
704
749
  for (const s of compSlots) {
750
+ // Hallucination insurance: same as $properties — drop empty-named slots
751
+ // before they reach buildManifest, which faithfully passes empty keys
752
+ // through and triggers a 422 from the preview API.
753
+ if (!s.name.trim())
754
+ continue;
705
755
  const ac = allowedComponentsBySlot.get(`${component_id}::${s.name}`);
706
756
  const slotDef = {};
707
757
  if (s.description !== null)
@@ -1,4 +1,11 @@
1
1
  import type { DesignTokenType } from '@contentful/experience-design-system-types';
2
+ export type ExtractionValidationIssueCode = 'EMPTY_COMPONENT_NAME' | 'EMPTY_PROP_NAME' | 'EMPTY_SLOT_NAME' | 'PROP_SLOT_NAME_COLLISION' | 'DUPLICATE_COMPONENT_NAME' | 'EMPTY_COMPONENT' | 'SERVER_VALIDATION_FAILED';
3
+ export type ExtractionValidationIssue = {
4
+ severity: 'error' | 'warning';
5
+ code: ExtractionValidationIssueCode;
6
+ message: string;
7
+ field?: string;
8
+ };
2
9
  export interface RawTokenDefinition {
3
10
  name: string;
4
11
  value: string;
@@ -37,6 +44,7 @@ export interface RawComponentDefinition {
37
44
  extractionConfidence?: number | null;
38
45
  reviewReasons?: string[];
39
46
  needsReview?: boolean;
47
+ validationIssues?: ExtractionValidationIssue[];
40
48
  }
41
49
  export interface ComponentExtractionResult {
42
50
  components: RawComponentDefinition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentful/experience-design-system-cli",
3
- "version": "2.7.4",
3
+ "version": "2.11.0",
4
4
  "description": "Contentful Experiences design system import CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "react-dom": "^18.3.1",
37
37
  "ts-morph": "^27.0.2",
38
38
  "typescript": "^5.9.3",
39
- "@contentful/experience-design-system-types": "2.7.4"
39
+ "@contentful/experience-design-system-types": "2.11.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@tsconfig/node24": "^24.0.3",
@@ -251,6 +251,8 @@ For each `RawSlotDefinition`:
251
251
  - `false` if clearly optional (icon slot, footer slot with a default, decorative slot)
252
252
  - Default to `true` when the source gives no signal
253
253
 
254
+ **Pre-named slots:** If the input contains a slot whose `name` was already inferred by the pipeline (e.g. `"children"`, `"slot_0"`), treat it as you would any named slot — classify it normally. The pipeline renames empty-named slots to heuristic names before passing them to you; your job is to confirm or enrich the classification (set `required`, `description`, `allowed_components`), not to rename again.
255
+
254
256
  ---
255
257
 
256
258
  ## Examples