@indigoai-us/hq-cloud 5.4.6 → 5.7.1

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.
Files changed (42) hide show
  1. package/dist/bin/sync-runner.d.ts +13 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +14 -2
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +37 -0
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +82 -16
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +102 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +22 -0
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +187 -62
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +81 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/lib/conflict-file.d.ts +46 -0
  19. package/dist/lib/conflict-file.d.ts.map +1 -0
  20. package/dist/lib/conflict-file.js +86 -0
  21. package/dist/lib/conflict-file.js.map +1 -0
  22. package/dist/lib/conflict-index.d.ts +66 -0
  23. package/dist/lib/conflict-index.d.ts.map +1 -0
  24. package/dist/lib/conflict-index.js +112 -0
  25. package/dist/lib/conflict-index.js.map +1 -0
  26. package/dist/lib/conflict.test.d.ts +7 -0
  27. package/dist/lib/conflict.test.d.ts.map +1 -0
  28. package/dist/lib/conflict.test.js +136 -0
  29. package/dist/lib/conflict.test.js.map +1 -0
  30. package/dist/types.d.ts +18 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/bin/sync-runner.test.ts +43 -0
  34. package/src/bin/sync-runner.ts +25 -2
  35. package/src/cli/share.test.ts +125 -0
  36. package/src/cli/share.ts +133 -18
  37. package/src/cli/sync.test.ts +97 -0
  38. package/src/cli/sync.ts +277 -68
  39. package/src/lib/conflict-file.ts +101 -0
  40. package/src/lib/conflict-index.ts +127 -0
  41. package/src/lib/conflict.test.ts +180 -0
  42. package/src/types.ts +27 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tests for the pure conflict primitives — path building, machine-id
3
+ * fallback, atomic index writes, dedup. Kept in one file so the related
4
+ * helpers stay co-located.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import * as fs from "fs";
9
+ import * as os from "os";
10
+ import * as path from "path";
11
+ import {
12
+ buildConflictPath,
13
+ buildConflictId,
14
+ readShortMachineId,
15
+ } from "./conflict-file.js";
16
+ import {
17
+ appendConflictEntry,
18
+ getConflictIndexPath,
19
+ readConflictIndex,
20
+ removeConflictEntry,
21
+ writeConflictIndex,
22
+ } from "./conflict-index.js";
23
+ import type { ConflictIndexEntry } from "../types.js";
24
+
25
+ describe("buildConflictPath", () => {
26
+ it("inserts the conflict marker before the original extension", () => {
27
+ expect(
28
+ buildConflictPath("knowledge/notes.md", "2026-04-27T22:05:14Z", "abc123"),
29
+ ).toBe("knowledge/notes.md.conflict-2026-04-27T22-05-14Z-abc123.md");
30
+ });
31
+
32
+ it("preserves nested paths and json extensions", () => {
33
+ expect(
34
+ buildConflictPath("projects/foo/prd.json", "2026-04-27T22:05:14.123Z", "abc123"),
35
+ ).toBe("projects/foo/prd.json.conflict-2026-04-27T22-05-14Z-abc123.json");
36
+ });
37
+
38
+ it("appends the suffix verbatim for files without an extension", () => {
39
+ expect(
40
+ buildConflictPath("secrets", "2026-04-27T22:05:14Z", "abc123"),
41
+ ).toBe("secrets.conflict-2026-04-27T22-05-14Z-abc123");
42
+ });
43
+ });
44
+
45
+ describe("buildConflictId", () => {
46
+ it("escapes path separators and dots so the id is filesystem-safe", () => {
47
+ expect(
48
+ buildConflictId("knowledge/notes.md", "2026-04-27T22:05:14Z"),
49
+ ).toBe("knowledge-notes-md-2026-04-27T22-05-14Z");
50
+ });
51
+
52
+ it("yields the same id for the same (path, ts) pair — dedup primitive", () => {
53
+ const a = buildConflictId("foo/bar.md", "2026-04-27T22:05:14Z");
54
+ const b = buildConflictId("foo/bar.md", "2026-04-27T22:05:14Z");
55
+ expect(a).toBe(b);
56
+ });
57
+ });
58
+
59
+ describe("readShortMachineId", () => {
60
+ let originalHome: string | undefined;
61
+ let tmpHome: string;
62
+
63
+ beforeEach(() => {
64
+ originalHome = process.env.HOME;
65
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hq-machineid-"));
66
+ process.env.HOME = tmpHome;
67
+ });
68
+
69
+ afterEach(() => {
70
+ if (originalHome) process.env.HOME = originalHome;
71
+ else delete process.env.HOME;
72
+ fs.rmSync(tmpHome, { recursive: true, force: true });
73
+ });
74
+
75
+ it("returns the first 6 chars when menubar.json has a machineId", () => {
76
+ fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
77
+ fs.writeFileSync(
78
+ path.join(tmpHome, ".hq", "menubar.json"),
79
+ JSON.stringify({ machineId: "deadbeefcafe1234567890" }),
80
+ );
81
+ expect(readShortMachineId()).toBe("deadbe");
82
+ });
83
+
84
+ it("falls back to 'unknown' when menubar.json is missing", () => {
85
+ expect(readShortMachineId()).toBe("unknown");
86
+ });
87
+
88
+ it("falls back to 'unknown' when menubar.json is malformed", () => {
89
+ fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
90
+ fs.writeFileSync(path.join(tmpHome, ".hq", "menubar.json"), "{not-json");
91
+ expect(readShortMachineId()).toBe("unknown");
92
+ });
93
+ });
94
+
95
+ describe("conflict index", () => {
96
+ let tmpHq: string;
97
+
98
+ beforeEach(() => {
99
+ tmpHq = fs.mkdtempSync(path.join(os.tmpdir(), "hq-cidx-"));
100
+ });
101
+
102
+ afterEach(() => {
103
+ fs.rmSync(tmpHq, { recursive: true, force: true });
104
+ });
105
+
106
+ function entry(overrides: Partial<ConflictIndexEntry> = {}): ConflictIndexEntry {
107
+ return {
108
+ id: "knowledge-notes-md-2026-04-27T22-05-14Z",
109
+ originalPath: "knowledge/notes.md",
110
+ conflictPath: "knowledge/notes.md.conflict-2026-04-27T22-05-14Z-abc123.md",
111
+ detectedAt: "2026-04-27T22:05:14Z",
112
+ side: "pull",
113
+ machineId: "abc123",
114
+ localHash: "local",
115
+ remoteHash: "remote",
116
+ remoteVersionId: "v2",
117
+ lastKnownVersionId: "v1",
118
+ ...overrides,
119
+ };
120
+ }
121
+
122
+ it("returns an empty index when the file does not exist", () => {
123
+ const idx = readConflictIndex(tmpHq);
124
+ expect(idx).toEqual({ version: 1, conflicts: [] });
125
+ });
126
+
127
+ it("creates the .hq-conflicts dir on first write", () => {
128
+ appendConflictEntry(tmpHq, entry());
129
+ expect(fs.existsSync(getConflictIndexPath(tmpHq))).toBe(true);
130
+ expect(fs.existsSync(path.join(tmpHq, ".hq-conflicts"))).toBe(true);
131
+ });
132
+
133
+ it("appends new entries and dedupes on id (idempotent re-detection)", () => {
134
+ appendConflictEntry(tmpHq, entry({ id: "a", remoteVersionId: "v1" }));
135
+ appendConflictEntry(tmpHq, entry({ id: "b", remoteVersionId: "v1" }));
136
+ // Re-detect "a" — the second push should update in place, not duplicate.
137
+ appendConflictEntry(tmpHq, entry({ id: "a", remoteVersionId: "v9" }));
138
+
139
+ const idx = readConflictIndex(tmpHq);
140
+ expect(idx.conflicts).toHaveLength(2);
141
+ const a = idx.conflicts.find((c) => c.id === "a");
142
+ expect(a?.remoteVersionId).toBe("v9"); // updated, not appended
143
+ });
144
+
145
+ it("sorts conflicts by detectedAt ascending on every write", () => {
146
+ appendConflictEntry(tmpHq, entry({ id: "newer", detectedAt: "2026-04-27T23:00:00Z" }));
147
+ appendConflictEntry(tmpHq, entry({ id: "older", detectedAt: "2026-04-27T22:00:00Z" }));
148
+ const idx = readConflictIndex(tmpHq);
149
+ expect(idx.conflicts.map((c) => c.id)).toEqual(["older", "newer"]);
150
+ });
151
+
152
+ it("removeConflictEntry removes a single entry by id", () => {
153
+ appendConflictEntry(tmpHq, entry({ id: "keep" }));
154
+ appendConflictEntry(tmpHq, entry({ id: "drop" }));
155
+ removeConflictEntry(tmpHq, "drop");
156
+ const idx = readConflictIndex(tmpHq);
157
+ expect(idx.conflicts.map((c) => c.id)).toEqual(["keep"]);
158
+ });
159
+
160
+ it("removeConflictEntry is a no-op when the id is not present", () => {
161
+ appendConflictEntry(tmpHq, entry({ id: "a" }));
162
+ expect(() => removeConflictEntry(tmpHq, "missing")).not.toThrow();
163
+ expect(readConflictIndex(tmpHq).conflicts).toHaveLength(1);
164
+ });
165
+
166
+ it("writeConflictIndex leaves no .tmp files on disk after success", () => {
167
+ writeConflictIndex(tmpHq, { version: 1, conflicts: [entry()] });
168
+ const files = fs.readdirSync(path.join(tmpHq, ".hq-conflicts"));
169
+ // Only index.json should remain; tmp files renamed atomically into place.
170
+ expect(files).toEqual(["index.json"]);
171
+ });
172
+
173
+ it("returns an empty index for malformed-but-parseable JSON", () => {
174
+ const indexPath = getConflictIndexPath(tmpHq);
175
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
176
+ fs.writeFileSync(indexPath, JSON.stringify({ version: 1 })); // no `conflicts` array
177
+ const idx = readConflictIndex(tmpHq);
178
+ expect(idx.conflicts).toEqual([]);
179
+ });
180
+ });
package/src/types.ts CHANGED
@@ -102,3 +102,30 @@ export interface VaultServiceConfig {
102
102
  /** AWS region for S3 client (defaults to entity region or us-east-1) */
103
103
  region?: string;
104
104
  }
105
+
106
+ // ── Conflict index (consumed by /resolve-conflicts) ─────────────────────────
107
+ //
108
+ // Restored from feat/lineage-conflict-tracking (83bf5fa1) so the producer
109
+ // can write `.conflict-<ts>-<machine>.<ext>` mirror files + append to
110
+ // `<hqRoot>/.hq-conflicts/index.json` whenever the hash-comparison detector
111
+ // flags a conflict that doesn't get overwritten or aborted.
112
+
113
+ export interface ConflictIndexEntry {
114
+ id: string;
115
+ originalPath: string;
116
+ conflictPath: string;
117
+ detectedAt: string;
118
+ side: "push" | "pull";
119
+ machineId: string;
120
+ localHash: string;
121
+ remoteHash: string;
122
+ /** S3 VersionId when known (present for VersionId-aware buckets). */
123
+ remoteVersionId?: string;
124
+ /** Last-known parent VersionId from journal, when known. */
125
+ lastKnownVersionId?: string | null;
126
+ }
127
+
128
+ export interface ConflictIndex {
129
+ version: 1;
130
+ conflicts: ConflictIndexEntry[];
131
+ }