@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.
- package/dist/bin/sync-runner.d.ts +13 -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 +37 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +82 -16
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +102 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +22 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +187 -62
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +81 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/conflict-file.d.ts +46 -0
- package/dist/lib/conflict-file.d.ts.map +1 -0
- package/dist/lib/conflict-file.js +86 -0
- package/dist/lib/conflict-file.js.map +1 -0
- package/dist/lib/conflict-index.d.ts +66 -0
- package/dist/lib/conflict-index.d.ts.map +1 -0
- package/dist/lib/conflict-index.js +112 -0
- package/dist/lib/conflict-index.js.map +1 -0
- package/dist/lib/conflict.test.d.ts +7 -0
- package/dist/lib/conflict.test.d.ts.map +1 -0
- package/dist/lib/conflict.test.js +136 -0
- package/dist/lib/conflict.test.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +43 -0
- package/src/bin/sync-runner.ts +25 -2
- package/src/cli/share.test.ts +125 -0
- package/src/cli/share.ts +133 -18
- package/src/cli/sync.test.ts +97 -0
- package/src/cli/sync.ts +277 -68
- package/src/lib/conflict-file.ts +101 -0
- package/src/lib/conflict-index.ts +127 -0
- package/src/lib/conflict.test.ts +180 -0
- 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
|
+
}
|