@botdocs/cli 0.3.2 → 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.
Files changed (89) hide show
  1. package/README.md +123 -37
  2. package/dist/commands/backups.d.ts +4 -0
  3. package/dist/commands/backups.js +291 -0
  4. package/dist/commands/edit.js +16 -8
  5. package/dist/commands/install.d.ts +4 -0
  6. package/dist/commands/install.js +21 -3
  7. package/dist/commands/login.d.ts +7 -0
  8. package/dist/commands/login.js +240 -75
  9. package/dist/commands/sync.d.ts +16 -0
  10. package/dist/commands/sync.js +337 -25
  11. package/dist/commands/team.d.ts +2 -0
  12. package/dist/commands/team.js +251 -0
  13. package/dist/commands/undo.d.ts +19 -0
  14. package/dist/commands/undo.js +88 -0
  15. package/dist/commands/views/conflict-prompt.d.ts +24 -0
  16. package/dist/commands/views/conflict-prompt.js +19 -0
  17. package/dist/commands/views/login-app.d.ts +30 -0
  18. package/dist/commands/views/login-app.js +57 -0
  19. package/dist/commands/views/sync-app.d.ts +27 -0
  20. package/dist/commands/views/sync-app.js +147 -0
  21. package/dist/commands/views/sync-state.d.ts +84 -0
  22. package/dist/commands/views/sync-state.js +93 -0
  23. package/dist/commands/views/theme.d.ts +16 -0
  24. package/dist/commands/views/theme.js +16 -0
  25. package/dist/commands/whoami.js +13 -13
  26. package/dist/index.js +44 -38
  27. package/dist/lib/api.d.ts +2 -3
  28. package/dist/lib/api.js +14 -7
  29. package/dist/lib/auto-detect.js +46 -0
  30. package/dist/lib/backup.d.ts +121 -0
  31. package/dist/lib/backup.js +387 -0
  32. package/dist/lib/canonical.d.ts +1 -1
  33. package/dist/lib/canonical.js +43 -1
  34. package/dist/lib/config.d.ts +8 -1
  35. package/dist/lib/config.js +18 -9
  36. package/dist/lib/lockfile.d.ts +9 -0
  37. package/dist/lib/prompts.d.ts +10 -0
  38. package/dist/lib/prompts.js +36 -12
  39. package/package.json +27 -7
  40. package/templates/agents.md +60 -47
  41. package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
  42. package/templates/ecosystem-prompts/compile-copilot.md +14 -0
  43. package/templates/ecosystem-prompts/compile-gemini.md +14 -0
  44. package/templates/ecosystem-prompts/compile-opencode.md +13 -0
  45. package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
  46. package/dist/commands/check-updates.test.d.ts +0 -1
  47. package/dist/commands/check-updates.test.js +0 -128
  48. package/dist/commands/clone.d.ts +0 -3
  49. package/dist/commands/clone.js +0 -70
  50. package/dist/commands/compile.test.d.ts +0 -1
  51. package/dist/commands/compile.test.js +0 -110
  52. package/dist/commands/diff.d.ts +0 -3
  53. package/dist/commands/diff.js +0 -65
  54. package/dist/commands/edit.test.d.ts +0 -1
  55. package/dist/commands/edit.test.js +0 -102
  56. package/dist/commands/endorse.d.ts +0 -7
  57. package/dist/commands/endorse.js +0 -70
  58. package/dist/commands/ingest.test.d.ts +0 -1
  59. package/dist/commands/ingest.test.js +0 -109
  60. package/dist/commands/install.test.d.ts +0 -1
  61. package/dist/commands/install.test.js +0 -253
  62. package/dist/commands/list.test.d.ts +0 -1
  63. package/dist/commands/list.test.js +0 -51
  64. package/dist/commands/publish.test.d.ts +0 -1
  65. package/dist/commands/publish.test.js +0 -138
  66. package/dist/commands/pull.d.ts +0 -3
  67. package/dist/commands/pull.js +0 -78
  68. package/dist/commands/sync.test.d.ts +0 -1
  69. package/dist/commands/sync.test.js +0 -263
  70. package/dist/commands/uninstall.test.d.ts +0 -1
  71. package/dist/commands/uninstall.test.js +0 -67
  72. package/dist/lib/auto-detect.test.d.ts +0 -1
  73. package/dist/lib/auto-detect.test.js +0 -58
  74. package/dist/lib/canonical.test.d.ts +0 -1
  75. package/dist/lib/canonical.test.js +0 -48
  76. package/dist/lib/diff.test.d.ts +0 -1
  77. package/dist/lib/diff.test.js +0 -28
  78. package/dist/lib/library-sync.test.d.ts +0 -1
  79. package/dist/lib/library-sync.test.js +0 -63
  80. package/dist/lib/llm.test.d.ts +0 -1
  81. package/dist/lib/llm.test.js +0 -72
  82. package/dist/lib/lockfile.test.d.ts +0 -1
  83. package/dist/lib/lockfile.test.js +0 -99
  84. package/dist/lib/manifest.test.d.ts +0 -1
  85. package/dist/lib/manifest.test.js +0 -72
  86. package/dist/lib/shell-hook.test.d.ts +0 -1
  87. package/dist/lib/shell-hook.test.js +0 -68
  88. package/dist/test-utils.d.ts +0 -43
  89. package/dist/test-utils.js +0 -101
@@ -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
- async function syncSkill(entry, manifest, options) {
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
- console.log(` ⌀ ${entry.ref}: removed ${installedFile.src} (no longer in upstream)`);
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
- console.log(renderDiff(localContent, upstreamContent));
66
- const conflict = await promptConflict(`${entry.ref}:${installedFile.src}`);
67
- if (conflict === 'skip') {
68
- newFiles.push(installedFile);
69
- skippedCount++;
70
- continue;
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 sure = await confirmOverwrite(`${entry.ref}:${installedFile.src}`);
73
- if (!sure) {
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
- export async function sync(rawRef, options) {
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 targets = rawRef
112
- ? lf.installs.filter((i) => i.ref === rawRef)
113
- : lf.installs.filter((i) => i.type === 'SKILL');
114
- if (targets.length === 0) {
115
- console.log('\n nothing installed to sync\n');
116
- await syncLibrary();
117
- return;
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, options);
143
- summary.push({ ref: entry.ref, status: updated ? 'updated' : skipped > 0 ? 'skipped' : 'up-to-date' });
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
- console.log(JSON.stringify({ summary }));
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
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerTeamCommands(program: Command): void;