@indigoai-us/hq-cloud 5.1.10 → 5.1.11

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.
@@ -198,4 +198,146 @@ describe("share", () => {
198
198
 
199
199
  expect(result.filesUploaded).toBe(1);
200
200
  });
201
+
202
+ it("skipUnchanged=true skips files whose local hash matches the journal", async () => {
203
+ const companyRoot = path.join(tmpDir, "companies", "acme");
204
+ fs.mkdirSync(companyRoot, { recursive: true });
205
+ const testFile = path.join(companyRoot, "unchanged.md");
206
+ fs.writeFileSync(testFile, "stable content");
207
+
208
+ // Precompute the hash of the file so the journal matches exactly.
209
+ const { hashFile } = await import("../journal.js");
210
+ const hash = hashFile(testFile);
211
+
212
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
213
+ fs.writeFileSync(
214
+ journalPath,
215
+ JSON.stringify({
216
+ version: "1",
217
+ lastSync: new Date().toISOString(),
218
+ files: {
219
+ "unchanged.md": {
220
+ hash,
221
+ size: 15,
222
+ syncedAt: new Date().toISOString(),
223
+ direction: "up",
224
+ },
225
+ },
226
+ }),
227
+ );
228
+
229
+ const result = await share({
230
+ paths: [testFile],
231
+ company: "acme",
232
+ vaultConfig: mockConfig,
233
+ hqRoot: tmpDir,
234
+ skipUnchanged: true,
235
+ });
236
+
237
+ expect(result.filesUploaded).toBe(0);
238
+ expect(result.filesSkipped).toBe(1);
239
+ expect(uploadFile).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("skipUnchanged=true still uploads files whose hash differs from the journal", async () => {
243
+ const companyRoot = path.join(tmpDir, "companies", "acme");
244
+ fs.mkdirSync(companyRoot, { recursive: true });
245
+ const testFile = path.join(companyRoot, "changed.md");
246
+ fs.writeFileSync(testFile, "new content");
247
+
248
+ // Journal has a stale hash for this path — simulating "local has been
249
+ // edited since the last push".
250
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
251
+ fs.writeFileSync(
252
+ journalPath,
253
+ JSON.stringify({
254
+ version: "1",
255
+ lastSync: new Date().toISOString(),
256
+ files: {
257
+ "changed.md": {
258
+ hash: "stale-hash-from-previous-sync",
259
+ size: 10,
260
+ syncedAt: new Date().toISOString(),
261
+ direction: "up",
262
+ },
263
+ },
264
+ }),
265
+ );
266
+
267
+ const result = await share({
268
+ paths: [testFile],
269
+ company: "acme",
270
+ vaultConfig: mockConfig,
271
+ hqRoot: tmpDir,
272
+ skipUnchanged: true,
273
+ });
274
+
275
+ expect(result.filesUploaded).toBe(1);
276
+ expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "changed.md");
277
+ });
278
+
279
+ it("skipUnchanged=false (default) uploads even when hash matches", async () => {
280
+ const companyRoot = path.join(tmpDir, "companies", "acme");
281
+ fs.mkdirSync(companyRoot, { recursive: true });
282
+ const testFile = path.join(companyRoot, "unchanged.md");
283
+ fs.writeFileSync(testFile, "stable content");
284
+
285
+ const { hashFile } = await import("../journal.js");
286
+ const hash = hashFile(testFile);
287
+
288
+ const journalPath = path.join(stateDir, "sync-journal.acme.json");
289
+ fs.writeFileSync(
290
+ journalPath,
291
+ JSON.stringify({
292
+ version: "1",
293
+ lastSync: new Date().toISOString(),
294
+ files: {
295
+ "unchanged.md": {
296
+ hash,
297
+ size: 15,
298
+ syncedAt: new Date().toISOString(),
299
+ direction: "up",
300
+ },
301
+ },
302
+ }),
303
+ );
304
+
305
+ const result = await share({
306
+ paths: [testFile],
307
+ company: "acme",
308
+ vaultConfig: mockConfig,
309
+ hqRoot: tmpDir,
310
+ // skipUnchanged omitted — preserves `hq share <file>` semantics
311
+ });
312
+
313
+ expect(result.filesUploaded).toBe(1);
314
+ expect(uploadFile).toHaveBeenCalled();
315
+ });
316
+
317
+ it("onEvent receives progress events instead of console output", async () => {
318
+ const companyRoot = path.join(tmpDir, "companies", "acme");
319
+ fs.mkdirSync(companyRoot, { recursive: true });
320
+ fs.writeFileSync(path.join(companyRoot, "a.md"), "aaa");
321
+ fs.writeFileSync(path.join(companyRoot, "b.md"), "bbb");
322
+
323
+ const events: Array<{ type: string; path: string; bytes?: number }> = [];
324
+ const result = await share({
325
+ paths: [companyRoot],
326
+ company: "acme",
327
+ vaultConfig: mockConfig,
328
+ hqRoot: tmpDir,
329
+ onEvent: (e) => {
330
+ events.push({
331
+ type: e.type,
332
+ path: e.path,
333
+ ...(e.type === "progress" ? { bytes: e.bytes } : {}),
334
+ });
335
+ },
336
+ });
337
+
338
+ expect(result.filesUploaded).toBe(2);
339
+ expect(events).toHaveLength(2);
340
+ expect(events.every((e) => e.type === "progress")).toBe(true);
341
+ expect(events.map((e) => e.path).sort()).toEqual(["a.md", "b.md"]);
342
+ });
201
343
  });
package/src/cli/share.ts CHANGED
@@ -14,6 +14,7 @@ import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js"
14
14
  import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
15
15
  import { resolveConflict } from "./conflict.js";
16
16
  import type { ConflictStrategy } from "./conflict.js";
17
+ import type { SyncProgressEvent } from "./sync.js";
17
18
 
18
19
  export interface ShareOptions {
19
20
  /** Path(s) to share (files or directories) */
@@ -28,6 +29,23 @@ export interface ShareOptions {
28
29
  vaultConfig: VaultServiceConfig;
29
30
  /** HQ root directory */
30
31
  hqRoot: string;
32
+ /**
33
+ * Per-file event callback. When present, suppresses the default
34
+ * `console.log`/`console.error` human output — same contract as `sync()`.
35
+ * This is the seam `hq-sync-runner` uses to stream ndjson for push events.
36
+ */
37
+ onEvent?: (event: SyncProgressEvent) => void;
38
+ /**
39
+ * When true, files whose local hash matches the journal entry from the
40
+ * last sync are skipped (no remote HEAD, no upload). This is the gate
41
+ * that makes "push everything that changed" efficient — without it, a
42
+ * bidirectional Sync Now would re-upload every file each tick.
43
+ *
44
+ * Default false to preserve `hq share <file>` semantics: when a user
45
+ * explicitly names a file, they expect it to be sent even if the local
46
+ * hash matches the last-sync state (e.g. to re-heal a bucket).
47
+ */
48
+ skipUnchanged?: boolean;
31
49
  }
32
50
 
33
51
  export interface ShareResult {
@@ -41,7 +59,8 @@ export interface ShareResult {
41
59
  * Share local file(s) to the entity vault.
42
60
  */
43
61
  export async function share(options: ShareOptions): Promise<ShareResult> {
44
- const { paths, company, message, onConflict, vaultConfig, hqRoot } = options;
62
+ const { paths, company, message, onConflict, vaultConfig, hqRoot, skipUnchanged } = options;
63
+ const emit = options.onEvent ?? defaultConsoleLogger;
45
64
 
46
65
  // Resolve company — slug, UID, or from active config
47
66
  const companyRef = company ?? resolveActiveCompany(hqRoot);
@@ -70,11 +89,28 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
70
89
 
71
90
  for (const { absolutePath, relativePath } of filesToShare) {
72
91
  if (!isWithinSizeLimit(absolutePath)) {
73
- console.error(` Skipped (too large): ${relativePath}`);
92
+ emit({
93
+ type: "error",
94
+ path: relativePath,
95
+ message: "file exceeds size limit",
96
+ });
74
97
  filesSkipped++;
75
98
  continue;
76
99
  }
77
100
 
101
+ // Skip-if-unchanged gate: the hot path for bidirectional Sync Now. When
102
+ // walking an entire company folder, this is what keeps us from re-uploading
103
+ // every file every tick. Off by default so `hq share <file>` keeps its
104
+ // explicit-intent semantics (user named it, user wants it sent).
105
+ const localHash = hashFile(absolutePath);
106
+ if (skipUnchanged) {
107
+ const existing = journal.files[relativePath];
108
+ if (existing && existing.hash === localHash) {
109
+ filesSkipped++;
110
+ continue;
111
+ }
112
+ }
113
+
78
114
  // Auto-refresh context if credentials expiring
79
115
  if (isExpiringSoon(ctx.expiresAt)) {
80
116
  ctx = await refreshEntityContext(companyRef, vaultConfig);
@@ -84,7 +120,6 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
84
120
  const remoteMeta = await headRemoteFile(ctx, relativePath);
85
121
  if (remoteMeta) {
86
122
  const journalEntry = journal.files[relativePath];
87
- const localHash = hashFile(absolutePath);
88
123
 
89
124
  // If remote has changed since our last sync, it's a conflict
90
125
  if (journalEntry && journalEntry.hash !== localHash) {
@@ -113,12 +148,11 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
113
148
  // Upload
114
149
  try {
115
150
  const stat = fs.statSync(absolutePath);
116
- const hash = hashFile(absolutePath);
117
151
 
118
152
  await uploadFile(ctx, absolutePath, relativePath);
119
153
 
120
154
  // Update journal with optional message
121
- updateEntry(journal, relativePath, hash, stat.size, "up");
155
+ updateEntry(journal, relativePath, localHash, stat.size, "up");
122
156
  if (message) {
123
157
  journal.files[relativePath] = {
124
158
  ...journal.files[relativePath],
@@ -128,11 +162,18 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
128
162
 
129
163
  filesUploaded++;
130
164
  bytesUploaded += stat.size;
131
- console.log(` ✓ ${relativePath}`);
165
+ emit({
166
+ type: "progress",
167
+ path: relativePath,
168
+ bytes: stat.size,
169
+ ...(message ? { message } : {}),
170
+ });
132
171
  } catch (err) {
133
- console.error(
134
- ` ✗ ${relativePath} — ${err instanceof Error ? err.message : err}`,
135
- );
172
+ emit({
173
+ type: "error",
174
+ path: relativePath,
175
+ message: err instanceof Error ? err.message : String(err),
176
+ });
136
177
  }
137
178
  }
138
179
 
@@ -141,6 +182,22 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
141
182
  return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
142
183
  }
143
184
 
185
+ /**
186
+ * Default human-readable share output. Preserves the exact format the CLI
187
+ * emitted before `onEvent` was added — tty users see no change.
188
+ */
189
+ function defaultConsoleLogger(event: SyncProgressEvent): void {
190
+ if (event.type === "progress") {
191
+ if (event.message) {
192
+ console.log(` ✓ ${event.path} — "${event.message}"`);
193
+ } else {
194
+ console.log(` ✓ ${event.path}`);
195
+ }
196
+ } else {
197
+ console.error(` ✗ ${event.path} — ${event.message}`);
198
+ }
199
+ }
200
+
144
201
  /**
145
202
  * Resolve active company from .hq/config.json or parent directory chain.
146
203
  */
@@ -560,4 +560,64 @@ describe("VaultClient identity bootstrap", () => {
560
560
  const body = JSON.parse(init.body as string);
561
561
  expect(body.slug).toBe("user-12345678");
562
562
  });
563
+
564
+ it("listByType_roundtrips_createdAt", async () => {
565
+ fetchSpy.mockResolvedValueOnce(
566
+ jsonResponse(200, {
567
+ entities: [
568
+ {
569
+ uid: "prs_x",
570
+ slug: "alice",
571
+ type: "person",
572
+ status: "active",
573
+ createdAt: "2026-01-01T00:00:00Z",
574
+ },
575
+ ],
576
+ }),
577
+ );
578
+
579
+ const entities = await client.entity.listByType("person");
580
+ expect(entities).toHaveLength(1);
581
+ expect(entities[0].createdAt).toBe("2026-01-01T00:00:00Z");
582
+ });
583
+
584
+ it("ensureMyPersonEntity_picks_oldest_when_multiple", async () => {
585
+ fetchSpy.mockResolvedValueOnce(
586
+ jsonResponse(200, {
587
+ entities: [
588
+ { uid: "prs_b", slug: "b", type: "person", status: "active", createdAt: "2026-03-01T00:00:00Z" },
589
+ { uid: "prs_a", slug: "a", type: "person", status: "active", createdAt: "2026-01-01T00:00:00Z" },
590
+ { uid: "prs_c", slug: "c", type: "person", status: "active", createdAt: "2026-06-01T00:00:00Z" },
591
+ ],
592
+ }),
593
+ );
594
+
595
+ const person = await client.ensureMyPersonEntity({
596
+ ownerSub: "sub-multi",
597
+ displayName: "Multi User",
598
+ });
599
+
600
+ expect(person.uid).toBe("prs_a");
601
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
602
+ });
603
+
604
+ it("ensureMyPersonEntity_handles_missing_createdAt_deterministically", async () => {
605
+ fetchSpy.mockResolvedValueOnce(
606
+ jsonResponse(200, {
607
+ entities: [
608
+ { uid: "prs_z", slug: "z", type: "person", status: "active" },
609
+ { uid: "prs_a", slug: "a", type: "person", status: "active" },
610
+ ],
611
+ }),
612
+ );
613
+
614
+ const person = await client.ensureMyPersonEntity({
615
+ ownerSub: "sub-nodates",
616
+ displayName: "No Dates User",
617
+ });
618
+
619
+ // Both missing createdAt → "" tie, uid tiebreak selects prs_a
620
+ expect(person.uid).toBe("prs_a");
621
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
622
+ });
563
623
  });
@@ -109,6 +109,7 @@ export interface EntityInfo {
109
109
  name?: string;
110
110
  bucketName?: string;
111
111
  status: string;
112
+ createdAt: string;
112
113
  }
113
114
 
114
115
  export interface PendingInviteByEmail {
@@ -343,7 +344,13 @@ export class VaultClient {
343
344
  displayName: string;
344
345
  }): Promise<EntityInfo> {
345
346
  const existing = await this.entity.listByType("person");
346
- if (existing.length > 0) return existing[0];
347
+ const sorted = [...existing].sort((a, b) => {
348
+ const ac = (a.createdAt as string | undefined) ?? "";
349
+ const bc = (b.createdAt as string | undefined) ?? "";
350
+ if (ac !== bc) return ac < bc ? -1 : 1;
351
+ return a.uid < b.uid ? -1 : 1;
352
+ });
353
+ if (sorted.length > 0) return sorted[0];
347
354
 
348
355
  const slug =
349
356
  hints.displayName