agentel 0.2.0 → 0.2.3

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/sync.js CHANGED
@@ -72,6 +72,56 @@ async function snapshotArchive(options = {}, env = process.env) {
72
72
  return syncArchive({ ...options, snapshot: true, force: true }, env);
73
73
  }
74
74
 
75
+ async function replaceRemoteArchive(options = {}, env = process.env) {
76
+ const cfg = loadConfig(env);
77
+ const mode = normalizeSyncMode(options["sync-mode"] || options.syncMode || options.mode || cfg.sync?.mode || "upload");
78
+ if (mode !== "upload") {
79
+ throw new Error(`remote sync mode "${mode}" is planned but not implemented yet; use upload mode to replace this device namespace`);
80
+ }
81
+ const lock = acquireSyncLock(env);
82
+ try {
83
+ const target = remoteTargetFromFlags(options, cfg, env);
84
+ if (target.protocol === "file") return replaceRemoteDirectory(target, options, env);
85
+ return replaceRemoteS3(target, options, env);
86
+ } finally {
87
+ releaseSyncLock(lock);
88
+ }
89
+ }
90
+
91
+ async function wipeRemoteArchive(options = {}, env = process.env) {
92
+ const lock = acquireSyncLock(env);
93
+ try {
94
+ const cfg = loadConfig(env);
95
+ const target = remoteTargetFromFlags(options, cfg, env);
96
+ if (target.protocol === "file") return wipeRemoteDirectory(target, options, env);
97
+ return wipeRemoteS3(target, options, env);
98
+ } finally {
99
+ releaseSyncLock(lock);
100
+ }
101
+ }
102
+
103
+ async function listRemoteSnapshots(options = {}, env = process.env) {
104
+ const cfg = loadConfig(env);
105
+ const target = remoteTargetFromFlags(options, cfg, env);
106
+ const prefix = remoteSnapshotsPrefix(target);
107
+ if (target.protocol === "file") {
108
+ const root = path.join(target.root, prefix);
109
+ const objects = listDirectoryObjects(root).map((file) => {
110
+ const stat = safeStat(file);
111
+ const relative = path.relative(root, file).split(path.sep).join("/");
112
+ return {
113
+ key: [prefix, relative].filter(Boolean).join("/"),
114
+ size: stat?.size || 0,
115
+ lastModified: stat ? stat.mtime.toISOString() : ""
116
+ };
117
+ });
118
+ return snapshotListSummary(target, prefix, objects);
119
+ }
120
+ const listPrefix = prefix ? `${prefix}/` : "";
121
+ const objects = await listS3Objects(target, listPrefix, options);
122
+ return snapshotListSummary(target, prefix, objects);
123
+ }
124
+
75
125
  async function syncArchiveIfConfigured(env = process.env, options = {}) {
76
126
  const cfg = loadConfig(env);
77
127
  if (!hasRemoteTarget(cfg, env)) return { configured: false, scanned: 0, uploaded: 0, skipped: 0, errors: [] };
@@ -150,12 +200,19 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
150
200
  const objectPrefix = remoteObjectPrefix(target, cfg, options);
151
201
  const localObjects = listLocalArchiveObjects(env, objectPrefix);
152
202
  const state = loadSyncState(env);
153
- const remoteObjects = await listS3Objects(target, objectPrefix);
203
+ reportSyncEvent(options, {
204
+ current: 0,
205
+ total: 0,
206
+ message: "listing remote objects",
207
+ path: objectPrefix
208
+ });
209
+ const remoteObjects = await listS3Objects(target, objectPrefix, options);
154
210
  const remoteByKey = new Map(remoteObjects.map((item) => [item.key, item]));
155
211
  const summary = {
156
212
  target: `${target.type}:${target.bucket}/${objectPrefix}`,
157
213
  mode: "upload",
158
214
  device: deviceLabel(cfg),
215
+ prefix: objectPrefix,
159
216
  snapshot: Boolean(options.snapshot),
160
217
  snapshotName: options.snapshot ? snapshotName(options) : "",
161
218
  scanned: localObjects.length,
@@ -172,7 +229,7 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
172
229
  const stateEntry = state.objects[object.key];
173
230
  const unchangedRemote = remote && Number(remote.size) === object.size;
174
231
  const unchangedState = stateEntry && stateEntry.fingerprint === object.fingerprint && unchangedRemote;
175
- if (unchangedState || (unchangedRemote && !options.force)) {
232
+ if (!options.force && (unchangedState || unchangedRemote)) {
176
233
  summary.skipped++;
177
234
  reportSyncProgress(options, summary, index + 1, localObjects.length, object.key);
178
235
  continue;
@@ -183,7 +240,7 @@ async function syncArchiveToS3(target, options = {}, env = process.env) {
183
240
  continue;
184
241
  }
185
242
  try {
186
- const upload = await putS3ObjectWithRetry(target, object.key, fs.readFileSync(object.file), options);
243
+ const upload = await putS3FileObjectWithRetry(target, object, options);
187
244
  summary.retried += upload.retried;
188
245
  state.objects[object.key] = {
189
246
  fingerprint: object.fingerprint,
@@ -210,6 +267,7 @@ function syncArchiveToDirectory(target, options = {}, env = process.env) {
210
267
  target: pathToFileURL(root).href,
211
268
  mode: "upload",
212
269
  device: deviceLabel(cfg),
270
+ prefix: objectPrefix,
213
271
  snapshot: Boolean(options.snapshot),
214
272
  snapshotName: options.snapshot ? snapshotName(options) : "",
215
273
  scanned: localObjects.length,
@@ -246,6 +304,221 @@ function syncArchiveToDirectory(target, options = {}, env = process.env) {
246
304
  return summary;
247
305
  }
248
306
 
307
+ async function replaceRemoteS3(target, options = {}, env = process.env) {
308
+ const cfg = loadConfig(env);
309
+ const objectPrefix = remoteObjectPrefix(target, cfg, options);
310
+ const localObjects = listLocalArchiveObjects(env, objectPrefix);
311
+ reportSyncEvent(options, {
312
+ current: 0,
313
+ total: 0,
314
+ message: "listing remote objects to replace",
315
+ path: objectPrefix
316
+ });
317
+ const remoteObjects = await listS3Objects(target, objectPrefix, options);
318
+ const summary = replaceSummary(target, cfg, options, objectPrefix, remoteObjects.length, localObjects.length);
319
+ if (options["dry-run"] || options.dryRun) {
320
+ reportSyncEvent(options, {
321
+ current: remoteObjects.length,
322
+ total: remoteObjects.length,
323
+ message: `would delete=${remoteObjects.length} upload=${localObjects.length}`,
324
+ path: objectPrefix
325
+ });
326
+ return summary;
327
+ }
328
+
329
+ reportSyncEvent(options, {
330
+ current: 0,
331
+ total: remoteObjects.length,
332
+ message: "deleting remote objects",
333
+ path: objectPrefix
334
+ });
335
+ for (let index = 0; index < remoteObjects.length; index++) {
336
+ const object = remoteObjects[index];
337
+ try {
338
+ const deleted = await deleteS3ObjectWithRetry(target, object.key, options);
339
+ summary.retried += deleted.retried;
340
+ summary.deleted++;
341
+ } catch (error) {
342
+ summary.errors.push(`${object.key}: ${error.message}`);
343
+ }
344
+ reportSyncEvent(options, {
345
+ current: index + 1,
346
+ total: remoteObjects.length,
347
+ message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
348
+ path: object.key
349
+ });
350
+ }
351
+ if (summary.errors.length) return summary;
352
+
353
+ const uploaded = await syncArchiveToS3(target, { ...options, force: true }, env);
354
+ mergeReplaceUploadSummary(summary, uploaded);
355
+ return summary;
356
+ }
357
+
358
+ async function wipeRemoteS3(target, options = {}, env = process.env) {
359
+ const cfg = loadConfig(env);
360
+ const scope = normalizeWipeScope(options.scope || options["wipe-scope"]);
361
+ const deletePrefix = remoteWipePrefix(target, cfg, scope, options);
362
+ reportSyncEvent(options, {
363
+ current: 0,
364
+ total: 0,
365
+ message: `listing remote objects to wipe (${scope})`,
366
+ path: deletePrefix || target.bucket
367
+ });
368
+ const remoteObjects = await listS3Objects(target, deletePrefix, options);
369
+ const summary = wipeSummary(target, cfg, options, scope, deletePrefix, remoteObjects.length);
370
+ if (options["dry-run"] || options.dryRun) {
371
+ reportSyncEvent(options, {
372
+ current: remoteObjects.length,
373
+ total: remoteObjects.length,
374
+ message: `would delete=${remoteObjects.length}`,
375
+ path: deletePrefix || target.bucket
376
+ });
377
+ return summary;
378
+ }
379
+
380
+ reportSyncEvent(options, {
381
+ current: 0,
382
+ total: remoteObjects.length,
383
+ message: `deleting remote objects (${scope})`,
384
+ path: deletePrefix || target.bucket
385
+ });
386
+ for (let index = 0; index < remoteObjects.length; index++) {
387
+ const object = remoteObjects[index];
388
+ try {
389
+ const deleted = await deleteS3ObjectWithRetry(target, object.key, options);
390
+ summary.retried += deleted.retried;
391
+ summary.deleted++;
392
+ } catch (error) {
393
+ summary.errors.push(`${object.key}: ${error.message}`);
394
+ }
395
+ reportSyncEvent(options, {
396
+ current: index + 1,
397
+ total: remoteObjects.length,
398
+ message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
399
+ path: object.key
400
+ });
401
+ }
402
+ return summary;
403
+ }
404
+
405
+ function replaceRemoteDirectory(target, options = {}, env = process.env) {
406
+ const cfg = loadConfig(env);
407
+ const objectPrefix = remoteObjectPrefix(target, cfg, options);
408
+ const localObjects = listLocalArchiveObjects(env, objectPrefix);
409
+ const root = path.join(target.root, objectPrefix);
410
+ const remoteObjects = listDirectoryObjects(root);
411
+ const summary = replaceSummary(target, cfg, options, objectPrefix, remoteObjects.length, localObjects.length);
412
+ summary.target = pathToFileURL(root).href;
413
+ if (options["dry-run"] || options.dryRun) {
414
+ reportSyncEvent(options, {
415
+ current: remoteObjects.length,
416
+ total: remoteObjects.length,
417
+ message: `would delete=${remoteObjects.length} upload=${localObjects.length}`,
418
+ path: objectPrefix
419
+ });
420
+ return summary;
421
+ }
422
+
423
+ reportSyncEvent(options, {
424
+ current: 0,
425
+ total: remoteObjects.length,
426
+ message: "deleting remote objects",
427
+ path: objectPrefix
428
+ });
429
+ fs.rmSync(root, { recursive: true, force: true });
430
+ summary.deleted = remoteObjects.length;
431
+ reportSyncEvent(options, {
432
+ current: remoteObjects.length,
433
+ total: remoteObjects.length,
434
+ message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
435
+ path: objectPrefix
436
+ });
437
+ const uploaded = syncArchiveToDirectory(target, { ...options, force: true }, env);
438
+ mergeReplaceUploadSummary(summary, uploaded);
439
+ return summary;
440
+ }
441
+
442
+ function wipeRemoteDirectory(target, options = {}, env = process.env) {
443
+ const cfg = loadConfig(env);
444
+ const scope = normalizeWipeScope(options.scope || options["wipe-scope"]);
445
+ const deletePrefix = remoteWipePrefix(target, cfg, scope, options);
446
+ const root = deletePrefix ? path.join(target.root, deletePrefix) : target.root;
447
+ const remoteObjects = listDirectoryObjects(root);
448
+ const summary = wipeSummary(target, cfg, options, scope, deletePrefix, remoteObjects.length);
449
+ summary.target = pathToFileURL(root).href;
450
+ if (options["dry-run"] || options.dryRun) {
451
+ reportSyncEvent(options, {
452
+ current: remoteObjects.length,
453
+ total: remoteObjects.length,
454
+ message: `would delete=${remoteObjects.length}`,
455
+ path: deletePrefix || target.root
456
+ });
457
+ return summary;
458
+ }
459
+
460
+ reportSyncEvent(options, {
461
+ current: 0,
462
+ total: remoteObjects.length,
463
+ message: `deleting remote objects (${scope})`,
464
+ path: deletePrefix || target.root
465
+ });
466
+ fs.rmSync(root, { recursive: true, force: true });
467
+ summary.deleted = remoteObjects.length;
468
+ reportSyncEvent(options, {
469
+ current: remoteObjects.length,
470
+ total: remoteObjects.length,
471
+ message: `deleted=${summary.deleted} errors=${summary.errors.length}`,
472
+ path: deletePrefix || target.root
473
+ });
474
+ return summary;
475
+ }
476
+
477
+ function replaceSummary(target, cfg, options, objectPrefix, deleteCount, uploadCount) {
478
+ return {
479
+ target: target.protocol === "file" ? target.endpoint : `${target.type}:${target.bucket}/${objectPrefix}`,
480
+ mode: "replace",
481
+ scope: "device",
482
+ device: deviceLabel(cfg),
483
+ prefix: objectPrefix,
484
+ dryRun: Boolean(options["dry-run"] || options.dryRun),
485
+ remoteScanned: deleteCount,
486
+ deleted: options["dry-run"] || options.dryRun ? deleteCount : 0,
487
+ scanned: uploadCount,
488
+ uploaded: options["dry-run"] || options.dryRun ? uploadCount : 0,
489
+ skipped: 0,
490
+ retried: 0,
491
+ errors: []
492
+ };
493
+ }
494
+
495
+ function wipeSummary(target, cfg, options, scope, deletePrefix, deleteCount) {
496
+ return {
497
+ target: target.protocol === "file" ? target.endpoint : `${target.type}:${target.bucket}/${deletePrefix || ""}`,
498
+ mode: "wipe",
499
+ scope,
500
+ snapshotName: scope === "snapshot" ? requestedSnapshotName(options) : "",
501
+ device: deviceLabel(cfg),
502
+ prefix: deletePrefix,
503
+ dryRun: Boolean(options["dry-run"] || options.dryRun),
504
+ remoteScanned: deleteCount,
505
+ deleted: options["dry-run"] || options.dryRun ? deleteCount : 0,
506
+ scanned: 0,
507
+ uploaded: 0,
508
+ skipped: 0,
509
+ retried: 0,
510
+ errors: []
511
+ };
512
+ }
513
+
514
+ function mergeReplaceUploadSummary(summary, uploaded) {
515
+ summary.scanned = uploaded.scanned;
516
+ summary.uploaded = uploaded.uploaded;
517
+ summary.skipped = uploaded.skipped;
518
+ summary.retried += uploaded.retried || 0;
519
+ summary.errors.push(...(uploaded.errors || []));
520
+ }
521
+
249
522
  function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
250
523
  const root = archiveRoot(env);
251
524
  const objects = [];
@@ -265,7 +538,17 @@ function listLocalArchiveObjects(env = process.env, prefix = "agentlog") {
265
538
  return objects.sort((a, b) => a.key.localeCompare(b.key));
266
539
  }
267
540
 
268
- async function listS3Objects(target, prefix) {
541
+ function listDirectoryObjects(root) {
542
+ const objects = [];
543
+ walk(root, (file) => {
544
+ const stat = safeStat(file);
545
+ if (!stat || !stat.isFile()) return;
546
+ objects.push(file);
547
+ });
548
+ return objects;
549
+ }
550
+
551
+ async function listS3Objects(target, prefix, options = {}) {
269
552
  const objects = [];
270
553
  let continuationToken = "";
271
554
  for (;;) {
@@ -278,6 +561,12 @@ async function listS3Objects(target, prefix) {
278
561
  if (response.statusCode >= 300) throw new Error(`list remote objects failed (${response.statusCode}): ${response.body.toString("utf8")}`);
279
562
  const parsed = parseListBucketResult(response.body.toString("utf8"));
280
563
  objects.push(...parsed.objects);
564
+ reportSyncEvent(options, {
565
+ current: objects.length,
566
+ total: 0,
567
+ message: `listed=${objects.length} remote`,
568
+ path: prefix
569
+ });
281
570
  if (!parsed.isTruncated || !parsed.nextContinuationToken) break;
282
571
  continuationToken = parsed.nextContinuationToken;
283
572
  }
@@ -297,6 +586,30 @@ async function putS3Object(target, key, body) {
297
586
  return response;
298
587
  }
299
588
 
589
+ async function putS3FileObject(target, object, bodySha256) {
590
+ const response = await s3Request(target, {
591
+ method: "PUT",
592
+ key: object.key,
593
+ bodyFile: object.file,
594
+ bodyLength: object.size,
595
+ bodySha256,
596
+ headers: {
597
+ "content-type": contentTypeForKey(object.key)
598
+ }
599
+ });
600
+ if (response.statusCode >= 300) throw new Error(`upload failed (${response.statusCode}): ${response.body.toString("utf8")}`);
601
+ return response;
602
+ }
603
+
604
+ async function deleteS3Object(target, key) {
605
+ const response = await s3Request(target, {
606
+ method: "DELETE",
607
+ key
608
+ });
609
+ if (response.statusCode >= 300) throw new Error(`delete failed (${response.statusCode}): ${response.body.toString("utf8")}`);
610
+ return response;
611
+ }
612
+
300
613
  async function putS3ObjectWithRetry(target, key, body, options = {}) {
301
614
  const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
302
615
  let lastError;
@@ -313,8 +626,42 @@ async function putS3ObjectWithRetry(target, key, body, options = {}) {
313
626
  throw lastError;
314
627
  }
315
628
 
629
+ async function putS3FileObjectWithRetry(target, object, options = {}) {
630
+ const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
631
+ const bodySha256 = await fileSha256Hex(object.file);
632
+ let lastError;
633
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
634
+ try {
635
+ await putS3FileObject(target, object, bodySha256);
636
+ return { retried: attempt - 1 };
637
+ } catch (error) {
638
+ lastError = error;
639
+ if (attempt >= maxAttempts || !isRetryableUploadError(error)) break;
640
+ await delay(Math.min(250 * 2 ** (attempt - 1), 2000));
641
+ }
642
+ }
643
+ throw lastError;
644
+ }
645
+
646
+ async function deleteS3ObjectWithRetry(target, key, options = {}) {
647
+ const maxAttempts = Math.max(1, Number(options.retries || options["retries"] || 3));
648
+ let lastError;
649
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
650
+ try {
651
+ await deleteS3Object(target, key);
652
+ return { retried: attempt - 1 };
653
+ } catch (error) {
654
+ lastError = error;
655
+ if (attempt >= maxAttempts || !isRetryableUploadError(error)) break;
656
+ await delay(Math.min(250 * 2 ** (attempt - 1), 2000));
657
+ }
658
+ }
659
+ throw lastError;
660
+ }
661
+
316
662
  function s3Request(target, request) {
317
- const body = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || "");
663
+ const hasBodyFile = Boolean(request.bodyFile);
664
+ const body = hasBodyFile ? null : Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || "");
318
665
  const method = request.method || "GET";
319
666
  const key = request.key || "";
320
667
  const query = request.query || {};
@@ -322,11 +669,12 @@ function s3Request(target, request) {
322
669
  const now = new Date();
323
670
  const headers = {
324
671
  host: target.url.host,
325
- "x-amz-content-sha256": sha256Hex(body),
672
+ "x-amz-content-sha256": hasBodyFile ? request.bodySha256 : sha256Hex(body),
326
673
  "x-amz-date": amzDate(now),
327
674
  ...(request.headers || {})
328
675
  };
329
- if (body.length) headers["content-length"] = String(body.length);
676
+ const bodyLength = hasBodyFile ? Number(request.bodyLength || 0) : body.length;
677
+ if (bodyLength) headers["content-length"] = String(bodyLength);
330
678
  headers.authorization = authorizationHeader({ target, method, pathName, query, headers, now });
331
679
 
332
680
  return new Promise((resolve, reject) => {
@@ -347,6 +695,14 @@ function s3Request(target, request) {
347
695
  }
348
696
  );
349
697
  req.on("error", reject);
698
+ if (hasBodyFile) {
699
+ const stream = fs.createReadStream(request.bodyFile);
700
+ stream.on("error", (error) => {
701
+ req.destroy(error);
702
+ });
703
+ stream.pipe(req);
704
+ return;
705
+ }
350
706
  if (body.length) req.write(body);
351
707
  req.end();
352
708
  });
@@ -434,10 +790,7 @@ function decodeXml(value) {
434
790
  }
435
791
 
436
792
  function reportSyncProgress(options, summary, current, total, key = "") {
437
- if (typeof options.onProgress !== "function") return;
438
- options.onProgress({
439
- kind: "sync",
440
- provider: "Remote Sync",
793
+ reportSyncEvent(options, {
441
794
  current,
442
795
  total,
443
796
  uploaded: summary.uploaded,
@@ -449,6 +802,22 @@ function reportSyncProgress(options, summary, current, total, key = "") {
449
802
  });
450
803
  }
451
804
 
805
+ function reportSyncEvent(options, event) {
806
+ if (typeof options.onProgress !== "function") return;
807
+ options.onProgress({
808
+ kind: "sync",
809
+ provider: "Remote Sync",
810
+ current: event.current || 0,
811
+ total: event.total || 0,
812
+ uploaded: event.uploaded || 0,
813
+ skipped: event.skipped || 0,
814
+ retried: event.retried || 0,
815
+ errors: event.errors || 0,
816
+ message: event.message || "working",
817
+ path: event.path || ""
818
+ });
819
+ }
820
+
452
821
  function loadSyncState(env = process.env) {
453
822
  return readJson(path.join(paths(env).state, "sync.json"), { objects: {} });
454
823
  }
@@ -582,6 +951,50 @@ function remoteObjectPrefix(target, cfg = {}, options = {}) {
582
951
  return [prefix, "devices", deviceSlug(cfg)].filter(Boolean).join("/");
583
952
  }
584
953
 
954
+ function remoteSnapshotsPrefix(target) {
955
+ return [normalizePrefix(target.prefix || "agentlog"), "snapshots"].filter(Boolean).join("/");
956
+ }
957
+
958
+ function remoteWipePrefix(target, cfg = {}, scope = "device", options = {}) {
959
+ if (scope === "bucket") return "";
960
+ const prefix = normalizePrefix(target.prefix || "agentlog");
961
+ if (scope === "prefix") return prefix;
962
+ if (scope === "snapshots") return `${remoteSnapshotsPrefix(target)}/`;
963
+ if (scope === "snapshot") return `${remoteSnapshotsPrefix(target)}/${requestedSnapshotName(options)}/`;
964
+ return remoteObjectPrefix(target, cfg, {});
965
+ }
966
+
967
+ function normalizeWipeScope(scope) {
968
+ const value = String(scope || "device").trim().toLowerCase();
969
+ const normalized = {
970
+ current: "device",
971
+ "current-device": "device",
972
+ device: "device",
973
+ namespace: "device",
974
+ prefix: "prefix",
975
+ archive: "prefix",
976
+ agentlog: "prefix",
977
+ snapshot: "snapshot",
978
+ "one-snapshot": "snapshot",
979
+ "single-snapshot": "snapshot",
980
+ snapshots: "snapshots",
981
+ "all-snapshots": "snapshots",
982
+ "snapshot-prefix": "snapshots",
983
+ all: "bucket",
984
+ bucket: "bucket"
985
+ }[value] || value;
986
+ if (!["device", "snapshot", "snapshots", "prefix", "bucket"].includes(normalized)) {
987
+ throw new Error("sync wipe --scope must be device, snapshot, snapshots, prefix, or bucket");
988
+ }
989
+ return normalized;
990
+ }
991
+
992
+ function requestedSnapshotName(options = {}) {
993
+ const explicit = value(options["snapshot-name"] || options.snapshotName || options.name || options.snapshot);
994
+ if (!explicit) throw new Error("sync wipe --scope snapshot requires --snapshot-name <name>");
995
+ return slugifyRemoteSegment(explicit);
996
+ }
997
+
585
998
  function snapshotName(options = {}) {
586
999
  const explicit = value(options["snapshot-name"] || options.snapshotName || options.name);
587
1000
  if (explicit) return slugifyRemoteSegment(explicit);
@@ -591,6 +1004,27 @@ function snapshotName(options = {}) {
591
1004
  return options._snapshotName;
592
1005
  }
593
1006
 
1007
+ function snapshotListSummary(target, prefix, objects = []) {
1008
+ const snapshots = new Map();
1009
+ for (const object of objects) {
1010
+ const relative = stripPrefix(object.key, prefix).replace(/^\/+/, "");
1011
+ const name = relative.split("/").filter(Boolean)[0];
1012
+ if (!name) continue;
1013
+ const current = snapshots.get(name) || { name, objects: 0, bytes: 0, lastModified: "" };
1014
+ current.objects++;
1015
+ current.bytes += Number(object.size || 0);
1016
+ if (object.lastModified && (!current.lastModified || String(object.lastModified) > current.lastModified)) {
1017
+ current.lastModified = String(object.lastModified);
1018
+ }
1019
+ snapshots.set(name, current);
1020
+ }
1021
+ return {
1022
+ target: target.protocol === "file" ? pathToFileURL(path.join(target.root, prefix)).href : `${target.type}:${target.bucket}/${prefix}`,
1023
+ prefix,
1024
+ snapshots: Array.from(snapshots.values()).sort((a, b) => b.name.localeCompare(a.name))
1025
+ };
1026
+ }
1027
+
594
1028
  function deviceSlug(cfg = {}) {
595
1029
  return slugifyRemoteSegment(cfg.device?.slug || cfg.device?.name || cfg.device?.id || "this-device");
596
1030
  }
@@ -652,6 +1086,16 @@ function sha256Hex(value) {
652
1086
  return crypto.createHash("sha256").update(value).digest("hex");
653
1087
  }
654
1088
 
1089
+ function fileSha256Hex(file) {
1090
+ return new Promise((resolve, reject) => {
1091
+ const hash = crypto.createHash("sha256");
1092
+ const stream = fs.createReadStream(file);
1093
+ stream.on("data", (chunk) => hash.update(chunk));
1094
+ stream.on("error", reject);
1095
+ stream.on("end", () => resolve(hash.digest("hex")));
1096
+ });
1097
+ }
1098
+
655
1099
  function hmac(key, value) {
656
1100
  return crypto.createHmac("sha256", key).update(value).digest();
657
1101
  }
@@ -668,10 +1112,13 @@ module.exports = {
668
1112
  configureRemoteFromFlags,
669
1113
  hasRemoteTarget,
670
1114
  listLocalArchiveObjects,
1115
+ listRemoteSnapshots,
671
1116
  parseListBucketResult,
672
1117
  remoteTargetFromFlags,
673
1118
  remoteObjectPrefix,
1119
+ replaceRemoteArchive,
674
1120
  snapshotArchive,
675
1121
  syncArchive,
676
- syncArchiveIfConfigured
1122
+ syncArchiveIfConfigured,
1123
+ wipeRemoteArchive
677
1124
  };