@lidtop/loadout 0.3.1 → 0.4.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/README.md +4 -6
- package/dist/cli.js +4 -50
- package/dist/external.d.ts +4 -0
- package/dist/external.js +40 -20
- package/dist/interactive.d.ts +12 -11
- package/dist/interactive.js +35 -33
- package/dist/picker.js +23 -7
- package/dist/prepare.d.ts +10 -0
- package/dist/prepare.js +92 -0
- package/dist/retry.d.ts +1 -1
- package/dist/retry.js +3 -1
- package/dist/review.d.ts +35 -0
- package/dist/review.js +346 -0
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +2 -1
- package/dist/setup.d.ts +9 -0
- package/dist/setup.js +120 -0
- package/dist/storage.d.ts +3 -1
- package/dist/storage.js +34 -7
- package/dist/targets.d.ts +1 -0
- package/dist/targets.js +7 -2
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +20 -8
- package/package.json +1 -1
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
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -83,8 +83,9 @@ export declare const stateSchema: z.ZodObject<{
|
|
|
83
83
|
selected: z.ZodArray<z.ZodString>;
|
|
84
84
|
answers: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>>;
|
|
85
85
|
}, z.core.$strict>;
|
|
86
|
-
export declare const
|
|
86
|
+
export declare const generatedSchema: z.ZodObject<{
|
|
87
87
|
schemaVersion: z.ZodLiteral<1>;
|
|
88
|
+
installedAt: z.ZodRecord<z.ZodString, z.ZodISODateTime>;
|
|
88
89
|
files: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
89
90
|
hash: z.ZodString;
|
|
90
91
|
mode: z.ZodUnion<readonly [z.ZodLiteral<420>, z.ZodLiteral<493>]>;
|
|
@@ -93,6 +94,7 @@ export declare const ownedSchema: z.ZodObject<{
|
|
|
93
94
|
export type Question = z.infer<typeof question>;
|
|
94
95
|
export type Answer = boolean | string;
|
|
95
96
|
export type State = z.infer<typeof stateSchema>;
|
|
97
|
+
export type Generated = z.infer<typeof generatedSchema>;
|
|
96
98
|
export type Kit = z.infer<typeof kitSchema> & {
|
|
97
99
|
directory: string;
|
|
98
100
|
external?: ExternalSource;
|
package/dist/schema.js
CHANGED
|
@@ -111,9 +111,10 @@ export const stateSchema = z
|
|
|
111
111
|
answers: z.record(idSchema, z.record(idSchema, z.union([z.boolean(), z.string()]))),
|
|
112
112
|
})
|
|
113
113
|
.strict();
|
|
114
|
-
export const
|
|
114
|
+
export const generatedSchema = z
|
|
115
115
|
.object({
|
|
116
116
|
schemaVersion: z.literal(1),
|
|
117
|
+
installedAt: z.record(idSchema, z.iso.datetime()),
|
|
117
118
|
files: z.record(z.string(), z
|
|
118
119
|
.object({
|
|
119
120
|
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
package/dist/setup.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type IgnoreTarget } from './ignore.js';
|
|
2
|
-
import { type Catalog, type State } from './schema.js';
|
|
2
|
+
import { type Catalog, type State, type Generated } from './schema.js';
|
|
3
3
|
import { type FileContent, type Rendered } from './render.js';
|
|
4
4
|
export type Change = {
|
|
5
5
|
path: string;
|
|
@@ -10,6 +10,7 @@ export type Change = {
|
|
|
10
10
|
export type Plan = {
|
|
11
11
|
root: string;
|
|
12
12
|
changes: Change[];
|
|
13
|
+
installing?: string[];
|
|
13
14
|
adopted?: string[];
|
|
14
15
|
skippedInstructions?: {
|
|
15
16
|
paths: string[];
|
|
@@ -27,6 +28,7 @@ export type Plan = {
|
|
|
27
28
|
};
|
|
28
29
|
};
|
|
29
30
|
export declare function loadState(catalog: Catalog): State;
|
|
31
|
+
export declare function loadGenerated(root: string): Generated;
|
|
30
32
|
type ExcludePlan = {
|
|
31
33
|
target: IgnoreTarget;
|
|
32
34
|
paths: string[];
|
package/dist/storage.js
CHANGED
|
@@ -6,7 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { exists, json, readOptional, safePath, walk, portableMode, } from './fs.js';
|
|
9
|
-
import { parse,
|
|
9
|
+
import { parse, generatedSchema, stateSchema, } from './schema.js';
|
|
10
|
+
import { resolveKits } from './resolve.js';
|
|
10
11
|
const hash = (content) => createHash('sha256').update(content).digest('hex');
|
|
11
12
|
export function loadState(catalog) {
|
|
12
13
|
const raw = readOptional(catalog.root, '.loadout-personal/local.json');
|
|
@@ -14,6 +15,12 @@ export function loadState(catalog) {
|
|
|
14
15
|
? parse(stateSchema, JSON.parse(raw.toString()), '.loadout-personal/local.json')
|
|
15
16
|
: { schemaVersion: 1, selected: [], answers: {} };
|
|
16
17
|
}
|
|
18
|
+
export function loadGenerated(root) {
|
|
19
|
+
const raw = readOptional(root, '.loadout-personal/generated.json');
|
|
20
|
+
return raw
|
|
21
|
+
? parse(generatedSchema, JSON.parse(raw.toString()), '.loadout-personal/generated.json')
|
|
22
|
+
: { schemaVersion: 1, installedAt: {}, files: {} };
|
|
23
|
+
}
|
|
17
24
|
function snapshot(root, relative) {
|
|
18
25
|
const content = readOptional(root, relative);
|
|
19
26
|
return content === undefined
|
|
@@ -71,10 +78,12 @@ function trackedFiles(root) {
|
|
|
71
78
|
}
|
|
72
79
|
export function plan(catalog, state, rendered, options = {}) {
|
|
73
80
|
const root = catalog.root;
|
|
74
|
-
const
|
|
75
|
-
const owned =
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
const generated = loadGenerated(root);
|
|
82
|
+
const owned = generated.files;
|
|
83
|
+
const enabled = resolveKits(catalog, state.selected).sort();
|
|
84
|
+
const installing = enabled.filter((id) => !Object.hasOwn(generated.installedAt, id));
|
|
85
|
+
const plannedAt = new Date().toISOString();
|
|
86
|
+
const installedAt = Object.fromEntries(enabled.map((id) => [id, generated.installedAt[id] ?? plannedAt]));
|
|
78
87
|
const tracked = trackedFiles(root);
|
|
79
88
|
const skippedInstructions = [];
|
|
80
89
|
const retainedKits = new Set(rendered.skillKits);
|
|
@@ -149,7 +158,10 @@ export function plan(catalog, state, rendered, options = {}) {
|
|
|
149
158
|
.map(([p, f]) => [p, { hash: hash(f.content), mode: f.mode }]));
|
|
150
159
|
const metadata = new Map([
|
|
151
160
|
['.loadout-personal/local.json', json(state)],
|
|
152
|
-
[
|
|
161
|
+
[
|
|
162
|
+
'.loadout-personal/generated.json',
|
|
163
|
+
json({ schemaVersion: 1, installedAt, files }),
|
|
164
|
+
],
|
|
153
165
|
]);
|
|
154
166
|
if (rendered.external) {
|
|
155
167
|
const current = readOptional(root, '.loadout-personal/external.json');
|
|
@@ -175,6 +187,7 @@ export function plan(catalog, state, rendered, options = {}) {
|
|
|
175
187
|
return {
|
|
176
188
|
root,
|
|
177
189
|
changes,
|
|
190
|
+
installing,
|
|
178
191
|
adopted: adoption.adopted,
|
|
179
192
|
skippedInstructions,
|
|
180
193
|
kitsWithoutOutputs,
|
|
@@ -321,9 +334,23 @@ export function applyAll(plans) {
|
|
|
321
334
|
for (const { root, change } of writes)
|
|
322
335
|
if (!equal(snapshot(root, change.path), change.before))
|
|
323
336
|
throw new Error(`File changed since preview: ${change.path}. Run the command again.`);
|
|
324
|
-
|
|
337
|
+
const installedAt = new Date().toISOString();
|
|
338
|
+
for (const { root, change: planned } of writes) {
|
|
339
|
+
let change = planned;
|
|
325
340
|
if (change.kind === 'unchanged')
|
|
326
341
|
continue;
|
|
342
|
+
const installing = plans.find((plan) => plan.root === root)?.installing;
|
|
343
|
+
if (change.path === '.loadout-personal/generated.json' &&
|
|
344
|
+
change.after &&
|
|
345
|
+
installing?.length) {
|
|
346
|
+
const generated = parse(generatedSchema, JSON.parse(change.after.content.toString()), change.path);
|
|
347
|
+
for (const id of installing)
|
|
348
|
+
generated.installedAt[id] = installedAt;
|
|
349
|
+
change = {
|
|
350
|
+
...change,
|
|
351
|
+
after: { ...change.after, content: json(generated) },
|
|
352
|
+
};
|
|
353
|
+
}
|
|
327
354
|
if (change.after)
|
|
328
355
|
writeAtomic(root, change.path, change.after);
|
|
329
356
|
else
|
package/dist/targets.d.ts
CHANGED
package/dist/targets.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadCatalog } from './catalog.js';
|
|
2
2
|
import { readExternal } from './external.js';
|
|
3
|
-
import { loadState } from './storage.js';
|
|
3
|
+
import { loadGenerated, loadState } from './storage.js';
|
|
4
4
|
export function loadTarget(root, global) {
|
|
5
5
|
const target = {
|
|
6
6
|
root,
|
|
@@ -15,7 +15,12 @@ export function loadTarget(root, global) {
|
|
|
15
15
|
if (kit?.external)
|
|
16
16
|
kit.pinned = snapshot.source;
|
|
17
17
|
}
|
|
18
|
-
return {
|
|
18
|
+
return {
|
|
19
|
+
...target,
|
|
20
|
+
catalog,
|
|
21
|
+
state: loadState(catalog),
|
|
22
|
+
installedAt: loadGenerated(root).installedAt,
|
|
23
|
+
};
|
|
19
24
|
}
|
|
20
25
|
catch (error) {
|
|
21
26
|
return { ...target, error: error.message };
|
package/dist/terminal.d.ts
CHANGED