@contentful/experience-design-system-cli 2.12.4-dev-build-5aa6a69.0 → 2.12.4-dev-build-ff57340.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.
- package/dist/package.json +1 -1
- package/dist/src/analyze/command.js +16 -1
- package/dist/src/analyze/cycle-detection.d.ts +93 -0
- package/dist/src/analyze/cycle-detection.js +326 -0
- package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +24 -1
- package/dist/src/analyze/select/tui/components/FieldEditor.js +136 -6
- package/dist/src/apply/command.d.ts +34 -1
- package/dist/src/apply/command.js +81 -0
- package/dist/src/apply/error-parser.d.ts +43 -0
- package/dist/src/apply/error-parser.js +163 -0
- package/dist/src/import/tui/WizardApp.js +59 -10
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +1 -1
- package/dist/src/import/tui/steps/GenerateReviewStep.js +211 -17
- package/dist/src/session/db.d.ts +9 -0
- package/dist/src/session/db.js +46 -0
- package/package.json +2 -2
|
@@ -4,6 +4,7 @@ import { Box, Text } from 'ink';
|
|
|
4
4
|
import { CDF_PROPERTY_TYPES, CDF_PROPERTY_CATEGORIES, DESIGN_TOKEN_TYPES, } from '@contentful/experience-design-system-types';
|
|
5
5
|
import { useImmediateInput } from '../hooks/useImmediateInput.js';
|
|
6
6
|
import { computeNextScrollOffset } from '../hooks/scroll-offset.js';
|
|
7
|
+
import { findSlotCycles } from '../../../cycle-detection.js';
|
|
7
8
|
// Section indices for top-level navigation
|
|
8
9
|
// 0 = component $description row
|
|
9
10
|
// 1 = $properties header (then props[0..n-1])
|
|
@@ -201,18 +202,34 @@ function PropRow({ prop, selected, activeField, textCursor, valueCursor, cursorV
|
|
|
201
202
|
return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
|
|
202
203
|
}), editingValue?.mode === 'add' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] }))] }));
|
|
203
204
|
}
|
|
204
|
-
function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, }) {
|
|
205
|
+
function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, pickerCandidates, pickerCursor, }) {
|
|
205
206
|
const cursor = cursorVisible ? '█' : ' ';
|
|
206
207
|
const bg = selected ? 'blue' : undefined;
|
|
207
208
|
const nameDisplay = slot.name.length > 14 ? slot.name.slice(0, 13) + '…' : slot.name.padEnd(14);
|
|
208
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any — press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
|
|
209
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), !selected && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "allowed:" }), slot.allowedComponents.length === 0 ? (_jsx(Text, { dimColor: true, children: "(any)" })) : (_jsx(Text, { color: "cyan", children: slot.allowedComponents.join(', ') }))] })), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any — press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
|
|
209
210
|
const isActiveCursor = activeField === 'allowedComponents' && valueCursor === i;
|
|
210
211
|
const isBeingEdited = editingValue?.mode === 'edit' && editingValue.index === i;
|
|
211
212
|
if (isBeingEdited) {
|
|
212
213
|
return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
|
|
213
214
|
}
|
|
214
215
|
return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
|
|
215
|
-
}), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))
|
|
216
|
+
}), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] })), editingValue?.mode === 'add' && activeField === 'allowedComponents' && pickerCandidates !== null && (_jsx(Box, { paddingLeft: 2, flexDirection: "column", children: pickerCandidates.length === 0 ? (_jsx(Text, { dimColor: true, children: '(no valid components to add — all remaining candidates would create cycles)' })) : ((() => {
|
|
217
|
+
const filtered = valueText.length === 0
|
|
218
|
+
? pickerCandidates
|
|
219
|
+
: pickerCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()));
|
|
220
|
+
if (filtered.length === 0) {
|
|
221
|
+
return _jsx(Text, { dimColor: true, children: '(no candidates match — Enter to add as free text)' });
|
|
222
|
+
}
|
|
223
|
+
const cursor = pickerCursor % filtered.length;
|
|
224
|
+
const MAX_VISIBLE = 5;
|
|
225
|
+
const start = Math.max(0, Math.min(filtered.length - MAX_VISIBLE, cursor - 2));
|
|
226
|
+
const slice = filtered.slice(start, start + MAX_VISIBLE);
|
|
227
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ' candidates (↑↓ cycle, Enter to add):' }), slice.map((name, i) => {
|
|
228
|
+
const absIdx = start + i;
|
|
229
|
+
const isCursor = absIdx === cursor;
|
|
230
|
+
return (_jsx(Text, { color: isCursor ? 'cyan' : undefined, dimColor: !isCursor, children: isCursor ? ` ▶ ${name}` : ` ${name}` }, name));
|
|
231
|
+
})] }));
|
|
232
|
+
})()) }))] })), selected && activeField === 'description' && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: slot.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: slot.description[textCursor] ?? cursor }), _jsx(Text, { children: slot.description.slice(textCursor + 1) })] })] })), selected && activeField !== 'description' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: "green", children: slot.description || '—' })] }))] }));
|
|
216
233
|
}
|
|
217
234
|
// Field order is intentional: description is LAST so it's the "edge" of the
|
|
218
235
|
// field cycle. The user reaches description by walking through type → category
|
|
@@ -238,8 +255,59 @@ function propFields(prop) {
|
|
|
238
255
|
// components is a list-typed field that swallows j/k similarly to enum $values
|
|
239
256
|
// — placing it before description preserves description-as-last.
|
|
240
257
|
const SLOT_FIELDS = ['required', 'allowedComponents', 'description'];
|
|
258
|
+
// ── INTEG-4401: cycle-aware $allowedComponents picker helpers ─────────────
|
|
259
|
+
/** Simulate adding a candidate to a slot; replaces self-entry with live edits. */
|
|
260
|
+
export function simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate) {
|
|
261
|
+
const selfEntry = {
|
|
262
|
+
name: selfName,
|
|
263
|
+
slots: currentSlots.map((s) => ({
|
|
264
|
+
name: s.name,
|
|
265
|
+
allowedComponents: s.name === targetSlotName ? [...s.allowedComponents, candidate] : [...s.allowedComponents],
|
|
266
|
+
})),
|
|
267
|
+
};
|
|
268
|
+
const withoutSelf = projectSlotGraph.filter((c) => c.name !== selfName);
|
|
269
|
+
return [...withoutSelf, selfEntry];
|
|
270
|
+
}
|
|
271
|
+
/** True iff `next` contains a cycle whose canonical edge set is absent in `before`. */
|
|
272
|
+
export function introducesNewCycle(before, next) {
|
|
273
|
+
const key = (edges) => {
|
|
274
|
+
if (edges.length === 0)
|
|
275
|
+
return '';
|
|
276
|
+
const encoded = edges.map((e) => `${e.fromComponent}|${e.slotName}|${e.toComponent}`);
|
|
277
|
+
let minIdx = 0;
|
|
278
|
+
for (let i = 1; i < encoded.length; i += 1)
|
|
279
|
+
if (encoded[i] < encoded[minIdx])
|
|
280
|
+
minIdx = i;
|
|
281
|
+
return [...encoded.slice(minIdx), ...encoded.slice(0, minIdx)].join(';');
|
|
282
|
+
};
|
|
283
|
+
const beforeSet = new Set(before.map((c) => key(c.edges)));
|
|
284
|
+
for (const cycle of next)
|
|
285
|
+
if (!beforeSet.has(key(cycle.edges)))
|
|
286
|
+
return true;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
/** Candidate names that can be added to a slot without creating a new cycle. */
|
|
290
|
+
export function computeAllowedComponentCandidates(projectSlotGraph, selfName, currentSlots, targetSlotName) {
|
|
291
|
+
const targetSlot = currentSlots.find((s) => s.name === targetSlotName);
|
|
292
|
+
const alreadyAdded = new Set(targetSlot?.allowedComponents ?? []);
|
|
293
|
+
const universe = new Set();
|
|
294
|
+
for (const c of projectSlotGraph)
|
|
295
|
+
universe.add(c.name);
|
|
296
|
+
universe.delete(selfName);
|
|
297
|
+
for (const added of alreadyAdded)
|
|
298
|
+
universe.delete(added);
|
|
299
|
+
const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, '', ''));
|
|
300
|
+
const safe = [];
|
|
301
|
+
for (const candidate of universe) {
|
|
302
|
+
const nextCycles = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate));
|
|
303
|
+
if (!introducesNewCycle(baseline, nextCycles))
|
|
304
|
+
safe.push(candidate);
|
|
305
|
+
}
|
|
306
|
+
safe.sort((a, b) => a.localeCompare(b));
|
|
307
|
+
return safe;
|
|
308
|
+
}
|
|
241
309
|
// ── Main component ────────────────────────────────────────────────────────────
|
|
242
|
-
export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, }) {
|
|
310
|
+
export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, projectSlotGraph, currentComponentName, }) {
|
|
243
311
|
const { state: initialState, error: parseError } = parseToState(value);
|
|
244
312
|
const [editorState, setEditorState] = useState(initialState);
|
|
245
313
|
const [parseErr] = useState(parseError);
|
|
@@ -291,6 +359,8 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
|
|
|
291
359
|
// mode='add' — append on Enter; mode='edit' — replace at index on Enter.
|
|
292
360
|
const [editingValue, setEditingValue] = useState(null);
|
|
293
361
|
const [valueText, setValueText] = useState('');
|
|
362
|
+
// INTEG-4401: picker cursor for the cycle-filtered $allowedComponents list.
|
|
363
|
+
const [pickerCursor, setPickerCursor] = useState(0);
|
|
294
364
|
const [validationError, setValidationError] = useState(null);
|
|
295
365
|
const [cursorVisible] = useState(true);
|
|
296
366
|
// Feature 1: source-view panel toggle. Opens with `s`, closes with `s` or Esc.
|
|
@@ -359,11 +429,57 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
|
|
|
359
429
|
// same add/edit/remove/reorder list shape.
|
|
360
430
|
const isPropValuesEntry = editingValue && currentProp && activeField === 'values' && !inSlots;
|
|
361
431
|
const isSlotAllowedEntry = editingValue && currentSlot && activeField === 'allowedComponents' && inSlots;
|
|
432
|
+
// INTEG-4401: cycle-filtered candidates for slot-add.
|
|
433
|
+
const slotAddCandidates = isSlotAllowedEntry && editingValue?.mode === 'add' && projectSlotGraph && currentSlot && currentComponentName
|
|
434
|
+
? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, currentSlot.name)
|
|
435
|
+
: null;
|
|
436
|
+
const filteredCandidates = slotAddCandidates
|
|
437
|
+
? valueText.length === 0
|
|
438
|
+
? slotAddCandidates
|
|
439
|
+
: slotAddCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()))
|
|
440
|
+
: null;
|
|
362
441
|
if (isPropValuesEntry || isSlotAllowedEntry) {
|
|
363
442
|
const vals = isPropValuesEntry ? currentProp.values : currentSlot.allowedComponents;
|
|
443
|
+
const pickerActive = isSlotAllowedEntry && editingValue?.mode === 'add' && slotAddCandidates !== null;
|
|
444
|
+
if (pickerActive && (key.upArrow || key.downArrow)) {
|
|
445
|
+
const len = filteredCandidates.length;
|
|
446
|
+
if (len > 0) {
|
|
447
|
+
setPickerCursor((c) => (key.upArrow ? (c - 1 + len) % len : (c + 1) % len));
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
364
451
|
if (key.return) {
|
|
365
|
-
|
|
452
|
+
let chosen = null;
|
|
453
|
+
if (pickerActive && filteredCandidates && filteredCandidates.length > 0) {
|
|
454
|
+
if (valueText.trim() === '') {
|
|
455
|
+
chosen = filteredCandidates[pickerCursor % filteredCandidates.length] ?? null;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const exact = filteredCandidates.find((n) => n === valueText.trim());
|
|
459
|
+
if (exact)
|
|
460
|
+
chosen = exact;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const trimmed = chosen ?? valueText.trim();
|
|
366
464
|
if (trimmed) {
|
|
465
|
+
// Cycle-aware free-text validation. Picker path is safe by construction.
|
|
466
|
+
if (isSlotAllowedEntry &&
|
|
467
|
+
editingValue?.mode === 'add' &&
|
|
468
|
+
projectSlotGraph &&
|
|
469
|
+
currentSlot &&
|
|
470
|
+
currentComponentName &&
|
|
471
|
+
chosen === null) {
|
|
472
|
+
if (trimmed === currentComponentName) {
|
|
473
|
+
setValidationError(`"${trimmed}" cannot be added to its own slot (self-loop).`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, '', ''));
|
|
477
|
+
const next = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, currentSlot.name, trimmed));
|
|
478
|
+
if (introducesNewCycle(baseline, next)) {
|
|
479
|
+
setValidationError(`Adding "${trimmed}" to slot "${currentSlot.name}" would create a slot-dependency cycle. Choose a different component.`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
367
483
|
let nextVals;
|
|
368
484
|
let cursorAfter;
|
|
369
485
|
if (editingValue.mode === 'add') {
|
|
@@ -384,22 +500,28 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
|
|
|
384
500
|
commit({ ...editorState, slots: nextSlots });
|
|
385
501
|
}
|
|
386
502
|
setValueCursor(cursorAfter);
|
|
503
|
+
setValidationError(null);
|
|
387
504
|
}
|
|
388
505
|
setEditingValue(null);
|
|
389
506
|
setValueText('');
|
|
507
|
+
setPickerCursor(0);
|
|
390
508
|
return;
|
|
391
509
|
}
|
|
392
510
|
if (key.escape) {
|
|
393
511
|
setEditingValue(null);
|
|
394
512
|
setValueText('');
|
|
513
|
+
setPickerCursor(0);
|
|
514
|
+
setValidationError(null);
|
|
395
515
|
return;
|
|
396
516
|
}
|
|
397
517
|
if (key.backspace) {
|
|
398
518
|
setValueText((t) => t.slice(0, -1));
|
|
519
|
+
setPickerCursor(0);
|
|
399
520
|
return;
|
|
400
521
|
}
|
|
401
522
|
if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
|
402
523
|
setValueText((t) => t + input);
|
|
524
|
+
setPickerCursor(0);
|
|
403
525
|
return;
|
|
404
526
|
}
|
|
405
527
|
return;
|
|
@@ -985,7 +1107,15 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
|
|
|
985
1107
|
// slot row
|
|
986
1108
|
const s = slots[row.idx];
|
|
987
1109
|
const isSelected = inSlots && row.idx === slotIdx;
|
|
988
|
-
|
|
1110
|
+
// INTEG-4401: compute picker candidates for selected slot in add-mode.
|
|
1111
|
+
const slotPickerCandidates = isSelected &&
|
|
1112
|
+
editingValue?.mode === 'add' &&
|
|
1113
|
+
activeField === 'allowedComponents' &&
|
|
1114
|
+
projectSlotGraph &&
|
|
1115
|
+
currentComponentName
|
|
1116
|
+
? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, s.name)
|
|
1117
|
+
: null;
|
|
1118
|
+
return (_jsx(SlotRow, { slot: s, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth, pickerCandidates: slotPickerCandidates, pickerCursor: pickerCursor }, `slot-${row.idx}`));
|
|
989
1119
|
}) }), sourceOpen &&
|
|
990
1120
|
!onToggleSourceExternal &&
|
|
991
1121
|
(() => {
|
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
-
import type { DTCGTokenEntry } from '@contentful/experience-design-system-types';
|
|
2
|
+
import type { CDFComponentEntry, DTCGTokenEntry } from '@contentful/experience-design-system-types';
|
|
3
|
+
import { findSlotCycles } from '../analyze/cycle-detection.js';
|
|
3
4
|
import type { ServerPreviewResponse } from '@contentful/experience-design-system-types';
|
|
4
5
|
export declare function readTokensFromPath(flag: string, p: string): Promise<DTCGTokenEntry[]>;
|
|
6
|
+
/**
|
|
7
|
+
* Pure cycle detection — returns [] when the graph is acyclic. Callers own
|
|
8
|
+
* how to react (CLI standalone uses `assertNoSlotCycles` which exits 1; the
|
|
9
|
+
* wizard uses `detectSlotCycles` directly to route to an in-TUI error step
|
|
10
|
+
* without ever POSTing to EDSI).
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectSlotCycles(components: Array<{
|
|
13
|
+
key: string;
|
|
14
|
+
entry: CDFComponentEntry;
|
|
15
|
+
}>): ReturnType<typeof findSlotCycles>;
|
|
16
|
+
/**
|
|
17
|
+
* Build a stderr-style message block for a set of cycles. Extracted so the
|
|
18
|
+
* wizard can render the same text in an in-TUI error panel and any future
|
|
19
|
+
* headless surface can log identical output.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatSlotCycleReport(cycles: ReturnType<typeof findSlotCycles>): string[];
|
|
22
|
+
export declare function assertNoSlotCycles(components: Array<{
|
|
23
|
+
key: string;
|
|
24
|
+
entry: CDFComponentEntry;
|
|
25
|
+
}>): void;
|
|
26
|
+
/**
|
|
27
|
+
* Reconstruct the `{key, entry}` shape from a built `ManifestPayload`. The
|
|
28
|
+
* wizard's push path holds a serialized manifest rather than the underlying
|
|
29
|
+
* CDF records, so we unwrap it here to feed `detectSlotCycles`. Skips the
|
|
30
|
+
* `$schema` sentinel key. Safe on undefined/empty manifests (returns []).
|
|
31
|
+
*/
|
|
32
|
+
export declare function extractComponentsFromManifest(manifest: {
|
|
33
|
+
componentsManifest?: Record<string, unknown>;
|
|
34
|
+
} | null | undefined): Array<{
|
|
35
|
+
key: string;
|
|
36
|
+
entry: CDFComponentEntry;
|
|
37
|
+
}>;
|
|
5
38
|
export declare function hasBreakingChangesWithImpact(preview: ServerPreviewResponse): boolean;
|
|
6
39
|
export declare function registerApplyCommand(program: Command): void;
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { validateCDF, flattenDTCG, validateDTCG, buildManifest, buildFilteredManifest, } from '@contentful/experience-design-system-types';
|
|
6
6
|
import { ApiError, ImportApiClient } from './api-client.js';
|
|
7
7
|
import { openPipelineDb, loadCDFComponents } from '../session/db.js';
|
|
8
|
+
import { findSlotCycles, suggestCycleBreakEdge, formatCyclePath } from '../analyze/cycle-detection.js';
|
|
8
9
|
import { isEmptyPreview } from './preview-utils.js';
|
|
9
10
|
import { ServerPreviewApp, ServerPreviewConfirm, ServerApplyProgress, ServerApplyDone } from './tui/ServerApplyView.js';
|
|
10
11
|
import { SelectView, makeSelectKey } from './tui/SelectView.js';
|
|
@@ -170,6 +171,75 @@ async function resolveSharedInputs(opts) {
|
|
|
170
171
|
});
|
|
171
172
|
return { components, tokens, client };
|
|
172
173
|
}
|
|
174
|
+
// --- INTEG-4401: pre-push slot-cycle hard block ---
|
|
175
|
+
//
|
|
176
|
+
// The backend apply worker rejects cyclic slot graphs at topo-sort time, but
|
|
177
|
+
// by then the request has already been serialized, transported, and partially
|
|
178
|
+
// applied — leading to confusing partial-failure states. Detecting the same
|
|
179
|
+
// violation locally lets us fail fast with a clear message and zero
|
|
180
|
+
// side-effects. Called from the `apply push` and `apply select` flows AND
|
|
181
|
+
// from the wizard's `experiences import` push path (via `detectSlotCycles`).
|
|
182
|
+
// `apply preview` is read-only and still runs (its diff is used to warn but
|
|
183
|
+
// not to block).
|
|
184
|
+
/**
|
|
185
|
+
* Pure cycle detection — returns [] when the graph is acyclic. Callers own
|
|
186
|
+
* how to react (CLI standalone uses `assertNoSlotCycles` which exits 1; the
|
|
187
|
+
* wizard uses `detectSlotCycles` directly to route to an in-TUI error step
|
|
188
|
+
* without ever POSTing to EDSI).
|
|
189
|
+
*/
|
|
190
|
+
export function detectSlotCycles(components) {
|
|
191
|
+
const cycleInput = components.map(({ key, entry }) => ({
|
|
192
|
+
name: key,
|
|
193
|
+
slots: Object.entries(entry.$slots ?? {}).map(([slotName, slotDef]) => ({
|
|
194
|
+
name: slotName,
|
|
195
|
+
allowedComponents: slotDef.$allowedComponents ?? [],
|
|
196
|
+
})),
|
|
197
|
+
}));
|
|
198
|
+
return findSlotCycles(cycleInput);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Build a stderr-style message block for a set of cycles. Extracted so the
|
|
202
|
+
* wizard can render the same text in an in-TUI error panel and any future
|
|
203
|
+
* headless surface can log identical output.
|
|
204
|
+
*/
|
|
205
|
+
export function formatSlotCycleReport(cycles) {
|
|
206
|
+
const lines = [];
|
|
207
|
+
lines.push(`Error: manifest:components/slot-cycles — ${cycles.length} slot dependency cycle(s) detected. Push refused.`);
|
|
208
|
+
for (let i = 0; i < cycles.length; i += 1) {
|
|
209
|
+
const cycle = cycles[i];
|
|
210
|
+
lines.push(` Cycle ${i + 1}: ${formatCyclePath(cycle)}`);
|
|
211
|
+
const suggested = suggestCycleBreakEdge(cycle, cycles);
|
|
212
|
+
lines.push(` Fix: remove '${suggested.toComponent}' from ${suggested.fromComponent}.$slots.${suggested.slotName}.$allowedComponents`);
|
|
213
|
+
}
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
export function assertNoSlotCycles(components) {
|
|
217
|
+
const cycles = detectSlotCycles(components);
|
|
218
|
+
if (cycles.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
process.stderr.write(formatSlotCycleReport(cycles).join('\n') + '\n');
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Reconstruct the `{key, entry}` shape from a built `ManifestPayload`. The
|
|
225
|
+
* wizard's push path holds a serialized manifest rather than the underlying
|
|
226
|
+
* CDF records, so we unwrap it here to feed `detectSlotCycles`. Skips the
|
|
227
|
+
* `$schema` sentinel key. Safe on undefined/empty manifests (returns []).
|
|
228
|
+
*/
|
|
229
|
+
export function extractComponentsFromManifest(manifest) {
|
|
230
|
+
const componentsManifest = manifest?.componentsManifest;
|
|
231
|
+
if (!componentsManifest)
|
|
232
|
+
return [];
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const [key, value] of Object.entries(componentsManifest)) {
|
|
235
|
+
if (key === '$schema')
|
|
236
|
+
continue;
|
|
237
|
+
if (!value || typeof value !== 'object')
|
|
238
|
+
continue;
|
|
239
|
+
out.push({ key, entry: value });
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
173
243
|
// --- Output helpers ---
|
|
174
244
|
export function hasBreakingChangesWithImpact(preview) {
|
|
175
245
|
const allChanged = [...preview.components.changed, ...preview.tokens.changed];
|
|
@@ -445,6 +515,12 @@ export function registerApplyCommand(program) {
|
|
|
445
515
|
throw e;
|
|
446
516
|
}
|
|
447
517
|
const { components, tokens, client } = inputs;
|
|
518
|
+
// INTEG-4401: refuse before spending any credentials or bytes on a
|
|
519
|
+
// manifest the backend will reject. Cycle detection is deterministic
|
|
520
|
+
// and cheap so we run it here (not just at extract time) — this also
|
|
521
|
+
// guards `apply push --components` where extract-time cycles are not
|
|
522
|
+
// in the session DB.
|
|
523
|
+
assertNoSlotCycles(components);
|
|
448
524
|
try {
|
|
449
525
|
await client.validateToken();
|
|
450
526
|
}
|
|
@@ -623,6 +699,11 @@ export function registerApplyCommand(program) {
|
|
|
623
699
|
throw e;
|
|
624
700
|
}
|
|
625
701
|
const { components, tokens, client } = inputs;
|
|
702
|
+
// INTEG-4401: same pre-push cycle block as `apply push`. Running the
|
|
703
|
+
// check on the FULL component set (not the filtered selection) is
|
|
704
|
+
// deliberate: partial selection cannot resolve a cycle whose
|
|
705
|
+
// participants are all still present in the target space.
|
|
706
|
+
assertNoSlotCycles(components);
|
|
626
707
|
try {
|
|
627
708
|
await client.validateToken();
|
|
628
709
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INTEG-4401 Fix C — parse EDSI failure bodies into a structured shape the
|
|
3
|
+
* TUI can render without dumping timestamps + trace ids at operators.
|
|
4
|
+
*
|
|
5
|
+
* EDSI failure bodies come in three broad shapes today:
|
|
6
|
+
*
|
|
7
|
+
* 1. Plain JSON error:
|
|
8
|
+
* `{"code":"TopoSortCycleError","message":"...","cycle":["CycleA","CycleB"]}`
|
|
9
|
+
*
|
|
10
|
+
* 2. Wrapper JSON with `details`:
|
|
11
|
+
* `{"sys":{"type":"Error"},"message":"...","details":{"code":"...","cycle":[...]}}`
|
|
12
|
+
*
|
|
13
|
+
* 3. Lambda log-line spill (the response body is the CloudWatch log
|
|
14
|
+
* formatting the worker's uncaught exception):
|
|
15
|
+
* `2026-07-07T22:26:26.479Z\t<request-id>\tERROR\t[dd.trace_id=... dd.span_id=...] <message> {\n operationId: '...',\n code: 'TopoSortCycleError',\n cycle: [ 'CycleA', 'CycleB' ]\n}`
|
|
16
|
+
*
|
|
17
|
+
* We parse each shape best-effort and fall back to a cleaned raw message so
|
|
18
|
+
* the ErrorStep never has to show the operator a timestamp + trace id.
|
|
19
|
+
*/
|
|
20
|
+
export interface ParsedEdsiError {
|
|
21
|
+
/** Server-side error code, if we could extract one. */
|
|
22
|
+
code: string | null;
|
|
23
|
+
/** Human-readable message, stripped of log/trace decoration. */
|
|
24
|
+
message: string;
|
|
25
|
+
/** Cycle participants, when `code === 'TopoSortCycleError'`. */
|
|
26
|
+
cycle: string[] | null;
|
|
27
|
+
/** True when the message survived cleaning as-is (no parseable structure). */
|
|
28
|
+
raw: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Strip the Lambda/CloudWatch log-line decoration from the head of a body so
|
|
32
|
+
* the operator-facing message reads as an error sentence, not a log entry.
|
|
33
|
+
*/
|
|
34
|
+
export declare function stripLambdaLogPrefix(body: string): string;
|
|
35
|
+
export declare function parseEdsiError(rawInput: string | undefined | null): ParsedEdsiError;
|
|
36
|
+
/**
|
|
37
|
+
* Render a parsed error into a short multi-line block for the ErrorStep.
|
|
38
|
+
* Callers can pass `verbose: true` to include the raw body underneath.
|
|
39
|
+
*/
|
|
40
|
+
export declare function formatParsedEdsiError(parsed: ParsedEdsiError, opts?: {
|
|
41
|
+
verbose?: boolean;
|
|
42
|
+
raw?: string;
|
|
43
|
+
}): string;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INTEG-4401 Fix C — parse EDSI failure bodies into a structured shape the
|
|
3
|
+
* TUI can render without dumping timestamps + trace ids at operators.
|
|
4
|
+
*
|
|
5
|
+
* EDSI failure bodies come in three broad shapes today:
|
|
6
|
+
*
|
|
7
|
+
* 1. Plain JSON error:
|
|
8
|
+
* `{"code":"TopoSortCycleError","message":"...","cycle":["CycleA","CycleB"]}`
|
|
9
|
+
*
|
|
10
|
+
* 2. Wrapper JSON with `details`:
|
|
11
|
+
* `{"sys":{"type":"Error"},"message":"...","details":{"code":"...","cycle":[...]}}`
|
|
12
|
+
*
|
|
13
|
+
* 3. Lambda log-line spill (the response body is the CloudWatch log
|
|
14
|
+
* formatting the worker's uncaught exception):
|
|
15
|
+
* `2026-07-07T22:26:26.479Z\t<request-id>\tERROR\t[dd.trace_id=... dd.span_id=...] <message> {\n operationId: '...',\n code: 'TopoSortCycleError',\n cycle: [ 'CycleA', 'CycleB' ]\n}`
|
|
16
|
+
*
|
|
17
|
+
* We parse each shape best-effort and fall back to a cleaned raw message so
|
|
18
|
+
* the ErrorStep never has to show the operator a timestamp + trace id.
|
|
19
|
+
*/
|
|
20
|
+
const LAMBDA_LOG_PREFIX_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\s+[0-9a-f-]+\s+ERROR\s+(?:\[dd\.[^\]]*\]\s*)?/;
|
|
21
|
+
const DD_TAG_RE = /\[dd\.(?:trace_id|span_id)=[^\]]*\]\s*/g;
|
|
22
|
+
/**
|
|
23
|
+
* Strip the Lambda/CloudWatch log-line decoration from the head of a body so
|
|
24
|
+
* the operator-facing message reads as an error sentence, not a log entry.
|
|
25
|
+
*/
|
|
26
|
+
export function stripLambdaLogPrefix(body) {
|
|
27
|
+
let out = body.replace(LAMBDA_LOG_PREFIX_RE, '');
|
|
28
|
+
out = out.replace(DD_TAG_RE, '');
|
|
29
|
+
return out.trim();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extract a JS-object-literal tail like `{ operationId: '...', code: '...', cycle: [ 'A', 'B' ] }`
|
|
33
|
+
* that Lambda-spilled errors trail with. Returns `null` if no such structure
|
|
34
|
+
* is present or the fields we care about can't be recovered.
|
|
35
|
+
*/
|
|
36
|
+
function parseObjectLiteralTail(body) {
|
|
37
|
+
const braceStart = body.lastIndexOf('{');
|
|
38
|
+
if (braceStart === -1)
|
|
39
|
+
return null;
|
|
40
|
+
const tail = body.slice(braceStart);
|
|
41
|
+
const codeMatch = tail.match(/code:\s*['"]([^'"]+)['"]/);
|
|
42
|
+
const cycleMatch = tail.match(/cycle:\s*\[\s*([^\]]*)\s*\]/);
|
|
43
|
+
if (!codeMatch && !cycleMatch)
|
|
44
|
+
return null;
|
|
45
|
+
const code = codeMatch ? codeMatch[1] : null;
|
|
46
|
+
let cycle = null;
|
|
47
|
+
if (cycleMatch) {
|
|
48
|
+
cycle = cycleMatch[1]
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
51
|
+
.filter((s) => s.length > 0);
|
|
52
|
+
if (cycle.length === 0)
|
|
53
|
+
cycle = null;
|
|
54
|
+
}
|
|
55
|
+
return { code, cycle };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort JSON parse walking one level deep into `details`. Fields we
|
|
59
|
+
* accept anywhere in the top-level or `details` object: `code`, `message`,
|
|
60
|
+
* `cycle`. Returns `null` when the input isn't JSON.
|
|
61
|
+
*/
|
|
62
|
+
function parseJsonBody(body) {
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(body);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== 'object')
|
|
71
|
+
return null;
|
|
72
|
+
const p = parsed;
|
|
73
|
+
const details = (p.details && typeof p.details === 'object' ? p.details : {});
|
|
74
|
+
const pick = (k) => (p[k] !== undefined ? p[k] : details[k]);
|
|
75
|
+
const codeRaw = pick('code');
|
|
76
|
+
const messageRaw = pick('message');
|
|
77
|
+
const cycleRaw = pick('cycle');
|
|
78
|
+
const out = {};
|
|
79
|
+
if (typeof codeRaw === 'string')
|
|
80
|
+
out.code = codeRaw;
|
|
81
|
+
if (typeof messageRaw === 'string')
|
|
82
|
+
out.message = messageRaw;
|
|
83
|
+
if (Array.isArray(cycleRaw)) {
|
|
84
|
+
const strs = cycleRaw.filter((x) => typeof x === 'string');
|
|
85
|
+
if (strs.length > 0)
|
|
86
|
+
out.cycle = strs;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse an EDSI error body (the raw response body, or an `ApiError.message`
|
|
92
|
+
* that has the body appended). Always returns a value — the fallback is the
|
|
93
|
+
* cleaned raw message with `code: null`.
|
|
94
|
+
*/
|
|
95
|
+
// ApiError.message shape: `${phasePrefix}\n${body}` where phasePrefix looks
|
|
96
|
+
// like `apply failed: 400`, `preview failed: 422`, `poll failed: 500`. We
|
|
97
|
+
// only strip the prefix line when the first line matches this shape —
|
|
98
|
+
// otherwise a raw body that happens to contain a newline (e.g. a Lambda log
|
|
99
|
+
// spill with a multi-line object literal) gets truncated mid-structure.
|
|
100
|
+
const API_ERROR_PREFIX_RE = /^(?:apply|preview|poll) failed: \d+$/;
|
|
101
|
+
export function parseEdsiError(rawInput) {
|
|
102
|
+
if (!rawInput)
|
|
103
|
+
return { code: null, message: '', cycle: null, raw: true };
|
|
104
|
+
const nlIndex = rawInput.indexOf('\n');
|
|
105
|
+
let body = rawInput;
|
|
106
|
+
let prefix = '';
|
|
107
|
+
if (nlIndex !== -1) {
|
|
108
|
+
const firstLine = rawInput.slice(0, nlIndex);
|
|
109
|
+
if (API_ERROR_PREFIX_RE.test(firstLine)) {
|
|
110
|
+
prefix = firstLine;
|
|
111
|
+
body = rawInput.slice(nlIndex + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cleaned = stripLambdaLogPrefix(body);
|
|
115
|
+
const json = parseJsonBody(cleaned) ?? parseJsonBody(body);
|
|
116
|
+
if (json && (json.code || json.message || json.cycle)) {
|
|
117
|
+
return {
|
|
118
|
+
code: json.code ?? null,
|
|
119
|
+
message: json.message ?? (cleaned || prefix),
|
|
120
|
+
cycle: json.cycle ?? null,
|
|
121
|
+
raw: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const literal = parseObjectLiteralTail(cleaned);
|
|
125
|
+
if (literal && (literal.code || literal.cycle)) {
|
|
126
|
+
// Grab the human-readable head — text before the trailing `{`. This is
|
|
127
|
+
// the sentence part of a Lambda log-line spill (e.g. "Apply operation
|
|
128
|
+
// <id> rejected: ComponentType slot dependency cycle detected among:
|
|
129
|
+
// CycleA, CycleB. Break the cycle by...").
|
|
130
|
+
const braceStart = cleaned.lastIndexOf('{');
|
|
131
|
+
const head = braceStart === -1 ? cleaned : cleaned.slice(0, braceStart).trim();
|
|
132
|
+
return {
|
|
133
|
+
code: literal.code ?? null,
|
|
134
|
+
message: head || cleaned,
|
|
135
|
+
cycle: literal.cycle ?? null,
|
|
136
|
+
raw: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { code: null, message: cleaned || prefix || rawInput, cycle: null, raw: true };
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Render a parsed error into a short multi-line block for the ErrorStep.
|
|
143
|
+
* Callers can pass `verbose: true` to include the raw body underneath.
|
|
144
|
+
*/
|
|
145
|
+
export function formatParsedEdsiError(parsed, opts = {}) {
|
|
146
|
+
const lines = [];
|
|
147
|
+
if (parsed.code) {
|
|
148
|
+
lines.push(`[${parsed.code}]`);
|
|
149
|
+
}
|
|
150
|
+
if (parsed.message) {
|
|
151
|
+
lines.push(parsed.message);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.cycle && parsed.cycle.length > 0) {
|
|
154
|
+
lines.push(`Cycle: ${parsed.cycle.join(' → ')} → ${parsed.cycle[0]}`);
|
|
155
|
+
lines.push('Break the cycle by removing at least one $allowedComponents entry.');
|
|
156
|
+
}
|
|
157
|
+
if (opts.verbose && opts.raw) {
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push('--- raw ---');
|
|
160
|
+
lines.push(opts.raw);
|
|
161
|
+
}
|
|
162
|
+
return lines.filter(Boolean).join('\n');
|
|
163
|
+
}
|