@botdocs/cli 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 +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/publish.js +53 -16
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -76
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- package/dist/test-utils.js +0 -101
package/dist/commands/sync.js
CHANGED
|
@@ -1,13 +1,54 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
2
4
|
import path from 'node:path';
|
|
5
|
+
import { render } from 'ink';
|
|
3
6
|
import { ApiError, apiFetch, fetchRawContent } from '../lib/api.js';
|
|
4
|
-
import { loadLockfile, fingerprintFile, upsertInstall } from '../lib/lockfile.js';
|
|
7
|
+
import { loadLockfile, fingerprintFile, fingerprintContent, upsertInstall, } from '../lib/lockfile.js';
|
|
5
8
|
import { renderDiff, hasChanges } from '../lib/diff.js';
|
|
6
9
|
import { promptConflict, confirmOverwrite, promptCleanUpdate } from '../lib/prompts.js';
|
|
7
10
|
import { syncLibrary } from '../lib/library-sync.js';
|
|
8
|
-
|
|
11
|
+
import { detectDestination } from '../lib/auto-detect.js';
|
|
12
|
+
import { backupDestination, backupFile, isLockfileOwnedAndUnchanged } from '../lib/backup.js';
|
|
13
|
+
import { SyncApp } from './views/sync-app.js';
|
|
14
|
+
/** Backup the existing content at `dest` before sync overwrites it. Skipped
|
|
15
|
+
* entirely if `--no-backup` is set or the file is "ours and unchanged" per
|
|
16
|
+
* the lockfile. Under `--dry-run`, prints the would-be backup path but does
|
|
17
|
+
* not write anything. */
|
|
18
|
+
function maybeBackupBeforeSyncOverwrite(dest, options, silent) {
|
|
19
|
+
if (options.noBackup)
|
|
20
|
+
return;
|
|
21
|
+
if (!fs.existsSync(dest))
|
|
22
|
+
return;
|
|
23
|
+
if (isLockfileOwnedAndUnchanged(dest))
|
|
24
|
+
return;
|
|
25
|
+
if (options.dryRun) {
|
|
26
|
+
if (!silent && !options.json) {
|
|
27
|
+
const projectDir = process.cwd();
|
|
28
|
+
const wouldDest = backupDestination(dest, projectDir);
|
|
29
|
+
const relSrc = path.relative(projectDir, dest);
|
|
30
|
+
const relDest = path.relative(projectDir, wouldDest);
|
|
31
|
+
console.log(` ⚠ Would back up ${relSrc} → ${relDest}`);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const result = backupFile(dest, process.cwd());
|
|
36
|
+
if (!silent && !options.json) {
|
|
37
|
+
if (result.ok) {
|
|
38
|
+
const relSrc = path.relative(process.cwd(), dest);
|
|
39
|
+
const relDest = path.relative(process.cwd(), result.dest);
|
|
40
|
+
console.log(` ⚠ Backed up existing file: ${relSrc} → ${relDest}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(` ⚠ Could not back up ${dest}: ${result.error} — proceeding with overwrite.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function syncSkill(entry, manifest, deps) {
|
|
48
|
+
const { options, awaitConflictChoice, silent } = deps;
|
|
9
49
|
let updatedCount = 0;
|
|
10
50
|
let skippedCount = 0;
|
|
51
|
+
let conflicted = false;
|
|
11
52
|
const newFiles = [];
|
|
12
53
|
for (const installedFile of entry.files) {
|
|
13
54
|
const upstream = manifest.files.find((f) => f.filename === installedFile.src);
|
|
@@ -15,7 +56,9 @@ async function syncSkill(entry, manifest, options) {
|
|
|
15
56
|
// Upstream removed this file — delete the local copy and drop the lockfile entry.
|
|
16
57
|
if (fs.existsSync(installedFile.dest))
|
|
17
58
|
fs.unlinkSync(installedFile.dest);
|
|
18
|
-
|
|
59
|
+
if (!silent) {
|
|
60
|
+
console.log(` ⌀ ${entry.ref}: removed ${installedFile.src} (no longer in upstream)`);
|
|
61
|
+
}
|
|
19
62
|
// Don't push to newFiles — let it drop from the lockfile.
|
|
20
63
|
continue;
|
|
21
64
|
}
|
|
@@ -40,6 +83,13 @@ async function syncSkill(entry, manifest, options) {
|
|
|
40
83
|
if (options.yes) {
|
|
41
84
|
choice = 'apply';
|
|
42
85
|
}
|
|
86
|
+
else if (silent) {
|
|
87
|
+
// Live Ink path treats a clean update as auto-apply — the user already
|
|
88
|
+
// implicitly consented to "sync" and there's no local edit at risk.
|
|
89
|
+
// The plain-text path keeps the three-way prompt because it has the
|
|
90
|
+
// screen real estate for a "diff" view.
|
|
91
|
+
choice = 'apply';
|
|
92
|
+
}
|
|
43
93
|
else {
|
|
44
94
|
choice = await promptCleanUpdate(`${entry.ref}:${installedFile.src}`);
|
|
45
95
|
while (choice === 'diff') {
|
|
@@ -48,6 +98,11 @@ async function syncSkill(entry, manifest, options) {
|
|
|
48
98
|
}
|
|
49
99
|
}
|
|
50
100
|
if (choice === 'apply') {
|
|
101
|
+
// Clean-update branch: by definition the file is tracked and unchanged
|
|
102
|
+
// (localFp === installedFile.fingerprint), so the helper short-circuits.
|
|
103
|
+
// Still routed through for symmetry — if a future code path lands here
|
|
104
|
+
// with an untracked file we'd want it backed up.
|
|
105
|
+
maybeBackupBeforeSyncOverwrite(installedFile.dest, options, silent);
|
|
51
106
|
if (!options.dryRun) {
|
|
52
107
|
fs.writeFileSync(installedFile.dest, upstreamContent, 'utf-8');
|
|
53
108
|
}
|
|
@@ -61,20 +116,23 @@ async function syncSkill(entry, manifest, options) {
|
|
|
61
116
|
}
|
|
62
117
|
continue;
|
|
63
118
|
}
|
|
64
|
-
// Conflict: local has been edited.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
119
|
+
// Conflict: local has been edited. Print the diff (plain-text path only)
|
|
120
|
+
// and ask via the awaiter. The awaiter is the "ask the user" abstraction —
|
|
121
|
+
// Ink supplies a Promise resolved by the inline SelectInput; the plain
|
|
122
|
+
// path supplies one that calls clack `promptConflict` (+ confirmOverwrite).
|
|
123
|
+
conflicted = true;
|
|
124
|
+
if (!silent) {
|
|
125
|
+
console.log(renderDiff(localContent, upstreamContent));
|
|
71
126
|
}
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
127
|
+
const choice = await awaitConflictChoice(entry.ref, installedFile.src);
|
|
128
|
+
if (choice === 'skip' || choice === 'keep') {
|
|
74
129
|
newFiles.push(installedFile);
|
|
75
130
|
skippedCount++;
|
|
76
131
|
continue;
|
|
77
132
|
}
|
|
133
|
+
// 'overwrite': the user accepted. Backup-on-overwrite always runs unless
|
|
134
|
+
// --no-backup. Under --dry-run we print the path but don't write.
|
|
135
|
+
maybeBackupBeforeSyncOverwrite(installedFile.dest, options, silent);
|
|
78
136
|
if (!options.dryRun) {
|
|
79
137
|
fs.writeFileSync(installedFile.dest, upstreamContent, 'utf-8');
|
|
80
138
|
}
|
|
@@ -97,7 +155,7 @@ async function syncSkill(entry, manifest, options) {
|
|
|
97
155
|
});
|
|
98
156
|
}
|
|
99
157
|
}
|
|
100
|
-
return { updated: updatedCount > 0, skipped: skippedCount };
|
|
158
|
+
return { updated: updatedCount > 0, skipped: skippedCount, conflicted };
|
|
101
159
|
}
|
|
102
160
|
function refToPath(ref) {
|
|
103
161
|
const cleaned = ref.startsWith('@') ? ref.slice(1) : ref;
|
|
@@ -106,47 +164,291 @@ function refToPath(ref) {
|
|
|
106
164
|
throw new Error(`Invalid ref in lockfile: ${ref}`);
|
|
107
165
|
return { username, slug };
|
|
108
166
|
}
|
|
109
|
-
|
|
167
|
+
/** Install a team-pinned skill at a specific version. Mirrors the
|
|
168
|
+
* personal-install path in commands/install.ts but tags the lockfile entry
|
|
169
|
+
* with source.type = 'team' so we can identify team-pinned entries on
|
|
170
|
+
* future syncs. Idempotent — re-running with the same version is a no-op. */
|
|
171
|
+
async function installTeamSkill(teamSlug, skill, options, silent) {
|
|
172
|
+
const ref = `@${skill.username}/${skill.slug}`;
|
|
173
|
+
// Determine target version: explicit pin wins, else current.
|
|
174
|
+
const targetVersion = skill.versionPin ?? skill.currentVersion;
|
|
175
|
+
if (!targetVersion) {
|
|
176
|
+
// Skill not yet published — nothing to install.
|
|
177
|
+
return { ref, status: 'skipped' };
|
|
178
|
+
}
|
|
110
179
|
const lf = loadLockfile();
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
180
|
+
const existing = lf.installs.find((i) => i.ref === ref);
|
|
181
|
+
if (existing && existing.version === targetVersion) {
|
|
182
|
+
// Ensure source is tagged so future syncs don't lose track.
|
|
183
|
+
const isTagged = existing.source?.type === 'team' && existing.source.slug === teamSlug;
|
|
184
|
+
if (!isTagged) {
|
|
185
|
+
upsertInstall({
|
|
186
|
+
...existing,
|
|
187
|
+
source: { type: 'team', slug: teamSlug },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return { ref, status: 'up-to-date' };
|
|
191
|
+
}
|
|
192
|
+
// Pull the manifest at the target version. The manifest endpoint always
|
|
193
|
+
// returns the latest published version — when we pinned to an older
|
|
194
|
+
// version we still install whatever's current. (v0 limitation; the
|
|
195
|
+
// versionPin is currently advisory for team curation rather than strict
|
|
196
|
+
// version locking.)
|
|
197
|
+
let manifest;
|
|
198
|
+
try {
|
|
199
|
+
manifest = await apiFetch(`/api/skills/${skill.username}/${skill.slug}/manifest`);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (err instanceof ApiError) {
|
|
203
|
+
return { ref, status: 'skipped' };
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
if (manifest.type !== 'SKILL') {
|
|
208
|
+
return { ref, status: 'skipped' };
|
|
209
|
+
}
|
|
210
|
+
const ctx = {
|
|
211
|
+
scope: skill.username,
|
|
212
|
+
slug: skill.slug,
|
|
213
|
+
homeDir: os.homedir(),
|
|
214
|
+
projectDir: process.cwd(),
|
|
215
|
+
flatScope: false,
|
|
216
|
+
};
|
|
217
|
+
const filesInstalled = [];
|
|
218
|
+
for (const file of manifest.files) {
|
|
219
|
+
const detection = detectDestination(file.filename, ctx);
|
|
220
|
+
if (detection.kind === 'skip' || detection.kind === 'manual')
|
|
221
|
+
continue;
|
|
222
|
+
const content = await fetchRawContent(file.rawUrl);
|
|
223
|
+
if (fs.existsSync(detection.dest)) {
|
|
224
|
+
const existingFp = fingerprintFile(detection.dest);
|
|
225
|
+
const tmpFp = fingerprintContent(content);
|
|
226
|
+
if (existingFp === tmpFp) {
|
|
227
|
+
filesInstalled.push({ src: file.filename, dest: detection.dest, fingerprint: existingFp });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// About to overwrite during team-pin install. Backup if untracked or
|
|
232
|
+
// locally edited.
|
|
233
|
+
maybeBackupBeforeSyncOverwrite(detection.dest, options, silent);
|
|
234
|
+
fs.mkdirSync(path.dirname(detection.dest), { recursive: true });
|
|
235
|
+
fs.writeFileSync(detection.dest, content, 'utf-8');
|
|
236
|
+
filesInstalled.push({
|
|
237
|
+
src: file.filename,
|
|
238
|
+
dest: detection.dest,
|
|
239
|
+
fingerprint: fingerprintFile(detection.dest),
|
|
240
|
+
});
|
|
118
241
|
}
|
|
242
|
+
upsertInstall({
|
|
243
|
+
ref,
|
|
244
|
+
type: 'SKILL',
|
|
245
|
+
version: manifest.version,
|
|
246
|
+
installedAt: new Date().toISOString(),
|
|
247
|
+
files: filesInstalled,
|
|
248
|
+
source: { type: 'team', slug: teamSlug },
|
|
249
|
+
});
|
|
250
|
+
return { ref, status: existing ? 'updated' : 'installed' };
|
|
251
|
+
}
|
|
252
|
+
/** The core sync work, decoupled from presentation. Takes a dispatch + awaiter
|
|
253
|
+
* pair so the Ink view and the plain-text path can share one loop. The function
|
|
254
|
+
* is structured so the live view can flicker rows through their states in real
|
|
255
|
+
* time — every meaningful transition dispatches a reducer action. */
|
|
256
|
+
async function runSyncCore(rawRef, targets, options, deps) {
|
|
257
|
+
const { dispatch, awaitConflictChoice, silent } = deps;
|
|
119
258
|
const summary = [];
|
|
120
259
|
for (const entry of targets) {
|
|
121
260
|
if (entry.type !== 'SKILL')
|
|
122
261
|
continue;
|
|
262
|
+
dispatch({ type: 'CHECKING', ref: entry.ref });
|
|
123
263
|
const { username, slug } = refToPath(entry.ref);
|
|
124
264
|
let manifest;
|
|
125
265
|
try {
|
|
126
266
|
const resp = await apiFetch(`/api/skills/${username}/${slug}/manifest`);
|
|
127
|
-
if (resp.type !== 'SKILL')
|
|
267
|
+
if (resp.type !== 'SKILL') {
|
|
268
|
+
// Bundles inside the sync flow are intentionally skipped — handled at
|
|
269
|
+
// install time. Mirror the existing behavior: silently continue.
|
|
128
270
|
continue;
|
|
271
|
+
}
|
|
129
272
|
manifest = resp;
|
|
130
273
|
}
|
|
131
274
|
catch (err) {
|
|
132
275
|
if (err instanceof ApiError) {
|
|
133
276
|
if (err.status === 401) {
|
|
277
|
+
// 401 is fatal — print the login hint on stderr unconditionally
|
|
278
|
+
// (mirrors pre-PR-#68 behavior) and exit non-zero. The Ink view
|
|
279
|
+
// gets an ERROR dispatch as a courtesy but the process exits
|
|
280
|
+
// before the next frame renders.
|
|
134
281
|
console.error('\n ✗ Not authenticated. Run `botdocs login` first.\n');
|
|
282
|
+
dispatch({ type: 'ERROR', ref: entry.ref, details: 'not authenticated' });
|
|
135
283
|
process.exit(1);
|
|
136
284
|
}
|
|
137
285
|
summary.push({ ref: entry.ref, status: 'skipped' });
|
|
286
|
+
dispatch({ type: 'ERROR', ref: entry.ref, details: `api error ${err.status}` });
|
|
138
287
|
continue;
|
|
139
288
|
}
|
|
140
289
|
throw err;
|
|
141
290
|
}
|
|
142
|
-
const { updated, skipped } = await syncSkill(entry, manifest,
|
|
143
|
-
|
|
291
|
+
const { updated, skipped } = await syncSkill(entry, manifest, {
|
|
292
|
+
options,
|
|
293
|
+
awaitConflictChoice,
|
|
294
|
+
silent,
|
|
295
|
+
});
|
|
296
|
+
const status = updated ? 'updated' : skipped > 0 ? 'skipped' : 'up-to-date';
|
|
297
|
+
summary.push({ ref: entry.ref, status });
|
|
298
|
+
if (status === 'updated') {
|
|
299
|
+
dispatch({
|
|
300
|
+
type: 'UPDATED',
|
|
301
|
+
ref: entry.ref,
|
|
302
|
+
details: entry.version !== manifest.version
|
|
303
|
+
? `${entry.version} → ${manifest.version}`
|
|
304
|
+
: 'updated',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
else if (status === 'up-to-date') {
|
|
308
|
+
dispatch({ type: 'UP_TO_DATE', ref: entry.ref });
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
dispatch({ type: 'SKIPPED', ref: entry.ref, details: 'changes skipped' });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// After per-target sync, also pull team-pinned skills (unless --ref-specific).
|
|
315
|
+
const teamResults = rawRef ? [] : await syncTeamSkills(options, dispatch, silent);
|
|
316
|
+
return { summary, teamResults };
|
|
317
|
+
}
|
|
318
|
+
async function syncTeamSkills(options, dispatch, silent) {
|
|
319
|
+
let payload;
|
|
320
|
+
try {
|
|
321
|
+
payload = await apiFetch('/api/cli/teams', { auth: true });
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
325
|
+
// User not logged in — skip silently. The personal sync path already
|
|
326
|
+
// surfaced the auth error to the user.
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
if (err instanceof ApiError)
|
|
330
|
+
return [];
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
const results = [];
|
|
334
|
+
for (const team of payload.teams) {
|
|
335
|
+
for (const skill of team.skills) {
|
|
336
|
+
const ref = `@${skill.username}/${skill.slug}`;
|
|
337
|
+
dispatch({ type: 'ADD_ROW', ref, team: team.slug });
|
|
338
|
+
dispatch({ type: 'CHECKING', ref });
|
|
339
|
+
if (options.dryRun) {
|
|
340
|
+
results.push({ ref, status: 'skipped', team: team.slug });
|
|
341
|
+
dispatch({ type: 'SKIPPED', ref, details: 'dry run' });
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const result = await installTeamSkill(team.slug, skill, options, silent);
|
|
345
|
+
results.push({ ...result, team: team.slug });
|
|
346
|
+
if (result.status === 'installed' || result.status === 'updated') {
|
|
347
|
+
dispatch({ type: 'UPDATED', ref, details: result.status });
|
|
348
|
+
}
|
|
349
|
+
else if (result.status === 'up-to-date') {
|
|
350
|
+
dispatch({ type: 'UP_TO_DATE', ref });
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
dispatch({ type: 'SKIPPED', ref, details: 'skipped' });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
/** Default awaiter for the plain-text path. Delegates to the existing clack
|
|
360
|
+
* prompts so screen-reader users and non-TTY runners keep the same UX. The
|
|
361
|
+
* three-way choice (`keep`/`overwrite`/`skip`) collapses to clack's existing
|
|
362
|
+
* binary skip/overwrite — `keep` and `skip` both map to skipping, since the
|
|
363
|
+
* plain prompt has no separate "keep" affordance today. */
|
|
364
|
+
function plainAwaitConflictChoice(ref, file) {
|
|
365
|
+
return (async () => {
|
|
366
|
+
const label = `${ref}:${file}`;
|
|
367
|
+
const choice = await promptConflict(label);
|
|
368
|
+
if (choice === 'skip')
|
|
369
|
+
return 'skip';
|
|
370
|
+
const sure = await confirmOverwrite(label);
|
|
371
|
+
return sure ? 'overwrite' : 'skip';
|
|
372
|
+
})();
|
|
373
|
+
}
|
|
374
|
+
export async function sync(rawRef, options) {
|
|
375
|
+
const lf = loadLockfile();
|
|
376
|
+
const targets = rawRef
|
|
377
|
+
? lf.installs.filter((i) => i.ref === rawRef)
|
|
378
|
+
: lf.installs.filter((i) => i.type === 'SKILL');
|
|
379
|
+
const useInk = !options.noInk && Boolean(process.stdout.isTTY) && !options.json;
|
|
380
|
+
if (targets.length === 0 && !rawRef) {
|
|
381
|
+
// Even with nothing installed, pull team-pinned skills.
|
|
382
|
+
const noopDispatch = () => { };
|
|
383
|
+
const teamResults = await syncTeamSkills(options, noopDispatch, false);
|
|
384
|
+
if (teamResults.length === 0) {
|
|
385
|
+
console.log('\n nothing installed to sync\n');
|
|
386
|
+
}
|
|
387
|
+
else if (options.json) {
|
|
388
|
+
console.log(JSON.stringify({ summary: [], teamSummary: teamResults }));
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
console.log('');
|
|
392
|
+
for (const r of teamResults) {
|
|
393
|
+
if (r.status === 'installed')
|
|
394
|
+
console.log(` ✓ ${r.ref}: installed (team ${r.team})`);
|
|
395
|
+
else if (r.status === 'updated')
|
|
396
|
+
console.log(` ✓ ${r.ref}: updated (team ${r.team})`);
|
|
397
|
+
else if (r.status === 'up-to-date')
|
|
398
|
+
console.log(` ✓ ${r.ref}: up to date (team ${r.team})`);
|
|
399
|
+
else
|
|
400
|
+
console.log(` ⌀ ${r.ref}: skipped (team ${r.team})`);
|
|
401
|
+
}
|
|
402
|
+
console.log('');
|
|
403
|
+
}
|
|
404
|
+
await syncLibrary();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (targets.length === 0) {
|
|
408
|
+
console.log('\n nothing installed to sync\n');
|
|
409
|
+
await syncLibrary();
|
|
410
|
+
return;
|
|
144
411
|
}
|
|
412
|
+
// JSON path: stay silent, no Ink, no console output until the final JSON.
|
|
145
413
|
if (options.json) {
|
|
146
|
-
|
|
414
|
+
const noopDispatch = () => { };
|
|
415
|
+
const { summary, teamResults } = await runSyncCore(rawRef, targets, options, {
|
|
416
|
+
dispatch: noopDispatch,
|
|
417
|
+
awaitConflictChoice: plainAwaitConflictChoice,
|
|
418
|
+
silent: true,
|
|
419
|
+
});
|
|
420
|
+
console.log(JSON.stringify({ summary, teamSummary: teamResults }));
|
|
421
|
+
await syncLibrary();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (useInk) {
|
|
425
|
+
// Ink path: mount <SyncApp /> and let it drive the loop. The component
|
|
426
|
+
// owns the run lifecycle — it constructs the dispatch+awaiter pair and
|
|
427
|
+
// calls runSyncCore via the supplied runSync callback. We await the
|
|
428
|
+
// returned Promise (waitUntilExit) so the parent process doesn't fall
|
|
429
|
+
// through to syncLibrary() before the live view has unmounted.
|
|
430
|
+
const initialRows = targets.map((t) => ({ ref: t.ref }));
|
|
431
|
+
const runSync = async (sdeps) => {
|
|
432
|
+
const start = Date.now();
|
|
433
|
+
await runSyncCore(rawRef, targets, options, {
|
|
434
|
+
dispatch: sdeps.dispatch,
|
|
435
|
+
awaitConflictChoice: sdeps.awaitConflictChoice,
|
|
436
|
+
silent: true,
|
|
437
|
+
});
|
|
438
|
+
sdeps.dispatch({ type: 'FINISH', elapsedMs: Date.now() - start });
|
|
439
|
+
};
|
|
440
|
+
const instance = render(React.createElement(SyncApp, { initialRows, runSync }));
|
|
441
|
+
await instance.waitUntilExit();
|
|
147
442
|
await syncLibrary();
|
|
148
443
|
return;
|
|
149
444
|
}
|
|
445
|
+
// Plain text path: console.log line-by-line, clack prompts for conflicts.
|
|
446
|
+
const noopDispatch = () => { };
|
|
447
|
+
const { summary, teamResults } = await runSyncCore(rawRef, targets, options, {
|
|
448
|
+
dispatch: noopDispatch,
|
|
449
|
+
awaitConflictChoice: plainAwaitConflictChoice,
|
|
450
|
+
silent: false,
|
|
451
|
+
});
|
|
150
452
|
console.log('');
|
|
151
453
|
for (const s of summary) {
|
|
152
454
|
if (s.status === 'up-to-date')
|
|
@@ -156,6 +458,16 @@ export async function sync(rawRef, options) {
|
|
|
156
458
|
else
|
|
157
459
|
console.log(` ⌀ ${s.ref}: changes skipped`);
|
|
158
460
|
}
|
|
461
|
+
for (const r of teamResults) {
|
|
462
|
+
if (r.status === 'installed')
|
|
463
|
+
console.log(` ✓ ${r.ref}: installed (team ${r.team})`);
|
|
464
|
+
else if (r.status === 'updated')
|
|
465
|
+
console.log(` ✓ ${r.ref}: updated (team ${r.team})`);
|
|
466
|
+
else if (r.status === 'up-to-date')
|
|
467
|
+
console.log(` ✓ ${r.ref}: up to date (team ${r.team})`);
|
|
468
|
+
else
|
|
469
|
+
console.log(` ⌀ ${r.ref}: skipped (team ${r.team})`);
|
|
470
|
+
}
|
|
159
471
|
console.log('');
|
|
160
472
|
await syncLibrary();
|
|
161
473
|
}
|