@indigoai-us/hq-cloud 5.14.0 → 5.16.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 +11 -13
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +19 -57
- 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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +44 -0
- package/dist/personal-vault.d.ts.map +1 -0
- package/dist/personal-vault.js +62 -0
- package/dist/personal-vault.js.map +1 -0
- 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 +22 -57
- 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/index.ts +7 -1
- package/src/personal-vault.ts +63 -0
- package/src/s3.ts +2 -1
package/src/cli/sync.test.ts
CHANGED
|
@@ -448,6 +448,249 @@ describe("sync", () => {
|
|
|
448
448
|
});
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
+
// ── New-file detection (US-001) ──────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
it("classifies all files as new on first sync (clean directory)", async () => {
|
|
454
|
+
const result = await sync({
|
|
455
|
+
company: "acme",
|
|
456
|
+
vaultConfig: mockConfig,
|
|
457
|
+
hqRoot: tmpDir,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(result.newFilesCount).toBe(2);
|
|
461
|
+
expect(result.newFiles).toEqual(
|
|
462
|
+
expect.arrayContaining([
|
|
463
|
+
{ path: "docs/handoff.md", bytes: 42 },
|
|
464
|
+
{ path: "knowledge/readme.md", bytes: 100 },
|
|
465
|
+
]),
|
|
466
|
+
);
|
|
467
|
+
expect(result.newFiles).toHaveLength(2);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("classifies only genuinely new files when some exist locally", async () => {
|
|
471
|
+
// Pre-create one file + journal entry so it's treated as an update,
|
|
472
|
+
// while the second file is genuinely new.
|
|
473
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
474
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
475
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "mock file content");
|
|
476
|
+
|
|
477
|
+
// Journal entry with matching remoteEtag but stale hash to force download
|
|
478
|
+
// (remote-only changed scenario).
|
|
479
|
+
fs.writeFileSync(
|
|
480
|
+
journalPath,
|
|
481
|
+
JSON.stringify({
|
|
482
|
+
version: "1",
|
|
483
|
+
lastSync: new Date().toISOString(),
|
|
484
|
+
files: {
|
|
485
|
+
"docs/handoff.md": {
|
|
486
|
+
hash: "old-hash-before-remote-update",
|
|
487
|
+
size: 20,
|
|
488
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
489
|
+
direction: "down",
|
|
490
|
+
// Stale etag — forces remoteChanged=true, localChanged=false
|
|
491
|
+
// (local hash matches what's on disk because we didn't edit it,
|
|
492
|
+
// but wait — journal hash != current disk hash because we wrote
|
|
493
|
+
// "mock file content" but stored "old-hash-before-remote-update".
|
|
494
|
+
// To avoid conflict we need localChanged=false. Let's compute
|
|
495
|
+
// the real hash and use it.)
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Re-read the journal to compute the actual hash of the file we wrote,
|
|
502
|
+
// then update the journal so localChanged is false.
|
|
503
|
+
const { hashFile: realHashFile } = await import("../journal.js");
|
|
504
|
+
const actualHash = realHashFile(path.join(companyDocs, "handoff.md"));
|
|
505
|
+
fs.writeFileSync(
|
|
506
|
+
journalPath,
|
|
507
|
+
JSON.stringify({
|
|
508
|
+
version: "1",
|
|
509
|
+
lastSync: new Date().toISOString(),
|
|
510
|
+
files: {
|
|
511
|
+
"docs/handoff.md": {
|
|
512
|
+
hash: actualHash,
|
|
513
|
+
size: 17,
|
|
514
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
515
|
+
direction: "down",
|
|
516
|
+
remoteEtag: "stale-etag-to-force-download",
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const result = await sync({
|
|
523
|
+
company: "acme",
|
|
524
|
+
onConflict: "overwrite",
|
|
525
|
+
vaultConfig: mockConfig,
|
|
526
|
+
hqRoot: tmpDir,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// handoff.md existed locally → isNew: false (update)
|
|
530
|
+
// readme.md did not exist → isNew: true (new)
|
|
531
|
+
expect(result.newFilesCount).toBe(1);
|
|
532
|
+
expect(result.newFiles).toEqual([
|
|
533
|
+
{ path: "knowledge/readme.md", bytes: 100 },
|
|
534
|
+
]);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("returns empty newFiles when all files already exist", async () => {
|
|
538
|
+
// First sync downloads everything
|
|
539
|
+
await sync({
|
|
540
|
+
company: "acme",
|
|
541
|
+
vaultConfig: mockConfig,
|
|
542
|
+
hqRoot: tmpDir,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Second sync — files are now local and journal is up to date.
|
|
546
|
+
// Nothing downloads, newFiles should be empty.
|
|
547
|
+
const result = await sync({
|
|
548
|
+
company: "acme",
|
|
549
|
+
vaultConfig: mockConfig,
|
|
550
|
+
hqRoot: tmpDir,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
expect(result.newFilesCount).toBe(0);
|
|
554
|
+
expect(result.newFiles).toEqual([]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── New-files event with attribution (US-002) ───────────────────────
|
|
558
|
+
|
|
559
|
+
it("emits a new-files event with attribution from HeadObject metadata", async () => {
|
|
560
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
561
|
+
if (key === "docs/handoff.md") {
|
|
562
|
+
return {
|
|
563
|
+
lastModified: new Date(),
|
|
564
|
+
etag: '"abc123"',
|
|
565
|
+
size: 42,
|
|
566
|
+
metadata: { "created-by": "alice@example.com" },
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (key === "knowledge/readme.md") {
|
|
570
|
+
return {
|
|
571
|
+
lastModified: new Date(),
|
|
572
|
+
etag: '"def456"',
|
|
573
|
+
size: 100,
|
|
574
|
+
metadata: { "created-by": "bob@example.com" },
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const newFilesEvents: Array<{
|
|
581
|
+
type: string;
|
|
582
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>;
|
|
583
|
+
}> = [];
|
|
584
|
+
await sync({
|
|
585
|
+
company: "acme",
|
|
586
|
+
vaultConfig: mockConfig,
|
|
587
|
+
hqRoot: tmpDir,
|
|
588
|
+
onEvent: (e) => {
|
|
589
|
+
if (e.type === "new-files") newFilesEvents.push(e);
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(newFilesEvents).toHaveLength(1);
|
|
594
|
+
expect(newFilesEvents[0].files).toHaveLength(2);
|
|
595
|
+
expect(newFilesEvents[0].files).toEqual(
|
|
596
|
+
expect.arrayContaining([
|
|
597
|
+
{ path: "docs/handoff.md", bytes: 42, addedBy: "alice@example.com" },
|
|
598
|
+
{ path: "knowledge/readme.md", bytes: 100, addedBy: "bob@example.com" },
|
|
599
|
+
]),
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("sets addedBy to null when HeadObject fails (best-effort)", async () => {
|
|
604
|
+
vi.mocked(s3Module.headRemoteFile).mockRejectedValue(new Error("S3 transient error"));
|
|
605
|
+
|
|
606
|
+
const newFilesEvents: Array<{
|
|
607
|
+
type: string;
|
|
608
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>;
|
|
609
|
+
}> = [];
|
|
610
|
+
await sync({
|
|
611
|
+
company: "acme",
|
|
612
|
+
vaultConfig: mockConfig,
|
|
613
|
+
hqRoot: tmpDir,
|
|
614
|
+
onEvent: (e) => {
|
|
615
|
+
if (e.type === "new-files") newFilesEvents.push(e);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(newFilesEvents).toHaveLength(1);
|
|
620
|
+
// Both files should still appear, just with null addedBy
|
|
621
|
+
expect(newFilesEvents[0].files).toHaveLength(2);
|
|
622
|
+
for (const f of newFilesEvents[0].files) {
|
|
623
|
+
expect(f.addedBy).toBeNull();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("sets addedBy to null when metadata has no created-by key", async () => {
|
|
628
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValue({
|
|
629
|
+
lastModified: new Date(),
|
|
630
|
+
etag: '"abc"',
|
|
631
|
+
size: 10,
|
|
632
|
+
metadata: {},
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const newFilesEvents: Array<{
|
|
636
|
+
type: string;
|
|
637
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>;
|
|
638
|
+
}> = [];
|
|
639
|
+
await sync({
|
|
640
|
+
company: "acme",
|
|
641
|
+
vaultConfig: mockConfig,
|
|
642
|
+
hqRoot: tmpDir,
|
|
643
|
+
onEvent: (e) => {
|
|
644
|
+
if (e.type === "new-files") newFilesEvents.push(e);
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
expect(newFilesEvents).toHaveLength(1);
|
|
649
|
+
for (const f of newFilesEvents[0].files) {
|
|
650
|
+
expect(f.addedBy).toBeNull();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("emits new-files event with empty array when no new files exist", async () => {
|
|
655
|
+
// First sync downloads everything
|
|
656
|
+
await sync({
|
|
657
|
+
company: "acme",
|
|
658
|
+
vaultConfig: mockConfig,
|
|
659
|
+
hqRoot: tmpDir,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Second sync — files already exist locally
|
|
663
|
+
const newFilesEvents: Array<{
|
|
664
|
+
type: string;
|
|
665
|
+
files: Array<{ path: string; bytes: number; addedBy: string | null }>;
|
|
666
|
+
}> = [];
|
|
667
|
+
await sync({
|
|
668
|
+
company: "acme",
|
|
669
|
+
vaultConfig: mockConfig,
|
|
670
|
+
hqRoot: tmpDir,
|
|
671
|
+
onEvent: (e) => {
|
|
672
|
+
if (e.type === "new-files") newFilesEvents.push(e);
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
expect(newFilesEvents).toHaveLength(1);
|
|
677
|
+
expect(newFilesEvents[0].files).toEqual([]);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("new-files event is emitted after all progress events", async () => {
|
|
681
|
+
const eventTypes: string[] = [];
|
|
682
|
+
await sync({
|
|
683
|
+
company: "acme",
|
|
684
|
+
vaultConfig: mockConfig,
|
|
685
|
+
hqRoot: tmpDir,
|
|
686
|
+
onEvent: (e) => eventTypes.push(e.type),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const lastProgressIdx = eventTypes.lastIndexOf("progress");
|
|
690
|
+
const newFilesIdx = eventTypes.indexOf("new-files");
|
|
691
|
+
expect(newFilesIdx).toBeGreaterThan(lastProgressIdx);
|
|
692
|
+
});
|
|
693
|
+
|
|
451
694
|
it("plan event counts a 3-way conflict separately from downloads", async () => {
|
|
452
695
|
// Local edit + journal-tracked + remote ETag drifted → conflict.
|
|
453
696
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
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/index.ts
CHANGED
|
@@ -50,8 +50,14 @@ export {
|
|
|
50
50
|
} from "./cognito-auth.js";
|
|
51
51
|
export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
|
|
52
52
|
|
|
53
|
+
// Personal-vault scope helpers — shared between hq-sync-runner and `hq sync`
|
|
54
|
+
export {
|
|
55
|
+
PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
|
|
56
|
+
computePersonalVaultPaths,
|
|
57
|
+
} from "./personal-vault.js";
|
|
58
|
+
|
|
53
59
|
// VaultClient SDK (VLT-7)
|
|
54
|
-
export { VaultClient } from "./vault-client.js";
|
|
60
|
+
export { VaultClient, pickCanonicalPersonEntity } from "./vault-client.js";
|
|
55
61
|
export {
|
|
56
62
|
VaultClientError,
|
|
57
63
|
VaultAuthError,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal-vault scope helpers — shared between the menubar runner
|
|
3
|
+
* (`hq-sync-runner`) and the `hq sync` CLI so every composer of a personal
|
|
4
|
+
* push uses the same exclusion list.
|
|
5
|
+
*
|
|
6
|
+
* The exclusion list mirrors the Rust constant of the same name in
|
|
7
|
+
* `hq-sync/src-tauri/src/commands/personal.rs` so the Tauri menubar's
|
|
8
|
+
* first-push and this Node engine's steady-state push enforce identical
|
|
9
|
+
* scope. Every other top-level entry under hq_root (e.g. `.claude/`,
|
|
10
|
+
* `knowledge/`, `modules/`, `README.md`, `.codex/`, `core/`, `data/`,
|
|
11
|
+
* `personal/`) is included, subject to the usual `.hqignore` filter.
|
|
12
|
+
*
|
|
13
|
+
* Excluded entries (and why):
|
|
14
|
+
* - `.git`: a git repo's own metadata is hostile to multi-machine
|
|
15
|
+
* sync; .gitignore alone doesn't cover `.git/` because it's the repo
|
|
16
|
+
* itself, not a tracked path.
|
|
17
|
+
* - `companies/`: synced separately by the runner's per-membership
|
|
18
|
+
* fanout; do not double-write into the personal vault.
|
|
19
|
+
* - `repos/`, `workspace/`: per user directive — heavy local-only
|
|
20
|
+
* content (cloned remotes, session threads) that has no business in
|
|
21
|
+
* the personal vault.
|
|
22
|
+
*
|
|
23
|
+
* Note: `core/`, `data/`, and `personal/` were previously excluded but are
|
|
24
|
+
* INCLUDED as of user directive 2026-05-13. `core/` ships the hq-core
|
|
25
|
+
* scaffold — policies/, settings/, skills/, workers/, plus the rules
|
|
26
|
+
* manifest at core/core.yaml. `data/` and `personal/` carry per-user data,
|
|
27
|
+
* policies, hooks, and skills that follow the user across machines. The
|
|
28
|
+
* hq-root identity marker `core.yaml` (at hq_root, distinct from
|
|
29
|
+
* `core/core.yaml`) is filtered separately by the root-anchored
|
|
30
|
+
* `/core.yaml` DEFAULT_IGNORES rule.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from "fs";
|
|
34
|
+
import * as path from "path";
|
|
35
|
+
|
|
36
|
+
export const PERSONAL_VAULT_EXCLUDED_TOP_LEVEL: readonly string[] = [
|
|
37
|
+
".git",
|
|
38
|
+
"companies",
|
|
39
|
+
"repos",
|
|
40
|
+
"workspace",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute absolute paths to share for the personal vault: every top-level
|
|
45
|
+
* entry under `hqRoot` whose basename is NOT in
|
|
46
|
+
* `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL`. Mirrors the Rust
|
|
47
|
+
* `is_personal_vault_path` predicate (just hoisted to the top-level step).
|
|
48
|
+
* Order is whatever `fs.readdirSync` returns — share() doesn't care, and
|
|
49
|
+
* the per-file walk inside share() handles recursion uniformly. Missing
|
|
50
|
+
* hqRoot returns []; callers treat that as "no personal content to push"
|
|
51
|
+
* rather than a hard error.
|
|
52
|
+
*/
|
|
53
|
+
export function computePersonalVaultPaths(hqRoot: string): string[] {
|
|
54
|
+
let entries: string[];
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(hqRoot);
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return entries
|
|
61
|
+
.filter((name) => !PERSONAL_VAULT_EXCLUDED_TOP_LEVEL.includes(name))
|
|
62
|
+
.map((name) => path.join(hqRoot, name));
|
|
63
|
+
}
|
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") {
|