@indigoai-us/hq-cloud 5.14.0 → 5.15.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/dist/bin/sync-runner.d.ts +9 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +13 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +32 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.test.js +1 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +18 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +59 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +199 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/s3.d.ts +1 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +1 -0
- package/dist/s3.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +38 -0
- package/src/bin/sync-runner.ts +13 -0
- package/src/cli/share.test.ts +1 -1
- package/src/cli/sync.test.ts +243 -0
- package/src/cli/sync.ts +79 -4
- package/src/s3.ts +2 -1
package/src/cli/sync.ts
CHANGED
|
@@ -9,7 +9,7 @@ import * as fs from "fs";
|
|
|
9
9
|
import * as path from "path";
|
|
10
10
|
import type { VaultServiceConfig, SyncJournal } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
|
-
import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
12
|
+
import { downloadFile, listRemoteFiles, headRemoteFile } from "../s3.js";
|
|
13
13
|
import type { RemoteFile } from "../s3.js";
|
|
14
14
|
import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
|
|
15
15
|
import { createIgnoreFilter } from "../ignore.js";
|
|
@@ -80,6 +80,10 @@ export type SyncProgressEvent =
|
|
|
80
80
|
path: string;
|
|
81
81
|
direction: "pull" | "push";
|
|
82
82
|
resolution: ConflictResolution;
|
|
83
|
+
}
|
|
84
|
+
| {
|
|
85
|
+
type: "new-files";
|
|
86
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>;
|
|
83
87
|
};
|
|
84
88
|
|
|
85
89
|
export interface SyncOptions {
|
|
@@ -125,6 +129,14 @@ export interface SyncResult {
|
|
|
125
129
|
*/
|
|
126
130
|
conflictPaths: string[];
|
|
127
131
|
aborted: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Files classified as "new" during pull — i.e. the remote file had no
|
|
134
|
+
* local counterpart at classification time. Additive field; empty array
|
|
135
|
+
* when no new files were detected or on push-only syncs.
|
|
136
|
+
*/
|
|
137
|
+
newFiles: Array<{ path: string; bytes: number }>;
|
|
138
|
+
/** Convenience count: `newFiles.length`. */
|
|
139
|
+
newFilesCount: number;
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
/**
|
|
@@ -276,6 +288,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
276
288
|
}
|
|
277
289
|
|
|
278
290
|
if (resolution === "abort") {
|
|
291
|
+
emit({ type: "new-files", files: [] });
|
|
279
292
|
writeJournal(journalSlug, journal);
|
|
280
293
|
return {
|
|
281
294
|
filesDownloaded,
|
|
@@ -284,6 +297,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
284
297
|
conflicts,
|
|
285
298
|
conflictPaths,
|
|
286
299
|
aborted: true,
|
|
300
|
+
newFiles: plan.newFiles,
|
|
301
|
+
newFilesCount: plan.newFilesCount,
|
|
287
302
|
};
|
|
288
303
|
}
|
|
289
304
|
if (resolution === "keep" || resolution === "skip") {
|
|
@@ -352,6 +367,41 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
352
367
|
}
|
|
353
368
|
}
|
|
354
369
|
|
|
370
|
+
// ── New-files attribution (US-002) ─────────────────────────────────────
|
|
371
|
+
// Enrich plan.newFiles with `addedBy` from S3 user metadata. HeadObject
|
|
372
|
+
// calls are best-effort and capped at 5 concurrent to avoid hammering S3.
|
|
373
|
+
const enrichedNewFiles: Array<{ path: string; bytes: number; addedBy: string | null }> = [];
|
|
374
|
+
const HEAD_CONCURRENCY = 5;
|
|
375
|
+
for (let i = 0; i < plan.newFiles.length; i += HEAD_CONCURRENCY) {
|
|
376
|
+
const batch = plan.newFiles.slice(i, i + HEAD_CONCURRENCY);
|
|
377
|
+
const results = await Promise.all(
|
|
378
|
+
batch.map(async (nf) => {
|
|
379
|
+
let addedBy: string | null = null;
|
|
380
|
+
try {
|
|
381
|
+
const head = await headRemoteFile(ctx, nf.path);
|
|
382
|
+
if (head?.metadata?.["created-by"]) {
|
|
383
|
+
addedBy = head.metadata["created-by"];
|
|
384
|
+
}
|
|
385
|
+
} catch (headErr) {
|
|
386
|
+
// Best-effort: log to console (Sentry captures via global handler)
|
|
387
|
+
// and fall through with addedBy = null.
|
|
388
|
+
try {
|
|
389
|
+
console.error(
|
|
390
|
+
`[hq-sync] HeadObject failed for ${nf.path}: ${
|
|
391
|
+
headErr instanceof Error ? headErr.message : String(headErr)
|
|
392
|
+
}`,
|
|
393
|
+
);
|
|
394
|
+
} catch {
|
|
395
|
+
// Swallow — logging must never break sync.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { path: nf.path, bytes: nf.bytes, addedBy };
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
enrichedNewFiles.push(...results);
|
|
402
|
+
}
|
|
403
|
+
emit({ type: "new-files", files: enrichedNewFiles });
|
|
404
|
+
|
|
355
405
|
// Stamp lastSync on every successful run so the menubar's "Last sync · X ago"
|
|
356
406
|
// ticks even when nothing transferred. updateEntry only fires on actual
|
|
357
407
|
// downloads; without this, a no-op sync leaves lastSync at the time of the
|
|
@@ -366,6 +416,8 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
366
416
|
conflicts,
|
|
367
417
|
conflictPaths,
|
|
368
418
|
aborted: false,
|
|
419
|
+
newFiles: plan.newFiles,
|
|
420
|
+
newFilesCount: plan.newFilesCount,
|
|
369
421
|
};
|
|
370
422
|
}
|
|
371
423
|
|
|
@@ -409,7 +461,7 @@ function hasRemoteChanged(
|
|
|
409
461
|
* executor can hand it to `resolveConflict` without re-hashing.
|
|
410
462
|
*/
|
|
411
463
|
type PullPlanItem =
|
|
412
|
-
| { action: "download"; remoteFile: RemoteFile; localPath: string }
|
|
464
|
+
| { action: "download"; remoteFile: RemoteFile; localPath: string; isNew: boolean }
|
|
413
465
|
| { action: "skip-ignored"; remoteFile: RemoteFile; localPath: string }
|
|
414
466
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
415
467
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
@@ -427,6 +479,9 @@ interface PullPlan {
|
|
|
427
479
|
bytesToDownload: number;
|
|
428
480
|
filesToSkip: number;
|
|
429
481
|
filesToConflict: number;
|
|
482
|
+
/** Files classified as new (no local counterpart at classification time). */
|
|
483
|
+
newFiles: Array<{ path: string; bytes: number }>;
|
|
484
|
+
newFilesCount: number;
|
|
430
485
|
}
|
|
431
486
|
|
|
432
487
|
/**
|
|
@@ -463,8 +518,9 @@ function computePullPlan(
|
|
|
463
518
|
}
|
|
464
519
|
|
|
465
520
|
const journalEntry = getEntry(journal, remoteFile.key);
|
|
521
|
+
const localExists = fs.existsSync(localPath);
|
|
466
522
|
|
|
467
|
-
if (
|
|
523
|
+
if (localExists) {
|
|
468
524
|
const localHash = hashFile(localPath);
|
|
469
525
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
470
526
|
const remoteChanged =
|
|
@@ -493,17 +549,21 @@ function computePullPlan(
|
|
|
493
549
|
// No journal entry, or remote-only changed → fall through to download.
|
|
494
550
|
}
|
|
495
551
|
|
|
496
|
-
items.push({ action: "download", remoteFile, localPath });
|
|
552
|
+
items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
|
|
497
553
|
}
|
|
498
554
|
|
|
499
555
|
let filesToDownload = 0;
|
|
500
556
|
let bytesToDownload = 0;
|
|
501
557
|
let filesToSkip = 0;
|
|
502
558
|
let filesToConflict = 0;
|
|
559
|
+
const newFiles: Array<{ path: string; bytes: number }> = [];
|
|
503
560
|
for (const item of items) {
|
|
504
561
|
if (item.action === "download") {
|
|
505
562
|
filesToDownload++;
|
|
506
563
|
bytesToDownload += item.remoteFile.size;
|
|
564
|
+
if (item.isNew) {
|
|
565
|
+
newFiles.push({ path: item.remoteFile.key, bytes: item.remoteFile.size });
|
|
566
|
+
}
|
|
507
567
|
} else if (item.action === "conflict") {
|
|
508
568
|
filesToConflict++;
|
|
509
569
|
} else {
|
|
@@ -517,6 +577,8 @@ function computePullPlan(
|
|
|
517
577
|
bytesToDownload,
|
|
518
578
|
filesToSkip,
|
|
519
579
|
filesToConflict,
|
|
580
|
+
newFiles,
|
|
581
|
+
newFilesCount: newFiles.length,
|
|
520
582
|
};
|
|
521
583
|
}
|
|
522
584
|
|
|
@@ -567,5 +629,18 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
567
629
|
console.error(
|
|
568
630
|
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
569
631
|
);
|
|
632
|
+
} else if (event.type === "new-files") {
|
|
633
|
+
if (event.files.length > 0) {
|
|
634
|
+
console.log(`${event.files.length} new file${event.files.length === 1 ? "" : "s"}`);
|
|
635
|
+
const MAX_SHOWN = 20;
|
|
636
|
+
const shown = event.files.slice(0, MAX_SHOWN);
|
|
637
|
+
for (const f of shown) {
|
|
638
|
+
const who = f.addedBy ? ` (added by ${f.addedBy})` : "";
|
|
639
|
+
console.log(` + ${f.path}${who}`);
|
|
640
|
+
}
|
|
641
|
+
if (event.files.length > MAX_SHOWN) {
|
|
642
|
+
console.log(` ... and ${event.files.length - MAX_SHOWN} more`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
570
645
|
}
|
|
571
646
|
}
|
package/src/s3.ts
CHANGED
|
@@ -214,7 +214,7 @@ export async function deleteRemoteFile(
|
|
|
214
214
|
export async function headRemoteFile(
|
|
215
215
|
ctx: EntityContext,
|
|
216
216
|
key: string,
|
|
217
|
-
): Promise<{ lastModified: Date; etag: string; size: number } | null> {
|
|
217
|
+
): Promise<{ lastModified: Date; etag: string; size: number; metadata?: Record<string, string> } | null> {
|
|
218
218
|
const client = buildClient(ctx);
|
|
219
219
|
try {
|
|
220
220
|
const response = await client.send(
|
|
@@ -227,6 +227,7 @@ export async function headRemoteFile(
|
|
|
227
227
|
lastModified: response.LastModified || new Date(),
|
|
228
228
|
etag: response.ETag || "",
|
|
229
229
|
size: response.ContentLength || 0,
|
|
230
|
+
metadata: response.Metadata,
|
|
230
231
|
};
|
|
231
232
|
} catch (err: unknown) {
|
|
232
233
|
if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
|