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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +214 -357
  2. package/dist/package.json +1 -1
  3. package/dist/src/analyze/extract/astro.js +9 -0
  4. package/dist/src/analyze/extract/react.js +23 -0
  5. package/dist/src/analyze/extract/stencil.js +3 -0
  6. package/dist/src/analyze/extract/vue.js +6 -0
  7. package/dist/src/analyze/extract/web-components.js +10 -0
  8. package/dist/src/analyze/pre-classify.d.ts +1 -1
  9. package/dist/src/analyze/pre-classify.js +3 -3
  10. package/dist/src/analyze/select/persistence.d.ts +13 -0
  11. package/dist/src/analyze/select/persistence.js +38 -0
  12. package/dist/src/analyze/select/preview-annotations.d.ts +26 -0
  13. package/dist/src/analyze/select/preview-annotations.js +62 -0
  14. package/dist/src/analyze/select/tui/App.js +5 -2
  15. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +30 -0
  16. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +137 -0
  17. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +58 -1
  18. package/dist/src/analyze/select/tui/components/FieldEditor.js +637 -161
  19. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +1 -1
  20. package/dist/src/analyze/select/tui/components/JsonPanel.js +5 -1
  21. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +34 -0
  22. package/dist/src/analyze/select/tui/components/RationalePanel.js +92 -0
  23. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +13 -1
  24. package/dist/src/analyze/select/tui/components/Sidebar.js +27 -3
  25. package/dist/src/analyze/select/tui/hooks/scroll-offset.d.ts +29 -0
  26. package/dist/src/analyze/select/tui/hooks/scroll-offset.js +26 -0
  27. package/dist/src/analyze/select-agent/command.d.ts +2 -0
  28. package/dist/src/analyze/select-agent/command.js +260 -45
  29. package/dist/src/analyze/select-agent/show-rationale.d.ts +35 -0
  30. package/dist/src/analyze/select-agent/show-rationale.js +92 -0
  31. package/dist/src/apply/command.js +7 -3
  32. package/dist/src/apply/tui/ServerApplyView.d.ts +3 -1
  33. package/dist/src/apply/tui/ServerApplyView.js +3 -2
  34. package/dist/src/credentials-store.d.ts +5 -0
  35. package/dist/src/credentials-store.js +7 -1
  36. package/dist/src/generate/agent-runner.d.ts +14 -0
  37. package/dist/src/generate/agent-runner.js +16 -0
  38. package/dist/src/generate/command.d.ts +6 -0
  39. package/dist/src/generate/command.js +45 -10
  40. package/dist/src/generate/progress.d.ts +9 -0
  41. package/dist/src/generate/progress.js +11 -0
  42. package/dist/src/generate/prompt-builder.d.ts +8 -0
  43. package/dist/src/generate/prompt-builder.js +22 -11
  44. package/dist/src/import/agent-model-resolve.d.ts +23 -0
  45. package/dist/src/import/agent-model-resolve.js +35 -0
  46. package/dist/src/import/auto-filter-resolve.d.ts +12 -0
  47. package/dist/src/import/auto-filter-resolve.js +16 -0
  48. package/dist/src/import/command.js +248 -8
  49. package/dist/src/import/orchestrator.d.ts +2 -0
  50. package/dist/src/import/orchestrator.js +11 -3
  51. package/dist/src/import/print-prompt.d.ts +35 -0
  52. package/dist/src/import/print-prompt.js +31 -0
  53. package/dist/src/import/tui/CustomPromptBanner.d.ts +11 -0
  54. package/dist/src/import/tui/CustomPromptBanner.js +7 -0
  55. package/dist/src/import/tui/WizardApp.d.ts +112 -8
  56. package/dist/src/import/tui/WizardApp.js +820 -333
  57. package/dist/src/import/tui/auto-filter-error.d.ts +2 -0
  58. package/dist/src/import/tui/auto-filter-error.js +29 -0
  59. package/dist/src/import/tui/final-review-host.d.ts +15 -0
  60. package/dist/src/import/tui/final-review-host.js +20 -0
  61. package/dist/src/import/tui/merge-ai-decisions.d.ts +5 -0
  62. package/dist/src/import/tui/merge-ai-decisions.js +22 -0
  63. package/dist/src/import/tui/push-decision-gate-helpers.d.ts +8 -0
  64. package/dist/src/import/tui/push-decision-gate-helpers.js +8 -0
  65. package/dist/src/import/tui/push-progress.d.ts +23 -0
  66. package/dist/src/import/tui/push-progress.js +14 -0
  67. package/dist/src/import/tui/run-print-files-helpers.d.ts +26 -0
  68. package/dist/src/import/tui/run-print-files-helpers.js +8 -0
  69. package/dist/src/import/tui/run-teaser.d.ts +5 -0
  70. package/dist/src/import/tui/run-teaser.js +9 -0
  71. package/dist/src/import/tui/runLivePreview.d.ts +35 -0
  72. package/dist/src/import/tui/runLivePreview.js +73 -0
  73. package/dist/src/import/tui/runScopeGate.d.ts +22 -0
  74. package/dist/src/import/tui/runScopeGate.js +28 -0
  75. package/dist/src/import/tui/scope-gate-host.d.ts +21 -0
  76. package/dist/src/import/tui/scope-gate-host.js +20 -0
  77. package/dist/src/import/tui/spawn-generate.d.ts +33 -0
  78. package/dist/src/import/tui/spawn-generate.js +53 -0
  79. package/dist/src/import/tui/steps/CredentialsStep.d.ts +25 -1
  80. package/dist/src/import/tui/steps/CredentialsStep.js +31 -3
  81. package/dist/src/import/tui/steps/DoneStep.d.ts +6 -1
  82. package/dist/src/import/tui/steps/DoneStep.js +10 -4
  83. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +29 -2
  84. package/dist/src/import/tui/steps/GenerateReviewStep.js +412 -48
  85. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +19 -0
  86. package/dist/src/import/tui/steps/PushDecisionGateStep.js +76 -0
  87. package/dist/src/import/tui/steps/PushingStep.d.ts +10 -0
  88. package/dist/src/import/tui/steps/PushingStep.js +40 -0
  89. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +23 -0
  90. package/dist/src/import/tui/steps/ScopeGateStep.js +250 -0
  91. package/dist/src/import/tui/steps/TokenInputStep.js +20 -3
  92. package/dist/src/import/tui/useLivePreview.d.ts +35 -0
  93. package/dist/src/import/tui/useLivePreview.js +120 -0
  94. package/dist/src/import/tui/wizard-generate-progress.d.ts +21 -0
  95. package/dist/src/import/tui/wizard-generate-progress.js +25 -0
  96. package/dist/src/import/tui/wizard-save-flow.d.ts +37 -0
  97. package/dist/src/import/tui/wizard-save-flow.js +20 -0
  98. package/dist/src/import/tui/wizard-state-transitions.d.ts +80 -0
  99. package/dist/src/import/tui/wizard-state-transitions.js +79 -0
  100. package/dist/src/lib/contentful-urls.d.ts +34 -0
  101. package/dist/src/lib/contentful-urls.js +41 -0
  102. package/dist/src/program.js +2 -0
  103. package/dist/src/runs/export-helpers.d.ts +30 -0
  104. package/dist/src/runs/export-helpers.js +59 -0
  105. package/dist/src/runs/fingerprint.d.ts +47 -0
  106. package/dist/src/runs/fingerprint.js +75 -0
  107. package/dist/src/runs/ls-command.d.ts +12 -0
  108. package/dist/src/runs/ls-command.js +137 -0
  109. package/dist/src/runs/modify-launcher.d.ts +24 -0
  110. package/dist/src/runs/modify-launcher.js +36 -0
  111. package/dist/src/runs/path-prompt.d.ts +8 -0
  112. package/dist/src/runs/path-prompt.js +72 -0
  113. package/dist/src/runs/push-creds-prompt.d.ts +25 -0
  114. package/dist/src/runs/push-creds-prompt.js +39 -0
  115. package/dist/src/runs/push-helpers.d.ts +29 -0
  116. package/dist/src/runs/push-helpers.js +69 -0
  117. package/dist/src/runs/replay-helpers.d.ts +67 -0
  118. package/dist/src/runs/replay-helpers.js +148 -0
  119. package/dist/src/runs/resolve-run-target.d.ts +12 -0
  120. package/dist/src/runs/resolve-run-target.js +45 -0
  121. package/dist/src/runs/run-picker-mount.d.ts +46 -0
  122. package/dist/src/runs/run-picker-mount.js +72 -0
  123. package/dist/src/runs/run-picker.d.ts +17 -0
  124. package/dist/src/runs/run-picker.js +145 -0
  125. package/dist/src/runs/save-conflict.d.ts +8 -0
  126. package/dist/src/runs/save-conflict.js +56 -0
  127. package/dist/src/runs/save-path-resolver.d.ts +55 -0
  128. package/dist/src/runs/save-path-resolver.js +71 -0
  129. package/dist/src/runs/staleness.d.ts +38 -0
  130. package/dist/src/runs/staleness.js +140 -0
  131. package/dist/src/runs/store.d.ts +78 -0
  132. package/dist/src/runs/store.js +136 -0
  133. package/dist/src/session/cache-keys.d.ts +15 -0
  134. package/dist/src/session/cache-keys.js +38 -0
  135. package/dist/src/session/db.d.ts +89 -3
  136. package/dist/src/session/db.js +447 -104
  137. package/dist/src/setup/auto-filter-prompt.d.ts +13 -0
  138. package/dist/src/setup/auto-filter-prompt.js +24 -0
  139. package/dist/src/setup/command.d.ts +7 -0
  140. package/dist/src/setup/command.js +58 -2
  141. package/dist/src/types.d.ts +6 -0
  142. package/package.json +2 -2
  143. package/skills/generate-components.md +54 -6
  144. package/skills/select-components.md +13 -4
@@ -0,0 +1,45 @@
1
+ import { homedir } from 'node:os';
2
+ import { resolve as resolvePath } from 'node:path';
3
+ import { findAllRunsBySavePath, getRun } from './store.js';
4
+ /**
5
+ * Decide whether a positional CLI arg refers to a run-id or a filesystem path,
6
+ * then look up the corresponding RunRecord.
7
+ *
8
+ * Mirrors `git checkout` accepting either a sha or a ref: anything that *looks
9
+ * like* a path is treated as one; otherwise we fall back to a run-id lookup.
10
+ *
11
+ * Path detection: arg starts with `/`, `./`, `../`, or `~/`, OR equals `.`,
12
+ * OR equals `~`.
13
+ */
14
+ export async function resolveRunTarget(arg) {
15
+ if (looksLikePath(arg)) {
16
+ const absolute = resolvePath(expandHome(arg));
17
+ const matches = await findAllRunsBySavePath(absolute);
18
+ if (matches.length === 0) {
19
+ throw new Error(`No run recorded for path ${absolute}. Run 'experiences runs' to list known runs.`);
20
+ }
21
+ if (matches.length === 1) {
22
+ return matches[0];
23
+ }
24
+ // Multi-match: newest createdAt wins. ISO UTC timestamps compare
25
+ // lexicographically (see store.ts: createdAt = new Date().toISOString()).
26
+ const sorted = [...matches].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
27
+ const newest = sorted[0];
28
+ const olderCount = matches.length - 1;
29
+ process.stderr.write(`Multiple runs at ${absolute}; using newest ${newest.id} (createdAt ${newest.createdAt}). ${olderCount} older candidates ignored.\n`);
30
+ return newest;
31
+ }
32
+ return getRun(arg);
33
+ }
34
+ function looksLikePath(arg) {
35
+ if (arg === '.' || arg === '~')
36
+ return true;
37
+ return arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../') || arg.startsWith('~/');
38
+ }
39
+ function expandHome(arg) {
40
+ if (arg === '~')
41
+ return homedir();
42
+ if (arg.startsWith('~/'))
43
+ return resolvePath(homedir(), arg.slice(2));
44
+ return arg;
45
+ }
@@ -0,0 +1,46 @@
1
+ import { type RunRecord } from './store.js';
2
+ /**
3
+ * Flags whose presence suppresses the run-picker. The picker is meant for the
4
+ * fresh interactive `experiences import` entry point; any flag that already
5
+ * routes the wizard down a non-default path means the operator has been
6
+ * specific about intent, so we honor that and skip silently.
7
+ *
8
+ * Flag names match the camelCase option object commander hands to the action
9
+ * handler (see `command.ts`).
10
+ */
11
+ export type RunPickerFlags = {
12
+ pushFromRun?: string;
13
+ modify?: string;
14
+ /** Set only when the operator explicitly passed `--project`. */
15
+ project?: string;
16
+ autoAcceptScope?: boolean;
17
+ printPrompt?: boolean;
18
+ dryRun?: boolean;
19
+ };
20
+ export type ShouldShowRunPickerInput = {
21
+ flags: RunPickerFlags;
22
+ isTTY: boolean;
23
+ /** Absolute path to runs.json. Defaults to the platform-standard path. */
24
+ runsJsonPath?: string;
25
+ };
26
+ export type ShouldShowRunPickerResult = {
27
+ shouldShow: boolean;
28
+ runs: RunRecord[];
29
+ };
30
+ /**
31
+ * Pure decision helper for whether the wizard should open with the run picker.
32
+ *
33
+ * Picker shows iff ALL hold:
34
+ * 1. `~/.config/experiences/runs.json` exists
35
+ * 2. The file has >=1 run entry
36
+ * 3. None of `--push-from-run`, `--modify`, `--project`,
37
+ * `--auto-accept-scope`, `--print-prompt`, or `--dry-run` were passed
38
+ * 4. stdin is a TTY
39
+ *
40
+ * On any failure (including ENOENT or a parse error), the helper returns
41
+ * `{ shouldShow: false, runs: [] }`. We deliberately swallow parse errors
42
+ * here: the wizard's normal entry point is more important than surfacing a
43
+ * malformed runs.json — the operator can still use `experiences runs ls` to
44
+ * diagnose later.
45
+ */
46
+ export declare function shouldShowRunPicker(input: ShouldShowRunPickerInput): Promise<ShouldShowRunPickerResult>;
@@ -0,0 +1,72 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { READABLE_VERSIONS, runsFilePath } from './store.js';
3
+ function migrateRecord(rec) {
4
+ return {
5
+ ...rec,
6
+ tokensPath: rec.tokensPath ?? null,
7
+ tokenSessionId: rec.tokenSessionId ?? null,
8
+ };
9
+ }
10
+ function hasBlockingFlag(flags) {
11
+ if (flags.pushFromRun !== undefined)
12
+ return true;
13
+ if (flags.modify !== undefined)
14
+ return true;
15
+ if (flags.project !== undefined)
16
+ return true;
17
+ if (flags.autoAcceptScope)
18
+ return true;
19
+ if (flags.printPrompt)
20
+ return true;
21
+ if (flags.dryRun)
22
+ return true;
23
+ return false;
24
+ }
25
+ /**
26
+ * Pure decision helper for whether the wizard should open with the run picker.
27
+ *
28
+ * Picker shows iff ALL hold:
29
+ * 1. `~/.config/experiences/runs.json` exists
30
+ * 2. The file has >=1 run entry
31
+ * 3. None of `--push-from-run`, `--modify`, `--project`,
32
+ * `--auto-accept-scope`, `--print-prompt`, or `--dry-run` were passed
33
+ * 4. stdin is a TTY
34
+ *
35
+ * On any failure (including ENOENT or a parse error), the helper returns
36
+ * `{ shouldShow: false, runs: [] }`. We deliberately swallow parse errors
37
+ * here: the wizard's normal entry point is more important than surfacing a
38
+ * malformed runs.json — the operator can still use `experiences runs ls` to
39
+ * diagnose later.
40
+ */
41
+ export async function shouldShowRunPicker(input) {
42
+ if (!input.isTTY)
43
+ return { shouldShow: false, runs: [] };
44
+ if (hasBlockingFlag(input.flags))
45
+ return { shouldShow: false, runs: [] };
46
+ const path = input.runsJsonPath ?? runsFilePath();
47
+ let raw;
48
+ try {
49
+ raw = await readFile(path, 'utf8');
50
+ }
51
+ catch {
52
+ return { shouldShow: false, runs: [] };
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ return { shouldShow: false, runs: [] };
60
+ }
61
+ // Accept any version this CLI can read (see store.ts READABLE_VERSIONS).
62
+ // Pre-PR-#78 v1 files are migrated to v2 in memory below; gating on strict
63
+ // equality with RUNS_FILE_VERSION would silently hide every v1 run.
64
+ if (!READABLE_VERSIONS.has(parsed.version)) {
65
+ return { shouldShow: false, runs: [] };
66
+ }
67
+ const rawRuns = Array.isArray(parsed.runs) ? parsed.runs : [];
68
+ if (rawRuns.length === 0)
69
+ return { shouldShow: false, runs: [] };
70
+ const runs = rawRuns.map(migrateRecord);
71
+ return { shouldShow: true, runs };
72
+ }
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ import type { RunRecord } from './store.js';
3
+ export type RunPickerAction = 'push' | 'modify' | 'new';
4
+ export type RunPickerSelection = {
5
+ /** null only when `action === 'new'`. */
6
+ runId: string | null;
7
+ action: RunPickerAction;
8
+ };
9
+ export type RunPickerProps = {
10
+ runs: RunRecord[];
11
+ /** Optional set of run ids that have been classified stale by the
12
+ * invalidation check. Rows in this set render with a dim "(stale)" tag. */
13
+ staleRunIds?: ReadonlySet<string>;
14
+ onSelect: (selection: RunPickerSelection) => void;
15
+ onCancel: () => void;
16
+ };
17
+ export declare function RunPicker({ runs, staleRunIds, onSelect, onCancel }: RunPickerProps): React.ReactElement;
@@ -0,0 +1,145 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useImmediateInput } from '../analyze/select/tui/hooks/useImmediateInput.js';
5
+ const COLLAPSED_LIMIT = 3;
6
+ /**
7
+ * The picker collapses to top 3 + Show all only when there are 5+ total.
8
+ * Four or fewer runs render in full.
9
+ */
10
+ const COLLAPSE_THRESHOLD = 5;
11
+ /**
12
+ * Format the createdAt timestamp as `YYYY-MM-DD HH:MM` in the operator's
13
+ * local timezone. We intentionally use local time (not UTC) because the
14
+ * picker is for human recognition — operators expect to see times in the
15
+ * same zone they were when the run happened.
16
+ */
17
+ function formatCreatedAt(iso) {
18
+ const d = new Date(iso);
19
+ if (Number.isNaN(d.getTime()))
20
+ return iso;
21
+ const yyyy = d.getFullYear();
22
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
23
+ const dd = String(d.getDate()).padStart(2, '0');
24
+ const hh = String(d.getHours()).padStart(2, '0');
25
+ const mi = String(d.getMinutes()).padStart(2, '0');
26
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
27
+ }
28
+ function runLine(run) {
29
+ const date = formatCreatedAt(run.createdAt);
30
+ const pushedTag = run.pushedTo ? 'pushed' : 'not pushed';
31
+ const count = `${run.componentCount} component${run.componentCount === 1 ? '' : 's'}`;
32
+ return `${run.id} - ${date} - ${run.projectPath} (${count}, ${pushedTag})`;
33
+ }
34
+ const ACTION_OPTIONS = [
35
+ { key: 'push', label: 'Push', description: "Push this run's recorded session to Contentful." },
36
+ { key: 'modify', label: 'Modify', description: 'Re-open the wizard at final-review with this run pre-filled.' },
37
+ { key: 'cancel', label: 'Cancel', description: 'Back to the run list.' },
38
+ ];
39
+ function ActionScreen({ runId, onChoose, onBack, }) {
40
+ const [focusIdx, setFocusIdx] = useState(0);
41
+ const fire = (opt) => {
42
+ if (opt === 'cancel')
43
+ return onBack();
44
+ onChoose(opt);
45
+ };
46
+ useImmediateInput((rawInput, key) => {
47
+ if (key.upArrow || rawInput === 'k') {
48
+ setFocusIdx((i) => (i - 1 + ACTION_OPTIONS.length) % ACTION_OPTIONS.length);
49
+ return;
50
+ }
51
+ if (key.downArrow || rawInput === 'j') {
52
+ setFocusIdx((i) => (i + 1) % ACTION_OPTIONS.length);
53
+ return;
54
+ }
55
+ if (key.return) {
56
+ fire(ACTION_OPTIONS[focusIdx].key);
57
+ return;
58
+ }
59
+ if (key.escape || rawInput === 'q') {
60
+ onBack();
61
+ return;
62
+ }
63
+ });
64
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Push or modify?" }), _jsxs(Text, { dimColor: true, children: ["Run ", runId] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: ACTION_OPTIONS.map((opt, i) => {
65
+ const focused = i === focusIdx;
66
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: focused ? 'cyan' : undefined, children: focused ? '>' : ' ' }), _jsx(Text, { color: focused ? 'cyan' : undefined, children: opt.label }), _jsxs(Text, { dimColor: true, children: ["- ", opt.description] })] }, opt.key));
67
+ }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[j/k] Navigate" }), _jsx(Text, { dimColor: true, children: "[Enter] Select" }), _jsx(Text, { dimColor: true, children: "[Esc] Back" })] })] }));
68
+ }
69
+ function buildRows(runs, expanded) {
70
+ const showAll = expanded || runs.length < COLLAPSE_THRESHOLD;
71
+ const visible = showAll ? runs : runs.slice(0, COLLAPSED_LIMIT);
72
+ const rows = visible.map((run) => ({ kind: 'run', run }));
73
+ if (!showAll)
74
+ rows.push({ kind: 'show-all' });
75
+ rows.push({ kind: 'new' });
76
+ return rows;
77
+ }
78
+ export function RunPicker({ runs, staleRunIds, onSelect, onCancel }) {
79
+ const [expanded, setExpanded] = useState(false);
80
+ const [focusIdx, setFocusIdx] = useState(0);
81
+ // When non-null, render the Push/Modify/Cancel sub-screen for this run.
82
+ const [actionRunId, setActionRunId] = useState(null);
83
+ const rows = buildRows(runs, expanded);
84
+ // Clamp focus if the row count shrinks (e.g. after expansion changes layout).
85
+ const clampedFocus = Math.min(focusIdx, rows.length - 1);
86
+ const selectRow = (row) => {
87
+ if (row.kind === 'show-all') {
88
+ setExpanded(true);
89
+ // Keep cursor on the first newly-revealed run for continuity.
90
+ setFocusIdx(COLLAPSED_LIMIT);
91
+ return;
92
+ }
93
+ if (row.kind === 'new') {
94
+ onSelect({ runId: null, action: 'new' });
95
+ return;
96
+ }
97
+ setActionRunId(row.run.id);
98
+ };
99
+ useImmediateInput((rawInput, key) => {
100
+ if (actionRunId !== null) {
101
+ // Sub-screen owns input while it's active. (ActionScreen also installs
102
+ // its own useImmediateInput; only one is mounted at a time so there's
103
+ // no double-handle.)
104
+ return;
105
+ }
106
+ if (key.upArrow || rawInput === 'k') {
107
+ setFocusIdx((i) => (i - 1 + rows.length) % rows.length);
108
+ return;
109
+ }
110
+ if (key.downArrow || rawInput === 'j') {
111
+ setFocusIdx((i) => (i + 1) % rows.length);
112
+ return;
113
+ }
114
+ if (rawInput === 'n') {
115
+ onSelect({ runId: null, action: 'new' });
116
+ return;
117
+ }
118
+ if (rawInput === 'q' || key.escape) {
119
+ onCancel();
120
+ return;
121
+ }
122
+ if (key.return) {
123
+ const row = rows[clampedFocus];
124
+ if (row)
125
+ selectRow(row);
126
+ return;
127
+ }
128
+ });
129
+ if (actionRunId !== null) {
130
+ return (_jsx(ActionScreen, { runId: actionRunId, onChoose: (action) => onSelect({ runId: actionRunId, action }), onBack: () => setActionRunId(null) }));
131
+ }
132
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { bold: true, children: ["Found ", runs.length, " prior run", runs.length === 1 ? '' : 's', ". Continue from one?"] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: rows.map((row, i) => {
133
+ const focused = i === clampedFocus;
134
+ const color = focused ? 'cyan' : undefined;
135
+ const cursor = focused ? '>' : ' ';
136
+ if (row.kind === 'run') {
137
+ const isStale = staleRunIds?.has(row.run.id) ?? false;
138
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: cursor }), _jsx(Text, { color: color, children: runLine(row.run) }), isStale ? _jsx(Text, { dimColor: true, children: " (stale)" }) : null] }, `run-${row.run.id}`));
139
+ }
140
+ if (row.kind === 'show-all') {
141
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: cursor }), _jsxs(Text, { color: color, children: ["Show all (", runs.length, ")"] })] }, "show-all"));
142
+ }
143
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: color, children: cursor }), _jsx(Text, { color: color, children: "[n] Start a new run" })] }, "new"));
144
+ }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[j/k] Navigate" }), _jsx(Text, { dimColor: true, children: "[Enter] Select" }), _jsx(Text, { dimColor: true, children: "[n] New" }), _jsx(Text, { dimColor: true, children: "[q] Quit" })] })] }));
145
+ }
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ export type SaveConflictGateProps = {
3
+ path: string;
4
+ onOverwrite: () => void;
5
+ onNew: () => void;
6
+ onCancel: () => void;
7
+ };
8
+ export declare function SaveConflictGate({ path, onOverwrite, onNew, onCancel }: SaveConflictGateProps): React.ReactElement;
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useImmediateInput } from '../analyze/select/tui/hooks/useImmediateInput.js';
5
+ const OPTIONS = [
6
+ { key: 'overwrite', label: 'overwrite', description: 'Replace existing files at this path.' },
7
+ { key: 'new', label: 'new', description: 'Write to a timestamped subdirectory.' },
8
+ { key: 'cancel', label: 'cancel', description: 'Go back and pick a different path.' },
9
+ ];
10
+ export function SaveConflictGate({ path, onOverwrite, onNew, onCancel }) {
11
+ // Default cursor on "new" (index 1) — the safer non-destructive choice.
12
+ const [focusIdx, setFocusIdx] = useState(1);
13
+ const fire = (opt) => {
14
+ if (opt === 'overwrite')
15
+ onOverwrite();
16
+ else if (opt === 'new')
17
+ onNew();
18
+ else
19
+ onCancel();
20
+ };
21
+ useImmediateInput((rawInput, key) => {
22
+ if (rawInput === 'o') {
23
+ fire('overwrite');
24
+ return;
25
+ }
26
+ if (rawInput === 'n') {
27
+ fire('new');
28
+ return;
29
+ }
30
+ if (rawInput === 'c') {
31
+ fire('cancel');
32
+ return;
33
+ }
34
+ if (key.upArrow) {
35
+ setFocusIdx((i) => (i - 1 + OPTIONS.length) % OPTIONS.length);
36
+ return;
37
+ }
38
+ if (key.downArrow) {
39
+ setFocusIdx((i) => (i + 1) % OPTIONS.length);
40
+ return;
41
+ }
42
+ if (key.return) {
43
+ fire(OPTIONS[focusIdx].key);
44
+ return;
45
+ }
46
+ if (key.escape) {
47
+ onCancel();
48
+ return;
49
+ }
50
+ });
51
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "A prior export exists at:" }), _jsx(Text, { children: path }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: OPTIONS.map((opt, i) => {
52
+ const focused = i === focusIdx;
53
+ const shortcut = opt.label[0];
54
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: focused ? 'cyan' : undefined, children: focused ? '❯' : ' ' }), _jsxs(Text, { color: focused ? 'cyan' : undefined, children: ["[", shortcut, "]", opt.label.slice(1)] }), _jsxs(Text, { dimColor: true, children: ["\u2014 ", opt.description] })] }, opt.key));
55
+ }) }), _jsxs(Box, { gap: 3, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "[o/n/c] Shortcut" }), _jsx(Text, { dimColor: true, children: "[\u2191/\u2193 + Enter] Navigate" }), _jsx(Text, { dimColor: true, children: "[Esc] Cancel" })] })] }));
56
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Returns true if `components.json` or `tokens.json` already exists at `path`.
3
+ * Used by the wizard to decide whether to render the save-conflict gate before
4
+ * writing.
5
+ */
6
+ export declare function detectSaveConflict(path: string): Promise<boolean>;
7
+ /**
8
+ * Build a timestamped subdirectory under `base` for the "new" branch of the
9
+ * save-conflict gate. Format: `<base>/dsi-YYYYMMDD-HHMMSS` (local time).
10
+ */
11
+ export declare function buildTimestampedSubdir(base: string, now?: Date): string;
12
+ /**
13
+ * Headless conflict-resolution mode for `experiences import --on-conflict`.
14
+ *
15
+ * - `overwrite` — write through; replace any existing files.
16
+ * - `skip` — write to a timestamped subdir under the requested path.
17
+ * - `fail` — refuse to write; surface the conflicting filenames.
18
+ */
19
+ export type OnConflictMode = 'overwrite' | 'skip' | 'fail';
20
+ export type ResolveSavePathOptions = {
21
+ /** When provided, applies the chosen mode automatically (no interactive gate). */
22
+ onConflict?: OnConflictMode;
23
+ /** Injected for deterministic tests; falls back to `new Date()`. */
24
+ now?: Date;
25
+ };
26
+ /**
27
+ * Result of resolving a save path.
28
+ *
29
+ * `no-conflict` / `conflict` preserve the original two-state shape consumed by
30
+ * the wizard's interactive gate. `write` and `fail` are the headless variants
31
+ * produced when `onConflict` is supplied — `write` means "go ahead with this
32
+ * path" and `fail` carries the conflicting filenames for the operator error.
33
+ */
34
+ export type ResolveSavePathResult = {
35
+ kind: 'no-conflict';
36
+ path: string;
37
+ } | {
38
+ kind: 'conflict';
39
+ path: string;
40
+ } | {
41
+ kind: 'write';
42
+ path: string;
43
+ } | {
44
+ kind: 'fail';
45
+ conflict: {
46
+ path: string;
47
+ files: string[];
48
+ };
49
+ };
50
+ /**
51
+ * Probe the requested save path and report whether the wizard needs to render
52
+ * the conflict gate, or — when `onConflict` is supplied — resolve the conflict
53
+ * headlessly using the chosen mode.
54
+ */
55
+ export declare function resolveSavePath(path: string, options?: ResolveSavePathOptions): Promise<ResolveSavePathResult>;
@@ -0,0 +1,71 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * The set of files the wizard / CLI may overwrite in the save directory.
5
+ * Centralised so conflict detection and conflict reporting stay in lock-step.
6
+ */
7
+ const SAVE_FILES = ['components.json', 'tokens.json'];
8
+ /**
9
+ * Returns the subset of `SAVE_FILES` that already exist at `path`. Order is
10
+ * stable so test assertions can reason about it.
11
+ */
12
+ async function listConflictingFiles(path) {
13
+ const conflicts = [];
14
+ for (const name of SAVE_FILES) {
15
+ try {
16
+ await access(join(path, name));
17
+ conflicts.push(name);
18
+ }
19
+ catch {
20
+ // ENOENT — file not present, keep checking the others.
21
+ }
22
+ }
23
+ return conflicts;
24
+ }
25
+ /**
26
+ * Returns true if `components.json` or `tokens.json` already exists at `path`.
27
+ * Used by the wizard to decide whether to render the save-conflict gate before
28
+ * writing.
29
+ */
30
+ export async function detectSaveConflict(path) {
31
+ return (await listConflictingFiles(path)).length > 0;
32
+ }
33
+ function pad2(n) {
34
+ return n < 10 ? `0${n}` : String(n);
35
+ }
36
+ /**
37
+ * Build a timestamped subdirectory under `base` for the "new" branch of the
38
+ * save-conflict gate. Format: `<base>/dsi-YYYYMMDD-HHMMSS` (local time).
39
+ */
40
+ export function buildTimestampedSubdir(base, now = new Date()) {
41
+ const y = now.getFullYear();
42
+ const m = pad2(now.getMonth() + 1);
43
+ const d = pad2(now.getDate());
44
+ const hh = pad2(now.getHours());
45
+ const mm = pad2(now.getMinutes());
46
+ const ss = pad2(now.getSeconds());
47
+ return join(base, `dsi-${y}${m}${d}-${hh}${mm}${ss}`);
48
+ }
49
+ /**
50
+ * Probe the requested save path and report whether the wizard needs to render
51
+ * the conflict gate, or — when `onConflict` is supplied — resolve the conflict
52
+ * headlessly using the chosen mode.
53
+ */
54
+ export async function resolveSavePath(path, options = {}) {
55
+ const conflicts = await listConflictingFiles(path);
56
+ const hasConflict = conflicts.length > 0;
57
+ if (options.onConflict === undefined) {
58
+ return hasConflict ? { kind: 'conflict', path } : { kind: 'no-conflict', path };
59
+ }
60
+ if (!hasConflict) {
61
+ return { kind: 'write', path };
62
+ }
63
+ switch (options.onConflict) {
64
+ case 'overwrite':
65
+ return { kind: 'write', path };
66
+ case 'skip':
67
+ return { kind: 'write', path: buildTimestampedSubdir(path, options.now ?? new Date()) };
68
+ case 'fail':
69
+ return { kind: 'fail', conflict: { path, files: conflicts } };
70
+ }
71
+ }
@@ -0,0 +1,38 @@
1
+ import type { RunRecord } from './store.js';
2
+ export type Staleness = {
3
+ stale: boolean;
4
+ /** Component names whose source file's mtime no longer matches the
5
+ * recorded fingerprint. */
6
+ staleComponents: string[];
7
+ /** True when the raw tokens file's mtime OR content hash drifted (or the
8
+ * file went missing). */
9
+ staleTokens: boolean;
10
+ /** True when components.json on disk differs from the hash recorded at
11
+ * save time, OR the file is missing. */
12
+ savedComponentsEdited: boolean;
13
+ /** True when tokens.json on disk differs from the hash recorded at save
14
+ * time, OR the file is missing while a hash was recorded. */
15
+ savedTokensEdited: boolean;
16
+ /** Absolute paths of source files that were fingerprinted but have since
17
+ * disappeared. Empty when nothing is missing. */
18
+ missingSourceFiles: string[];
19
+ };
20
+ /**
21
+ * Re-stat the source files and re-hash the saved artifacts captured by a
22
+ * run record's fingerprints, and report what (if anything) drifted.
23
+ *
24
+ * Returns `stale: false` when the run is missing a source fingerprint
25
+ * entirely (v2 records pre-dating runs.json v3 — treat as UNKNOWN, not
26
+ * stale, so existing runs keep replaying).
27
+ */
28
+ export declare function checkRunStaleness(run: RunRecord): Promise<Staleness>;
29
+ /**
30
+ * Render a short human-readable summary of a Staleness result, e.g.
31
+ * `src: 3, tokens, saved`. Returns the empty string when not stale.
32
+ */
33
+ export declare function shortStalenessSummary(s: Staleness): string;
34
+ /**
35
+ * Render a multi-line detail block for staleness output (used by `runs <id>`
36
+ * detail view and replay refusals). Caps long lists at 5 entries.
37
+ */
38
+ export declare function formatStalenessDetail(s: Staleness): string[];