@indigoai-us/hq-cloud 5.3.0 → 5.4.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 +45 -16
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +76 -13
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +65 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +6 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +25 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +45 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +12 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +27 -2
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +34 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +6 -4
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +15 -0
- package/dist/ignore.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +80 -17
- package/src/bin/sync-runner.ts +91 -17
- package/src/cli/share.test.ts +56 -0
- package/src/cli/share.ts +34 -2
- package/src/cli/sync.test.ts +44 -0
- package/src/cli/sync.ts +43 -4
- package/src/ignore.test.ts +16 -0
- package/src/ignore.ts +6 -4
package/src/cli/share.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface ShareResult {
|
|
|
52
52
|
filesUploaded: number;
|
|
53
53
|
bytesUploaded: number;
|
|
54
54
|
filesSkipped: number;
|
|
55
|
+
/**
|
|
56
|
+
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
57
|
+
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
58
|
+
* way to runner/UI consumers.
|
|
59
|
+
*/
|
|
60
|
+
conflictPaths: string[];
|
|
55
61
|
aborted: boolean;
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -83,6 +89,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
83
89
|
let filesUploaded = 0;
|
|
84
90
|
let bytesUploaded = 0;
|
|
85
91
|
let filesSkipped = 0;
|
|
92
|
+
const conflictPaths: string[] = [];
|
|
86
93
|
|
|
87
94
|
// Collect all files to share
|
|
88
95
|
const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
|
|
@@ -123,6 +130,8 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
123
130
|
|
|
124
131
|
// If remote has changed since our last sync, it's a conflict
|
|
125
132
|
if (journalEntry && journalEntry.hash !== localHash) {
|
|
133
|
+
conflictPaths.push(relativePath);
|
|
134
|
+
|
|
126
135
|
// Local has changes — check if remote also changed
|
|
127
136
|
const resolution = await resolveConflict(
|
|
128
137
|
{
|
|
@@ -134,8 +143,21 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
134
143
|
onConflict,
|
|
135
144
|
);
|
|
136
145
|
|
|
146
|
+
emit({
|
|
147
|
+
type: "conflict",
|
|
148
|
+
path: relativePath,
|
|
149
|
+
direction: "push",
|
|
150
|
+
resolution,
|
|
151
|
+
});
|
|
152
|
+
|
|
137
153
|
if (resolution === "abort") {
|
|
138
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
filesUploaded,
|
|
156
|
+
bytesUploaded,
|
|
157
|
+
filesSkipped,
|
|
158
|
+
conflictPaths,
|
|
159
|
+
aborted: true,
|
|
160
|
+
};
|
|
139
161
|
}
|
|
140
162
|
if (resolution === "keep" || resolution === "skip") {
|
|
141
163
|
filesSkipped++;
|
|
@@ -179,7 +201,13 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
179
201
|
|
|
180
202
|
writeJournal(ctx.slug, journal);
|
|
181
203
|
|
|
182
|
-
return {
|
|
204
|
+
return {
|
|
205
|
+
filesUploaded,
|
|
206
|
+
bytesUploaded,
|
|
207
|
+
filesSkipped,
|
|
208
|
+
conflictPaths,
|
|
209
|
+
aborted: false,
|
|
210
|
+
};
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
/**
|
|
@@ -193,6 +221,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
193
221
|
} else {
|
|
194
222
|
console.log(` ✓ ${event.path}`);
|
|
195
223
|
}
|
|
224
|
+
} else if (event.type === "conflict") {
|
|
225
|
+
console.error(
|
|
226
|
+
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
227
|
+
);
|
|
196
228
|
} else {
|
|
197
229
|
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
198
230
|
}
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -176,10 +176,54 @@ describe("sync", () => {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
expect(result.conflicts).toBe(1);
|
|
179
|
+
expect(result.conflictPaths).toEqual(["docs/handoff.md"]);
|
|
179
180
|
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
180
181
|
expect(fs.readFileSync(path.join(companyDocs, "handoff.md"), "utf-8")).toBe("local version");
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
185
|
+
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
186
|
+
fs.mkdirSync(companyDocs, { recursive: true });
|
|
187
|
+
fs.writeFileSync(path.join(companyDocs, "handoff.md"), "local version");
|
|
188
|
+
|
|
189
|
+
fs.writeFileSync(
|
|
190
|
+
journalPath,
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
version: "1",
|
|
193
|
+
lastSync: new Date().toISOString(),
|
|
194
|
+
files: {
|
|
195
|
+
"docs/handoff.md": {
|
|
196
|
+
hash: "stale-hash",
|
|
197
|
+
size: 20,
|
|
198
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
199
|
+
direction: "down",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const events: unknown[] = [];
|
|
206
|
+
await sync({
|
|
207
|
+
company: "acme",
|
|
208
|
+
onConflict: "keep",
|
|
209
|
+
vaultConfig: mockConfig,
|
|
210
|
+
hqRoot: tmpDir,
|
|
211
|
+
onEvent: (e) => events.push(e),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const conflicts = events.filter(
|
|
215
|
+
(e): e is { type: "conflict"; path: string; direction: "pull"; resolution: string } =>
|
|
216
|
+
typeof e === "object" && e !== null && (e as { type?: string }).type === "conflict",
|
|
217
|
+
);
|
|
218
|
+
expect(conflicts).toHaveLength(1);
|
|
219
|
+
expect(conflicts[0]).toMatchObject({
|
|
220
|
+
type: "conflict",
|
|
221
|
+
path: "docs/handoff.md",
|
|
222
|
+
direction: "pull",
|
|
223
|
+
resolution: "keep",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
183
227
|
it("aborts on --on-conflict abort", async () => {
|
|
184
228
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
185
229
|
fs.mkdirSync(companyDocs, { recursive: true });
|
package/src/cli/sync.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
|
13
13
|
import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
|
|
14
14
|
import { createIgnoreFilter } from "../ignore.js";
|
|
15
15
|
import { resolveConflict } from "./conflict.js";
|
|
16
|
-
import type { ConflictStrategy } from "./conflict.js";
|
|
16
|
+
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Per-file events emitted by `sync()` as it progresses.
|
|
@@ -28,7 +28,13 @@ import type { ConflictStrategy } from "./conflict.js";
|
|
|
28
28
|
*/
|
|
29
29
|
export type SyncProgressEvent =
|
|
30
30
|
| { type: "progress"; path: string; bytes: number; message?: string }
|
|
31
|
-
| { type: "error"; path: string; message: string }
|
|
31
|
+
| { type: "error"; path: string; message: string }
|
|
32
|
+
| {
|
|
33
|
+
type: "conflict";
|
|
34
|
+
path: string;
|
|
35
|
+
direction: "pull" | "push";
|
|
36
|
+
resolution: ConflictResolution;
|
|
37
|
+
};
|
|
32
38
|
|
|
33
39
|
export interface SyncOptions {
|
|
34
40
|
/** Company slug or UID (defaults to active company from config) */
|
|
@@ -66,6 +72,12 @@ export interface SyncResult {
|
|
|
66
72
|
bytesDownloaded: number;
|
|
67
73
|
filesSkipped: number;
|
|
68
74
|
conflicts: number;
|
|
75
|
+
/**
|
|
76
|
+
* Paths (remote keys) that were detected as conflicts during this run.
|
|
77
|
+
* Always populated when `conflicts > 0` so callers can surface them in UI
|
|
78
|
+
* or logs without re-streaming the per-file events.
|
|
79
|
+
*/
|
|
80
|
+
conflictPaths: string[];
|
|
69
81
|
aborted: boolean;
|
|
70
82
|
}
|
|
71
83
|
|
|
@@ -106,6 +118,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
106
118
|
let bytesDownloaded = 0;
|
|
107
119
|
let filesSkipped = 0;
|
|
108
120
|
let conflicts = 0;
|
|
121
|
+
const conflictPaths: string[] = [];
|
|
109
122
|
|
|
110
123
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
111
124
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
@@ -138,6 +151,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
138
151
|
// If local file has changed since last sync, it's a conflict
|
|
139
152
|
if (journalEntry && journalEntry.hash !== localHash) {
|
|
140
153
|
conflicts++;
|
|
154
|
+
conflictPaths.push(remoteFile.key);
|
|
141
155
|
|
|
142
156
|
const resolution = await resolveConflict(
|
|
143
157
|
{
|
|
@@ -150,9 +164,23 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
150
164
|
onConflict,
|
|
151
165
|
);
|
|
152
166
|
|
|
167
|
+
emit({
|
|
168
|
+
type: "conflict",
|
|
169
|
+
path: remoteFile.key,
|
|
170
|
+
direction: "pull",
|
|
171
|
+
resolution,
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
if (resolution === "abort") {
|
|
154
175
|
writeJournal(journalSlug, journal);
|
|
155
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
filesDownloaded,
|
|
178
|
+
bytesDownloaded,
|
|
179
|
+
filesSkipped,
|
|
180
|
+
conflicts,
|
|
181
|
+
conflictPaths,
|
|
182
|
+
aborted: true,
|
|
183
|
+
};
|
|
156
184
|
}
|
|
157
185
|
if (resolution === "keep" || resolution === "skip") {
|
|
158
186
|
filesSkipped++;
|
|
@@ -208,7 +236,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
208
236
|
|
|
209
237
|
writeJournal(journalSlug, journal);
|
|
210
238
|
|
|
211
|
-
return {
|
|
239
|
+
return {
|
|
240
|
+
filesDownloaded,
|
|
241
|
+
bytesDownloaded,
|
|
242
|
+
filesSkipped,
|
|
243
|
+
conflicts,
|
|
244
|
+
conflictPaths,
|
|
245
|
+
aborted: false,
|
|
246
|
+
};
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
/**
|
|
@@ -251,5 +286,9 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
251
286
|
}
|
|
252
287
|
} else if (event.type === "error") {
|
|
253
288
|
console.error(` ✗ ${event.path} — ${event.message}`);
|
|
289
|
+
} else if (event.type === "conflict") {
|
|
290
|
+
console.error(
|
|
291
|
+
` ⚠ conflict (${event.direction}): ${event.path} — ${event.resolution}`,
|
|
292
|
+
);
|
|
254
293
|
}
|
|
255
294
|
}
|
package/src/ignore.test.ts
CHANGED
|
@@ -38,6 +38,22 @@ describe("createIgnoreFilter", () => {
|
|
|
38
38
|
expect(shouldSync(path.join(hqRoot, "companies/indigo/notes.md"))).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
|
|
42
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
43
|
+
// Internal state files that must never round-trip through the bucket.
|
|
44
|
+
expect(shouldSync(path.join(hqRoot, ".hq-sync.pid"))).toBe(false);
|
|
45
|
+
expect(shouldSync(path.join(hqRoot, ".hq-sync-journal.json"))).toBe(false);
|
|
46
|
+
expect(shouldSync(path.join(hqRoot, ".hq-sync-state.json"))).toBe(false);
|
|
47
|
+
expect(shouldSync(path.join(hqRoot, ".hq-embeddings-pending.json"))).toBe(false);
|
|
48
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq-foo.json"))).toBe(false);
|
|
49
|
+
expect(shouldSync(path.join(hqRoot, ".hq-cache/blob.bin"))).toBe(false);
|
|
50
|
+
// Sync-config files and the .hq/ directory still sync.
|
|
51
|
+
expect(shouldSync(path.join(hqRoot, ".hqignore"))).toBe(true);
|
|
52
|
+
expect(shouldSync(path.join(hqRoot, ".hqsyncignore"))).toBe(true);
|
|
53
|
+
expect(shouldSync(path.join(hqRoot, ".hqinclude"))).toBe(true);
|
|
54
|
+
expect(shouldSync(path.join(hqRoot, "companies/indigo/.hq/config.json"))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
41
57
|
it("allowlist mode: presence of .hqinclude switches to opt-in", () => {
|
|
42
58
|
fs.writeFileSync(
|
|
43
59
|
path.join(hqRoot, ".hqinclude"),
|
package/src/ignore.ts
CHANGED
|
@@ -67,11 +67,13 @@ export const DEFAULT_IGNORES = [
|
|
|
67
67
|
"tmp/",
|
|
68
68
|
".tmp/",
|
|
69
69
|
|
|
70
|
-
// HQ sync internal state (never round-trip these)
|
|
70
|
+
// HQ sync internal state (never round-trip these). The `.hq-*` wildcard
|
|
71
|
+
// covers `.hq-sync.pid`, `.hq-sync-journal.json`, `.hq-sync-state.json`,
|
|
72
|
+
// `.hq-embeddings-pending.json`, and any future internal-state file. The
|
|
73
|
+
// `.hqignore` / `.hqsyncignore` / `.hqinclude` config files don't match
|
|
74
|
+
// (no hyphen) and the `.hq/` directory is unaffected.
|
|
71
75
|
"*.pid",
|
|
72
|
-
".hq
|
|
73
|
-
".hq-sync-journal.json",
|
|
74
|
-
".hq-sync-state.json",
|
|
76
|
+
".hq-*",
|
|
75
77
|
"modules.lock",
|
|
76
78
|
|
|
77
79
|
// HQ repos directory (managed separately, not synced)
|