@indigoai-us/hq-cloud 5.29.0 → 5.31.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.map +1 -1
- package/dist/bin/sync-runner.js +13 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +30 -7
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync.d.ts +9 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +3 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +55 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +43 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +52 -0
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +78 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/s3.d.ts +12 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +11 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +24 -0
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +34 -7
- package/src/bin/sync-runner.ts +17 -1
- package/src/cli/sync.test.ts +61 -0
- package/src/cli/sync.ts +12 -1
- package/src/journal.test.ts +101 -0
- package/src/journal.ts +52 -0
- package/src/s3.test.ts +30 -0
- package/src/s3.ts +12 -2
package/src/journal.test.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
gcTombstones,
|
|
25
25
|
generatePullId,
|
|
26
26
|
TOMBSTONE_TTL_MS,
|
|
27
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
28
|
+
migratePersonalVaultJournal,
|
|
27
29
|
} from "./journal.js";
|
|
28
30
|
import type { SyncJournal, PullRecord } from "./types.js";
|
|
29
31
|
|
|
@@ -422,4 +424,103 @@ describe("journal", () => {
|
|
|
422
424
|
expect(j.files["bogus.md"]).toBeDefined();
|
|
423
425
|
});
|
|
424
426
|
});
|
|
427
|
+
|
|
428
|
+
// ── Personal-vault journal-slug collision fix ──────────────────────────────
|
|
429
|
+
//
|
|
430
|
+
// Root cause: the --companies fanout's personal-vault slot journaled under
|
|
431
|
+
// the literal slug "personal", colliding with the companies/personal
|
|
432
|
+
// company (entity slug "personal"). The two targets have different sync
|
|
433
|
+
// roots, so the company's whole-tree delete-plan walked the shared
|
|
434
|
+
// sync-journal.personal.json, resolved the vault's hq-root keys against
|
|
435
|
+
// hqRoot/companies/personal (where they don't exist), tombstoned them as
|
|
436
|
+
// "remote already 404", and dropped them — only for the vault slot to
|
|
437
|
+
// re-upload them next cycle (~190 .claude/skills/* files of churn/sync).
|
|
438
|
+
describe("PERSONAL_VAULT_JOURNAL_SLUG", () => {
|
|
439
|
+
it("is decoupled from the 'personal' company slug", () => {
|
|
440
|
+
expect(PERSONAL_VAULT_JOURNAL_SLUG).not.toBe("personal");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("survives sanitizeSlug unchanged (no path/dot/empty mangling)", () => {
|
|
444
|
+
// getJournalPath runs the slug through sanitizeSlug; the sentinel must
|
|
445
|
+
// pass through verbatim (it is all [a-zA-Z0-9_-] and not all [_-]).
|
|
446
|
+
expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).toBe(
|
|
447
|
+
path.join(
|
|
448
|
+
stateDir,
|
|
449
|
+
`sync-journal.${PERSONAL_VAULT_JOURNAL_SLUG}.json`,
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("maps to a different journal file than 'personal'", () => {
|
|
455
|
+
expect(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG)).not.toBe(
|
|
456
|
+
getJournalPath("personal"),
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("migratePersonalVaultJournal", () => {
|
|
462
|
+
const legacyJournal = (): SyncJournal => ({
|
|
463
|
+
version: "2",
|
|
464
|
+
lastSync: "2026-05-22T00:00:00.000Z",
|
|
465
|
+
files: {
|
|
466
|
+
".claude/skills/deep-plan/SKILL.md": {
|
|
467
|
+
hash: "abc123",
|
|
468
|
+
size: 42,
|
|
469
|
+
syncedAt: "2026-05-22T00:00:00.000Z",
|
|
470
|
+
direction: "up",
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
pulls: [],
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("seeds the reserved slug from the legacy 'personal' journal when absent", () => {
|
|
477
|
+
writeJournal("personal", legacyJournal());
|
|
478
|
+
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
|
|
479
|
+
|
|
480
|
+
migratePersonalVaultJournal();
|
|
481
|
+
|
|
482
|
+
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(true);
|
|
483
|
+
const seeded = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
484
|
+
// The hq-root-relative vault entry is preserved so it is NOT re-uploaded.
|
|
485
|
+
expect(seeded.files[".claude/skills/deep-plan/SKILL.md"]).toEqual(
|
|
486
|
+
legacyJournal().files[".claude/skills/deep-plan/SKILL.md"],
|
|
487
|
+
);
|
|
488
|
+
// Legacy journal is left intact (still used by the companies/personal company).
|
|
489
|
+
expect(fs.existsSync(getJournalPath("personal"))).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("is idempotent — does not clobber an existing reserved-slug journal", () => {
|
|
493
|
+
// Pre-existing reserved-slug journal with distinct content.
|
|
494
|
+
const existing: SyncJournal = {
|
|
495
|
+
version: "2",
|
|
496
|
+
lastSync: "2026-05-22T12:00:00.000Z",
|
|
497
|
+
files: {
|
|
498
|
+
"core/keep-me.md": {
|
|
499
|
+
hash: "keep",
|
|
500
|
+
size: 7,
|
|
501
|
+
syncedAt: "2026-05-22T12:00:00.000Z",
|
|
502
|
+
direction: "up",
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
pulls: [],
|
|
506
|
+
};
|
|
507
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, existing);
|
|
508
|
+
writeJournal("personal", legacyJournal());
|
|
509
|
+
|
|
510
|
+
migratePersonalVaultJournal();
|
|
511
|
+
|
|
512
|
+
const after = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
513
|
+
expect(after.files["core/keep-me.md"]).toBeDefined();
|
|
514
|
+
// The legacy entry was NOT copied over the existing reserved journal.
|
|
515
|
+
expect(after.files[".claude/skills/deep-plan/SKILL.md"]).toBeUndefined();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("no-ops when the legacy 'personal' journal is absent", () => {
|
|
519
|
+
expect(fs.existsSync(getJournalPath("personal"))).toBe(false);
|
|
520
|
+
|
|
521
|
+
migratePersonalVaultJournal();
|
|
522
|
+
|
|
523
|
+
expect(fs.existsSync(getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG))).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
425
526
|
});
|
package/src/journal.ts
CHANGED
|
@@ -60,6 +60,58 @@ export function getJournalPath(slug: string): string {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Reserved journal slug for the personal-vault fanout slot in the `--companies`
|
|
65
|
+
* runner. The vault slot uploads the whole HQ overlay (`.claude/`, `core/`,
|
|
66
|
+
* `personal/`, …) and journals hq-root-relative keys; its `syncRoot` is the HQ
|
|
67
|
+
* root itself.
|
|
68
|
+
*
|
|
69
|
+
* It MUST NOT share a journal with any real cloud company. Previously the slot
|
|
70
|
+
* used the literal slug `"personal"`, which collided with the
|
|
71
|
+
* `companies/personal` company (whose entity slug is also `"personal"`). The
|
|
72
|
+
* two targets have different sync roots, so the company's whole-tree
|
|
73
|
+
* `computeDeletePlan` walked the shared `sync-journal.personal.json`, resolved
|
|
74
|
+
* the vault's hq-root keys against `hqRoot/companies/personal` (where they
|
|
75
|
+
* don't exist), tombstoned them as "remote already 404", and dropped them from
|
|
76
|
+
* the journal — only for the vault slot to re-upload them next cycle. ~190
|
|
77
|
+
* `.claude/skills/*` files churned every sync.
|
|
78
|
+
*
|
|
79
|
+
* This sentinel value can never be produced by a real company slug from the
|
|
80
|
+
* entity service (which yields URL-safe lowercase slugs without leading
|
|
81
|
+
* underscores), and it survives `sanitizeSlug` unchanged (only `[a-zA-Z0-9_-]`
|
|
82
|
+
* chars; the embedded letters keep it off the all-`[_-]` reject path).
|
|
83
|
+
*/
|
|
84
|
+
export const PERSONAL_VAULT_JOURNAL_SLUG = "__hq_personal_vault__";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One-time seed migration for the personal-vault journal slug.
|
|
88
|
+
*
|
|
89
|
+
* Before this fix the personal-vault slot journaled under the slug
|
|
90
|
+
* `"personal"`. After the fix it journals under
|
|
91
|
+
* `PERSONAL_VAULT_JOURNAL_SLUG`. Without a seed, the first run under the new
|
|
92
|
+
* slug would start from an empty journal and re-upload the entire HQ overlay.
|
|
93
|
+
*
|
|
94
|
+
* To avoid that mass re-upload, this copies the legacy `sync-journal.personal.json`
|
|
95
|
+
* to `sync-journal.__hq_personal_vault__.json` exactly once: only when the new
|
|
96
|
+
* file does NOT exist and the legacy file DOES. Idempotent — a no-op when the
|
|
97
|
+
* new file already exists or the legacy file is absent.
|
|
98
|
+
*
|
|
99
|
+
* The legacy `personal` journal is left untouched (it is still the journal for
|
|
100
|
+
* the real `companies/personal` company). After the seed, both journals
|
|
101
|
+
* converge after one cleanup cycle: the legacy `personal` journal tombstones
|
|
102
|
+
* the now-foreign hq-root keys once; the new vault journal tombstones any
|
|
103
|
+
* companies/personal-relative keys once. That single convergence pass is
|
|
104
|
+
* expected and harmless.
|
|
105
|
+
*/
|
|
106
|
+
export function migratePersonalVaultJournal(): void {
|
|
107
|
+
const newPath = getJournalPath(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
108
|
+
if (fs.existsSync(newPath)) return;
|
|
109
|
+
const legacyPath = getJournalPath("personal");
|
|
110
|
+
if (!fs.existsSync(legacyPath)) return;
|
|
111
|
+
const legacy = readJournal("personal");
|
|
112
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, legacy);
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
/**
|
|
64
116
|
* Read a per-company journal from disk.
|
|
65
117
|
*
|
package/src/s3.test.ts
CHANGED
|
@@ -629,4 +629,34 @@ describe("downloadFile", () => {
|
|
|
629
629
|
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
|
|
630
630
|
expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
|
|
631
631
|
});
|
|
632
|
+
|
|
633
|
+
it("returns the object's user-metadata (including created-by) for a regular file", async () => {
|
|
634
|
+
nextGetObjectResponse = {
|
|
635
|
+
Body: (async function* () {
|
|
636
|
+
yield new Uint8Array([104, 105]); // "hi"
|
|
637
|
+
})(),
|
|
638
|
+
Metadata: { "created-by": "alice@example.com", "created-by-sub": "sub-123" },
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const localPath = path.join(tmpRoot, "authored.md");
|
|
642
|
+
const result = await downloadFile(makeCtx(), "authored.md", localPath);
|
|
643
|
+
|
|
644
|
+
expect(result.metadata?.["created-by"]).toBe("alice@example.com");
|
|
645
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("returns the object's user-metadata for a symlink record", async () => {
|
|
649
|
+
const localPath = path.join(tmpRoot, "authored-link.md");
|
|
650
|
+
nextGetObjectResponse = {
|
|
651
|
+
Body: (async function* () {
|
|
652
|
+
yield new Uint8Array();
|
|
653
|
+
})(),
|
|
654
|
+
Metadata: { "hq-symlink-target": "x", "created-by": "bob@example.com" },
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const result = await downloadFile(makeCtx(), "authored-link.md", localPath);
|
|
658
|
+
|
|
659
|
+
expect(result.metadata?.["created-by"]).toBe("bob@example.com");
|
|
660
|
+
expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
|
|
661
|
+
});
|
|
632
662
|
});
|
package/src/s3.ts
CHANGED
|
@@ -279,11 +279,20 @@ export async function uploadSymlink(
|
|
|
279
279
|
return { etag: response.ETag || "" };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Download an object to localPath and return its S3 user-metadata.
|
|
284
|
+
*
|
|
285
|
+
* Materializes regular files and symlink records (the symlink branch
|
|
286
|
+
* reconstructs the link from the body/marker). The GetObject response
|
|
287
|
+
* already carries `response.Metadata` (S3 lowercases keys), so we
|
|
288
|
+
* return it to callers — e.g. the pull loop reads `created-by` to
|
|
289
|
+
* attribute downloaded files to their author with zero extra network.
|
|
290
|
+
*/
|
|
282
291
|
export async function downloadFile(
|
|
283
292
|
ctx: EntityContext,
|
|
284
293
|
key: string,
|
|
285
294
|
localPath: string,
|
|
286
|
-
): Promise<
|
|
295
|
+
): Promise<{ metadata?: Record<string, string> }> {
|
|
287
296
|
const client = buildClient(ctx);
|
|
288
297
|
|
|
289
298
|
const response = await client.send(
|
|
@@ -362,7 +371,7 @@ export async function downloadFile(
|
|
|
362
371
|
}
|
|
363
372
|
}
|
|
364
373
|
fs.symlinkSync(symlinkTarget, localPath);
|
|
365
|
-
return;
|
|
374
|
+
return { metadata: response.Metadata };
|
|
366
375
|
}
|
|
367
376
|
|
|
368
377
|
// Symmetric to the symlink branch above: when a key was previously a
|
|
@@ -395,6 +404,7 @@ export async function downloadFile(
|
|
|
395
404
|
chunks.push(Buffer.from(chunk));
|
|
396
405
|
}
|
|
397
406
|
fs.writeFileSync(localPath, Buffer.concat(chunks));
|
|
407
|
+
return { metadata: response.Metadata };
|
|
398
408
|
}
|
|
399
409
|
|
|
400
410
|
export interface RemoteFile {
|