@indigoai-us/hq-cloud 6.14.23 → 6.14.24
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-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +11 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +11 -0
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +37 -2
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +1 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +17 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +6 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +14 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +155 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/doctor.d.ts +119 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +485 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/doctor.test.d.ts +13 -0
- package/dist/cli/doctor.test.d.ts.map +1 -0
- package/dist/cli/doctor.test.js +485 -0
- package/dist/cli/doctor.test.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/share.js +6 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts +29 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +140 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +223 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.test.js +7 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/conflict-file.d.ts.map +1 -1
- package/dist/lib/conflict-file.js +5 -1
- package/dist/lib/conflict-file.js.map +1 -1
- package/dist/lib/conflict-index.d.ts.map +1 -1
- package/dist/lib/conflict-index.js +7 -2
- package/dist/lib/conflict-index.js.map +1 -1
- package/dist/lib/machine-id.test.js +14 -0
- package/dist/lib/machine-id.test.js.map +1 -1
- package/dist/local-path-codec.d.ts +18 -0
- package/dist/local-path-codec.d.ts.map +1 -1
- package/dist/local-path-codec.js +63 -0
- package/dist/local-path-codec.js.map +1 -1
- package/dist/local-path-codec.test.d.ts +14 -0
- package/dist/local-path-codec.test.d.ts.map +1 -0
- package/dist/local-path-codec.test.js +102 -0
- package/dist/local-path-codec.test.js.map +1 -0
- package/dist/machine-auth.test.js +7 -0
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/s3.d.ts +5 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +129 -3
- package/dist/s3.js.map +1 -1
- package/dist/s3.symlink-materialize.test.d.ts +18 -0
- package/dist/s3.symlink-materialize.test.d.ts.map +1 -0
- package/dist/s3.symlink-materialize.test.js +394 -0
- package/dist/s3.symlink-materialize.test.js.map +1 -0
- package/dist/s3.test.js +19 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +23 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +17 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +10 -1
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +28 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +10 -0
- package/src/bin/sync-runner-planning.test.ts +1 -0
- package/src/bin/sync-runner-planning.ts +46 -3
- package/src/bin/sync-runner-watch-loop.ts +19 -1
- package/src/bin/sync-runner.test.ts +174 -0
- package/src/bin/sync-runner.ts +17 -1
- package/src/cli/doctor.test.ts +581 -0
- package/src/cli/doctor.ts +640 -0
- package/src/cli/index.ts +14 -0
- package/src/cli/share.ts +6 -1
- package/src/cli/sync.test.ts +281 -1
- package/src/cli/sync.ts +184 -2
- package/src/cognito-auth.test.ts +5 -0
- package/src/index.ts +10 -0
- package/src/lib/conflict-file.ts +6 -1
- package/src/lib/conflict-index.ts +7 -2
- package/src/lib/machine-id.test.ts +10 -0
- package/src/local-path-codec.test.ts +138 -0
- package/src/local-path-codec.ts +66 -0
- package/src/machine-auth.test.ts +5 -0
- package/src/s3.symlink-materialize.test.ts +492 -0
- package/src/s3.test.ts +24 -0
- package/src/s3.ts +148 -4
- package/src/skill-telemetry.test.ts +19 -0
- package/src/skill-telemetry.ts +31 -2
- package/src/watcher.test.ts +33 -0
- package/src/watcher.ts +10 -1
package/src/cli/sync.test.ts
CHANGED
|
@@ -53,7 +53,8 @@ vi.mock("./reindex.js", () => ({
|
|
|
53
53
|
reindex: vi.fn(() => ({ status: 0 })),
|
|
54
54
|
}));
|
|
55
55
|
|
|
56
|
-
import { sync, reportNewFilesToNotify } from "./sync.js";
|
|
56
|
+
import { sync, reportNewFilesToNotify, win32SymlinkFlavorBroken } from "./sync.js";
|
|
57
|
+
import type { SyncProgressEvent } from "./sync.js";
|
|
57
58
|
import { share } from "./share.js";
|
|
58
59
|
import * as s3Module from "../s3.js";
|
|
59
60
|
import { reindex } from "./reindex.js";
|
|
@@ -4613,3 +4614,282 @@ describe("scope-invalid key hardening (companies/ prefix in a company vault —
|
|
|
4613
4614
|
expect(fs.existsSync(path.join(tmpDir, "companies", "localco", "notes.md"))).toBe(true);
|
|
4614
4615
|
});
|
|
4615
4616
|
});
|
|
4617
|
+
|
|
4618
|
+
describe("win32SymlinkFlavorBroken (round-8 flavor-health probe)", () => {
|
|
4619
|
+
let tmp: string;
|
|
4620
|
+
|
|
4621
|
+
beforeEach(() => {
|
|
4622
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "flavor-probe-"));
|
|
4623
|
+
});
|
|
4624
|
+
|
|
4625
|
+
afterEach(() => {
|
|
4626
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
4627
|
+
});
|
|
4628
|
+
|
|
4629
|
+
it("false for a healthy dir link, false for a dangling target, always false off win32", () => {
|
|
4630
|
+
const target = path.join(tmp, "real-dir");
|
|
4631
|
+
fs.mkdirSync(target);
|
|
4632
|
+
const healthy = path.join(tmp, "healthy-link");
|
|
4633
|
+
fs.symlinkSync(target, healthy, "dir");
|
|
4634
|
+
expect(win32SymlinkFlavorBroken(healthy, true)).toBe(false);
|
|
4635
|
+
expect(win32SymlinkFlavorBroken(healthy, false)).toBe(false);
|
|
4636
|
+
|
|
4637
|
+
const dangling = path.join(tmp, "dangling-link");
|
|
4638
|
+
fs.symlinkSync(path.join(tmp, "missing"), dangling);
|
|
4639
|
+
expect(win32SymlinkFlavorBroken(dangling, true)).toBe(false);
|
|
4640
|
+
});
|
|
4641
|
+
|
|
4642
|
+
it.runIf(process.platform === "win32")(
|
|
4643
|
+
"true for a real FILE-flavor link at a DIRECTORY target (the broken shape the repair branch re-downloads)",
|
|
4644
|
+
() => {
|
|
4645
|
+
// Only constructible on Windows: flavor is a win32-only concept. The
|
|
4646
|
+
// physical shape from the incident — link minted 'file' while the
|
|
4647
|
+
// target was absent, target directory created afterwards.
|
|
4648
|
+
const target = path.join(tmp, "late-dir");
|
|
4649
|
+
const link = path.join(tmp, "wrong-flavor-link");
|
|
4650
|
+
fs.symlinkSync(target, link, "file");
|
|
4651
|
+
fs.mkdirSync(target);
|
|
4652
|
+
expect(win32SymlinkFlavorBroken(link, true)).toBe(true);
|
|
4653
|
+
},
|
|
4654
|
+
);
|
|
4655
|
+
});
|
|
4656
|
+
|
|
4657
|
+
describe("junk key-spelling guard (US-002 mixed-version amplifier)", () => {
|
|
4658
|
+
let tmpDir: string;
|
|
4659
|
+
let stateDir: string;
|
|
4660
|
+
let journalPath: string;
|
|
4661
|
+
|
|
4662
|
+
// Journal keys are company-root-relative; sync() resolves the company to
|
|
4663
|
+
// slug "acme", so entries land in sync-journal.acme.json and materialize
|
|
4664
|
+
// under {hqRoot}/companies/acme/.
|
|
4665
|
+
const canonicalKey = ".claude/skills/indigo:call-prep-brief/scripts";
|
|
4666
|
+
const junkKey = ".claude/skills/indigo%3Acall-prep-brief/scripts";
|
|
4667
|
+
|
|
4668
|
+
function seedJournal(files: Record<string, unknown>): void {
|
|
4669
|
+
fs.writeFileSync(
|
|
4670
|
+
journalPath,
|
|
4671
|
+
JSON.stringify({
|
|
4672
|
+
version: "2",
|
|
4673
|
+
lastSync: new Date().toISOString(),
|
|
4674
|
+
files,
|
|
4675
|
+
pulls: [],
|
|
4676
|
+
}),
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
function journalEntry(remoteEtag: string): Record<string, unknown> {
|
|
4681
|
+
return {
|
|
4682
|
+
hash: "0".repeat(64),
|
|
4683
|
+
size: 0,
|
|
4684
|
+
syncedAt: new Date().toISOString(),
|
|
4685
|
+
direction: "up",
|
|
4686
|
+
remoteEtag,
|
|
4687
|
+
kind: "symlink",
|
|
4688
|
+
};
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
async function runSync(): Promise<{
|
|
4692
|
+
events: SyncProgressEvent[];
|
|
4693
|
+
result: Awaited<ReturnType<typeof sync>>;
|
|
4694
|
+
}> {
|
|
4695
|
+
const events: SyncProgressEvent[] = [];
|
|
4696
|
+
const result = await sync({
|
|
4697
|
+
company: "acme",
|
|
4698
|
+
vaultConfig: mockConfig,
|
|
4699
|
+
hqRoot: tmpDir,
|
|
4700
|
+
skipReindex: true,
|
|
4701
|
+
onEvent: (e) => events.push(e),
|
|
4702
|
+
});
|
|
4703
|
+
return { events, result };
|
|
4704
|
+
}
|
|
4705
|
+
|
|
4706
|
+
beforeEach(() => {
|
|
4707
|
+
clearContextCache();
|
|
4708
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-junkkey-test-"));
|
|
4709
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-junkkey-test-"));
|
|
4710
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
4711
|
+
journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
4712
|
+
setupFetchMock();
|
|
4713
|
+
});
|
|
4714
|
+
|
|
4715
|
+
afterEach(() => {
|
|
4716
|
+
vi.unstubAllGlobals();
|
|
4717
|
+
vi.clearAllMocks();
|
|
4718
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
4719
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
4720
|
+
delete process.env.HQ_STATE_DIR;
|
|
4721
|
+
});
|
|
4722
|
+
|
|
4723
|
+
it("skips a remote %3A twin of an already-journaled canonical key — loudly, never as an error", async () => {
|
|
4724
|
+
seedJournal({ [canonicalKey]: journalEntry("aaa111") });
|
|
4725
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4726
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
|
|
4727
|
+
]);
|
|
4728
|
+
|
|
4729
|
+
const { events, result } = await runSync();
|
|
4730
|
+
|
|
4731
|
+
// The twin never downloads, never materializes, never journals.
|
|
4732
|
+
expect(vi.mocked(s3Module.downloadFile)).not.toHaveBeenCalled();
|
|
4733
|
+
const skips = events.filter((e) => e.type === "skip-junk-key-spelling");
|
|
4734
|
+
expect(skips).toHaveLength(1);
|
|
4735
|
+
expect(skips[0]).toMatchObject({ path: junkKey, journaledKey: canonicalKey });
|
|
4736
|
+
// Policy hq-sync-deliberate-skip-not-fatal-error-exit2: a deliberate
|
|
4737
|
+
// per-file skip must never surface as a fatal {type:"error"} event.
|
|
4738
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|
|
4739
|
+
expect(result.aborted).toBe(false);
|
|
4740
|
+
expect(result.filesSkipped).toBe(1);
|
|
4741
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
|
|
4742
|
+
files: Record<string, unknown>;
|
|
4743
|
+
};
|
|
4744
|
+
expect(Object.keys(journalAfter.files)).not.toContain(junkKey);
|
|
4745
|
+
});
|
|
4746
|
+
|
|
4747
|
+
it("still pulls a junk-spelling key that is ALREADY journaled under that exact spelling", async () => {
|
|
4748
|
+
// Pre-existing fork (both machines journaled the %3A family before the
|
|
4749
|
+
// guard shipped): the established spelling keeps syncing untouched —
|
|
4750
|
+
// collapsing the family is the vault doctor's job (US-003), not the
|
|
4751
|
+
// planner's.
|
|
4752
|
+
seedJournal({ [junkKey]: journalEntry("aaa111") });
|
|
4753
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4754
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"bbb222"' },
|
|
4755
|
+
]);
|
|
4756
|
+
|
|
4757
|
+
const { events, result } = await runSync();
|
|
4758
|
+
|
|
4759
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4760
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe(junkKey);
|
|
4761
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4762
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4763
|
+
});
|
|
4764
|
+
|
|
4765
|
+
it("downloads a %3A key with NO journaled twin — the guard needs an established spelling to defend", async () => {
|
|
4766
|
+
seedJournal({});
|
|
4767
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4768
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
|
|
4769
|
+
]);
|
|
4770
|
+
|
|
4771
|
+
const { events, result } = await runSync();
|
|
4772
|
+
|
|
4773
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4774
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4775
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4776
|
+
});
|
|
4777
|
+
|
|
4778
|
+
it("an exact FILE_TOMBSTONE for the journaled canonical key beats the junk-twin protection (authoritative delete propagates)", async () => {
|
|
4779
|
+
// Round-7 review P2: a deliberately-deleted wrapper must not be kept
|
|
4780
|
+
// alive by a stale junk spelling lingering in the LIST.
|
|
4781
|
+
seedJournal({ [canonicalKey]: journalEntry("aaa111") });
|
|
4782
|
+
setupFetchMock({
|
|
4783
|
+
tombstones: [{ key: canonicalKey, deletedAt: new Date().toISOString() }],
|
|
4784
|
+
});
|
|
4785
|
+
// Earlier suites install persistent keyed headRemoteFile implementations;
|
|
4786
|
+
// the HEAD-verify pass must see the canonical key as absent here.
|
|
4787
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
|
|
4788
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4789
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
|
|
4790
|
+
]);
|
|
4791
|
+
|
|
4792
|
+
const { events, result } = await runSync();
|
|
4793
|
+
|
|
4794
|
+
// Junk twin still gated; the canonical journal entry tombstones out.
|
|
4795
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
4796
|
+
expect(result.filesTombstoned).toBe(1);
|
|
4797
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
|
|
4798
|
+
files: Record<string, unknown>;
|
|
4799
|
+
};
|
|
4800
|
+
expect(Object.keys(journalAfter.files)).not.toContain(canonicalKey);
|
|
4801
|
+
});
|
|
4802
|
+
|
|
4803
|
+
it("always downloads a CANONICAL remote key even when the journal holds only its junk twin (post-doctor convergence)", async () => {
|
|
4804
|
+
// Round-6 review P1: after `hq sync doctor` collapses the vault to the
|
|
4805
|
+
// canonical spelling, a machine whose journal still carries the junk
|
|
4806
|
+
// spelling must pick the canonical object up — gating it would deadlock
|
|
4807
|
+
// that machine out of the wrapper (and all future updates) forever.
|
|
4808
|
+
seedJournal({ [junkKey]: journalEntry("aaa111") });
|
|
4809
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4810
|
+
{ key: canonicalKey, size: 20, lastModified: new Date(), etag: '"can1"' },
|
|
4811
|
+
]);
|
|
4812
|
+
|
|
4813
|
+
const { events, result } = await runSync();
|
|
4814
|
+
|
|
4815
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4816
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4817
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe(canonicalKey);
|
|
4818
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4819
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
|
|
4820
|
+
files: Record<string, unknown>;
|
|
4821
|
+
};
|
|
4822
|
+
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
4823
|
+
});
|
|
4824
|
+
|
|
4825
|
+
it("does not tombstone a journaled canonical key when the LIST holds only its junk twin", async () => {
|
|
4826
|
+
// Round-4 review P1: skip-junk-key-spelling alone is self-defeating if
|
|
4827
|
+
// the delete-resync pass then classes the canonical journal entry as
|
|
4828
|
+
// remote-deleted (raw remoteKeySet miss) — it would unlink the good
|
|
4829
|
+
// local wrapper and drop the journal entry, and the NEXT pull would
|
|
4830
|
+
// materialize the junk spelling the guard just refused.
|
|
4831
|
+
seedJournal({ [canonicalKey]: journalEntry("aaa111") });
|
|
4832
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4833
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"junk1"' },
|
|
4834
|
+
]);
|
|
4835
|
+
|
|
4836
|
+
const { events, result } = await runSync();
|
|
4837
|
+
|
|
4838
|
+
// Junk twin skipped, canonical entry NOT tombstoned.
|
|
4839
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(1);
|
|
4840
|
+
const journalAfter = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as {
|
|
4841
|
+
files: Record<string, unknown>;
|
|
4842
|
+
};
|
|
4843
|
+
expect(Object.keys(journalAfter.files)).toContain(canonicalKey);
|
|
4844
|
+
expect(result.filesTombstoned ?? 0).toBe(0);
|
|
4845
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|
|
4846
|
+
});
|
|
4847
|
+
|
|
4848
|
+
it("never gates COLON-FREE skill names — foo%20bar and foo bar are distinct skills (round-9)", async () => {
|
|
4849
|
+
seedJournal({ [".claude/skills/foo bar/SKILL.md"]: journalEntry("aaa111") });
|
|
4850
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4851
|
+
{ key: ".claude/skills/foo%20bar/SKILL.md", size: 20, lastModified: new Date(), etag: '"pct1"' },
|
|
4852
|
+
]);
|
|
4853
|
+
|
|
4854
|
+
const { events, result } = await runSync();
|
|
4855
|
+
|
|
4856
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4857
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4858
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4859
|
+
});
|
|
4860
|
+
|
|
4861
|
+
it("never gates keys outside .claude/skills/ — percent-decode collisions there are distinct files", async () => {
|
|
4862
|
+
// `notes/a b.md` and `notes/a%20b.md` canonicalize to the same fixpoint
|
|
4863
|
+
// string, but outside the skill farm they are two legitimate files (the
|
|
4864
|
+
// win32 codec encodes the literal '%' as '%25' locally). The guard must
|
|
4865
|
+
// not skip the unjournaled one.
|
|
4866
|
+
seedJournal({ ["notes/a b.md"]: journalEntry("aaa111") });
|
|
4867
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4868
|
+
{ key: "notes/a%20b.md", size: 20, lastModified: new Date(), etag: '"pct1"' },
|
|
4869
|
+
]);
|
|
4870
|
+
|
|
4871
|
+
const { events, result } = await runSync();
|
|
4872
|
+
|
|
4873
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(1);
|
|
4874
|
+
expect(vi.mocked(s3Module.downloadFile).mock.calls[0]![1]).toBe("notes/a%20b.md");
|
|
4875
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4876
|
+
expect(result.filesDownloaded).toBe(1);
|
|
4877
|
+
});
|
|
4878
|
+
|
|
4879
|
+
it("keeps syncing BOTH spellings when both are already journaled (no retroactive gating)", async () => {
|
|
4880
|
+
seedJournal({
|
|
4881
|
+
[canonicalKey]: journalEntry("aaa111"),
|
|
4882
|
+
[junkKey]: journalEntry("ccc333"),
|
|
4883
|
+
});
|
|
4884
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4885
|
+
{ key: canonicalKey, size: 20, lastModified: new Date(), etag: '"bbb222"' },
|
|
4886
|
+
{ key: junkKey, size: 20, lastModified: new Date(), etag: '"ddd444"' },
|
|
4887
|
+
]);
|
|
4888
|
+
|
|
4889
|
+
const { events, result } = await runSync();
|
|
4890
|
+
|
|
4891
|
+
expect(vi.mocked(s3Module.downloadFile)).toHaveBeenCalledTimes(2);
|
|
4892
|
+
expect(events.filter((e) => e.type === "skip-junk-key-spelling")).toHaveLength(0);
|
|
4893
|
+
expect(result.filesDownloaded).toBe(2);
|
|
4894
|
+
});
|
|
4895
|
+
});
|
package/src/cli/sync.ts
CHANGED
|
@@ -19,7 +19,12 @@ import {
|
|
|
19
19
|
} from "../telemetry-events.js";
|
|
20
20
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
21
21
|
import { createSyncProgressRecorder } from "../sync-progress.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
canonicalVaultKeySpelling,
|
|
24
|
+
localPathForVaultKey,
|
|
25
|
+
vaultKeyForLocalPath,
|
|
26
|
+
} from "../local-path-codec.js";
|
|
27
|
+
import { SKILLS_KEY_PREFIX } from "./doctor.js";
|
|
23
28
|
import {
|
|
24
29
|
downloadFile,
|
|
25
30
|
listRemoteFiles,
|
|
@@ -401,6 +406,27 @@ export type SyncProgressEvent =
|
|
|
401
406
|
path: string;
|
|
402
407
|
/** Server-compatible invalid-key code, such as INVALID_KEY_CONTROL_CHARS. */
|
|
403
408
|
errorCode?: string;
|
|
409
|
+
}
|
|
410
|
+
| {
|
|
411
|
+
/**
|
|
412
|
+
* Emitted by the PULL leg once per remote key skipped because its
|
|
413
|
+
* spelling percent-decodes to the same logical path as an
|
|
414
|
+
* already-journaled key with a DIFFERENT spelling — the mixed-version
|
|
415
|
+
* amplifier (a pre-codec peer minting `%3A`/`%253A` twins of a
|
|
416
|
+
* canonical `:` key). Downloading the twin would materialize a junk
|
|
417
|
+
* local dir and fork the journal into a second key family for one
|
|
418
|
+
* logical path, so the established spelling wins and the twin is
|
|
419
|
+
* skipped. Like `skip-size-limit`, this is deliberately NOT
|
|
420
|
+
* `type: "error"` (policy hq-sync-deliberate-skip-not-fatal-error-exit2:
|
|
421
|
+
* a recurring benign per-file skip emitted as an error lands in the
|
|
422
|
+
* runner's `errors[]`, forces exit 2 on every pass, and the menubar
|
|
423
|
+
* Sentry-alerts each time). The US-003 vault doctor is the cleanup
|
|
424
|
+
* path that collapses the junk family itself.
|
|
425
|
+
*/
|
|
426
|
+
type: "skip-junk-key-spelling";
|
|
427
|
+
path: string;
|
|
428
|
+
/** The already-journaled spelling that owns this logical path. */
|
|
429
|
+
journaledKey: string;
|
|
404
430
|
};
|
|
405
431
|
|
|
406
432
|
export interface SyncOptions {
|
|
@@ -1211,6 +1237,18 @@ async function executeConflictExecutor(
|
|
|
1211
1237
|
counters.filesOutOfScope++;
|
|
1212
1238
|
continue;
|
|
1213
1239
|
}
|
|
1240
|
+
if (item.action === "skip-junk-key-spelling") {
|
|
1241
|
+
// Deliberate, recoverable skip — counted as skipped and surfaced via a
|
|
1242
|
+
// dedicated warning event, never `type: "error"` (see the event doc /
|
|
1243
|
+
// policy hq-sync-deliberate-skip-not-fatal-error-exit2).
|
|
1244
|
+
counters.filesSkipped++;
|
|
1245
|
+
run.emit({
|
|
1246
|
+
type: "skip-junk-key-spelling",
|
|
1247
|
+
path: item.remoteFile.key,
|
|
1248
|
+
journaledKey: item.journaledKey,
|
|
1249
|
+
});
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1214
1252
|
if (item.action === "tombstone-delete") {
|
|
1215
1253
|
executeFileTombstoneDelete(run, item, counters);
|
|
1216
1254
|
continue;
|
|
@@ -2111,6 +2149,14 @@ type PullPlanItem =
|
|
|
2111
2149
|
// the remote LIST (and accessible per STS) but deliberately not downloaded
|
|
2112
2150
|
// because the membership's sync scope doesn't cover them.
|
|
2113
2151
|
| { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
|
|
2152
|
+
| {
|
|
2153
|
+
// Mixed-version amplifier guard — see the `skip-junk-key-spelling`
|
|
2154
|
+
// event doc. Carries the journaled spelling for the warning surface.
|
|
2155
|
+
action: "skip-junk-key-spelling";
|
|
2156
|
+
remoteFile: RemoteFile;
|
|
2157
|
+
localPath: string;
|
|
2158
|
+
journaledKey: string;
|
|
2159
|
+
}
|
|
2114
2160
|
// Remote key present in the LIST but carrying a FILE_TOMBSTONE that marks it
|
|
2115
2161
|
// intentionally deleted (and the remote object is NOT a newer re-create). The
|
|
2116
2162
|
// executor deletes any local copy and drops the journal entry — the
|
|
@@ -2208,6 +2254,39 @@ interface PullPlan {
|
|
|
2208
2254
|
const PULL_INTENTIONAL_DELETE_MIN_ABS = 10;
|
|
2209
2255
|
const PULL_INTENTIONAL_DELETE_RATIO = 0.1;
|
|
2210
2256
|
|
|
2257
|
+
/**
|
|
2258
|
+
* win32 only: true when an existing local symlink's TARGET exists but the
|
|
2259
|
+
* link cannot be traversed as that target's type — the file-flavor-link-at-
|
|
2260
|
+
* a-directory shape minted while the target was absent (or by an older
|
|
2261
|
+
* client). A dangling link is NOT broken-flavored (there is nothing to
|
|
2262
|
+
* repair until the target appears). POSIX links are flavorless: always
|
|
2263
|
+
* false off win32.
|
|
2264
|
+
*/
|
|
2265
|
+
export function win32SymlinkFlavorBroken(
|
|
2266
|
+
localPath: string,
|
|
2267
|
+
win32: boolean = process.platform === "win32",
|
|
2268
|
+
): boolean {
|
|
2269
|
+
if (!win32) return false;
|
|
2270
|
+
let target: string;
|
|
2271
|
+
try {
|
|
2272
|
+
target = fs.readlinkSync(localPath);
|
|
2273
|
+
} catch {
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2276
|
+
const resolved = path.resolve(path.dirname(localPath), target);
|
|
2277
|
+
let targetIsDir: boolean;
|
|
2278
|
+
try {
|
|
2279
|
+
targetIsDir = fs.statSync(resolved).isDirectory();
|
|
2280
|
+
} catch {
|
|
2281
|
+
return false; // dangling target — nothing to repair yet
|
|
2282
|
+
}
|
|
2283
|
+
try {
|
|
2284
|
+
return fs.statSync(localPath).isDirectory() !== targetIsDir;
|
|
2285
|
+
} catch {
|
|
2286
|
+
return true; // target exists but the link cannot traverse: wrong flavor
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2211
2290
|
function computePullPlan(
|
|
2212
2291
|
remoteFiles: RemoteFile[],
|
|
2213
2292
|
journal: SyncJournal,
|
|
@@ -2245,6 +2324,24 @@ function computePullPlan(
|
|
|
2245
2324
|
localPath: string;
|
|
2246
2325
|
}> = [];
|
|
2247
2326
|
|
|
2327
|
+
// Junk-spelling guard (mixed-version amplifier): index every journaled key
|
|
2328
|
+
// by its canonical (percent-decode fixpoint) form so a remote twin minted
|
|
2329
|
+
// under a different spelling is recognized before it materializes. When
|
|
2330
|
+
// several journaled spellings already share one canonical form (a
|
|
2331
|
+
// pre-guard fork), the exactly-canonical spelling is preferred as the
|
|
2332
|
+
// representative; entries journaled under their own exact spelling are
|
|
2333
|
+
// never gated by this map (see the guard below), so pre-existing families
|
|
2334
|
+
// keep syncing untouched until the vault doctor (US-003) collapses them.
|
|
2335
|
+
const journaledKeyByCanonical = new Map<string, string>();
|
|
2336
|
+
for (const journaledKey of Object.keys(journal.files)) {
|
|
2337
|
+
if (!journaledKey.startsWith(SKILLS_KEY_PREFIX)) continue;
|
|
2338
|
+
const canonical = canonicalVaultKeySpelling(journaledKey);
|
|
2339
|
+
const existing = journaledKeyByCanonical.get(canonical);
|
|
2340
|
+
if (existing === undefined || journaledKey === canonical) {
|
|
2341
|
+
journaledKeyByCanonical.set(canonical, journaledKey);
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2248
2345
|
// Remote keys that ALSO appear as an ancestor of another remote key — i.e.
|
|
2249
2346
|
// keys that carry child objects under `${key}/…` in this same LIST. The
|
|
2250
2347
|
// vault stores a directory OVERLAY (a symlink into another tree, e.g. a
|
|
@@ -2406,6 +2503,43 @@ function computePullPlan(
|
|
|
2406
2503
|
continue;
|
|
2407
2504
|
}
|
|
2408
2505
|
|
|
2506
|
+
// Junk-spelling guard — SKILL-FARM KEYS ONLY. The amplifier is specific
|
|
2507
|
+
// to `.claude/skills/<ns>:<skill>` wrapper spellings; outside that prefix,
|
|
2508
|
+
// keys whose percent-decode fixpoints collide (`notes/a b.md` vs a genuine
|
|
2509
|
+
// `notes/a%20b.md`) are DISTINCT files that must both pull.
|
|
2510
|
+
// Within the farm: a remote key that is NOT journaled under its own
|
|
2511
|
+
// spelling but whose percent-decode fixpoint matches an already-journaled
|
|
2512
|
+
// key with a different spelling is a mixed-version twin (`%3A`/`%253A`
|
|
2513
|
+
// generations of a canonical `:` key). Materializing it would mint junk
|
|
2514
|
+
// local dirs and fork the journal into a second key family for the same
|
|
2515
|
+
// logical path — skip it (loudly, never fatally; see the
|
|
2516
|
+
// `skip-junk-key-spelling` event doc) and leave the journaled spelling
|
|
2517
|
+
// authoritative for this machine.
|
|
2518
|
+
if (
|
|
2519
|
+
journalEntry === undefined &&
|
|
2520
|
+
remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
|
|
2521
|
+
// Gate NON-canonical remote spellings only. A remote key that already
|
|
2522
|
+
// IS the canonical spelling must always be downloadable — after the
|
|
2523
|
+
// doctor collapses the vault, a machine whose journal still holds only
|
|
2524
|
+
// the junk spelling would otherwise skip the canonical object forever
|
|
2525
|
+
// (and the delete-resync twin protection keeps that stale junk entry),
|
|
2526
|
+
// deadlocking it out of every future update.
|
|
2527
|
+
canonicalVaultKeySpelling(remoteFile.key) !== remoteFile.key
|
|
2528
|
+
) {
|
|
2529
|
+
const journaledTwin = journaledKeyByCanonical.get(
|
|
2530
|
+
canonicalVaultKeySpelling(remoteFile.key),
|
|
2531
|
+
);
|
|
2532
|
+
if (journaledTwin !== undefined && journaledTwin !== remoteFile.key) {
|
|
2533
|
+
items.push({
|
|
2534
|
+
action: "skip-junk-key-spelling",
|
|
2535
|
+
remoteFile,
|
|
2536
|
+
localPath,
|
|
2537
|
+
journaledKey: journaledTwin,
|
|
2538
|
+
});
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2409
2543
|
// ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
|
|
2410
2544
|
// A remote object present in the LIST may be an intentionally-deleted key
|
|
2411
2545
|
// (a peer re-pushed it, or its delete-marker hasn't propagated to this
|
|
@@ -2652,6 +2786,25 @@ function computePullPlan(
|
|
|
2652
2786
|
continue;
|
|
2653
2787
|
}
|
|
2654
2788
|
if (journalEntry && !localChanged && !remoteChanged) {
|
|
2789
|
+
// Flavor-health repair (win32 skill farm): a link journaled while its
|
|
2790
|
+
// target was absent was minted file-flavor; once the target directory
|
|
2791
|
+
// exists the hashes still match (hash = sha256(target)) so this
|
|
2792
|
+
// branch would skip it forever, leaving the wrapper broken. Re-download
|
|
2793
|
+
// instead — the materializer's flavor-aware path recreates it 'dir'.
|
|
2794
|
+
if (
|
|
2795
|
+
isLocalSymlink &&
|
|
2796
|
+
remoteFile.key.startsWith(SKILLS_KEY_PREFIX) &&
|
|
2797
|
+
win32SymlinkFlavorBroken(localPath)
|
|
2798
|
+
) {
|
|
2799
|
+
items.push({
|
|
2800
|
+
action: "download",
|
|
2801
|
+
remoteFile,
|
|
2802
|
+
localPath,
|
|
2803
|
+
isNew: false,
|
|
2804
|
+
localSnapshot: plannedLocalSnapshot,
|
|
2805
|
+
});
|
|
2806
|
+
continue;
|
|
2807
|
+
}
|
|
2655
2808
|
items.push({ action: "skip-unchanged", remoteFile, localPath });
|
|
2656
2809
|
continue;
|
|
2657
2810
|
}
|
|
@@ -2845,7 +2998,20 @@ function computePullPlan(
|
|
|
2845
2998
|
// as a local delete + journal removal. Symmetric to the push side's
|
|
2846
2999
|
// `propagateDeletes` plan in share.ts.
|
|
2847
3000
|
const remoteKeySet = new Set<string>();
|
|
2848
|
-
|
|
3001
|
+
// Skill-farm twin protection (companion to the junk-spelling pull guard):
|
|
3002
|
+
// when the LIST holds only a junk spelling of a journaled canonical key,
|
|
3003
|
+
// the logical object is still remotely present — a raw has() would class
|
|
3004
|
+
// the canonical journal entry as remote-deleted, unlink the good local
|
|
3005
|
+
// wrapper, and drop the journal entry (after which the next pull would
|
|
3006
|
+
// materialize the junk spelling the guard just refused). Track canonical
|
|
3007
|
+
// forms of listed skill keys and consult them below.
|
|
3008
|
+
const remoteCanonicalSkillKeys = new Set<string>();
|
|
3009
|
+
for (const rf of remoteFiles) {
|
|
3010
|
+
remoteKeySet.add(rf.key);
|
|
3011
|
+
if (rf.key.startsWith(SKILLS_KEY_PREFIX)) {
|
|
3012
|
+
remoteCanonicalSkillKeys.add(canonicalVaultKeySpelling(rf.key));
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
2849
3015
|
const tombstones: PullPlan["tombstones"] = [];
|
|
2850
3016
|
for (const key of Object.keys(journal.files)) {
|
|
2851
3017
|
// Compare membership in POSIX space. A pre-5.47.2 Windows journal key can
|
|
@@ -2855,6 +3021,18 @@ function computePullPlan(
|
|
|
2855
3021
|
// POSIX compare is defense-in-depth (ridge data-loss, feedback_b8d09d0f).
|
|
2856
3022
|
const posixKey = toPosixKey(key);
|
|
2857
3023
|
if (remoteKeySet.has(posixKey)) continue;
|
|
3024
|
+
if (
|
|
3025
|
+
posixKey.startsWith(SKILLS_KEY_PREFIX) &&
|
|
3026
|
+
remoteCanonicalSkillKeys.has(canonicalVaultKeySpelling(posixKey)) &&
|
|
3027
|
+
// An EXACT FILE_TOMBSTONE for the journaled key is an authoritative
|
|
3028
|
+
// delete and wins over the twin protection — a lingering junk
|
|
3029
|
+
// spelling in the LIST must not keep a deliberately-deleted wrapper
|
|
3030
|
+
// alive forever. (The key is absent from the LIST here, so there is
|
|
3031
|
+
// no newer re-create to defer to.)
|
|
3032
|
+
fileTombstones.get(posixKey) === undefined
|
|
3033
|
+
) {
|
|
3034
|
+
continue;
|
|
3035
|
+
}
|
|
2858
3036
|
const localPath = resolveContainedVaultPath(companyRoot, key);
|
|
2859
3037
|
if (localPath === null) continue;
|
|
2860
3038
|
// PersonalMode key gating — mirror the download branch. Local (non-cloud)
|
|
@@ -3040,5 +3218,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
3040
3218
|
console.warn(
|
|
3041
3219
|
` ! ${event.path} skipped — invalid vault key (${event.errorCode ?? "INVALID_KEY_COMPANIES_SCOPED"})`,
|
|
3042
3220
|
);
|
|
3221
|
+
} else if (event.type === "skip-junk-key-spelling") {
|
|
3222
|
+
console.warn(
|
|
3223
|
+
` ! ${event.path} skipped — junk key spelling; this logical path is already synced as ${event.journaledKey} (mixed-version peer artifact; the vault doctor collapses the family)`,
|
|
3224
|
+
);
|
|
3043
3225
|
}
|
|
3044
3226
|
}
|
package/src/cognito-auth.test.ts
CHANGED
|
@@ -14,18 +14,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
14
14
|
// Sandbox HOME *before* importing the module — it reads os.homedir() at load
|
|
15
15
|
// time to compute the cache file path.
|
|
16
16
|
let originalHome: string | undefined;
|
|
17
|
+
let originalUserProfile: string | undefined;
|
|
17
18
|
let tmpHome: string;
|
|
18
19
|
|
|
19
20
|
beforeEach(() => {
|
|
20
21
|
originalHome = process.env.HOME;
|
|
22
|
+
originalUserProfile = process.env.USERPROFILE;
|
|
21
23
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cognito-auth-test-"));
|
|
22
24
|
process.env.HOME = tmpHome;
|
|
25
|
+
process.env.USERPROFILE = tmpHome;
|
|
23
26
|
vi.resetModules();
|
|
24
27
|
});
|
|
25
28
|
|
|
26
29
|
afterEach(() => {
|
|
27
30
|
if (originalHome === undefined) delete process.env.HOME;
|
|
28
31
|
else process.env.HOME = originalHome;
|
|
32
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
33
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
29
34
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
30
35
|
vi.unstubAllGlobals();
|
|
31
36
|
vi.restoreAllMocks();
|
package/src/index.ts
CHANGED
|
@@ -300,6 +300,16 @@ export type { ReindexOptions, ReindexResult } from "./cli/index.js";
|
|
|
300
300
|
export { rescue, buildRescueArgs } from "./cli/index.js";
|
|
301
301
|
export type { RescueOptions, RescueResult } from "./cli/index.js";
|
|
302
302
|
|
|
303
|
+
// `hq sync doctor` — skill-key dedupe + local farm GC (US-003). Consumed by
|
|
304
|
+
// @indigoai-us/hq-cli's `sync doctor` subcommand.
|
|
305
|
+
export { syncDoctor, SKILLS_KEY_PREFIX } from "./cli/index.js";
|
|
306
|
+
export type {
|
|
307
|
+
SyncDoctorOptions,
|
|
308
|
+
SyncDoctorResult,
|
|
309
|
+
SyncDoctorPlan,
|
|
310
|
+
DoctorStore,
|
|
311
|
+
} from "./cli/index.js";
|
|
312
|
+
|
|
303
313
|
export type {
|
|
304
314
|
EntityContext,
|
|
305
315
|
VaultCredentials,
|
package/src/lib/conflict-file.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
|
|
21
|
+
import { localPathForVaultKey } from "../local-path-codec.js";
|
|
22
|
+
|
|
21
23
|
export { readShortMachineId, getOrCreateMachineId } from "./machine-id.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
@@ -58,7 +60,10 @@ export function writeConflictFile(
|
|
|
58
60
|
conflictRelative: string,
|
|
59
61
|
contents: Buffer,
|
|
60
62
|
): void {
|
|
61
|
-
|
|
63
|
+
// Codec boundary: conflict paths are canonical (hq-root-relative,
|
|
64
|
+
// forward-slash) keys, so the mirror must land at the encoded local name
|
|
65
|
+
// on win32 — a raw join of a colon key can never be created there.
|
|
66
|
+
const abs = localPathForVaultKey(hqRoot, conflictRelative);
|
|
62
67
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
63
68
|
fs.writeFileSync(abs, contents);
|
|
64
69
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import * as crypto from "crypto";
|
|
23
23
|
import * as fs from "fs";
|
|
24
24
|
import * as path from "path";
|
|
25
|
+
import { localPathForVaultKey } from "../local-path-codec.js";
|
|
25
26
|
import type { ConflictIndex, ConflictIndexEntry } from "../types.js";
|
|
26
27
|
|
|
27
28
|
const CONFLICTS_DIR = ".hq-conflicts";
|
|
@@ -215,8 +216,12 @@ export function pruneConflictIndex(hqRoot: string): PruneConflictIndexResult {
|
|
|
215
216
|
const mirrorsToRemove: string[] = [];
|
|
216
217
|
|
|
217
218
|
for (const entry of index.conflicts) {
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
// Codec boundary: index rows store canonical (hq-root-relative) keys, so
|
|
220
|
+
// both probes must resolve through the codec — a raw join of a win32
|
|
221
|
+
// colon key always lstat-fails, which would mis-classify a live encoded
|
|
222
|
+
// mirror as an orphaned row and silently drop it.
|
|
223
|
+
const mirrorAbs = localPathForVaultKey(hqRoot, entry.conflictPath);
|
|
224
|
+
const originalAbs = localPathForVaultKey(hqRoot, entry.originalPath);
|
|
220
225
|
|
|
221
226
|
let mirrorStat: fs.Stats;
|
|
222
227
|
try {
|