@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/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Choose agent skills and instructions for yourself or your team. Loadout configures Codex and Claude Code.
|
|
4
4
|
|
|
5
|
+
[Watch the demo](demo.mp4)
|
|
6
|
+
|
|
5
7
|
Private kits and selections live in `.loadout-personal/`, ignored by Git. Add reusable kits to `~/.loadout/kits/` and enable them per repository.
|
|
6
8
|
|
|
7
9
|
## Use it
|
|
@@ -13,13 +15,9 @@ npm install -g @lidtop/loadout
|
|
|
13
15
|
loadout
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
Pick your kits, select **Review changes**,
|
|
17
|
-
|
|
18
|
-
The destination selector above the tabs shows **Repository** in cyan (this repository only) and **Global** in magenta (your user configuration across repositories). Active controls and selection markers follow the destination's color. Press **Tab** to switch destinations instantly, or **Shift+Tab** to cycle backward. **←/→** switches between the Kits, Browse, and Installed sections. Each destination keeps its own selections, provider, search, and cursor position, ready to browse as soon as you switch. A **\*** marks destinations with unapplied selections; **Review changes** includes the session's changes across both. Descriptions appear beneath every provider and kit, with dependency or update notes when needed.
|
|
19
|
-
|
|
20
|
-
Press **Esc** at a setup question or review prompt to return to the picker with your selections and position preserved. Press **Esc twice quickly** or **Ctrl+C** to cancel setup.
|
|
18
|
+
Pick your kits, select **Review changes**, then **Apply changes**. Run `loadout` again to change your selection.
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
Choose **Repository** for the current project or **Global** for your user configuration across repositories.
|
|
23
21
|
|
|
24
22
|
## Share kits with your team
|
|
25
23
|
|
package/dist/cli.js
CHANGED
|
@@ -5,10 +5,9 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { discover } from './catalog.js';
|
|
7
7
|
import { initialize } from './init.js';
|
|
8
|
-
import { interactive
|
|
9
|
-
import { apply,
|
|
8
|
+
import { interactive } from './setup.js';
|
|
9
|
+
import { apply, hasChanges, plan } from './storage.js';
|
|
10
10
|
import { renderWithExternal } from './external.js';
|
|
11
|
-
import { DownloadCancelledError } from './retry.js';
|
|
12
11
|
import { loadTarget } from './targets.js';
|
|
13
12
|
import { configure, disableKits, reasons, resolveKits, setAnswers, } from './resolve.js';
|
|
14
13
|
import { kitSource } from './schema.js';
|
|
@@ -134,54 +133,9 @@ async function generate(catalog, state, opts) {
|
|
|
134
133
|
}
|
|
135
134
|
async function setup() {
|
|
136
135
|
const { targets, initial } = interactiveTargets();
|
|
137
|
-
await interactive(targets, initial, { review: reviewSelections });
|
|
138
|
-
}
|
|
139
|
-
async function reviewSelections(configured) {
|
|
140
136
|
const offline = program.opts().offline;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const catalog = target.catalog;
|
|
144
|
-
console.log(`\n${target.label} · ${target.root}`);
|
|
145
|
-
const update = await selectUpdates(catalog, state, offline);
|
|
146
|
-
const rendered = await renderWithExternal(catalog, state, {
|
|
147
|
-
offline,
|
|
148
|
-
update,
|
|
149
|
-
retry: confirmRetry,
|
|
150
|
-
onRetry: (id) => console.log(`Retrying ${id}…`),
|
|
151
|
-
onFetch: (id, source) => console.log(`Fetching ${id} from ${source.repo}…`),
|
|
152
|
-
});
|
|
153
|
-
for (const id of resolveKits(catalog, state.selected)) {
|
|
154
|
-
const kit = catalog.kits.get(id);
|
|
155
|
-
if (kit.external) {
|
|
156
|
-
const source = update.includes(id)
|
|
157
|
-
? kit.external
|
|
158
|
-
: (kit.pinned ?? kit.external);
|
|
159
|
-
console.log(`${id}: ${source.repo}@${source.ref.slice(0, 12)} · license: ${source.license}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const result = plan(catalog, state, rendered, { adopt: true });
|
|
163
|
-
preview(result, update.length > 0 || !!result.adopted?.length);
|
|
164
|
-
if (result.adopted?.length && !(await confirmAdoption(result.adopted)))
|
|
165
|
-
throw new DownloadCancelledError();
|
|
166
|
-
plans.push(result);
|
|
167
|
-
}
|
|
168
|
-
if (!plans.some(hasChanges))
|
|
169
|
-
return;
|
|
170
|
-
const scopes = configured
|
|
171
|
-
.filter((_, index) => hasChanges(plans[index]))
|
|
172
|
-
.map(({ target }) => target.label);
|
|
173
|
-
if (await confirmApply(undefined, scopes)) {
|
|
174
|
-
applyAll(plans);
|
|
175
|
-
console.log('\nYour loadout is ready.');
|
|
176
|
-
for (const result of plans) {
|
|
177
|
-
if (!result.skippedInstructions?.length)
|
|
178
|
-
continue;
|
|
179
|
-
console.log(result.root);
|
|
180
|
-
showSkipped(result);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
else
|
|
184
|
-
console.log('Cancelled. No kit selections or agent outputs saved.');
|
|
137
|
+
await interactive(targets, initial, { offline });
|
|
138
|
+
console.log('\nYour loadout is ready.');
|
|
185
139
|
}
|
|
186
140
|
program.action(setup);
|
|
187
141
|
program
|
package/dist/external.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ declare const storeSchema: z.ZodObject<{
|
|
|
33
33
|
}, z.core.$strict>>;
|
|
34
34
|
}, z.core.$strict>;
|
|
35
35
|
export type Snapshot = z.infer<typeof snapshotSchema>;
|
|
36
|
+
export type SnapshotCache = Map<string, Snapshot>;
|
|
36
37
|
export type ExternalStore = z.infer<typeof storeSchema>;
|
|
37
38
|
export type FetchBytes = (url: string, limit: number, signal?: AbortSignal) => Promise<Buffer>;
|
|
38
39
|
export declare function readExternal(root: string): {
|
|
@@ -47,5 +48,8 @@ export declare function renderWithExternal(catalog: Catalog, state: State, optio
|
|
|
47
48
|
onFetch?: (id: string, source: ExternalSource) => void;
|
|
48
49
|
onRetry?: (id: string) => void;
|
|
49
50
|
retry?: (id: string, error: Error) => Promise<boolean>;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
cache?: SnapshotCache;
|
|
53
|
+
onReady?: (id: string) => void;
|
|
50
54
|
}): Promise<Rendered>;
|
|
51
55
|
export {};
|
package/dist/external.js
CHANGED
|
@@ -30,6 +30,12 @@ const storeSchema = z
|
|
|
30
30
|
kits: z.record(idSchema, snapshotSchema),
|
|
31
31
|
})
|
|
32
32
|
.strict();
|
|
33
|
+
const sourceKey = (source) => JSON.stringify({
|
|
34
|
+
repo: source.repo,
|
|
35
|
+
ref: source.ref,
|
|
36
|
+
license: source.license,
|
|
37
|
+
skills: [...source.skills].sort(),
|
|
38
|
+
});
|
|
33
39
|
export function readExternal(root) {
|
|
34
40
|
const raw = readOptional(root, '.loadout-personal/external.json');
|
|
35
41
|
return {
|
|
@@ -215,6 +221,7 @@ async function download(source, get) {
|
|
|
215
221
|
return complete;
|
|
216
222
|
}
|
|
217
223
|
export async function renderWithExternal(catalog, state, options = {}) {
|
|
224
|
+
options.signal?.throwIfAborted();
|
|
218
225
|
const result = render(catalog, state);
|
|
219
226
|
const enabled = resolveKits(catalog, state.selected);
|
|
220
227
|
const { raw, store } = readExternal(catalog.root);
|
|
@@ -222,34 +229,46 @@ export async function renderWithExternal(catalog, state, options = {}) {
|
|
|
222
229
|
if (!enabled.includes(id) || !catalog.kits.get(id)?.external)
|
|
223
230
|
throw new Error(`Cannot update ${id}: choose an enabled external kit.`);
|
|
224
231
|
for (const id of enabled) {
|
|
232
|
+
options.signal?.throwIfAborted();
|
|
225
233
|
const kit = catalog.kits.get(id);
|
|
226
234
|
const source = kit.external;
|
|
227
|
-
if (!source)
|
|
235
|
+
if (!source) {
|
|
236
|
+
options.onReady?.(id);
|
|
228
237
|
continue;
|
|
238
|
+
}
|
|
229
239
|
let snapshot = Object.hasOwn(store.kits, id) ? store.kits[id] : undefined;
|
|
230
240
|
const unchanged = snapshot && sameSource(snapshot.source, source);
|
|
231
241
|
if (!snapshot ||
|
|
232
242
|
(options.update?.includes(id) && !(options.offline && unchanged))) {
|
|
233
|
-
|
|
243
|
+
const cached = options.cache?.get(sourceKey(source));
|
|
244
|
+
if (!cached && options.offline)
|
|
234
245
|
throw new Error(`${id} is not available at the requested revision offline. Run without --offline once to fetch it.`);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
requests
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
246
|
+
if (!cached)
|
|
247
|
+
options.onFetch?.(id, source);
|
|
248
|
+
snapshot =
|
|
249
|
+
cached ??
|
|
250
|
+
(await retryDownload(async () => {
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
const requests = new Map();
|
|
253
|
+
const get = (url, limit) => {
|
|
254
|
+
if (!requests.has(url))
|
|
255
|
+
requests.set(url, (options.fetch ?? fetchBytes)(url, limit, options.signal
|
|
256
|
+
? AbortSignal.any([options.signal, controller.signal])
|
|
257
|
+
: controller.signal));
|
|
258
|
+
return requests.get(url);
|
|
259
|
+
};
|
|
260
|
+
try {
|
|
261
|
+
return await download(source, get);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
controller.abort();
|
|
265
|
+
await Promise.allSettled(requests.values());
|
|
266
|
+
requests.clear();
|
|
267
|
+
}
|
|
268
|
+
}, options.retry ? (error) => options.retry(id, error) : undefined, () => options.onRetry?.(id), options.signal));
|
|
269
|
+
options.signal?.throwIfAborted();
|
|
270
|
+
validateSnapshot(snapshot, id);
|
|
271
|
+
options.cache?.set(sourceKey(source), snapshot);
|
|
253
272
|
store.kits[id] = snapshot;
|
|
254
273
|
}
|
|
255
274
|
validateSnapshot(snapshot, id);
|
|
@@ -271,6 +290,7 @@ export async function renderWithExternal(catalog, state, options = {}) {
|
|
|
271
290
|
});
|
|
272
291
|
}
|
|
273
292
|
}
|
|
293
|
+
options.onReady?.(id);
|
|
274
294
|
}
|
|
275
295
|
if (raw || Object.keys(store.kits).length)
|
|
276
296
|
result.external = {
|
package/dist/interactive.d.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { checkbox, confirm, select } from '@inquirer/prompts';
|
|
2
|
-
import { type Catalog, type State } from './schema.js';
|
|
3
|
-
import { type TargetSelection } from './picker.js';
|
|
2
|
+
import { type Answer, type Catalog, type State } from './schema.js';
|
|
4
3
|
import { type Target } from './targets.js';
|
|
5
|
-
type PromptContext = NonNullable<Parameters<typeof confirm>[1]>;
|
|
6
|
-
export declare
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}): Promise<TargetSelection[]>;
|
|
10
|
-
export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1]): Promise<State>;
|
|
4
|
+
export type PromptContext = NonNullable<Parameters<typeof confirm>[1]>;
|
|
5
|
+
export declare class BackNavigation extends Error {
|
|
6
|
+
}
|
|
7
|
+
export declare function configureSelection(target: Target, state: State, context?: Parameters<typeof confirm>[1], onAnswer?: (kit: string, key: string, answer: Answer) => void): Promise<State>;
|
|
11
8
|
export declare function confirmApply(context?: PromptContext, scopes?: string[]): Promise<boolean>;
|
|
12
|
-
export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1]
|
|
9
|
+
export declare function selectUpdates(catalog: Catalog, state: State, offline?: boolean, context?: Parameters<typeof checkbox>[1], options?: {
|
|
10
|
+
message?: string;
|
|
11
|
+
selected?: string[];
|
|
12
|
+
quiet?: boolean;
|
|
13
|
+
}): Promise<string[]>;
|
|
13
14
|
export declare function confirmRetry(id: string, error: Error, context?: Parameters<typeof select>[1]): Promise<boolean>;
|
|
14
|
-
export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1]): Promise<boolean>;
|
|
15
|
-
export
|
|
15
|
+
export declare function confirmAdoption(paths: string[], context?: Parameters<typeof confirm>[1], scope?: string): Promise<boolean>;
|
|
16
|
+
export declare function retryReview(error: unknown, context?: PromptContext): Promise<boolean>;
|
package/dist/interactive.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { checkbox, confirm, select } from '@inquirer/prompts';
|
|
2
2
|
import { configure } from './resolve.js';
|
|
3
|
-
import { validAnswer } from './schema.js';
|
|
4
|
-
import { targetPicker, } from './picker.js';
|
|
3
|
+
import { validAnswer, } from './schema.js';
|
|
5
4
|
import { availableUpdates, updateDescription } from './updates.js';
|
|
6
5
|
import { prepareInput } from './terminal.js';
|
|
7
|
-
class
|
|
6
|
+
export class BackNavigation extends Error {
|
|
8
7
|
}
|
|
9
8
|
async function backPrompt(prompt, context = {}) {
|
|
10
9
|
const input = context.input ?? process.stdin;
|
|
@@ -12,7 +11,7 @@ async function backPrompt(prompt, context = {}) {
|
|
|
12
11
|
const controller = new AbortController();
|
|
13
12
|
const back = (_text, key) => {
|
|
14
13
|
if (key.name === 'escape')
|
|
15
|
-
controller.abort(new
|
|
14
|
+
controller.abort(new BackNavigation());
|
|
16
15
|
};
|
|
17
16
|
input.on('keypress', back);
|
|
18
17
|
try {
|
|
@@ -24,7 +23,7 @@ async function backPrompt(prompt, context = {}) {
|
|
|
24
23
|
});
|
|
25
24
|
}
|
|
26
25
|
catch (error) {
|
|
27
|
-
if (error instanceof Error && error.cause instanceof
|
|
26
|
+
if (error instanceof Error && error.cause instanceof BackNavigation)
|
|
28
27
|
throw error.cause;
|
|
29
28
|
throw error;
|
|
30
29
|
}
|
|
@@ -32,30 +31,9 @@ async function backPrompt(prompt, context = {}) {
|
|
|
32
31
|
input.off('keypress', back);
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
|
-
export async function
|
|
36
|
-
if (!options.context && (!process.stdin.isTTY || !process.stdout.isTTY))
|
|
37
|
-
throw new Error('Interactive setup needs a terminal. Use loadout enable <kit>, then loadout apply.');
|
|
38
|
-
const session = {};
|
|
39
|
-
for (;;) {
|
|
40
|
-
const chosen = await targetPicker({ targets, initial, session }, options.context);
|
|
41
|
-
try {
|
|
42
|
-
const configured = [];
|
|
43
|
-
for (const { target, state } of chosen) {
|
|
44
|
-
const value = await configureSelection(target, state, options.context);
|
|
45
|
-
configured.push({ target, state: value });
|
|
46
|
-
}
|
|
47
|
-
await options.review?.(configured, options.context);
|
|
48
|
-
return configured;
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
if (!(error instanceof BackToPicker))
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
export async function configureSelection(target, state, context) {
|
|
34
|
+
export async function configureSelection(target, state, context, onAnswer) {
|
|
57
35
|
const change = new Map();
|
|
58
|
-
|
|
36
|
+
const ask = async (kit, key, question, value) => {
|
|
59
37
|
if (!change.has(kit)) {
|
|
60
38
|
const hasSaved = Object.entries(target.catalog.kits.get(kit).questions).some(([name, q]) => validAnswer(q, state.answers[kit]?.[name]));
|
|
61
39
|
change.set(kit, hasSaved
|
|
@@ -76,6 +54,11 @@ export async function configureSelection(target, state, context) {
|
|
|
76
54
|
choices: question.choices.map((v) => ({ name: v, value: v })),
|
|
77
55
|
default: typeof value === 'string' ? value : undefined,
|
|
78
56
|
}, context), context);
|
|
57
|
+
};
|
|
58
|
+
return configure(target.catalog, state, async (kit, key, question, value) => {
|
|
59
|
+
const answer = await ask(kit, key, question, value);
|
|
60
|
+
onAnswer?.(kit, key, answer);
|
|
61
|
+
return answer;
|
|
79
62
|
});
|
|
80
63
|
}
|
|
81
64
|
export async function confirmApply(context, scopes = []) {
|
|
@@ -86,20 +69,23 @@ export async function confirmApply(context, scopes = []) {
|
|
|
86
69
|
default: true,
|
|
87
70
|
}, context), context);
|
|
88
71
|
}
|
|
89
|
-
export async function selectUpdates(catalog, state, offline = false, context) {
|
|
72
|
+
export async function selectUpdates(catalog, state, offline = false, context, options = {}) {
|
|
90
73
|
const updates = availableUpdates(catalog, state.selected);
|
|
91
74
|
if (!updates.length)
|
|
92
75
|
return [];
|
|
93
76
|
if (offline) {
|
|
94
|
-
|
|
77
|
+
if (!options.quiet)
|
|
78
|
+
console.log(`${updates.length} catalog update(s) available. Run loadout online to review them; keeping saved versions.`);
|
|
95
79
|
return [];
|
|
96
80
|
}
|
|
97
81
|
return backPrompt((context) => checkbox({
|
|
98
|
-
message:
|
|
82
|
+
message: options.message ??
|
|
83
|
+
'Catalog updates available · choose kits to update (Enter skips)',
|
|
99
84
|
choices: updates.map((kit) => ({
|
|
100
85
|
name: kit.id,
|
|
101
86
|
value: kit.id,
|
|
102
87
|
description: updateDescription(kit),
|
|
88
|
+
checked: options.selected?.includes(kit.id) ?? false,
|
|
103
89
|
})),
|
|
104
90
|
required: false,
|
|
105
91
|
}, context), context);
|
|
@@ -113,9 +99,25 @@ export async function confirmRetry(id, error, context) {
|
|
|
113
99
|
],
|
|
114
100
|
}, context), context);
|
|
115
101
|
}
|
|
116
|
-
export async function confirmAdoption(paths, context) {
|
|
102
|
+
export async function confirmAdoption(paths, context, scope) {
|
|
117
103
|
return backPrompt((context) => confirm({
|
|
118
|
-
message: `Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
|
|
104
|
+
message: `${scope ? `${scope} · ` : ''}Keep existing content and let Loadout manage ${paths.join(', ')}? Originals will be restored when disabled.`,
|
|
119
105
|
default: true,
|
|
120
106
|
}, context), context);
|
|
121
107
|
}
|
|
108
|
+
export async function retryReview(error, context) {
|
|
109
|
+
try {
|
|
110
|
+
return await backPrompt((context) => select({
|
|
111
|
+
message: `Could not finish: ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
choices: [
|
|
113
|
+
{ name: 'Try again', value: true },
|
|
114
|
+
{ name: 'Back to kits', value: false },
|
|
115
|
+
],
|
|
116
|
+
}, context), context);
|
|
117
|
+
}
|
|
118
|
+
catch (failure) {
|
|
119
|
+
if (failure instanceof BackNavigation)
|
|
120
|
+
return false;
|
|
121
|
+
throw failure;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/picker.js
CHANGED
|
@@ -11,6 +11,11 @@ const muted = (value) => styleText('dim', value);
|
|
|
11
11
|
const bold = (value) => styleText('bold', value);
|
|
12
12
|
const scopeColor = (target, value) => styleText(target.global ? 'magenta' : 'cyan', value);
|
|
13
13
|
const clean = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
|
|
14
|
+
function installationTime(value) {
|
|
15
|
+
const date = new Date(value);
|
|
16
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
17
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
18
|
+
}
|
|
14
19
|
function fit(value, width) {
|
|
15
20
|
const text = clean(value);
|
|
16
21
|
if (stringWidth(text) <= width)
|
|
@@ -182,13 +187,16 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
182
187
|
if (section === 'Kits')
|
|
183
188
|
return !providerFor(kit);
|
|
184
189
|
if (section === 'Installed')
|
|
185
|
-
return (
|
|
186
|
-
(!!kit.pinned ||
|
|
187
|
-
enabledSet.has(kit.id) ||
|
|
188
|
-
installedSet.has(kit.id)));
|
|
190
|
+
return installedSet.has(kit.id);
|
|
189
191
|
return providerFor(kit) === provider;
|
|
190
192
|
})
|
|
191
|
-
.sort((a, b) =>
|
|
193
|
+
.sort((a, b) => {
|
|
194
|
+
if (section === 'Installed') {
|
|
195
|
+
const time = (id) => target.installedAt?.[id] ? Date.parse(target.installedAt[id]) : 0;
|
|
196
|
+
return time(b.id) - time(a.id);
|
|
197
|
+
}
|
|
198
|
+
return Number(a.ready === false) - Number(b.ready === false);
|
|
199
|
+
})
|
|
192
200
|
.map((kit) => ({ id: kit.id, description: kit.description, kit }));
|
|
193
201
|
}
|
|
194
202
|
if (provider)
|
|
@@ -352,7 +360,7 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
352
360
|
...wordmark(width, height, accent),
|
|
353
361
|
` ${accent('✓')} ${selected.length} selected${requiredCount ? muted(` · ${requiredCount} required`) : ''}\n`,
|
|
354
362
|
].join('\n');
|
|
355
|
-
const updates = availableUpdates(catalog).length;
|
|
363
|
+
const updates = availableUpdates(catalog, selected).length;
|
|
356
364
|
const counts = `${selected.length} selected${requiredCount ? ` · ${requiredCount} required` : ''}`;
|
|
357
365
|
const tabLabel = (name) => name === 'Browse' && provider ? `Browse › ${clean(provider)}` : name;
|
|
358
366
|
const inlineProvider = !!provider && sections.map(tabLabel).join(' ').length + 2 <= width - 2;
|
|
@@ -421,7 +429,8 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
421
429
|
const spacing = height - beforeList.length - footerHeight >= 8 ? 1 : 0;
|
|
422
430
|
if (spacing)
|
|
423
431
|
beforeList.push('');
|
|
424
|
-
const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) /
|
|
432
|
+
const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) /
|
|
433
|
+
(section === 'Installed' ? 3 : 2))));
|
|
425
434
|
const listEntries = entries.filter((row) => !row.action);
|
|
426
435
|
const listCursor = Math.min(cursor, Math.max(0, listEntries.length - 1));
|
|
427
436
|
const start = Math.max(0, Math.min(listCursor - Math.floor(pageSize / 2), listEntries.length - pageSize));
|
|
@@ -472,6 +481,13 @@ const renderPicker = createPrompt((config, done) => {
|
|
|
472
481
|
const name = fit(displayName(row.id), width - label.length - 9);
|
|
473
482
|
const gap = ' '.repeat(Math.max(2, width - stringWidth(name) - label.length - 6));
|
|
474
483
|
lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`, ` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
|
|
484
|
+
if (section === 'Installed' && row.kit) {
|
|
485
|
+
const timestamp = target.installedAt?.[row.id];
|
|
486
|
+
const installed = timestamp
|
|
487
|
+
? `Installed ${installationTime(timestamp)}`
|
|
488
|
+
: 'Installed';
|
|
489
|
+
lines.push(` ${muted(fit(installed, width - 6))}`);
|
|
490
|
+
}
|
|
475
491
|
}
|
|
476
492
|
if (!page.length) {
|
|
477
493
|
const empty = !catalog.kits.size
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type PromptContext } from './interactive.js';
|
|
2
|
+
import { type ReviewTarget, type PreparedTarget } from './review.js';
|
|
3
|
+
type Progress = (scope: number, message: string) => void;
|
|
4
|
+
type Retry = (scope: number, id: string, error: Error) => Promise<boolean>;
|
|
5
|
+
type Config = {
|
|
6
|
+
selections: ReviewTarget[];
|
|
7
|
+
run: (progress: Progress, retry: Retry, signal: AbortSignal) => Promise<PreparedTarget[]>;
|
|
8
|
+
};
|
|
9
|
+
export declare function prepareScreen(config: Config, context?: PromptContext): Promise<PreparedTarget[]>;
|
|
10
|
+
export {};
|
package/dist/prepare.js
ADDED
|
@@ -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?.();
|
package/dist/review.d.ts
ADDED
|
@@ -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 {};
|