@indigoai-us/hq-cloud 5.1.10 → 5.1.12
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 +28 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +104 -37
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +194 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +18 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +48 -7
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +116 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +2 -2
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.test.js +1 -1
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/vault-client.d.ts +12 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +12 -2
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +65 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +256 -1
- package/src/bin/sync-runner.ts +141 -37
- package/src/cli/share.test.ts +142 -0
- package/src/cli/share.ts +66 -9
- package/src/cognito-auth.test.ts +1 -1
- package/src/cognito-auth.ts +2 -2
- package/src/vault-client.test.ts +85 -0
- package/src/vault-client.ts +14 -1
package/src/bin/sync-runner.ts
CHANGED
|
@@ -54,8 +54,24 @@ import type {
|
|
|
54
54
|
SyncResult,
|
|
55
55
|
SyncProgressEvent,
|
|
56
56
|
} from "../cli/sync.js";
|
|
57
|
+
import { share as defaultShare } from "../cli/share.js";
|
|
58
|
+
import type { ShareOptions, ShareResult } from "../cli/share.js";
|
|
57
59
|
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Sync direction for a run.
|
|
63
|
+
*
|
|
64
|
+
* - `pull`: download-only (legacy `hq sync` behaviour, and the default for
|
|
65
|
+
* back-compat with pre-5.1.11 callers of the runner).
|
|
66
|
+
* - `push`: upload-only. Walks the company folder and sends every file whose
|
|
67
|
+
* local hash differs from the journal (skipUnchanged).
|
|
68
|
+
* - `both`: push first, then pull. "Sync Now" in the menubar app targets this.
|
|
69
|
+
* Push runs first so the subsequent pull doesn't redownload files we were
|
|
70
|
+
* about to replace; if a company aborts on push conflict, pull is skipped
|
|
71
|
+
* for that company but the fanout continues.
|
|
72
|
+
*/
|
|
73
|
+
export type Direction = "pull" | "push" | "both";
|
|
74
|
+
|
|
59
75
|
// ---------------------------------------------------------------------------
|
|
60
76
|
// Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
|
|
61
77
|
// imported) to avoid a circular dep between hq-cli and hq-cloud. If these
|
|
@@ -65,8 +81,8 @@ import type { ConflictStrategy } from "../cli/conflict.js";
|
|
|
65
81
|
|
|
66
82
|
const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
67
83
|
region: process.env.AWS_REGION ?? "us-east-1",
|
|
68
|
-
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "hq-
|
|
69
|
-
clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "
|
|
84
|
+
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-dev",
|
|
85
|
+
clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "7r7an9keh0u6hlsvepl74tvqb0",
|
|
70
86
|
port: process.env.HQ_COGNITO_CALLBACK_PORT
|
|
71
87
|
? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
|
|
72
88
|
: 8765,
|
|
@@ -74,7 +90,7 @@ const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
|
74
90
|
|
|
75
91
|
const DEFAULT_VAULT_API_URL =
|
|
76
92
|
process.env.HQ_VAULT_API_URL ??
|
|
77
|
-
"https://
|
|
93
|
+
"https://ky8cgbl4yh.execute-api.us-east-1.amazonaws.com";
|
|
78
94
|
|
|
79
95
|
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
80
96
|
|
|
@@ -97,12 +113,27 @@ export type RunnerEvent =
|
|
|
97
113
|
}
|
|
98
114
|
| ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
|
|
99
115
|
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
100
|
-
| ({
|
|
116
|
+
| ({
|
|
117
|
+
type: "complete";
|
|
118
|
+
company: string;
|
|
119
|
+
/**
|
|
120
|
+
* Upload counters. Always emitted (0 when the run was pull-only) so
|
|
121
|
+
* downstream consumers don't need to conditionally read the field.
|
|
122
|
+
* Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
|
|
123
|
+
* to the Rust struct is a follow-up when the UI needs to surface push
|
|
124
|
+
* totals.
|
|
125
|
+
*/
|
|
126
|
+
filesUploaded: number;
|
|
127
|
+
bytesUploaded: number;
|
|
128
|
+
} & SyncResult)
|
|
101
129
|
| {
|
|
102
130
|
type: "all-complete";
|
|
103
131
|
companiesAttempted: number;
|
|
104
132
|
filesDownloaded: number;
|
|
105
133
|
bytesDownloaded: number;
|
|
134
|
+
/** Always emitted; 0 when no push phase ran. */
|
|
135
|
+
filesUploaded: number;
|
|
136
|
+
bytesUploaded: number;
|
|
106
137
|
errors: Array<{ company: string; message: string }>;
|
|
107
138
|
};
|
|
108
139
|
|
|
@@ -158,6 +189,8 @@ export interface RunnerDeps {
|
|
|
158
189
|
createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
|
|
159
190
|
/** Sync function. Defaults to `cli/sync.sync`. */
|
|
160
191
|
sync?: (options: SyncOptions) => Promise<SyncResult>;
|
|
192
|
+
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
193
|
+
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
161
194
|
}
|
|
162
195
|
|
|
163
196
|
// ---------------------------------------------------------------------------
|
|
@@ -239,6 +272,7 @@ interface ParsedArgs {
|
|
|
239
272
|
company?: string;
|
|
240
273
|
onConflict: ConflictStrategy;
|
|
241
274
|
hqRoot: string;
|
|
275
|
+
direction: Direction;
|
|
242
276
|
}
|
|
243
277
|
|
|
244
278
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -246,6 +280,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
246
280
|
let company: string | undefined;
|
|
247
281
|
let onConflict: ConflictStrategy = "abort";
|
|
248
282
|
let hqRoot = DEFAULT_HQ_ROOT;
|
|
283
|
+
let direction: Direction = "pull";
|
|
249
284
|
|
|
250
285
|
for (let i = 0; i < argv.length; i++) {
|
|
251
286
|
const arg = argv[i];
|
|
@@ -267,6 +302,16 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
267
302
|
onConflict = val;
|
|
268
303
|
break;
|
|
269
304
|
}
|
|
305
|
+
case "--direction": {
|
|
306
|
+
const val = argv[++i];
|
|
307
|
+
if (val !== "pull" && val !== "push" && val !== "both") {
|
|
308
|
+
return {
|
|
309
|
+
error: `--direction must be one of pull|push|both, got: ${val ?? "(missing)"}`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
direction = val;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
270
315
|
case "--hq-root":
|
|
271
316
|
hqRoot = argv[++i];
|
|
272
317
|
if (!hqRoot) return { error: "--hq-root requires a value" };
|
|
@@ -286,7 +331,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
286
331
|
return { error: "Pass --companies or --company <slug>" };
|
|
287
332
|
}
|
|
288
333
|
|
|
289
|
-
return { companies, company, onConflict, hqRoot };
|
|
334
|
+
return { companies, company, onConflict, hqRoot, direction };
|
|
290
335
|
}
|
|
291
336
|
|
|
292
337
|
// ---------------------------------------------------------------------------
|
|
@@ -402,42 +447,99 @@ export async function runRunner(
|
|
|
402
447
|
|
|
403
448
|
// ---- fanout -----------------------------------------------------------
|
|
404
449
|
const syncFn = deps.sync ?? defaultSync;
|
|
405
|
-
|
|
406
|
-
|
|
450
|
+
const shareFn = deps.share ?? defaultShare;
|
|
451
|
+
const doPush = parsed.direction === "push" || parsed.direction === "both";
|
|
452
|
+
const doPull = parsed.direction === "pull" || parsed.direction === "both";
|
|
453
|
+
let totalDownloaded = 0;
|
|
454
|
+
let totalDownloadedBytes = 0;
|
|
455
|
+
let totalUploaded = 0;
|
|
456
|
+
let totalUploadedBytes = 0;
|
|
407
457
|
const errors: Array<{ company: string; message: string }> = [];
|
|
408
458
|
|
|
409
459
|
for (const target of plan) {
|
|
410
460
|
const companyLabel = target.slug;
|
|
461
|
+
// Per-company event tagger — shared by push and pull phases so progress
|
|
462
|
+
// rows land on the right company regardless of which phase emitted them.
|
|
463
|
+
const tagAndEmit = (event: SyncProgressEvent): void => {
|
|
464
|
+
if (event.type === "progress") {
|
|
465
|
+
emit({
|
|
466
|
+
type: "progress",
|
|
467
|
+
company: companyLabel,
|
|
468
|
+
path: event.path,
|
|
469
|
+
bytes: event.bytes,
|
|
470
|
+
...(event.message ? { message: event.message } : {}),
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
emit({
|
|
474
|
+
type: "error",
|
|
475
|
+
company: companyLabel,
|
|
476
|
+
path: event.path,
|
|
477
|
+
message: event.message,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
411
482
|
try {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
483
|
+
let pushResult: ShareResult = {
|
|
484
|
+
filesUploaded: 0,
|
|
485
|
+
bytesUploaded: 0,
|
|
486
|
+
filesSkipped: 0,
|
|
487
|
+
aborted: false,
|
|
488
|
+
};
|
|
489
|
+
let pullResult: SyncResult = {
|
|
490
|
+
filesDownloaded: 0,
|
|
491
|
+
bytesDownloaded: 0,
|
|
492
|
+
filesSkipped: 0,
|
|
493
|
+
conflicts: 0,
|
|
494
|
+
aborted: false,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Push first so a subsequent pull doesn't overwrite files we were about
|
|
498
|
+
// to broadcast. Uses the walk-everything-under-companies/{slug}/ entry
|
|
499
|
+
// point with `skipUnchanged` so we don't re-upload files that haven't
|
|
500
|
+
// changed since the last sync.
|
|
501
|
+
if (doPush) {
|
|
502
|
+
pushResult = await shareFn({
|
|
503
|
+
paths: [path.join(parsed.hqRoot, "companies", target.slug)],
|
|
504
|
+
company: target.uid,
|
|
505
|
+
vaultConfig,
|
|
506
|
+
hqRoot: parsed.hqRoot,
|
|
507
|
+
onConflict: parsed.onConflict,
|
|
508
|
+
skipUnchanged: true,
|
|
509
|
+
onEvent: tagAndEmit,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Pull runs unless the push phase aborted on conflict — aborted means
|
|
514
|
+
// the user has local edits + remote drift; blindly pulling would erase
|
|
515
|
+
// whichever side `--on-conflict abort` just protected.
|
|
516
|
+
if (doPull && !pushResult.aborted) {
|
|
517
|
+
pullResult = await syncFn({
|
|
518
|
+
company: target.uid,
|
|
519
|
+
vaultConfig,
|
|
520
|
+
hqRoot: parsed.hqRoot,
|
|
521
|
+
onConflict: parsed.onConflict,
|
|
522
|
+
onEvent: tagAndEmit,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
emit({
|
|
527
|
+
type: "complete",
|
|
528
|
+
company: companyLabel,
|
|
529
|
+
filesDownloaded: pullResult.filesDownloaded,
|
|
530
|
+
bytesDownloaded: pullResult.bytesDownloaded,
|
|
531
|
+
filesUploaded: pushResult.filesUploaded,
|
|
532
|
+
bytesUploaded: pushResult.bytesUploaded,
|
|
533
|
+
filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
|
|
534
|
+
conflicts: pullResult.conflicts,
|
|
535
|
+
// Either phase aborting marks the company aborted — the UI treats
|
|
536
|
+
// `aborted: true` as "sync didn't complete cleanly for this company".
|
|
537
|
+
aborted: pullResult.aborted || pushResult.aborted,
|
|
437
538
|
});
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
539
|
+
totalDownloaded += pullResult.filesDownloaded;
|
|
540
|
+
totalDownloadedBytes += pullResult.bytesDownloaded;
|
|
541
|
+
totalUploaded += pushResult.filesUploaded;
|
|
542
|
+
totalUploadedBytes += pushResult.bytesUploaded;
|
|
441
543
|
} catch (err) {
|
|
442
544
|
const message = err instanceof Error ? err.message : String(err);
|
|
443
545
|
errors.push({ company: companyLabel, message });
|
|
@@ -454,8 +556,10 @@ export async function runRunner(
|
|
|
454
556
|
emit({
|
|
455
557
|
type: "all-complete",
|
|
456
558
|
companiesAttempted: plan.length,
|
|
457
|
-
filesDownloaded:
|
|
458
|
-
bytesDownloaded:
|
|
559
|
+
filesDownloaded: totalDownloaded,
|
|
560
|
+
bytesDownloaded: totalDownloadedBytes,
|
|
561
|
+
filesUploaded: totalUploaded,
|
|
562
|
+
bytesUploaded: totalUploadedBytes,
|
|
459
563
|
errors,
|
|
460
564
|
});
|
|
461
565
|
return 0;
|
package/src/cli/share.test.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
165
|
+
emit({
|
|
166
|
+
type: "progress",
|
|
167
|
+
path: relativePath,
|
|
168
|
+
bytes: stat.size,
|
|
169
|
+
...(message ? { message } : {}),
|
|
170
|
+
});
|
|
132
171
|
} catch (err) {
|
|
133
|
-
|
|
134
|
-
|
|
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
|
*/
|
package/src/cognito-auth.test.ts
CHANGED
|
@@ -146,7 +146,7 @@ describe("expiresAt shape round-trip", () => {
|
|
|
146
146
|
const result = await refreshTokens(
|
|
147
147
|
{
|
|
148
148
|
region: "us-east-1",
|
|
149
|
-
userPoolDomain: "hq-
|
|
149
|
+
userPoolDomain: "vault-indigo-hq-dev",
|
|
150
150
|
clientId: "test-client",
|
|
151
151
|
},
|
|
152
152
|
"prior-refresh-token",
|
package/src/cognito-auth.ts
CHANGED
|
@@ -30,9 +30,9 @@ import open from "open";
|
|
|
30
30
|
export interface CognitoAuthConfig {
|
|
31
31
|
/** AWS region the User Pool lives in (e.g. "us-east-1"). */
|
|
32
32
|
region: string;
|
|
33
|
-
/** Cognito User Pool Domain prefix (e.g. "vault-indigo-
|
|
33
|
+
/** Cognito User Pool Domain prefix (e.g. "vault-indigo-hq-dev"). */
|
|
34
34
|
userPoolDomain: string;
|
|
35
|
-
/** App Client ID (e.g. "
|
|
35
|
+
/** App Client ID (e.g. "7r7an9keh0u6hlsvepl74tvqb0"). */
|
|
36
36
|
clientId: string;
|
|
37
37
|
/** Loopback callback port. Defaults to 3000. */
|
|
38
38
|
port?: number;
|
package/src/vault-client.test.ts
CHANGED
|
@@ -560,4 +560,89 @@ 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
|
+
});
|
|
623
|
+
|
|
624
|
+
it("vendSelf_roundtrip", async () => {
|
|
625
|
+
fetchSpy.mockResolvedValueOnce(
|
|
626
|
+
jsonResponse(200, {
|
|
627
|
+
credentials: {
|
|
628
|
+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
|
629
|
+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
630
|
+
sessionToken: "FwoGZXIvYXdzEBY...",
|
|
631
|
+
},
|
|
632
|
+
expiresAt: "2026-01-01T01:00:00.000Z",
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const result = await client.sts.vendSelf({ personUid: "prs_x" });
|
|
637
|
+
|
|
638
|
+
expect(result.credentials.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE");
|
|
639
|
+
expect(result.credentials.secretAccessKey).toBe("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
|
|
640
|
+
expect(result.credentials.sessionToken).toBe("FwoGZXIvYXdzEBY...");
|
|
641
|
+
expect(typeof result.expiresAt).toBe("string");
|
|
642
|
+
|
|
643
|
+
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
|
644
|
+
expect(url).toBe("https://vault.test.example.com/sts/vend-self");
|
|
645
|
+
expect((init.method as string).toUpperCase()).toBe("POST");
|
|
646
|
+
expect(JSON.parse(init.body as string)).toEqual({ personUid: "prs_x" });
|
|
647
|
+
});
|
|
563
648
|
});
|