@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/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 ownedSchema: z.ZodObject<{
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 ownedSchema = z
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}$/),
@@ -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, ownedSchema, stateSchema, } from './schema.js';
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 raw = readOptional(root, '.loadout-personal/generated.json');
75
- const owned = raw
76
- ? parse(ownedSchema, JSON.parse(raw.toString()), '.loadout-personal/generated.json').files
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
- ['.loadout-personal/generated.json', json({ schemaVersion: 1, files })],
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
- for (const { root, change } of writes) {
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
@@ -5,6 +5,7 @@ export type Target = {
5
5
  global: boolean;
6
6
  catalog?: Catalog;
7
7
  state?: State;
8
+ installedAt?: Record<string, string>;
8
9
  error?: string;
9
10
  };
10
11
  export declare function loadTarget(root: string, global: boolean): Target;
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 { ...target, catalog, state: loadState(catalog) };
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 };
@@ -1 +1,2 @@
1
+ export declare function suspendEscapeCancellation(input: NodeJS.ReadableStream): () => void;
1
2
  export declare function prepareInput(input: NodeJS.ReadableStream): void;