rubrkit 0.1.1 → 0.3.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/src/pull.js CHANGED
@@ -1,676 +1,682 @@
1
- import fs from 'node:fs';
2
- import fsp from 'node:fs/promises';
3
- import path from 'node:path';
4
- import readline from 'node:readline/promises';
5
-
6
- import { RubrkitApiClient } from './api.js';
7
- import { resolveAgentAdapter } from './adapters.js';
8
- import { authError, RubrkitCliError } from './errors.js';
9
- import {
10
- findManifestEntry,
11
- hashContent,
12
- loadManifest,
13
- removeManifestEntriesByDestination,
14
- upsertManifestEntry,
15
- writeManifest,
16
- } from './manifest.js';
17
- import { normalizeArtifactPath, relativeToRoot, resolveInsideRoot, slugifyPathSegment } from './pathSafety.js';
18
- import { promptForPullSelections } from './prompts.js';
19
-
20
- /**
21
- * @param {{
22
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
23
- * stdin?: NodeJS.ReadableStream,
24
- * stdout?: NodeJS.WritableStream,
25
- * stderr?: NodeJS.WritableStream,
26
- * fetchImpl?: typeof fetch,
27
- * fsImpl?: typeof fsp,
28
- * fsSyncImpl?: Pick<typeof fs, 'existsSync'>,
29
- * }} params
30
- */
31
- export async function runPull({
32
- config,
33
- stdin = process.stdin,
34
- stdout = process.stdout,
35
- fetchImpl = globalThis.fetch,
36
- fsImpl = fsp,
37
- fsSyncImpl = fs,
38
- }) {
39
- if (!config.apiKey) {
40
- throw authError('Missing Rubrkit API key. Set RUBRKIT_API_KEY or pass --api-key.');
41
- }
42
-
43
- const destinationRoot = path.resolve(config.destination);
44
- const apiClient = new RubrkitApiClient({
45
- apiUrl: config.apiUrl,
46
- apiKey: config.apiKey,
47
- fetchImpl,
48
- });
49
- const manifest = await loadManifest(destinationRoot, fsImpl);
50
- const bundles = await apiClient.listArtifactBundles();
51
- const selections = await resolveSelections({
52
- config,
53
- apiClient,
54
- bundles,
55
- manifest,
56
- stdin,
57
- stdout,
58
- });
59
-
60
- if (selections.length === 0) {
61
- stdout.write('No artifacts matched the pull request.\n');
62
- return;
63
- }
64
-
65
- const hydrated = await hydrateSelections({ apiClient, selections });
66
- const plan = await createPullPlan({
67
- destinationRoot,
68
- items: hydrated,
69
- manifest,
70
- config,
71
- fsImpl,
72
- fsSyncImpl,
73
- });
74
-
75
- printPlan({ plan, destinationRoot, dryRun: config.dryRun, stdout });
76
-
77
- const conflicts = plan.actions.filter((action) => action.blocked);
78
-
79
- if (conflicts.length > 0 && !config.dryRun) {
80
- throw new RubrkitCliError(
81
- `Pull stopped because ${conflicts.length} local change${conflicts.length === 1 ? '' : 's'} would be overwritten. Re-run with --force to overwrite.`,
82
- { code: 'local_changes_protected' },
83
- );
84
- }
85
-
86
- if (config.dryRun) {
87
- return;
88
- }
89
-
90
- await applyPlan({ plan, manifest, destinationRoot, fsImpl });
91
-
92
- stdout.write(
93
- `Rubrkit pull complete: ${plan.summary.writes} written, ${plan.summary.skips} skipped, ${plan.summary.prunes} pruned.\n`,
94
- );
95
- }
96
-
97
- /**
98
- * @param {{
99
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
100
- * apiClient: RubrkitApiClient,
101
- * bundles: Array<Record<string, any>>,
102
- * manifest: Awaited<ReturnType<typeof loadManifest>>,
103
- * stdin: NodeJS.ReadableStream,
104
- * stdout: NodeJS.WritableStream,
105
- * }} params
106
- */
107
- async function resolveSelections({ config, apiClient, bundles, manifest, stdin, stdout }) {
108
- const fileCache = new Map();
109
- const loadFiles = async (bundle) => {
110
- if (!fileCache.has(bundle.id)) {
111
- fileCache.set(bundle.id, await apiClient.listFiles(bundle.id));
112
- }
113
-
114
- return fileCache.get(bundle.id);
115
- };
116
-
117
- if (config.updateOnly) {
118
- return resolveUpdateOnlySelections({ config, bundles, manifest, loadFiles });
119
- }
120
-
121
- if (config.all) {
122
- if (!config.yes) {
123
- await confirmAll({ bundleCount: bundles.length, stdin, stdout });
124
- }
125
-
126
- return selectAll({ bundles, loadFiles });
127
- }
128
-
129
- if (config.artifactBundle || config.artifact) {
130
- return resolveFlagSelections({ config, bundles, loadFiles });
131
- }
132
-
133
- if (config.selector) {
134
- return resolveSelectorSelections({ selector: config.selector, yes: config.yes, bundles, loadFiles });
135
- }
136
-
137
- if (config.yes) {
138
- throw new RubrkitCliError('No selector was provided. Use "all", --artifact-bundle, or --artifact with --yes.', {
139
- code: 'selector_required',
140
- exitCode: 2,
141
- });
142
- }
143
-
144
- return promptForPullSelections({ bundles, loadFiles, stdin, stdout });
145
- }
146
-
147
- /**
148
- * @param {{ bundleCount: number, stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream }} params
149
- */
150
- async function confirmAll({ bundleCount, stdin, stdout }) {
151
- const rl = readline.createInterface({ input: stdin, output: stdout });
152
-
153
- try {
154
- const answer = await rl.question(`Pull all files from ${bundleCount} artifact bundle${bundleCount === 1 ? '' : 's'}? [y/N] `);
155
-
156
- if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
157
- throw new RubrkitCliError('Pull cancelled.', { code: 'pull_cancelled', exitCode: 2 });
158
- }
159
- } finally {
160
- rl.close();
161
- }
162
- }
163
-
164
- /**
165
- * @param {{ bundles: Array<Record<string, any>>, loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>> }} params
166
- */
167
- async function selectAll({ bundles, loadFiles }) {
168
- const selections = [];
169
-
170
- for (const bundle of bundles) {
171
- const files = await loadFiles(bundle);
172
-
173
- for (const file of files) {
174
- selections.push({ artifactBundle: bundle, file });
175
- }
176
- }
177
-
178
- return selections;
179
- }
180
-
181
- /**
182
- * @param {{
183
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
184
- * bundles: Array<Record<string, any>>,
185
- * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
186
- * }} params
187
- */
188
- async function resolveFlagSelections({ config, bundles, loadFiles }) {
189
- const selectedBundles = config.artifactBundle ? findBundles(bundles, config.artifactBundle) : bundles;
190
-
191
- if (selectedBundles.length === 0) {
192
- throw new RubrkitCliError(`No artifact bundle matched "${config.artifactBundle}".`, { code: 'artifact_bundle_not_found' });
193
- }
194
-
195
- const selections = [];
196
-
197
- for (const bundle of selectedBundles) {
198
- const files = await loadFiles(bundle);
199
- const selectedFiles = config.artifact ? findFiles(files, config.artifact) : files;
200
-
201
- for (const file of selectedFiles) {
202
- selections.push({ artifactBundle: bundle, file });
203
- }
204
- }
205
-
206
- if (config.artifact && selections.length === 0) {
207
- throw new RubrkitCliError(`No artifact matched "${config.artifact}".`, { code: 'artifact_not_found' });
208
- }
209
-
210
- if (config.yes && selectedBundles.length > 1 && config.artifactBundle) {
211
- throw new RubrkitCliError(`Artifact bundle selector "${config.artifactBundle}" is ambiguous.`, {
212
- code: 'ambiguous_selector',
213
- exitCode: 2,
214
- });
215
- }
216
-
217
- return selections;
218
- }
219
-
220
- /**
221
- * @param {{
222
- * selector: string,
223
- * yes: boolean,
224
- * bundles: Array<Record<string, any>>,
225
- * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
226
- * }} params
227
- */
228
- async function resolveSelectorSelections({ selector, yes, bundles, loadFiles }) {
229
- const bundleMatches = findBundles(bundles, selector);
230
- const fileMatches = [];
231
-
232
- for (const bundle of bundles) {
233
- const files = await loadFiles(bundle);
234
-
235
- for (const file of findFiles(files, selector)) {
236
- fileMatches.push({ artifactBundle: bundle, file });
237
- }
238
- }
239
-
240
- const candidateCount = bundleMatches.length + fileMatches.length;
241
-
242
- if (candidateCount === 0) {
243
- throw new RubrkitCliError(`No artifact bundle or artifact matched "${selector}".`, { code: 'selector_not_found' });
244
- }
245
-
246
- if (candidateCount > 1 && yes) {
247
- throw new RubrkitCliError(`Selector "${selector}" is ambiguous. Use --artifact-bundle or --artifact.`, {
248
- code: 'ambiguous_selector',
249
- exitCode: 2,
250
- });
251
- }
252
-
253
- if (bundleMatches.length === 1 && fileMatches.length === 0) {
254
- const files = await loadFiles(bundleMatches[0]);
255
- return files.map((file) => ({ artifactBundle: bundleMatches[0], file }));
256
- }
257
-
258
- if (fileMatches.length === 1 && bundleMatches.length === 0) {
259
- return fileMatches;
260
- }
261
-
262
- throw new RubrkitCliError(`Selector "${selector}" is ambiguous. Use --artifact-bundle or --artifact.`, {
263
- code: 'ambiguous_selector',
264
- exitCode: 2,
265
- });
266
- }
267
-
268
- /**
269
- * @param {{
270
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
271
- * bundles: Array<Record<string, any>>,
272
- * manifest: Awaited<ReturnType<typeof loadManifest>>,
273
- * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
274
- * }} params
275
- */
276
- async function resolveUpdateOnlySelections({ config, bundles, manifest, loadFiles }) {
277
- const entries = manifest.entries.filter((entry) => matchesEntrySelectors(entry, config));
278
- const selections = [];
279
-
280
- for (const entry of entries) {
281
- const bundle = bundles.find((candidate) => candidate.id === entry.artifactBundleId);
282
-
283
- if (!bundle) {
284
- continue;
285
- }
286
-
287
- const files = await loadFiles(bundle);
288
- const file = files.find((candidate) => candidate.id === entry.artifactId || candidate.path === entry.artifactPath);
289
-
290
- if (file) {
291
- selections.push({ artifactBundle: bundle, file });
292
- }
293
- }
294
-
295
- return selections;
296
- }
297
-
298
- /**
299
- * @param {Record<string, any>} entry
300
- * @param {Awaited<ReturnType<import('./config.js').resolveConfig>>} config
301
- */
302
- function matchesEntrySelectors(entry, config) {
303
- const selector = config.selector && config.selector !== 'all' ? config.selector : null;
304
- const bundleSelector = config.artifactBundle ?? selector;
305
- const artifactSelector = config.artifact ?? (bundleSelector === selector ? null : selector);
306
- const bundleMatches = !bundleSelector || [entry.artifactBundleId, entry.artifactBundleName].some((value) => matchesSelector(value, bundleSelector));
307
- const artifactMatches =
308
- !artifactSelector ||
309
- [entry.artifactId, entry.artifactPath, path.posix.basename(String(entry.artifactPath ?? ''))].some((value) =>
310
- matchesSelector(value, artifactSelector),
311
- );
312
-
313
- return bundleMatches && artifactMatches;
314
- }
315
-
316
- /**
317
- * @param {Array<Record<string, any>>} bundles
318
- * @param {string} selector
319
- */
320
- function findBundles(bundles, selector) {
321
- return bundles.filter((bundle) => {
322
- const slug = slugifyPathSegment(bundle.name ?? bundle.id);
323
-
324
- return [bundle.id, bundle.name, slug].some((value) => matchesSelector(value, selector));
325
- });
326
- }
327
-
328
- /**
329
- * @param {Array<Record<string, any>>} files
330
- * @param {string} selector
331
- */
332
- function findFiles(files, selector) {
333
- return files.filter((file) => {
334
- const normalizedPath = normalizeArtifactPath(file.path);
335
-
336
- return [file.id, normalizedPath, path.posix.basename(normalizedPath)].some((value) => matchesSelector(value, selector));
337
- });
338
- }
339
-
340
- /**
341
- * @param {unknown} value
342
- * @param {string} selector
343
- */
344
- function matchesSelector(value, selector) {
345
- if (typeof value !== 'string') {
346
- return false;
347
- }
348
-
349
- return value === selector || value.toLowerCase() === selector.toLowerCase();
350
- }
351
-
352
- /**
353
- * @param {{ apiClient: RubrkitApiClient, selections: Array<{ artifactBundle: Record<string, any>, file: Record<string, any> }> }} params
354
- */
355
- async function hydrateSelections({ apiClient, selections }) {
356
- const hydrated = [];
357
-
358
- for (const selection of selections) {
359
- const detail = await apiClient.getFile(selection.artifactBundle.id, selection.file.id);
360
-
361
- hydrated.push({
362
- artifactBundle: selection.artifactBundle,
363
- file: detail.file ?? selection.file,
364
- version: detail.version ?? {},
365
- content: typeof detail.content === 'string' ? detail.content : '',
366
- });
367
- }
368
-
369
- return hydrated;
370
- }
371
-
372
- /**
373
- * @param {{
374
- * destinationRoot: string,
375
- * items: Array<{ artifactBundle: Record<string, any>, file: Record<string, any>, version: Record<string, any>, content: string }>,
376
- * manifest: Awaited<ReturnType<typeof loadManifest>>,
377
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
378
- * fsImpl: typeof fsp,
379
- * fsSyncImpl: Pick<typeof fs, 'existsSync'>,
380
- * }} params
381
- */
382
- async function createPullPlan({ destinationRoot, items, manifest, config, fsImpl, fsSyncImpl }) {
383
- const adapter = resolveAgentAdapter({
384
- root: destinationRoot,
385
- requestedAgent: config.agent,
386
- fsImpl: fsSyncImpl,
387
- });
388
- const actions = [];
389
- const selectedDestinationPaths = new Set();
390
- const selectedBundleIds = new Set(items.map((item) => item.artifactBundle.id));
391
-
392
- for (const item of items) {
393
- const placement = adapter.place({
394
- artifactBundle: item.artifactBundle,
395
- file: item.file,
396
- exists(relativePath) {
397
- return fsSyncImpl.existsSync(resolveInsideRoot(destinationRoot, relativePath));
398
- },
399
- });
400
- const targetPath = resolveInsideRoot(destinationRoot, placement.destinationPath);
401
- const destinationPath = relativeToRoot(destinationRoot, targetPath);
402
- selectedDestinationPaths.add(destinationPath);
403
-
404
- actions.push(
405
- await createWriteAction({
406
- adapterName: adapter.name,
407
- destinationRoot,
408
- destinationPath,
409
- targetPath,
410
- placementReason: placement.reason,
411
- item,
412
- manifest,
413
- config,
414
- fsImpl,
415
- }),
416
- );
417
- }
418
-
419
- if (config.prune) {
420
- for (const entry of manifest.entries) {
421
- const destinationPath = String(entry.destinationPath ?? '');
422
-
423
- if (!selectedBundleIds.has(entry.artifactBundleId) || selectedDestinationPaths.has(destinationPath)) {
424
- continue;
425
- }
426
-
427
- const targetPath = resolveInsideRoot(destinationRoot, destinationPath);
428
- actions.push(await createPruneAction({ destinationRoot, destinationPath, targetPath, entry, config, fsImpl }));
429
- }
430
- }
431
-
432
- const summary = {
433
- writes: actions.filter((action) => ['write', 'overwrite', 'update'].includes(action.action)).length,
434
- skips: actions.filter((action) => action.action === 'skip').length,
435
- prunes: actions.filter((action) => action.action === 'prune').length,
436
- conflicts: actions.filter((action) => action.blocked).length,
437
- };
438
-
439
- return { adapterName: adapter.name, actions, summary };
440
- }
441
-
442
- /**
443
- * @param {{
444
- * adapterName: string,
445
- * destinationRoot: string,
446
- * destinationPath: string,
447
- * targetPath: string,
448
- * placementReason: string,
449
- * item: { artifactBundle: Record<string, any>, file: Record<string, any>, version: Record<string, any>, content: string },
450
- * manifest: Awaited<ReturnType<typeof loadManifest>>,
451
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
452
- * fsImpl: typeof fsp,
453
- * }} params
454
- */
455
- async function createWriteAction({ adapterName, destinationRoot, destinationPath, targetPath, placementReason, item, manifest, config, fsImpl }) {
456
- const existingContent = await readTextIfExists(targetPath, fsImpl);
457
- const localHash = existingContent === null ? null : hashContent(existingContent);
458
- const remoteHash = item.version.contentHash ?? item.file.contentHash ?? hashContent(item.content);
459
- const manifestEntry = findManifestEntry(manifest, {
460
- artifactBundleId: item.artifactBundle.id,
461
- artifactId: item.file.id,
462
- destinationPath,
463
- });
464
- const base = {
465
- kind: 'write',
466
- action: 'write',
467
- adapterName,
468
- destinationPath,
469
- targetPath,
470
- placementReason,
471
- artifactBundle: item.artifactBundle,
472
- file: item.file,
473
- version: item.version,
474
- content: item.content,
475
- remoteHash,
476
- localHash,
477
- manifestEntry,
478
- blocked: false,
479
- reason: '',
480
- };
481
-
482
- if (manifestEntry) {
483
- const expectedHash = String(manifestEntry.lastKnownLocalContentHash ?? '');
484
-
485
- if (localHash && expectedHash && localHash !== expectedHash && !config.force) {
486
- return {
487
- ...base,
488
- action: 'conflict',
489
- blocked: true,
490
- reason: 'local changes differ from the last Rubrkit pull',
491
- };
492
- }
493
-
494
- if (localHash === remoteHash && manifestEntry.contentHash === remoteHash) {
495
- return {
496
- ...base,
497
- action: 'skip',
498
- reason: 'already up to date',
499
- };
500
- }
501
-
502
- return {
503
- ...base,
504
- action: existingContent === null ? 'write' : 'update',
505
- reason: existingContent === null ? 'tracked file is missing locally' : 'remote artifact changed',
506
- };
507
- }
508
-
509
- if (config.updateOnly) {
510
- return {
511
- ...base,
512
- action: 'skip',
513
- reason: 'not tracked in the local manifest',
514
- };
515
- }
516
-
517
- if (existingContent !== null && !config.force) {
518
- return {
519
- ...base,
520
- action: 'conflict',
521
- blocked: true,
522
- reason: 'destination exists and is not managed by Rubrkit',
523
- };
524
- }
525
-
526
- return {
527
- ...base,
528
- action: existingContent === null ? 'write' : 'overwrite',
529
- reason: existingContent === null ? 'new artifact' : 'forced overwrite of an unmanaged file',
530
- };
531
- }
532
-
533
- /**
534
- * @param {{
535
- * destinationRoot: string,
536
- * destinationPath: string,
537
- * targetPath: string,
538
- * entry: Record<string, any>,
539
- * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
540
- * fsImpl: typeof fsp,
541
- * }} params
542
- */
543
- async function createPruneAction({ destinationRoot, destinationPath, targetPath, entry, config, fsImpl }) {
544
- const existingContent = await readTextIfExists(targetPath, fsImpl);
545
- const localHash = existingContent === null ? null : hashContent(existingContent);
546
- const expectedHash = String(entry.lastKnownLocalContentHash ?? '');
547
-
548
- if (existingContent !== null && expectedHash && localHash !== expectedHash && !config.force) {
549
- return {
550
- kind: 'prune',
551
- action: 'conflict',
552
- destinationPath,
553
- targetPath,
554
- entry,
555
- localHash,
556
- blocked: true,
557
- reason: 'local changes differ from the last Rubrkit pull',
558
- };
559
- }
560
-
561
- return {
562
- kind: 'prune',
563
- action: 'prune',
564
- destinationPath,
565
- targetPath,
566
- entry,
567
- localHash,
568
- blocked: false,
569
- reason: existingContent === null ? 'tracked file is already missing' : 'remote artifact is no longer selected',
570
- destinationRoot,
571
- };
572
- }
573
-
574
- /**
575
- * @param {string} targetPath
576
- * @param {typeof fsp} fsImpl
577
- */
578
- async function readTextIfExists(targetPath, fsImpl) {
579
- try {
580
- return await fsImpl.readFile(targetPath, 'utf8');
581
- } catch (error) {
582
- if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
583
- return null;
584
- }
585
-
586
- throw error;
587
- }
588
- }
589
-
590
- /**
591
- * @param {{ plan: Awaited<ReturnType<typeof createPullPlan>>, destinationRoot: string, dryRun: boolean, stdout: NodeJS.WritableStream }} params
592
- */
593
- function printPlan({ plan, destinationRoot, dryRun, stdout }) {
594
- stdout.write(`${dryRun ? 'Rubrkit pull dry run' : 'Rubrkit pull plan'}\n`);
595
- stdout.write(`Destination: ${destinationRoot}\n`);
596
- stdout.write(`Agent adapter: ${plan.adapterName}\n`);
597
-
598
- for (const action of plan.actions) {
599
- const label = action.blocked ? 'protected' : action.action;
600
- let source;
601
-
602
- if (action.kind === 'write') {
603
- const writeAction = /** @type {Record<string, any>} */ (action);
604
- source = `${writeAction.artifactBundle.name ?? writeAction.artifactBundle.id}/${writeAction.file.path}`;
605
- } else {
606
- const pruneAction = /** @type {Record<string, any>} */ (action);
607
- source = String(pruneAction.entry.artifactPath ?? pruneAction.entry.destinationPath);
608
- }
609
-
610
- stdout.write(`- ${label}: ${action.destinationPath} <- ${source}`);
611
-
612
- if (action.reason) {
613
- stdout.write(` (${action.reason})`);
614
- }
615
-
616
- stdout.write('\n');
617
- }
618
-
619
- stdout.write(
620
- `Summary: ${plan.summary.writes} write/update, ${plan.summary.skips} skipped, ${plan.summary.prunes} pruned, ${plan.summary.conflicts} protected.\n`,
621
- );
622
- }
623
-
624
- /**
625
- * @param {{
626
- * plan: Awaited<ReturnType<typeof createPullPlan>>,
627
- * manifest: Awaited<ReturnType<typeof loadManifest>>,
628
- * destinationRoot: string,
629
- * fsImpl: typeof fsp,
630
- * }} params
631
- */
632
- async function applyPlan({ plan, manifest, destinationRoot, fsImpl }) {
633
- let manifestChanged = false;
634
- const prunedDestinations = new Set();
635
-
636
- for (const action of plan.actions) {
637
- if (action.blocked || action.action === 'skip') {
638
- continue;
639
- }
640
-
641
- if (action.kind === 'prune') {
642
- const pruneAction = /** @type {Record<string, any>} */ (action);
643
- await fsImpl.rm(pruneAction.targetPath, { force: true });
644
- prunedDestinations.add(pruneAction.destinationPath);
645
- manifestChanged = true;
646
- continue;
647
- }
648
-
649
- const writeAction = /** @type {Record<string, any>} */ (action);
650
- await fsImpl.mkdir(path.dirname(writeAction.targetPath), { recursive: true });
651
- await fsImpl.writeFile(writeAction.targetPath, writeAction.content, 'utf8');
652
- upsertManifestEntry(manifest, {
653
- artifactBundleId: writeAction.artifactBundle.id,
654
- artifactBundleName: writeAction.artifactBundle.name ?? null,
655
- artifactId: writeAction.file.id,
656
- artifactPath: normalizeArtifactPath(writeAction.file.path),
657
- artifactType: writeAction.file.artifactType ?? null,
658
- versionId: writeAction.version.id ?? null,
659
- versionNumber: writeAction.version.versionNumber ?? writeAction.file.latestVersionNumber ?? null,
660
- contentHash: writeAction.remoteHash,
661
- destinationPath: writeAction.destinationPath,
662
- agent: writeAction.adapterName,
663
- lastPulledAt: new Date().toISOString(),
664
- lastKnownLocalContentHash: hashContent(writeAction.content),
665
- });
666
- manifestChanged = true;
667
- }
668
-
669
- if (prunedDestinations.size > 0) {
670
- removeManifestEntriesByDestination(manifest, prunedDestinations);
671
- }
672
-
673
- if (manifestChanged) {
674
- await writeManifest(destinationRoot, manifest, fsImpl);
675
- }
676
- }
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+
6
+ import { RubrkitApiClient } from './api.js';
7
+ import { resolveAgentAdapter } from './adapters.js';
8
+ import { authError, RubrkitCliError } from './errors.js';
9
+ import {
10
+ findManifestEntry,
11
+ hashContent,
12
+ loadManifest,
13
+ removeManifestEntriesByDestination,
14
+ upsertManifestEntry,
15
+ writeManifest,
16
+ } from './manifest.js';
17
+ import { normalizeArtifactPath, relativeToRoot, resolveInsideRoot, slugifyPathSegment } from './pathSafety.js';
18
+ import { promptForPullSelections } from './prompts.js';
19
+
20
+ /**
21
+ * @param {{
22
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
23
+ * stdin?: NodeJS.ReadableStream,
24
+ * stdout?: NodeJS.WritableStream,
25
+ * stderr?: NodeJS.WritableStream,
26
+ * fetchImpl?: typeof fetch,
27
+ * fsImpl?: typeof fsp,
28
+ * fsSyncImpl?: Pick<typeof fs, 'existsSync'>,
29
+ * }} params
30
+ */
31
+ export async function runPull({
32
+ config,
33
+ stdin = process.stdin,
34
+ stdout = process.stdout,
35
+ fetchImpl = globalThis.fetch,
36
+ fsImpl = fsp,
37
+ fsSyncImpl = fs,
38
+ }) {
39
+ if (!config.apiKey) {
40
+ throw authError('Missing Rubrkit API key. Set RUBRKIT_API_KEY or pass --api-key.');
41
+ }
42
+
43
+ const destinationRoot = path.resolve(config.destination);
44
+ const apiClient = new RubrkitApiClient({
45
+ apiUrl: config.apiUrl,
46
+ apiKey: config.apiKey,
47
+ fetchImpl,
48
+ });
49
+ const manifest = await loadManifest(destinationRoot, fsImpl);
50
+ const bundles = await apiClient.listArtifactBundles({ labels: config.label ?? [] });
51
+ const selections = await resolveSelections({
52
+ config,
53
+ apiClient,
54
+ bundles,
55
+ manifest,
56
+ stdin,
57
+ stdout,
58
+ });
59
+
60
+ if (selections.length === 0) {
61
+ stdout.write('No artifacts matched the pull request.\n');
62
+ return;
63
+ }
64
+
65
+ const hydrated = await hydrateSelections({ apiClient, selections });
66
+ const plan = await createPullPlan({
67
+ destinationRoot,
68
+ items: hydrated,
69
+ manifest,
70
+ config,
71
+ fsImpl,
72
+ fsSyncImpl,
73
+ });
74
+
75
+ printPlan({ plan, destinationRoot, dryRun: config.dryRun, stdout });
76
+
77
+ const conflicts = plan.actions.filter((action) => action.blocked);
78
+
79
+ if (conflicts.length > 0 && !config.dryRun) {
80
+ throw new RubrkitCliError(
81
+ `Pull stopped because ${conflicts.length} local change${conflicts.length === 1 ? '' : 's'} would be overwritten. Re-run with --force to overwrite.`,
82
+ { code: 'local_changes_protected' },
83
+ );
84
+ }
85
+
86
+ if (config.dryRun) {
87
+ return;
88
+ }
89
+
90
+ await applyPlan({ plan, manifest, destinationRoot, fsImpl });
91
+
92
+ stdout.write(
93
+ `Rubrkit pull complete: ${plan.summary.writes} written, ${plan.summary.skips} skipped, ${plan.summary.prunes} pruned.\n`,
94
+ );
95
+ }
96
+
97
+ /**
98
+ * @param {{
99
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
100
+ * apiClient: RubrkitApiClient,
101
+ * bundles: Array<Record<string, any>>,
102
+ * manifest: Awaited<ReturnType<typeof loadManifest>>,
103
+ * stdin: NodeJS.ReadableStream,
104
+ * stdout: NodeJS.WritableStream,
105
+ * }} params
106
+ */
107
+ async function resolveSelections({ config, apiClient, bundles, manifest, stdin, stdout }) {
108
+ const fileCache = new Map();
109
+ const loadFiles = async (bundle) => {
110
+ if (!fileCache.has(bundle.id)) {
111
+ fileCache.set(bundle.id, await apiClient.listFiles(bundle.id));
112
+ }
113
+
114
+ return fileCache.get(bundle.id);
115
+ };
116
+
117
+ if (config.updateOnly) {
118
+ return resolveUpdateOnlySelections({ config, bundles, manifest, loadFiles });
119
+ }
120
+
121
+ if (Array.isArray(config.label) && config.label.length > 0) {
122
+ if (!config.yes) {
123
+ await confirmAll({ bundleCount: bundles.length, stdin, stdout });
124
+ }
125
+
126
+ return selectAll({ bundles, loadFiles });
127
+ }
128
+
129
+ if (config.all) {
130
+ if (!config.yes) {
131
+ await confirmAll({ bundleCount: bundles.length, stdin, stdout });
132
+ }
133
+
134
+ return selectAll({ bundles, loadFiles });
135
+ }
136
+
137
+ if (config.artifactBundle || config.artifact) {
138
+ return resolveFlagSelections({ config, bundles, loadFiles });
139
+ }
140
+
141
+ if (config.selector) {
142
+ return resolveSelectorSelections({ selector: config.selector, yes: config.yes, bundles, loadFiles });
143
+ }
144
+
145
+ if (config.yes) {
146
+ throw new RubrkitCliError('No selector was provided. Use "all", --artifact-bundle, or --artifact with --yes.', {
147
+ code: 'selector_required',
148
+ exitCode: 2,
149
+ });
150
+ }
151
+
152
+ return promptForPullSelections({ bundles, loadFiles, stdin, stdout });
153
+ }
154
+
155
+ /**
156
+ * @param {{ bundleCount: number, stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream }} params
157
+ */
158
+ async function confirmAll({ bundleCount, stdin, stdout }) {
159
+ const rl = readline.createInterface({ input: stdin, output: stdout });
160
+
161
+ try {
162
+ const answer = await rl.question(`Pull all files from ${bundleCount} artifact bundle${bundleCount === 1 ? '' : 's'}? [y/N] `);
163
+
164
+ if (!['y', 'yes'].includes(answer.trim().toLowerCase())) {
165
+ throw new RubrkitCliError('Pull cancelled.', { code: 'pull_cancelled', exitCode: 2 });
166
+ }
167
+ } finally {
168
+ rl.close();
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {{ bundles: Array<Record<string, any>>, loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>> }} params
174
+ */
175
+ async function selectAll({ bundles, loadFiles }) {
176
+ const selections = [];
177
+
178
+ for (const bundle of bundles) {
179
+ const files = await loadFiles(bundle);
180
+
181
+ for (const file of files) {
182
+ selections.push({ artifactBundle: bundle, file });
183
+ }
184
+ }
185
+
186
+ return selections;
187
+ }
188
+
189
+ /**
190
+ * @param {{
191
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
192
+ * bundles: Array<Record<string, any>>,
193
+ * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
194
+ * }} params
195
+ */
196
+ async function resolveFlagSelections({ config, bundles, loadFiles }) {
197
+ const selectedBundles = config.artifactBundle ? findBundles(bundles, config.artifactBundle) : bundles;
198
+
199
+ if (selectedBundles.length === 0) {
200
+ throw new RubrkitCliError(`No artifact bundle matched "${config.artifactBundle}".`, { code: 'artifact_bundle_not_found' });
201
+ }
202
+
203
+ const selections = [];
204
+
205
+ for (const bundle of selectedBundles) {
206
+ const files = await loadFiles(bundle);
207
+ const selectedFiles = config.artifact ? findFiles(files, config.artifact) : files;
208
+
209
+ for (const file of selectedFiles) {
210
+ selections.push({ artifactBundle: bundle, file });
211
+ }
212
+ }
213
+
214
+ if (config.artifact && selections.length === 0) {
215
+ throw new RubrkitCliError(`No artifact matched "${config.artifact}".`, { code: 'artifact_not_found' });
216
+ }
217
+
218
+ if (config.yes && selectedBundles.length > 1 && config.artifactBundle) {
219
+ throw new RubrkitCliError(`Artifact bundle selector "${config.artifactBundle}" is ambiguous.`, {
220
+ code: 'ambiguous_selector',
221
+ exitCode: 2,
222
+ });
223
+ }
224
+
225
+ return selections;
226
+ }
227
+
228
+ /**
229
+ * @param {{
230
+ * selector: string,
231
+ * yes: boolean,
232
+ * bundles: Array<Record<string, any>>,
233
+ * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
234
+ * }} params
235
+ */
236
+ async function resolveSelectorSelections({ selector, yes, bundles, loadFiles }) {
237
+ const bundleMatches = findBundles(bundles, selector);
238
+ const fileMatches = [];
239
+
240
+ for (const bundle of bundles) {
241
+ const files = await loadFiles(bundle);
242
+
243
+ for (const file of findFiles(files, selector)) {
244
+ fileMatches.push({ artifactBundle: bundle, file });
245
+ }
246
+ }
247
+
248
+ const candidateCount = bundleMatches.length + fileMatches.length;
249
+
250
+ if (candidateCount === 0) {
251
+ throw new RubrkitCliError(`No artifact bundle or artifact matched "${selector}".`, { code: 'selector_not_found' });
252
+ }
253
+
254
+ if (candidateCount > 1 && yes) {
255
+ throw new RubrkitCliError(`Selector "${selector}" is ambiguous. Use --artifact-bundle or --artifact.`, {
256
+ code: 'ambiguous_selector',
257
+ exitCode: 2,
258
+ });
259
+ }
260
+
261
+ if (bundleMatches.length === 1 && fileMatches.length === 0) {
262
+ const files = await loadFiles(bundleMatches[0]);
263
+ return files.map((file) => ({ artifactBundle: bundleMatches[0], file }));
264
+ }
265
+
266
+ if (fileMatches.length === 1 && bundleMatches.length === 0) {
267
+ return fileMatches;
268
+ }
269
+
270
+ throw new RubrkitCliError(`Selector "${selector}" is ambiguous. Use --artifact-bundle or --artifact.`, {
271
+ code: 'ambiguous_selector',
272
+ exitCode: 2,
273
+ });
274
+ }
275
+
276
+ /**
277
+ * @param {{
278
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
279
+ * bundles: Array<Record<string, any>>,
280
+ * manifest: Awaited<ReturnType<typeof loadManifest>>,
281
+ * loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
282
+ * }} params
283
+ */
284
+ async function resolveUpdateOnlySelections({ config, bundles, manifest, loadFiles }) {
285
+ const entries = manifest.entries.filter((entry) => matchesEntrySelectors(entry, config));
286
+ const selections = [];
287
+
288
+ for (const entry of entries) {
289
+ const bundle = bundles.find((candidate) => candidate.id === entry.artifactBundleId);
290
+
291
+ if (!bundle) {
292
+ continue;
293
+ }
294
+
295
+ const files = await loadFiles(bundle);
296
+ const file = files.find((candidate) => candidate.id === entry.artifactId || candidate.path === entry.artifactPath);
297
+
298
+ if (file) {
299
+ selections.push({ artifactBundle: bundle, file });
300
+ }
301
+ }
302
+
303
+ return selections;
304
+ }
305
+
306
+ /**
307
+ * @param {Record<string, any>} entry
308
+ * @param {Awaited<ReturnType<import('./config.js').resolveConfig>>} config
309
+ */
310
+ function matchesEntrySelectors(entry, config) {
311
+ const selector = config.selector && config.selector !== 'all' ? config.selector : null;
312
+ const bundleSelector = config.artifactBundle ?? selector;
313
+ const artifactSelector = config.artifact ?? (bundleSelector === selector ? null : selector);
314
+ const bundleMatches = !bundleSelector || [entry.artifactBundleId, entry.artifactBundleName].some((value) => matchesSelector(value, bundleSelector));
315
+ const artifactMatches =
316
+ !artifactSelector ||
317
+ [entry.artifactId, entry.artifactPath, path.posix.basename(String(entry.artifactPath ?? ''))].some((value) =>
318
+ matchesSelector(value, artifactSelector),
319
+ );
320
+
321
+ return bundleMatches && artifactMatches;
322
+ }
323
+
324
+ /**
325
+ * @param {Array<Record<string, any>>} bundles
326
+ * @param {string} selector
327
+ */
328
+ function findBundles(bundles, selector) {
329
+ return bundles.filter((bundle) => {
330
+ const slug = slugifyPathSegment(bundle.name ?? bundle.id);
331
+
332
+ return [bundle.id, bundle.name, slug].some((value) => matchesSelector(value, selector));
333
+ });
334
+ }
335
+
336
+ /**
337
+ * @param {Array<Record<string, any>>} files
338
+ * @param {string} selector
339
+ */
340
+ function findFiles(files, selector) {
341
+ return files.filter((file) => {
342
+ const normalizedPath = normalizeArtifactPath(file.path);
343
+
344
+ return [file.id, normalizedPath, path.posix.basename(normalizedPath)].some((value) => matchesSelector(value, selector));
345
+ });
346
+ }
347
+
348
+ /**
349
+ * @param {unknown} value
350
+ * @param {string} selector
351
+ */
352
+ function matchesSelector(value, selector) {
353
+ if (typeof value !== 'string') {
354
+ return false;
355
+ }
356
+
357
+ return value === selector || value.toLowerCase() === selector.toLowerCase();
358
+ }
359
+
360
+ /**
361
+ * @param {{ apiClient: RubrkitApiClient, selections: Array<{ artifactBundle: Record<string, any>, file: Record<string, any> }> }} params
362
+ */
363
+ async function hydrateSelections({ apiClient, selections }) {
364
+ const hydrated = [];
365
+
366
+ for (const selection of selections) {
367
+ const detail = await apiClient.getFile(selection.artifactBundle.id, selection.file.id);
368
+
369
+ hydrated.push({
370
+ artifactBundle: selection.artifactBundle,
371
+ file: detail.file ?? selection.file,
372
+ version: detail.version ?? {},
373
+ content: typeof detail.content === 'string' ? detail.content : '',
374
+ });
375
+ }
376
+
377
+ return hydrated;
378
+ }
379
+
380
+ /**
381
+ * @param {{
382
+ * destinationRoot: string,
383
+ * items: Array<{ artifactBundle: Record<string, any>, file: Record<string, any>, version: Record<string, any>, content: string }>,
384
+ * manifest: Awaited<ReturnType<typeof loadManifest>>,
385
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
386
+ * fsImpl: typeof fsp,
387
+ * fsSyncImpl: Pick<typeof fs, 'existsSync'>,
388
+ * }} params
389
+ */
390
+ async function createPullPlan({ destinationRoot, items, manifest, config, fsImpl, fsSyncImpl }) {
391
+ const adapter = resolveAgentAdapter({
392
+ root: destinationRoot,
393
+ requestedAgent: config.agent,
394
+ fsImpl: fsSyncImpl,
395
+ });
396
+ const actions = [];
397
+ const selectedDestinationPaths = new Set();
398
+ const selectedBundleIds = new Set(items.map((item) => item.artifactBundle.id));
399
+
400
+ for (const item of items) {
401
+ const placement = adapter.place({
402
+ artifactBundle: item.artifactBundle,
403
+ file: item.file,
404
+ exists(relativePath) {
405
+ return fsSyncImpl.existsSync(resolveInsideRoot(destinationRoot, relativePath));
406
+ },
407
+ });
408
+ const targetPath = resolveInsideRoot(destinationRoot, placement.destinationPath);
409
+ const destinationPath = relativeToRoot(destinationRoot, targetPath);
410
+ selectedDestinationPaths.add(destinationPath);
411
+
412
+ actions.push(
413
+ await createWriteAction({
414
+ adapterName: adapter.name,
415
+ destinationPath,
416
+ targetPath,
417
+ placementReason: placement.reason,
418
+ item,
419
+ manifest,
420
+ config,
421
+ fsImpl,
422
+ }),
423
+ );
424
+ }
425
+
426
+ if (config.prune) {
427
+ for (const entry of manifest.entries) {
428
+ const destinationPath = String(entry.destinationPath ?? '');
429
+
430
+ if (!selectedBundleIds.has(entry.artifactBundleId) || selectedDestinationPaths.has(destinationPath)) {
431
+ continue;
432
+ }
433
+
434
+ const targetPath = resolveInsideRoot(destinationRoot, destinationPath);
435
+ actions.push(await createPruneAction({ destinationRoot, destinationPath, targetPath, entry, config, fsImpl }));
436
+ }
437
+ }
438
+
439
+ const summary = {
440
+ writes: actions.filter((action) => ['write', 'overwrite', 'update'].includes(action.action)).length,
441
+ skips: actions.filter((action) => action.action === 'skip').length,
442
+ prunes: actions.filter((action) => action.action === 'prune').length,
443
+ conflicts: actions.filter((action) => action.blocked).length,
444
+ };
445
+
446
+ return { adapterName: adapter.name, actions, summary };
447
+ }
448
+
449
+ /**
450
+ * @param {{
451
+ * adapterName: string,
452
+ * destinationPath: string,
453
+ * targetPath: string,
454
+ * placementReason: string,
455
+ * item: { artifactBundle: Record<string, any>, file: Record<string, any>, version: Record<string, any>, content: string },
456
+ * manifest: Awaited<ReturnType<typeof loadManifest>>,
457
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
458
+ * fsImpl: typeof fsp,
459
+ * }} params
460
+ */
461
+ async function createWriteAction({ adapterName, destinationPath, targetPath, placementReason, item, manifest, config, fsImpl }) {
462
+ const existingContent = await readTextIfExists(targetPath, fsImpl);
463
+ const localHash = existingContent === null ? null : hashContent(existingContent);
464
+ const remoteHash = item.version.contentHash ?? item.file.contentHash ?? hashContent(item.content);
465
+ const manifestEntry = findManifestEntry(manifest, {
466
+ artifactBundleId: item.artifactBundle.id,
467
+ artifactId: item.file.id,
468
+ destinationPath,
469
+ });
470
+ const base = {
471
+ kind: 'write',
472
+ action: 'write',
473
+ adapterName,
474
+ destinationPath,
475
+ targetPath,
476
+ placementReason,
477
+ artifactBundle: item.artifactBundle,
478
+ file: item.file,
479
+ version: item.version,
480
+ content: item.content,
481
+ remoteHash,
482
+ localHash,
483
+ manifestEntry,
484
+ blocked: false,
485
+ reason: '',
486
+ };
487
+
488
+ if (manifestEntry) {
489
+ const expectedHash = String(manifestEntry.lastKnownLocalContentHash ?? '');
490
+
491
+ if (localHash && expectedHash && localHash !== expectedHash && !config.force) {
492
+ return {
493
+ ...base,
494
+ action: 'conflict',
495
+ blocked: true,
496
+ reason: 'local changes differ from the last Rubrkit pull',
497
+ };
498
+ }
499
+
500
+ if (localHash === remoteHash && manifestEntry.contentHash === remoteHash) {
501
+ return {
502
+ ...base,
503
+ action: 'skip',
504
+ reason: 'already up to date',
505
+ };
506
+ }
507
+
508
+ return {
509
+ ...base,
510
+ action: existingContent === null ? 'write' : 'update',
511
+ reason: existingContent === null ? 'tracked file is missing locally' : 'remote artifact changed',
512
+ };
513
+ }
514
+
515
+ if (config.updateOnly) {
516
+ return {
517
+ ...base,
518
+ action: 'skip',
519
+ reason: 'not tracked in the local manifest',
520
+ };
521
+ }
522
+
523
+ if (existingContent !== null && !config.force) {
524
+ return {
525
+ ...base,
526
+ action: 'conflict',
527
+ blocked: true,
528
+ reason: 'destination exists and is not managed by Rubrkit',
529
+ };
530
+ }
531
+
532
+ return {
533
+ ...base,
534
+ action: existingContent === null ? 'write' : 'overwrite',
535
+ reason: existingContent === null ? 'new artifact' : 'forced overwrite of an unmanaged file',
536
+ };
537
+ }
538
+
539
+ /**
540
+ * @param {{
541
+ * destinationRoot: string,
542
+ * destinationPath: string,
543
+ * targetPath: string,
544
+ * entry: Record<string, any>,
545
+ * config: Awaited<ReturnType<import('./config.js').resolveConfig>>,
546
+ * fsImpl: typeof fsp,
547
+ * }} params
548
+ */
549
+ async function createPruneAction({ destinationRoot, destinationPath, targetPath, entry, config, fsImpl }) {
550
+ const existingContent = await readTextIfExists(targetPath, fsImpl);
551
+ const localHash = existingContent === null ? null : hashContent(existingContent);
552
+ const expectedHash = String(entry.lastKnownLocalContentHash ?? '');
553
+
554
+ if (existingContent !== null && expectedHash && localHash !== expectedHash && !config.force) {
555
+ return {
556
+ kind: 'prune',
557
+ action: 'conflict',
558
+ destinationPath,
559
+ targetPath,
560
+ entry,
561
+ localHash,
562
+ blocked: true,
563
+ reason: 'local changes differ from the last Rubrkit pull',
564
+ };
565
+ }
566
+
567
+ return {
568
+ kind: 'prune',
569
+ action: 'prune',
570
+ destinationPath,
571
+ targetPath,
572
+ entry,
573
+ localHash,
574
+ blocked: false,
575
+ reason: existingContent === null ? 'tracked file is already missing' : 'remote artifact is no longer selected',
576
+ destinationRoot,
577
+ };
578
+ }
579
+
580
+ /**
581
+ * @param {string} targetPath
582
+ * @param {typeof fsp} fsImpl
583
+ */
584
+ async function readTextIfExists(targetPath, fsImpl) {
585
+ try {
586
+ return await fsImpl.readFile(targetPath, 'utf8');
587
+ } catch (error) {
588
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
589
+ return null;
590
+ }
591
+
592
+ throw error;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * @param {{ plan: Awaited<ReturnType<typeof createPullPlan>>, destinationRoot: string, dryRun: boolean, stdout: NodeJS.WritableStream }} params
598
+ */
599
+ function printPlan({ plan, destinationRoot, dryRun, stdout }) {
600
+ stdout.write(`${dryRun ? 'Rubrkit pull dry run' : 'Rubrkit pull plan'}\n`);
601
+ stdout.write(`Destination: ${destinationRoot}\n`);
602
+ stdout.write(`Agent adapter: ${plan.adapterName}\n`);
603
+
604
+ for (const action of plan.actions) {
605
+ const label = action.blocked ? 'protected' : action.action;
606
+ let source;
607
+
608
+ if (action.kind === 'write') {
609
+ const writeAction = /** @type {Record<string, any>} */ (action);
610
+ source = `${writeAction.artifactBundle.name ?? writeAction.artifactBundle.id}/${writeAction.file.path}`;
611
+ } else {
612
+ const pruneAction = /** @type {Record<string, any>} */ (action);
613
+ source = String(pruneAction.entry.artifactPath ?? pruneAction.entry.destinationPath);
614
+ }
615
+
616
+ stdout.write(`- ${label}: ${action.destinationPath} <- ${source}`);
617
+
618
+ if (action.reason) {
619
+ stdout.write(` (${action.reason})`);
620
+ }
621
+
622
+ stdout.write('\n');
623
+ }
624
+
625
+ stdout.write(
626
+ `Summary: ${plan.summary.writes} write/update, ${plan.summary.skips} skipped, ${plan.summary.prunes} pruned, ${plan.summary.conflicts} protected.\n`,
627
+ );
628
+ }
629
+
630
+ /**
631
+ * @param {{
632
+ * plan: Awaited<ReturnType<typeof createPullPlan>>,
633
+ * manifest: Awaited<ReturnType<typeof loadManifest>>,
634
+ * destinationRoot: string,
635
+ * fsImpl: typeof fsp,
636
+ * }} params
637
+ */
638
+ async function applyPlan({ plan, manifest, destinationRoot, fsImpl }) {
639
+ let manifestChanged = false;
640
+ const prunedDestinations = new Set();
641
+
642
+ for (const action of plan.actions) {
643
+ if (action.blocked || action.action === 'skip') {
644
+ continue;
645
+ }
646
+
647
+ if (action.kind === 'prune') {
648
+ const pruneAction = /** @type {Record<string, any>} */ (action);
649
+ await fsImpl.rm(pruneAction.targetPath, { force: true });
650
+ prunedDestinations.add(pruneAction.destinationPath);
651
+ manifestChanged = true;
652
+ continue;
653
+ }
654
+
655
+ const writeAction = /** @type {Record<string, any>} */ (action);
656
+ await fsImpl.mkdir(path.dirname(writeAction.targetPath), { recursive: true });
657
+ await fsImpl.writeFile(writeAction.targetPath, writeAction.content, 'utf8');
658
+ upsertManifestEntry(manifest, {
659
+ artifactBundleId: writeAction.artifactBundle.id,
660
+ artifactBundleName: writeAction.artifactBundle.name ?? null,
661
+ artifactId: writeAction.file.id,
662
+ artifactPath: normalizeArtifactPath(writeAction.file.path),
663
+ artifactType: writeAction.file.artifactType ?? null,
664
+ versionId: writeAction.version.id ?? null,
665
+ versionNumber: writeAction.version.versionNumber ?? writeAction.file.latestVersionNumber ?? null,
666
+ contentHash: writeAction.remoteHash,
667
+ destinationPath: writeAction.destinationPath,
668
+ agent: writeAction.adapterName,
669
+ lastPulledAt: new Date().toISOString(),
670
+ lastKnownLocalContentHash: hashContent(writeAction.content),
671
+ });
672
+ manifestChanged = true;
673
+ }
674
+
675
+ if (prunedDestinations.size > 0) {
676
+ removeManifestEntriesByDestination(manifest, prunedDestinations);
677
+ }
678
+
679
+ if (manifestChanged) {
680
+ await writeManifest(destinationRoot, manifest, fsImpl);
681
+ }
682
+ }