@lidtop/loadout 0.3.0 → 0.3.2

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.
@@ -0,0 +1,92 @@
1
+ import { createPrompt, isEnterKey, useEffect, useKeypress, useRef, useState, } from '@inquirer/core';
2
+ import { styleText } from 'node:util';
3
+ import { BackNavigation } from './interactive.js';
4
+ import { wrapReviewText, } from './review.js';
5
+ // A single live screen owns all network activity, including retry decisions.
6
+ const progressScreen = createPrompt((config, done) => {
7
+ const [statuses, setStatuses] = useState(config.selections.map(() => 'Waiting'));
8
+ const [failure, setFailure] = useState();
9
+ const [active, setActive] = useState(0);
10
+ const [visible, setVisible] = useState(false);
11
+ const controller = useRef(new AbortController());
12
+ const pending = useRef(undefined);
13
+ useEffect(() => {
14
+ let mounted = true;
15
+ // Cached and local kits go straight to review without flashing a screen.
16
+ const timer = setTimeout(() => setVisible(true), 150);
17
+ const signal = controller.current.signal;
18
+ const progress = (scope, message) => {
19
+ if (mounted)
20
+ setStatuses((previous) => previous.map((value, index) => (index === scope ? message : value)));
21
+ };
22
+ const retry = (scope, id, error) => new Promise((resolve, reject) => {
23
+ signal.throwIfAborted();
24
+ const abort = () => {
25
+ pending.current = undefined;
26
+ reject(signal.reason);
27
+ };
28
+ signal.addEventListener('abort', abort, { once: true });
29
+ pending.current = (value) => {
30
+ signal.removeEventListener('abort', abort);
31
+ pending.current = undefined;
32
+ setFailure(undefined);
33
+ resolve(value);
34
+ };
35
+ setActive(0);
36
+ setFailure({ scope, id, error });
37
+ });
38
+ void config.run(progress, retry, signal).then((prepared) => {
39
+ if (mounted)
40
+ done({ prepared });
41
+ }, (error) => {
42
+ if (mounted)
43
+ done({ error });
44
+ });
45
+ return () => {
46
+ mounted = false;
47
+ clearTimeout(timer);
48
+ controller.current.abort(new BackNavigation());
49
+ };
50
+ }, []);
51
+ useKeypress((key) => {
52
+ if (key.name === 'escape') {
53
+ controller.current.abort(new BackNavigation());
54
+ done({ error: new BackNavigation() });
55
+ }
56
+ else if (failure) {
57
+ if (key.name === 'up' || key.name === 'down')
58
+ setActive(active === 0 ? 1 : 0);
59
+ else if (isEnterKey(key)) {
60
+ if (active === 0)
61
+ pending.current?.(true);
62
+ else {
63
+ controller.current.abort(new BackNavigation());
64
+ done({ error: new BackNavigation() });
65
+ }
66
+ }
67
+ }
68
+ });
69
+ const width = Math.max(20, (process.stdout.columns || 80) - 4);
70
+ if (!visible && !failure)
71
+ return '';
72
+ return [
73
+ ...config.selections.map(({ target }, index) => ` ${styleText(target.global ? 'magenta' : 'cyan', target.label)} · ${wrapReviewText(statuses[index], width - target.label.length - 3)[0]}`),
74
+ ...(failure
75
+ ? [
76
+ ...wrapReviewText(`${config.selections[failure.scope].target.label} · ${failure.id}: ${failure.error.message}`, width).map((line) => ` ${line}`),
77
+ ` ${active === 0 ? '›' : ' '} [ Retry download ]`,
78
+ ` ${active === 1 ? '›' : ' '} [ Back to kits ]`,
79
+ ]
80
+ : []),
81
+ ' Esc back · Ctrl+C cancel',
82
+ ].join('\n');
83
+ });
84
+ export async function prepareScreen(config, context) {
85
+ const result = await progressScreen(config, {
86
+ ...context,
87
+ clearPromptOnDone: true,
88
+ });
89
+ if ('error' in result)
90
+ throw result.error;
91
+ return result.prepared;
92
+ }
package/dist/retry.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export declare class DownloadCancelledError extends Error {
2
2
  constructor();
3
3
  }
4
- export declare function retryDownload<T>(attempt: () => Promise<T>, retry?: (error: Error) => Promise<boolean>, onRetry?: () => void): Promise<T>;
4
+ export declare function retryDownload<T>(attempt: () => Promise<T>, retry?: (error: Error) => Promise<boolean>, onRetry?: () => void, signal?: AbortSignal): Promise<T>;
package/dist/retry.js CHANGED
@@ -4,13 +4,15 @@ export class DownloadCancelledError extends Error {
4
4
  this.name = 'DownloadCancelledError';
5
5
  }
6
6
  }
7
- export async function retryDownload(attempt, retry, onRetry) {
7
+ export async function retryDownload(attempt, retry, onRetry, signal) {
8
8
  for (;;) {
9
9
  for (let tries = 0; tries < 2; tries++) {
10
10
  try {
11
+ signal?.throwIfAborted();
11
12
  return await attempt();
12
13
  }
13
14
  catch (failure) {
15
+ signal?.throwIfAborted();
14
16
  const error = failure instanceof Error ? failure : new Error(String(failure));
15
17
  if (tries === 0) {
16
18
  onRetry?.();
@@ -0,0 +1,35 @@
1
+ import { type TargetSelection } from './picker.js';
2
+ import { type PromptContext } from './interactive.js';
3
+ import { type Change, type Plan } from './storage.js';
4
+ export type ReviewTarget = TargetSelection & {
5
+ update: string[];
6
+ plan?: Plan;
7
+ };
8
+ export type PreparedTarget = ReviewTarget & {
9
+ plan: Plan;
10
+ };
11
+ export type ReviewAction = 'back' | 'apply' | 'done';
12
+ type Row = {
13
+ text: string;
14
+ scope?: number;
15
+ heading?: boolean;
16
+ warning?: boolean;
17
+ expand?: number;
18
+ file?: Change;
19
+ };
20
+ export type KitReview = {
21
+ id: string;
22
+ effect: 'Add' | 'Remove' | 'Update' | 'Configure' | 'Keep';
23
+ notes: string[];
24
+ unchanged: boolean;
25
+ };
26
+ export declare function selectionSummary({ target, state, update, }: ReviewTarget): KitReview[];
27
+ export declare function reviewRows(selections: ReviewTarget[], expanded: number[]): Row[];
28
+ export declare function wrapReviewText(value: string, width: number): string[];
29
+ type ReviewConfig = {
30
+ selections: ReviewTarget[];
31
+ columns?: number;
32
+ rows?: number;
33
+ };
34
+ export declare function reviewScreen(config: ReviewConfig, context?: PromptContext): Promise<ReviewAction>;
35
+ export {};
package/dist/review.js ADDED
@@ -0,0 +1,346 @@
1
+ import { createPrompt, isEnterKey, isSpaceKey, useEffect, useKeypress, useState, } from '@inquirer/core';
2
+ import { createTwoFilesPatch } from 'diff';
3
+ import { stripVTControlCharacters, styleText } from 'node:util';
4
+ import stringWidth from 'string-width';
5
+ import { kitSource } from './schema.js';
6
+ import { reasons, resolveKits } from './resolve.js';
7
+ import { hasChanges } from './storage.js';
8
+ import { suspendEscapeCancellation } from './terminal.js';
9
+ // Compare effective kits, not just checkboxes: a deselected dependency may stay enabled.
10
+ export function selectionSummary({ target, state, update, }) {
11
+ const catalog = target.catalog;
12
+ const saved = target.state ?? { schemaVersion: 1, selected: [], answers: {} };
13
+ const old = new Set([
14
+ ...resolveKits(catalog, saved.selected.filter((id) => catalog.kits.has(id) && catalog.kits.get(id)?.ready !== false)),
15
+ ...saved.selected,
16
+ ]);
17
+ const next = new Set(resolveKits(catalog, state.selected));
18
+ return [...new Set([...old, ...next])].sort().map((id) => {
19
+ const kit = catalog.kits.get(id);
20
+ const explicitChanged = saved.selected.includes(id) !== state.selected.includes(id);
21
+ const answerKeys = Object.keys(kit?.questions ?? {}).sort();
22
+ const configured = next.has(id) &&
23
+ answerKeys.some((key) => saved.answers[id]?.[key] !== state.answers[id]?.[key]);
24
+ const effect = !next.has(id)
25
+ ? 'Remove'
26
+ : !old.has(id)
27
+ ? 'Add'
28
+ : update.includes(id)
29
+ ? 'Update'
30
+ : configured
31
+ ? 'Configure'
32
+ : 'Keep';
33
+ const notes = [];
34
+ if (next.has(id) && !state.selected.includes(id))
35
+ notes.push(`${saved.selected.includes(id) ? 'still required' : 'dependency'} of ${reasons(catalog, state.selected, id).join(', ')}`);
36
+ else if (effect === 'Keep' && explicitChanged)
37
+ notes.push('chosen directly');
38
+ if (effect === 'Update' && kit?.external)
39
+ notes.push(`${kit.pinned?.ref.slice(0, 7) ?? 'saved'} → ${kit.external.ref.slice(0, 7)}`);
40
+ if (next.has(id))
41
+ for (const key of answerKeys) {
42
+ const answer = state.answers[id]?.[key];
43
+ if (answer !== undefined)
44
+ notes.push(`${key === 'placement' ? 'Install as' : key.replaceAll('-', ' ')}: ${answer}`);
45
+ }
46
+ if (kit && kitSource(kit) !== 'Repository')
47
+ notes.push(kitSource(kit));
48
+ return {
49
+ id,
50
+ effect,
51
+ notes,
52
+ unchanged: effect === 'Keep' && !explicitChanged,
53
+ };
54
+ });
55
+ }
56
+ const outputChanges = (plan) => plan.changes.filter((change) => change.kind !== 'unchanged' &&
57
+ !change.path.startsWith('.loadout-personal/'));
58
+ export function reviewRows(selections, expanded) {
59
+ const rows = [];
60
+ const summaries = selections.map(selectionSummary);
61
+ if (!selections.some(({ plan }) => plan && hasChanges(plan)))
62
+ rows.push({ text: 'No changes to apply' });
63
+ selections.forEach(({ target, plan }, scope) => {
64
+ const kits = summaries[scope];
65
+ rows.push({
66
+ text: `${target.label} · ${target.root}`,
67
+ scope,
68
+ heading: true,
69
+ });
70
+ const changed = kits.filter((kit) => !kit.unchanged);
71
+ const unchanged = kits.filter((kit) => kit.unchanged);
72
+ const kitRow = (kit) => ({
73
+ text: ` ${kit.effect.padEnd(9)} ${kit.id}${kit.notes.length ? ` · ${kit.notes.join(' · ')}` : ''}`,
74
+ scope,
75
+ });
76
+ rows.push(...changed.map(kitRow));
77
+ if (unchanged.length) {
78
+ rows.push({
79
+ text: ` ${expanded.includes(scope) ? '▾' : '▸'} Unchanged (${unchanged.length})`,
80
+ scope,
81
+ expand: scope,
82
+ });
83
+ if (expanded.includes(scope))
84
+ rows.push(...unchanged.map(kitRow));
85
+ }
86
+ if (!kits.length)
87
+ rows.push({ text: ' No kits selected', scope });
88
+ if (plan) {
89
+ const changes = outputChanges(plan);
90
+ const counts = ['create', 'update', 'delete'].flatMap((kind) => {
91
+ const count = changes.filter((change) => change.kind === kind).length;
92
+ return count
93
+ ? [`${count} ${kind === 'delete' ? 'remove' : kind}`]
94
+ : [];
95
+ });
96
+ if (counts.length)
97
+ rows.push({ text: ` Files: ${counts.join(' · ')}`, scope });
98
+ else if (hasChanges(plan))
99
+ rows.push({ text: ' Settings only', scope });
100
+ if (plan.adopted?.length)
101
+ rows.push({
102
+ text: ` Existing content kept: ${plan.adopted.join(', ')}`,
103
+ scope,
104
+ });
105
+ for (const skipped of plan.skippedInstructions ?? [])
106
+ rows.push({
107
+ text: ` Skipped instructions · ${skipped.kits.join(', ')}: ${skipped.reason} (${skipped.paths.join(', ')})`,
108
+ scope,
109
+ warning: true,
110
+ });
111
+ for (const id of plan.kitsWithoutOutputs ?? [])
112
+ rows.push({
113
+ text: ` ${id}: no agent outputs will be applied`,
114
+ scope,
115
+ warning: true,
116
+ });
117
+ }
118
+ rows.push({ text: '' });
119
+ });
120
+ return rows;
121
+ }
122
+ function fileRows(selections) {
123
+ return selections.flatMap(({ target, state, update, plan }, scope) => {
124
+ const rows = [
125
+ { text: `${target.label} · ${target.root}`, scope, heading: true },
126
+ ];
127
+ for (const id of resolveKits(target.catalog, state.selected)) {
128
+ const kit = target.catalog.kits.get(id);
129
+ if (!kit.external)
130
+ continue;
131
+ const source = update.includes(id)
132
+ ? kit.external
133
+ : (kit.pinned ?? kit.external);
134
+ rows.push({
135
+ text: `${id}: ${source.repo}@${source.ref} · license: ${source.license}`,
136
+ scope,
137
+ });
138
+ }
139
+ const files = plan ? outputChanges(plan) : [];
140
+ rows.push(...files.map((file) => ({
141
+ text: `${file.kind === 'create' ? '+' : file.kind === 'delete' ? '-' : '~'} ${file.path}`,
142
+ scope,
143
+ file,
144
+ })));
145
+ if (!files.length)
146
+ rows.push({ text: 'No agent file changes', scope });
147
+ rows.push({ text: '' });
148
+ return rows;
149
+ });
150
+ }
151
+ function diffRows(row) {
152
+ const file = row.file;
153
+ const before = file.before?.content ?? Buffer.alloc(0);
154
+ const after = file.after?.content ?? Buffer.alloc(0);
155
+ const header = { text: file.path, scope: row.scope, heading: true };
156
+ if (before.equals(after))
157
+ return [header, { text: 'File mode changed; content is unchanged.' }];
158
+ if (before.length + after.length > 200_000 ||
159
+ before.includes(0) ||
160
+ after.includes(0))
161
+ return [
162
+ header,
163
+ { text: 'Binary or large content; inspect the source for details.' },
164
+ ];
165
+ return [
166
+ header,
167
+ ...createTwoFilesPatch(`a/${file.path}`, `b/${file.path}`, before.toString('utf8'), after.toString('utf8'), undefined, undefined, { context: 3 })
168
+ .split('\n')
169
+ .map((text) => ({ text, scope: row.scope })),
170
+ ];
171
+ }
172
+ export function wrapReviewText(value, width) {
173
+ const text = stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
174
+ const lines = [];
175
+ let line = '';
176
+ for (const character of text) {
177
+ if (line && stringWidth(line + character) > width) {
178
+ lines.push(line);
179
+ line = '';
180
+ }
181
+ line += character;
182
+ }
183
+ lines.push(line);
184
+ return lines;
185
+ }
186
+ const screen = createPrompt((config, done) => {
187
+ const [expanded, setExpanded] = useState([]);
188
+ const [view, setView] = useState('summary');
189
+ const [diff, setDiff] = useState([]);
190
+ const [active, setActive] = useState(undefined);
191
+ const [lastContent, setLastContent] = useState(0);
192
+ const [summaryCursor, setSummaryCursor] = useState(0);
193
+ const [fileCursor, setFileCursor] = useState(0);
194
+ const [size, setSize] = useState({
195
+ columns: process.stdout.columns || 80,
196
+ rows: process.stdout.rows || 24,
197
+ });
198
+ useEffect(() => {
199
+ const resize = () => setSize({
200
+ columns: process.stdout.columns || 80,
201
+ rows: process.stdout.rows || 24,
202
+ });
203
+ process.stdout.on('resize', resize);
204
+ return () => {
205
+ process.stdout.off('resize', resize);
206
+ };
207
+ }, []);
208
+ const width = Math.max(16, (config.columns ?? size.columns) - 4);
209
+ const changed = config.selections.some(({ plan }) => plan && hasChanges(plan));
210
+ const actions = view !== 'summary'
211
+ ? [
212
+ {
213
+ label: view === 'diff' ? 'Back to files' : 'Back to review',
214
+ value: 'close',
215
+ },
216
+ ]
217
+ : [
218
+ { label: 'Back to kits', value: 'back' },
219
+ { label: 'View files', value: 'files' },
220
+ {
221
+ label: changed ? 'Apply changes' : 'Done',
222
+ value: changed ? 'apply' : 'done',
223
+ },
224
+ ];
225
+ const content = view === 'diff'
226
+ ? diff
227
+ : view === 'files'
228
+ ? fileRows(config.selections)
229
+ : reviewRows(config.selections, expanded);
230
+ const rows = content.flatMap((row) => wrapReviewText(row.text, width - 2).map((text, index) => ({
231
+ ...row,
232
+ text,
233
+ ...(index ? { expand: undefined, file: undefined } : {}),
234
+ })));
235
+ const cursor = Math.min(active ?? rows.length + actions.length - 1, rows.length + actions.length - 1);
236
+ const focus = (index) => {
237
+ setActive(index);
238
+ if (index < rows.length)
239
+ setLastContent(index);
240
+ };
241
+ const pageSize = Math.min(rows.length, Math.max(2, (config.rows ?? size.rows) - actions.length - 5));
242
+ const start = Math.max(0, Math.min((cursor < rows.length ? cursor : lastContent) - Math.floor(pageSize / 2), rows.length - pageSize));
243
+ const back = () => {
244
+ if (view === 'diff') {
245
+ setView('files');
246
+ setActive(fileCursor);
247
+ }
248
+ else if (view === 'files') {
249
+ setView('summary');
250
+ setActive(summaryCursor);
251
+ }
252
+ else
253
+ done('back');
254
+ };
255
+ useKeypress((key, rl) => {
256
+ rl.clearLine(0);
257
+ if (key.name === 'escape') {
258
+ back();
259
+ return;
260
+ }
261
+ if (key.name === 'tab') {
262
+ const index = cursor < rows.length
263
+ ? key.shift
264
+ ? actions.length
265
+ : -1
266
+ : cursor - rows.length;
267
+ setActive(rows.length +
268
+ ((index + (key.shift ? -1 : 1) + actions.length) % actions.length));
269
+ }
270
+ else if (key.name === 'home')
271
+ focus(0);
272
+ else if (key.name === 'end')
273
+ setActive(rows.length + actions.length - 1);
274
+ else if (['up', 'down', 'pageup', 'pagedown'].includes(key.name)) {
275
+ const step = key.name.startsWith('page') ? pageSize : 1;
276
+ const backwards = key.name === 'up' || key.name === 'pageup';
277
+ const length = rows.length + actions.length;
278
+ focus((cursor + ((backwards ? -step : step) % length) + length) % length);
279
+ }
280
+ else if (isEnterKey(key) || isSpaceKey(key)) {
281
+ const action = actions[cursor - rows.length];
282
+ if (action?.value === 'close')
283
+ back();
284
+ else if (action?.value === 'files') {
285
+ setSummaryCursor(cursor);
286
+ setView('files');
287
+ setActive(0);
288
+ setLastContent(0);
289
+ }
290
+ else if (action)
291
+ done(action.value);
292
+ else {
293
+ const row = rows[cursor];
294
+ if (row?.expand !== undefined)
295
+ setExpanded(expanded.includes(row.expand)
296
+ ? expanded.filter((scope) => scope !== row.expand)
297
+ : [...expanded, row.expand]);
298
+ if (row?.file) {
299
+ setFileCursor(cursor);
300
+ setDiff(diffRows(row));
301
+ setView('diff');
302
+ setActive(0);
303
+ setLastContent(0);
304
+ }
305
+ }
306
+ }
307
+ });
308
+ const accent = (text) => styleText(config.selections.every(({ target }) => target.global)
309
+ ? 'magenta'
310
+ : 'cyan', text);
311
+ const title = view === 'files'
312
+ ? 'Files · Enter to inspect a diff'
313
+ : view === 'diff'
314
+ ? 'File diff'
315
+ : 'Review changes';
316
+ const page = rows.slice(start, start + pageSize).map((row, index) => {
317
+ const global = row.scope !== undefined && config.selections[row.scope]?.target.global;
318
+ const text = row.warning
319
+ ? styleText('yellow', row.text)
320
+ : row.heading
321
+ ? styleText(global ? 'magenta' : 'cyan', styleText('bold', row.text))
322
+ : row.text;
323
+ return ` ${start + index === cursor ? styleText(global ? 'magenta' : 'cyan', '›') : ' '} ${text}`;
324
+ });
325
+ return [
326
+ ` ${accent(styleText('bold', title))}`,
327
+ '',
328
+ ...page,
329
+ ...actions.map((action, index) => ` ${cursor === rows.length + index ? accent('›') : ' '} ${cursor === rows.length + index ? accent(`[ ${action.label} ]`) : `[ ${action.label} ]`}`),
330
+ ` ${styleText('dim', `↑↓ move · Tab actions · Enter select · Esc back${rows.length > pageSize ? ` · ${start + 1}–${Math.min(start + pageSize, rows.length)}/${rows.length}` : ''}`)}`,
331
+ '\u001b[?25l',
332
+ ]
333
+ .map((line) => stringWidth(line) > width + 4
334
+ ? wrapReviewText(line, width + 4)[0]
335
+ : line)
336
+ .join('\n');
337
+ });
338
+ export async function reviewScreen(config, context) {
339
+ const restore = suspendEscapeCancellation(context?.input ?? process.stdin);
340
+ try {
341
+ return await screen(config, { ...context, clearPromptOnDone: true });
342
+ }
343
+ finally {
344
+ restore();
345
+ }
346
+ }
@@ -0,0 +1,9 @@
1
+ import { type TargetSelection } from './picker.js';
2
+ import { type PromptContext } from './interactive.js';
3
+ import { type FetchBytes } from './external.js';
4
+ import { type Target } from './targets.js';
5
+ export declare function interactive(targets: Target[], initial?: number, options?: {
6
+ context?: PromptContext;
7
+ offline?: boolean;
8
+ fetch?: FetchBytes;
9
+ }): Promise<TargetSelection[]>;
package/dist/setup.js ADDED
@@ -0,0 +1,120 @@
1
+ import { targetPicker, } from './picker.js';
2
+ import { BackNavigation, configureSelection, confirmAdoption, selectUpdates, retryReview, } from './interactive.js';
3
+ import { renderWithExternal, } from './external.js';
4
+ import { applyAll, plan } from './storage.js';
5
+ import { reviewScreen, } from './review.js';
6
+ import { prepareScreen } from './prepare.js';
7
+ import { suspendEscapeCancellation } from './terminal.js';
8
+ export async function interactive(targets, initial = 0, options = {}) {
9
+ if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
10
+ throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
11
+ const session = {};
12
+ const drafts = new Map();
13
+ const cache = new Map();
14
+ const context = { ...options.context, clearPromptOnDone: true };
15
+ for (;;) {
16
+ const chosen = await targetPicker({ targets, initial, session }, context);
17
+ const selections = chosen.map(({ target, state }) => {
18
+ const previous = drafts.get(target);
19
+ const draft = {
20
+ target,
21
+ state: structuredClone({
22
+ ...state,
23
+ answers: previous?.state.answers ?? state.answers,
24
+ }),
25
+ update: previous?.update ?? [],
26
+ };
27
+ drafts.set(target, draft);
28
+ return draft;
29
+ });
30
+ for (const [target, draft] of drafts) {
31
+ if (selections.some((selection) => selection.target === target))
32
+ continue;
33
+ // The picker omits inactive scopes whose checkboxes match saved state.
34
+ // They can still have draft answers, but must use the latest checkboxes.
35
+ draft.state.selected = [
36
+ ...(session.snapshot?.selections[targets.indexOf(target)] ??
37
+ draft.state.selected),
38
+ ];
39
+ if (draft.update.length ||
40
+ JSON.stringify(draft.state.answers) !==
41
+ JSON.stringify(target.state?.answers ?? {}))
42
+ selections.push(draft);
43
+ }
44
+ selections.sort((a, b) => targets.indexOf(a.target) - targets.indexOf(b.target));
45
+ const restoreEscape = suspendEscapeCancellation(context.input ?? process.stdin);
46
+ try {
47
+ for (;;) {
48
+ try {
49
+ // Finish questions for every destination before starting any downloads.
50
+ for (const selection of selections) {
51
+ const { target, state } = selection;
52
+ selection.state = await configureSelection(target, state, context, (kit, key, answer) => {
53
+ state.answers[kit] ??= {};
54
+ state.answers[kit][key] = answer;
55
+ });
56
+ selection.update = await selectUpdates(target.catalog, selection.state, options.offline, context, {
57
+ message: `${target.label} · Choose catalog updates (Enter keeps your choices)`,
58
+ selected: selection.update,
59
+ quiet: true,
60
+ });
61
+ }
62
+ const prepared = await prepareScreen({
63
+ selections,
64
+ run: async (progress, retry, signal) => {
65
+ const prepared = [];
66
+ for (const [scope, selection] of selections.entries()) {
67
+ const { target, state, update } = selection;
68
+ progress(scope, 'Preparing kits…');
69
+ const rendered = await renderWithExternal(target.catalog, state, {
70
+ offline: options.offline,
71
+ fetch: options.fetch,
72
+ update,
73
+ cache,
74
+ signal,
75
+ onFetch: (id) => progress(scope, `Downloading ${id}…`),
76
+ onRetry: (id) => progress(scope, `Retrying ${id}…`),
77
+ onReady: (id) => progress(scope, `${id} · Ready`),
78
+ retry: (id, error) => retry(scope, id, error),
79
+ });
80
+ signal.throwIfAborted();
81
+ progress(scope, 'Checking file changes…');
82
+ prepared.push({
83
+ ...selection,
84
+ plan: plan(target.catalog, state, rendered, {
85
+ adopt: true,
86
+ }),
87
+ });
88
+ progress(scope, 'Ready');
89
+ }
90
+ return prepared;
91
+ },
92
+ }, context);
93
+ for (const { target, plan } of prepared) {
94
+ if (plan.adopted?.length &&
95
+ !(await confirmAdoption(plan.adopted, context, target.label)))
96
+ throw new BackNavigation();
97
+ }
98
+ const action = await reviewScreen({ selections: prepared }, context);
99
+ if (action === 'back')
100
+ break;
101
+ if (action === 'apply')
102
+ applyAll(prepared.map(({ plan }) => plan));
103
+ return selections.map(({ target, state }) => ({ target, state }));
104
+ }
105
+ catch (failure) {
106
+ if (failure instanceof BackNavigation)
107
+ break;
108
+ if (failure instanceof Error &&
109
+ ['ExitPromptError', 'AbortPromptError'].includes(failure.name))
110
+ throw failure;
111
+ if (!(await retryReview(failure, context)))
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ finally {
117
+ restoreEscape();
118
+ }
119
+ }
120
+ }
@@ -1 +1,2 @@
1
+ export declare function suspendEscapeCancellation(input: NodeJS.ReadableStream): () => void;
1
2
  export declare function prepareInput(input: NodeJS.ReadableStream): void;