@indigoai-us/hq-cli 5.12.5 → 5.13.0
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/CHANGELOG.md +51 -0
- package/dist/commands/cloud.d.ts +68 -0
- package/dist/commands/cloud.js +459 -8
- package/dist/index.js +11 -2
- package/package.json +1 -1
- package/src/commands/cloud.push-all.test.ts +381 -0
- package/src/commands/cloud.selectors.test.ts +155 -0
- package/src/commands/cloud.ts +674 -8
- package/src/index.ts +10 -0
package/src/commands/cloud.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
getJournalPath,
|
|
26
26
|
loadCachedTokens,
|
|
27
27
|
VaultClient,
|
|
28
|
+
computePersonalVaultPaths,
|
|
28
29
|
type ConflictStrategy,
|
|
29
30
|
type EntityContext,
|
|
30
31
|
type SyncProgressEvent,
|
|
@@ -98,6 +99,63 @@ export interface PullAllRow {
|
|
|
98
99
|
error?: string;
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
// `hq sync push --all` orchestrator — symmetric with pullAll.
|
|
104
|
+
//
|
|
105
|
+
// Plan: every membership → company target (paths = the company's local
|
|
106
|
+
// subtree); plus the canonical person entity → personal target (paths =
|
|
107
|
+
// computePersonalVaultPaths(hqRoot)). Each target calls share() with
|
|
108
|
+
// skipUnchanged + propagateDeletes so re-runs are cheap and on-disk deletes
|
|
109
|
+
// propagate to the vault — same defaults the menubar runner uses.
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface ShareCallOptions {
|
|
113
|
+
company: string;
|
|
114
|
+
hqRoot: string;
|
|
115
|
+
paths: string[];
|
|
116
|
+
onConflict?: ConflictStrategy;
|
|
117
|
+
personalMode?: boolean;
|
|
118
|
+
journalSlug?: string;
|
|
119
|
+
message?: string;
|
|
120
|
+
skipUnchanged?: boolean;
|
|
121
|
+
propagateDeletes?: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ShareCallResult {
|
|
125
|
+
filesUploaded: number;
|
|
126
|
+
bytesUploaded: number;
|
|
127
|
+
filesSkipped: number;
|
|
128
|
+
filesDeleted: number;
|
|
129
|
+
conflictPaths: string[];
|
|
130
|
+
aborted: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface PushAllDeps {
|
|
134
|
+
vaultClient: PullAllVaultClient;
|
|
135
|
+
share: (options: ShareCallOptions) => Promise<ShareCallResult>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface PushAllOptions {
|
|
139
|
+
hqRoot: string;
|
|
140
|
+
onConflict?: ConflictStrategy;
|
|
141
|
+
message?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface PushAllRow {
|
|
145
|
+
slug: string;
|
|
146
|
+
result?: ShareCallResult;
|
|
147
|
+
error?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface PushAllResult {
|
|
151
|
+
attempted: number;
|
|
152
|
+
filesUploaded: number;
|
|
153
|
+
bytesUploaded: number;
|
|
154
|
+
filesDeleted: number;
|
|
155
|
+
errors: Array<{ company: string; message: string }>;
|
|
156
|
+
perCompany: PushAllRow[];
|
|
157
|
+
}
|
|
158
|
+
|
|
101
159
|
export interface PullAllResult {
|
|
102
160
|
attempted: number;
|
|
103
161
|
filesDownloaded: number;
|
|
@@ -193,6 +251,135 @@ export async function pullAll(
|
|
|
193
251
|
return result;
|
|
194
252
|
}
|
|
195
253
|
|
|
254
|
+
interface PushPlanEntry {
|
|
255
|
+
slug: string;
|
|
256
|
+
shareOptions: ShareCallOptions;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Drives `hq sync push --all`: same membership + canonical-person fanout as
|
|
261
|
+
* pullAll, but each leg calls share() with the runner's bulk defaults
|
|
262
|
+
* (skipUnchanged: true, propagateDeletes: true). Pure function with injected
|
|
263
|
+
* deps so tests can drive it without network or filesystem.
|
|
264
|
+
*/
|
|
265
|
+
export async function pushAll(
|
|
266
|
+
options: PushAllOptions,
|
|
267
|
+
deps: PushAllDeps,
|
|
268
|
+
): Promise<PushAllResult> {
|
|
269
|
+
const memberships = await deps.vaultClient.listMyMemberships();
|
|
270
|
+
const persons = await deps.vaultClient.listPersonEntities();
|
|
271
|
+
|
|
272
|
+
const plan: PushPlanEntry[] = [];
|
|
273
|
+
for (const m of memberships) {
|
|
274
|
+
let slug = m.companyUid;
|
|
275
|
+
try {
|
|
276
|
+
const info = await deps.vaultClient.getEntity(m.companyUid);
|
|
277
|
+
if (info?.slug) slug = info.slug;
|
|
278
|
+
} catch {
|
|
279
|
+
// Best-effort — keep UID as the row label.
|
|
280
|
+
}
|
|
281
|
+
plan.push({
|
|
282
|
+
slug,
|
|
283
|
+
shareOptions: {
|
|
284
|
+
company: m.companyUid,
|
|
285
|
+
hqRoot: options.hqRoot,
|
|
286
|
+
paths: [path.join(options.hqRoot, "companies", slug)],
|
|
287
|
+
skipUnchanged: true,
|
|
288
|
+
propagateDeletes: true,
|
|
289
|
+
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
290
|
+
...(options.message ? { message: options.message } : {}),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const personal = pickCanonicalPerson(persons);
|
|
296
|
+
if (personal) {
|
|
297
|
+
plan.push({
|
|
298
|
+
slug: "personal",
|
|
299
|
+
shareOptions: {
|
|
300
|
+
company: personal.uid,
|
|
301
|
+
hqRoot: options.hqRoot,
|
|
302
|
+
paths: computePersonalVaultPaths(options.hqRoot),
|
|
303
|
+
personalMode: true,
|
|
304
|
+
journalSlug: "personal",
|
|
305
|
+
skipUnchanged: true,
|
|
306
|
+
propagateDeletes: true,
|
|
307
|
+
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
308
|
+
...(options.message ? { message: options.message } : {}),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result: PushAllResult = {
|
|
314
|
+
attempted: 0,
|
|
315
|
+
filesUploaded: 0,
|
|
316
|
+
bytesUploaded: 0,
|
|
317
|
+
filesDeleted: 0,
|
|
318
|
+
errors: [],
|
|
319
|
+
perCompany: [],
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
for (const entry of plan) {
|
|
323
|
+
result.attempted += 1;
|
|
324
|
+
try {
|
|
325
|
+
const r = await deps.share(entry.shareOptions);
|
|
326
|
+
result.filesUploaded += r.filesUploaded;
|
|
327
|
+
result.bytesUploaded += r.bytesUploaded;
|
|
328
|
+
result.filesDeleted += r.filesDeleted;
|
|
329
|
+
result.perCompany.push({ slug: entry.slug, result: r });
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
332
|
+
result.errors.push({ company: entry.slug, message });
|
|
333
|
+
result.perCompany.push({ slug: entry.slug, error: message });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolve the canonical person entity UID for the logged-in user. Used by
|
|
342
|
+
* `hq sync {push,pull,now} --personal` to target the personal vault without
|
|
343
|
+
* the caller needing to know the UID. Throws a clean error if the user has
|
|
344
|
+
* no person entity (typically means they haven't run `hq onboard`).
|
|
345
|
+
*/
|
|
346
|
+
export async function resolveCanonicalPersonUid(
|
|
347
|
+
vaultClient: PullAllVaultClient,
|
|
348
|
+
): Promise<string> {
|
|
349
|
+
const persons = await vaultClient.listPersonEntities();
|
|
350
|
+
const pick = pickCanonicalPerson(persons);
|
|
351
|
+
if (!pick) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
"No personal vault found for the logged-in user. Run `hq onboard` " +
|
|
354
|
+
"first, or check `hq whoami` to confirm you're signed in to the " +
|
|
355
|
+
"right account.",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return pick.uid;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
363
|
+
* `--company` are mutually exclusive — at most one may be set per
|
|
364
|
+
* invocation; zero means "use the active company from .hq/config.json".
|
|
365
|
+
*/
|
|
366
|
+
export function assertSingleSelector(opts: {
|
|
367
|
+
all?: boolean;
|
|
368
|
+
personal?: boolean;
|
|
369
|
+
company?: string;
|
|
370
|
+
}, command: string): void {
|
|
371
|
+
const selectors: string[] = [];
|
|
372
|
+
if (opts.all) selectors.push("--all");
|
|
373
|
+
if (opts.personal) selectors.push("--personal");
|
|
374
|
+
if (opts.company) selectors.push(`--company ${opts.company}`);
|
|
375
|
+
if (selectors.length > 1) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`\`hq sync ${command}\` accepts at most one of --all, --personal, ` +
|
|
378
|
+
`--company; got: ${selectors.join(", ")}.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
196
383
|
export function registerCloudCommands(program: Command): void {
|
|
197
384
|
program
|
|
198
385
|
.command("push")
|
|
@@ -231,6 +418,23 @@ export function registerCloudCommands(program: Command): void {
|
|
|
231
418
|
"the final ShareResult. Subprocess callers parse these to render their " +
|
|
232
419
|
"own UI (e.g. AppBar Tauri events).",
|
|
233
420
|
)
|
|
421
|
+
.option(
|
|
422
|
+
"--all",
|
|
423
|
+
"Push every company you are a member of plus your personal vault. " +
|
|
424
|
+
"Company targets push `<hq-root>/companies/<slug>/`; personal pushes " +
|
|
425
|
+
"every top-level entry under <hq-root> minus the excluded set " +
|
|
426
|
+
"(.git, companies, repos, workspace). Implies --skip-unchanged + " +
|
|
427
|
+
"--propagate-deletes. Mutually exclusive with --company, --personal, " +
|
|
428
|
+
"and any explicit [paths].",
|
|
429
|
+
)
|
|
430
|
+
.option(
|
|
431
|
+
"--personal",
|
|
432
|
+
"Push to the caller's canonical personal vault (resolved from the " +
|
|
433
|
+
"cached Cognito session). When no [paths] are given, defaults to " +
|
|
434
|
+
"every top-level entry under <hq-root> minus the excluded set " +
|
|
435
|
+
"(.git, companies, repos, workspace) — same scope as `--all`'s " +
|
|
436
|
+
"personal slot. Mutually exclusive with --company and --all.",
|
|
437
|
+
)
|
|
234
438
|
.action(
|
|
235
439
|
async (
|
|
236
440
|
paths: string[],
|
|
@@ -239,8 +443,46 @@ export function registerCloudCommands(program: Command): void {
|
|
|
239
443
|
onConflict?: ConflictStrategy;
|
|
240
444
|
credsFromStdin?: boolean;
|
|
241
445
|
json?: boolean;
|
|
446
|
+
all?: boolean;
|
|
447
|
+
personal?: boolean;
|
|
242
448
|
},
|
|
243
449
|
) => {
|
|
450
|
+
try {
|
|
451
|
+
assertSingleSelector(options, "push");
|
|
452
|
+
} catch (err) {
|
|
453
|
+
console.error(
|
|
454
|
+
chalk.red("\n✗ Push failed:"),
|
|
455
|
+
err instanceof Error ? err.message : String(err),
|
|
456
|
+
);
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
if (options.all) {
|
|
460
|
+
if (paths && paths.length > 0) {
|
|
461
|
+
console.error(
|
|
462
|
+
chalk.red("\n✗ Push failed:"),
|
|
463
|
+
"`--all` cannot be combined with explicit [paths]. " +
|
|
464
|
+
"Drop the paths to fan out to every membership + personal, " +
|
|
465
|
+
"or drop --all to push specific paths to a single target.",
|
|
466
|
+
);
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
if (options.credsFromStdin) {
|
|
470
|
+
console.error(
|
|
471
|
+
chalk.red("\n✗ Push failed:"),
|
|
472
|
+
"`--all` cannot be combined with --creds-from-stdin (fanout " +
|
|
473
|
+
"needs to vend per-target credentials via the cached Cognito " +
|
|
474
|
+
"session). Run separate `--creds-from-stdin` invocations per " +
|
|
475
|
+
"target instead.",
|
|
476
|
+
);
|
|
477
|
+
process.exit(1);
|
|
478
|
+
}
|
|
479
|
+
await runPushAll(
|
|
480
|
+
options.hqRoot,
|
|
481
|
+
options.message,
|
|
482
|
+
options.onConflict,
|
|
483
|
+
);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
244
486
|
const jsonMode = options.json === true;
|
|
245
487
|
// Suppress the human banner/result output in JSON mode — the parent
|
|
246
488
|
// process renders its own UI from the stderr ndjson stream.
|
|
@@ -252,15 +494,17 @@ export function registerCloudCommands(program: Command): void {
|
|
|
252
494
|
};
|
|
253
495
|
|
|
254
496
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
497
|
+
if (options.personal && options.credsFromStdin) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
"`--personal` cannot be combined with --creds-from-stdin: " +
|
|
500
|
+
"--personal resolves the canonical person UID via the cached " +
|
|
501
|
+
"Cognito session, while --creds-from-stdin expects the caller " +
|
|
502
|
+
"to have already resolved entity + credentials. Pick one.",
|
|
503
|
+
);
|
|
504
|
+
}
|
|
257
505
|
|
|
258
506
|
log(chalk.bold("\nHQ Sync — Push"));
|
|
259
507
|
log(` HQ root: ${options.hqRoot}`);
|
|
260
|
-
log(
|
|
261
|
-
` Company: ${options.company ?? "(from .hq/config.json or stdin)"}`,
|
|
262
|
-
);
|
|
263
|
-
log(` Paths: ${targetPaths.join(", ")}\n`);
|
|
264
508
|
|
|
265
509
|
// Resolve credentials. Two paths:
|
|
266
510
|
// 1. --creds-from-stdin: parse JSON EntityContext from stdin (the
|
|
@@ -292,6 +536,47 @@ export function registerCloudCommands(program: Command): void {
|
|
|
292
536
|
vaultConfig = buildVaultConfig(accessToken);
|
|
293
537
|
}
|
|
294
538
|
|
|
539
|
+
// Resolve the target. For `--personal`, look up the caller's
|
|
540
|
+
// canonical person entity and force personalMode + journalSlug so
|
|
541
|
+
// share() lands files at hqRoot directly (no companies/<slug>/
|
|
542
|
+
// prefix). For everything else, the company is whatever the user
|
|
543
|
+
// passed or the active company from .hq/config.json.
|
|
544
|
+
let targetCompany = options.company;
|
|
545
|
+
let personalMode = false;
|
|
546
|
+
let journalSlug: string | undefined;
|
|
547
|
+
if (options.personal) {
|
|
548
|
+
const client = new VaultClient(vaultConfig!);
|
|
549
|
+
targetCompany = await resolveCanonicalPersonUid({
|
|
550
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
551
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
552
|
+
getEntity: async () => null,
|
|
553
|
+
});
|
|
554
|
+
personalMode = true;
|
|
555
|
+
journalSlug = "personal";
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Default paths. For `--personal` with no explicit paths, default to
|
|
559
|
+
// the canonical personal-vault top-level scope so re-runs from a
|
|
560
|
+
// user shell push the same surface as `--all`'s personal slot.
|
|
561
|
+
// Otherwise preserve the historical "default to cwd" semantics so
|
|
562
|
+
// `hq sync push <file>` and bare `hq sync push` from inside the
|
|
563
|
+
// company tree both work as before.
|
|
564
|
+
const targetPaths =
|
|
565
|
+
paths && paths.length > 0
|
|
566
|
+
? paths
|
|
567
|
+
: options.personal
|
|
568
|
+
? computePersonalVaultPaths(options.hqRoot)
|
|
569
|
+
: [process.cwd()];
|
|
570
|
+
|
|
571
|
+
log(
|
|
572
|
+
` Company: ${
|
|
573
|
+
options.personal
|
|
574
|
+
? `(personal: ${targetCompany})`
|
|
575
|
+
: (options.company ?? "(from .hq/config.json or stdin)")
|
|
576
|
+
}`,
|
|
577
|
+
);
|
|
578
|
+
log(` Paths: ${targetPaths.join(", ")}\n`);
|
|
579
|
+
|
|
295
580
|
// In JSON mode, forward every share() event verbatim to stderr as
|
|
296
581
|
// ndjson. In human mode, share()'s defaultConsoleLogger handles the
|
|
297
582
|
// rendering (no onEvent → falls through to stdout/stderr printing).
|
|
@@ -310,13 +595,15 @@ export function registerCloudCommands(program: Command): void {
|
|
|
310
595
|
|
|
311
596
|
const result = await share({
|
|
312
597
|
paths: targetPaths,
|
|
313
|
-
company:
|
|
598
|
+
company: targetCompany,
|
|
314
599
|
message: options.message,
|
|
315
600
|
onConflict: options.onConflict,
|
|
316
601
|
vaultConfig,
|
|
317
602
|
entityContext,
|
|
318
603
|
hqRoot: options.hqRoot,
|
|
319
604
|
onEvent,
|
|
605
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
606
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
320
607
|
...(author ? { author } : {}),
|
|
321
608
|
});
|
|
322
609
|
|
|
@@ -384,19 +671,41 @@ export function registerCloudCommands(program: Command): void {
|
|
|
384
671
|
"--all",
|
|
385
672
|
"Pull every company you are a member of plus your personal vault " +
|
|
386
673
|
"into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
|
|
387
|
-
"the personal vault syncs at <hq-root>.
|
|
674
|
+
"the personal vault syncs at <hq-root>. Mutually exclusive with " +
|
|
675
|
+
"--company and --personal.",
|
|
676
|
+
)
|
|
677
|
+
.option(
|
|
678
|
+
"--personal",
|
|
679
|
+
"Pull the caller's canonical personal vault into <hq-root> directly " +
|
|
680
|
+
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
681
|
+
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
682
|
+
"and --all.",
|
|
388
683
|
)
|
|
389
684
|
.action(
|
|
390
685
|
async (
|
|
391
686
|
options: CommonSyncOptions & {
|
|
392
687
|
onConflict?: ConflictStrategy;
|
|
393
688
|
all?: boolean;
|
|
689
|
+
personal?: boolean;
|
|
394
690
|
},
|
|
395
691
|
) => {
|
|
692
|
+
try {
|
|
693
|
+
assertSingleSelector(options, "pull");
|
|
694
|
+
} catch (err) {
|
|
695
|
+
console.error(
|
|
696
|
+
chalk.red("\n✗ Pull failed:"),
|
|
697
|
+
err instanceof Error ? err.message : String(err),
|
|
698
|
+
);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
396
701
|
if (options.all) {
|
|
397
702
|
await runPullAll(options.hqRoot, options.onConflict);
|
|
398
703
|
return;
|
|
399
704
|
}
|
|
705
|
+
if (options.personal) {
|
|
706
|
+
await runPullPersonal(options.hqRoot, options.onConflict);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
400
709
|
try {
|
|
401
710
|
console.log(chalk.bold("\nHQ Sync — Pull"));
|
|
402
711
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
@@ -489,6 +798,76 @@ export function registerCloudCommands(program: Command): void {
|
|
|
489
798
|
process.exit(1);
|
|
490
799
|
}
|
|
491
800
|
});
|
|
801
|
+
|
|
802
|
+
program
|
|
803
|
+
.command("now")
|
|
804
|
+
.description(
|
|
805
|
+
"Bidirectional sync: push local changes, then pull remote updates " +
|
|
806
|
+
"(mirrors AppBar HQ Sync's \"Sync Now\" button)",
|
|
807
|
+
)
|
|
808
|
+
.option(
|
|
809
|
+
"--hq-root <path>",
|
|
810
|
+
`Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`,
|
|
811
|
+
DEFAULT_HQ_ROOT,
|
|
812
|
+
)
|
|
813
|
+
.option(
|
|
814
|
+
"--company <slug>",
|
|
815
|
+
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
816
|
+
)
|
|
817
|
+
.option(
|
|
818
|
+
"--message <msg>",
|
|
819
|
+
"Optional message attached to journal entries for the push leg",
|
|
820
|
+
)
|
|
821
|
+
.option(
|
|
822
|
+
"--on-conflict <strategy>",
|
|
823
|
+
"Conflict strategy: overwrite | keep | abort (omit for interactive)",
|
|
824
|
+
)
|
|
825
|
+
.option(
|
|
826
|
+
"--all",
|
|
827
|
+
"Sync every company you are a member of plus your personal vault " +
|
|
828
|
+
"(pushAll then pullAll). Mutually exclusive with --company and " +
|
|
829
|
+
"--personal.",
|
|
830
|
+
)
|
|
831
|
+
.option(
|
|
832
|
+
"--personal",
|
|
833
|
+
"Sync the caller's canonical personal vault bidirectionally. " +
|
|
834
|
+
"Mutually exclusive with --company and --all.",
|
|
835
|
+
)
|
|
836
|
+
.action(
|
|
837
|
+
async (
|
|
838
|
+
options: CommonSyncOptions & {
|
|
839
|
+
onConflict?: ConflictStrategy;
|
|
840
|
+
message?: string;
|
|
841
|
+
all?: boolean;
|
|
842
|
+
personal?: boolean;
|
|
843
|
+
},
|
|
844
|
+
) => {
|
|
845
|
+
try {
|
|
846
|
+
assertSingleSelector(options, "now");
|
|
847
|
+
if (options.all) {
|
|
848
|
+
await runNowAll(
|
|
849
|
+
options.hqRoot,
|
|
850
|
+
options.message,
|
|
851
|
+
options.onConflict,
|
|
852
|
+
);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
await runNowSingle(
|
|
856
|
+
options.hqRoot,
|
|
857
|
+
options.company,
|
|
858
|
+
options.personal === true,
|
|
859
|
+
options.message,
|
|
860
|
+
options.onConflict,
|
|
861
|
+
);
|
|
862
|
+
} catch (err) {
|
|
863
|
+
console.error(
|
|
864
|
+
chalk.red("\n✗ Sync now failed:"),
|
|
865
|
+
err instanceof Error ? err.message : String(err),
|
|
866
|
+
);
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
);
|
|
492
871
|
}
|
|
493
872
|
|
|
494
873
|
async function runPullAll(
|
|
@@ -568,6 +947,293 @@ async function runPullAll(
|
|
|
568
947
|
if (errored > 0) process.exit(1);
|
|
569
948
|
}
|
|
570
949
|
|
|
950
|
+
async function runPullPersonal(
|
|
951
|
+
hqRoot: string,
|
|
952
|
+
onConflict?: ConflictStrategy,
|
|
953
|
+
): Promise<void> {
|
|
954
|
+
console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
|
|
955
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
956
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
957
|
+
|
|
958
|
+
try {
|
|
959
|
+
const accessToken = await ensureCognitoToken();
|
|
960
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
961
|
+
const client = new VaultClient(vaultConfig);
|
|
962
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
963
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
964
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
965
|
+
getEntity: async () => null,
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const result = await sync({
|
|
969
|
+
company: personalUid,
|
|
970
|
+
...(onConflict ? { onConflict } : {}),
|
|
971
|
+
vaultConfig,
|
|
972
|
+
hqRoot,
|
|
973
|
+
personalMode: true,
|
|
974
|
+
journalSlug: "personal",
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
if (result.aborted) {
|
|
978
|
+
console.log(
|
|
979
|
+
chalk.yellow(
|
|
980
|
+
`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
process.exit(1);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
console.log(
|
|
987
|
+
chalk.green(
|
|
988
|
+
`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`,
|
|
989
|
+
),
|
|
990
|
+
);
|
|
991
|
+
} catch (err) {
|
|
992
|
+
console.error(
|
|
993
|
+
chalk.red("\n✗ Pull (personal) failed:"),
|
|
994
|
+
err instanceof Error ? err.message : String(err),
|
|
995
|
+
);
|
|
996
|
+
process.exit(1);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function runPushAll(
|
|
1001
|
+
hqRoot: string,
|
|
1002
|
+
message?: string,
|
|
1003
|
+
onConflict?: ConflictStrategy,
|
|
1004
|
+
): Promise<void> {
|
|
1005
|
+
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
1006
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
1007
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
1008
|
+
|
|
1009
|
+
let result: PushAllResult;
|
|
1010
|
+
try {
|
|
1011
|
+
const accessToken = await ensureCognitoToken();
|
|
1012
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
1013
|
+
const realClient = new VaultClient(vaultConfig);
|
|
1014
|
+
const author = resolveUploadAuthorFromCache();
|
|
1015
|
+
|
|
1016
|
+
const adapter: PullAllVaultClient = {
|
|
1017
|
+
listMyMemberships: () => realClient.listMyMemberships(),
|
|
1018
|
+
listPersonEntities: () => realClient.entity.listByType("person"),
|
|
1019
|
+
getEntity: async (uid: string) => {
|
|
1020
|
+
try {
|
|
1021
|
+
return await realClient.entity.get(uid);
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
result = await pushAll(
|
|
1029
|
+
{
|
|
1030
|
+
hqRoot,
|
|
1031
|
+
...(onConflict ? { onConflict } : {}),
|
|
1032
|
+
...(message ? { message } : {}),
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
vaultClient: adapter,
|
|
1036
|
+
share: (opts) =>
|
|
1037
|
+
share({
|
|
1038
|
+
paths: opts.paths,
|
|
1039
|
+
company: opts.company,
|
|
1040
|
+
vaultConfig,
|
|
1041
|
+
hqRoot: opts.hqRoot,
|
|
1042
|
+
...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
|
|
1043
|
+
...(opts.personalMode !== undefined
|
|
1044
|
+
? { personalMode: opts.personalMode }
|
|
1045
|
+
: {}),
|
|
1046
|
+
...(opts.journalSlug !== undefined
|
|
1047
|
+
? { journalSlug: opts.journalSlug }
|
|
1048
|
+
: {}),
|
|
1049
|
+
...(opts.message !== undefined ? { message: opts.message } : {}),
|
|
1050
|
+
...(opts.skipUnchanged !== undefined
|
|
1051
|
+
? { skipUnchanged: opts.skipUnchanged }
|
|
1052
|
+
: {}),
|
|
1053
|
+
...(opts.propagateDeletes !== undefined
|
|
1054
|
+
? { propagateDeletes: opts.propagateDeletes }
|
|
1055
|
+
: {}),
|
|
1056
|
+
...(author ? { author } : {}),
|
|
1057
|
+
}),
|
|
1058
|
+
},
|
|
1059
|
+
);
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
console.error(
|
|
1062
|
+
chalk.red("\n✗ Push-all failed:"),
|
|
1063
|
+
err instanceof Error ? err.message : String(err),
|
|
1064
|
+
);
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
for (const row of result.perCompany) {
|
|
1069
|
+
if (row.error) {
|
|
1070
|
+
console.log(chalk.red(` ✗ ${row.slug}: ${row.error}`));
|
|
1071
|
+
} else if (row.result) {
|
|
1072
|
+
const r = row.result;
|
|
1073
|
+
const status = r.aborted ? chalk.yellow("⚠") : chalk.green("✓");
|
|
1074
|
+
console.log(
|
|
1075
|
+
` ${status} ${row.slug}: ${r.filesUploaded} file(s), ` +
|
|
1076
|
+
`${formatBytes(r.bytesUploaded)}, ${r.filesSkipped} skipped, ` +
|
|
1077
|
+
`${r.filesDeleted} deleted, ${r.conflictPaths.length} conflict(s)` +
|
|
1078
|
+
(r.aborted ? " — aborted" : ""),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const errored = result.errors.length;
|
|
1084
|
+
const summary =
|
|
1085
|
+
`\nPushed ${result.filesUploaded} file(s) ` +
|
|
1086
|
+
`(${formatBytes(result.bytesUploaded)}) across ${result.attempted} ` +
|
|
1087
|
+
`target(s); ${result.filesDeleted} deleted; ${errored} error(s)`;
|
|
1088
|
+
console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
|
|
1089
|
+
if (errored > 0) process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async function runNowSingle(
|
|
1093
|
+
hqRoot: string,
|
|
1094
|
+
company: string | undefined,
|
|
1095
|
+
personal: boolean,
|
|
1096
|
+
message?: string,
|
|
1097
|
+
onConflict?: ConflictStrategy,
|
|
1098
|
+
): Promise<void> {
|
|
1099
|
+
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
1100
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
1101
|
+
console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
|
|
1102
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
1103
|
+
|
|
1104
|
+
try {
|
|
1105
|
+
const accessToken = await ensureCognitoToken();
|
|
1106
|
+
const vaultConfig = buildVaultConfig(accessToken);
|
|
1107
|
+
const author = resolveUploadAuthorFromCache();
|
|
1108
|
+
|
|
1109
|
+
// Resolve the target. For --personal, look up the canonical person and
|
|
1110
|
+
// route paths/journal through personalMode. Otherwise use the company arg
|
|
1111
|
+
// (or fall back to the active company inside share()/sync()).
|
|
1112
|
+
let targetCompany = company;
|
|
1113
|
+
let personalMode = false;
|
|
1114
|
+
let journalSlug: string | undefined;
|
|
1115
|
+
let pushPaths: string[];
|
|
1116
|
+
|
|
1117
|
+
if (personal) {
|
|
1118
|
+
const client = new VaultClient(vaultConfig);
|
|
1119
|
+
targetCompany = await resolveCanonicalPersonUid({
|
|
1120
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
1121
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
1122
|
+
getEntity: async () => null,
|
|
1123
|
+
});
|
|
1124
|
+
personalMode = true;
|
|
1125
|
+
journalSlug = "personal";
|
|
1126
|
+
pushPaths = computePersonalVaultPaths(hqRoot);
|
|
1127
|
+
} else {
|
|
1128
|
+
// For company targets we need a concrete slug to compute the push path.
|
|
1129
|
+
// share() can resolve `company` itself for the upload, but the path
|
|
1130
|
+
// computation must happen here. Use the explicit company if given;
|
|
1131
|
+
// otherwise fall back to the active-company resolution inside the
|
|
1132
|
+
// engine and compute paths from `hqRoot/companies` (share() will refuse
|
|
1133
|
+
// anything outside that subtree anyway).
|
|
1134
|
+
const slug = company ?? readActiveCompanySlug(hqRoot);
|
|
1135
|
+
if (!slug) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
"No company specified and no active company found. " +
|
|
1138
|
+
"Use --company <slug>, --personal, or set up .hq/config.json.",
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
pushPaths = [path.join(hqRoot, "companies", slug)];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Push first so the subsequent pull doesn't redownload files we were
|
|
1145
|
+
// about to broadcast (matches hq-sync-runner ordering).
|
|
1146
|
+
console.log(chalk.dim(" → push leg"));
|
|
1147
|
+
const pushResult = await share({
|
|
1148
|
+
paths: pushPaths,
|
|
1149
|
+
company: targetCompany,
|
|
1150
|
+
vaultConfig,
|
|
1151
|
+
hqRoot,
|
|
1152
|
+
skipUnchanged: true,
|
|
1153
|
+
propagateDeletes: true,
|
|
1154
|
+
...(onConflict ? { onConflict } : {}),
|
|
1155
|
+
...(message ? { message } : {}),
|
|
1156
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
1157
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
1158
|
+
...(author ? { author } : {}),
|
|
1159
|
+
});
|
|
1160
|
+
console.log(
|
|
1161
|
+
` ${pushResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
|
|
1162
|
+
`${pushResult.filesUploaded} uploaded, ${pushResult.filesSkipped} skipped, ` +
|
|
1163
|
+
`${pushResult.filesDeleted} deleted` +
|
|
1164
|
+
(pushResult.aborted ? " — aborted" : ""),
|
|
1165
|
+
);
|
|
1166
|
+
if (pushResult.aborted) {
|
|
1167
|
+
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
1168
|
+
process.exit(1);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
console.log(chalk.dim(" → pull leg"));
|
|
1172
|
+
const pullResult = await sync({
|
|
1173
|
+
company: targetCompany,
|
|
1174
|
+
vaultConfig,
|
|
1175
|
+
hqRoot,
|
|
1176
|
+
...(onConflict ? { onConflict } : {}),
|
|
1177
|
+
...(personalMode ? { personalMode: true } : {}),
|
|
1178
|
+
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
1179
|
+
});
|
|
1180
|
+
console.log(
|
|
1181
|
+
` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
|
|
1182
|
+
`${pullResult.filesDownloaded} downloaded, ${pullResult.filesSkipped} skipped, ` +
|
|
1183
|
+
`${pullResult.conflicts} conflict(s)` +
|
|
1184
|
+
(pullResult.aborted ? " — aborted" : ""),
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
if (pullResult.aborted) {
|
|
1188
|
+
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
console.log(chalk.green("\n✓ Sync now complete"));
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
console.error(
|
|
1194
|
+
chalk.red("\n✗ Sync now failed:"),
|
|
1195
|
+
err instanceof Error ? err.message : String(err),
|
|
1196
|
+
);
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function runNowAll(
|
|
1202
|
+
hqRoot: string,
|
|
1203
|
+
message?: string,
|
|
1204
|
+
onConflict?: ConflictStrategy,
|
|
1205
|
+
): Promise<void> {
|
|
1206
|
+
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
1207
|
+
console.log(` HQ root: ${hqRoot}`);
|
|
1208
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
1209
|
+
|
|
1210
|
+
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
1211
|
+
// so the per-target rendering, error isolation, and exit codes are
|
|
1212
|
+
// identical to running `push --all` then `pull --all` back-to-back.
|
|
1213
|
+
console.log(chalk.dim("→ push --all"));
|
|
1214
|
+
await runPushAll(hqRoot, message, onConflict);
|
|
1215
|
+
console.log(chalk.dim("\n→ pull --all"));
|
|
1216
|
+
await runPullAll(hqRoot, onConflict);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
1221
|
+
* Returns undefined when the file is missing, malformed, or has no
|
|
1222
|
+
* `activeCompany` field — `runNowSingle` surfaces a clean error in that case.
|
|
1223
|
+
*/
|
|
1224
|
+
function readActiveCompanySlug(hqRoot: string): string | undefined {
|
|
1225
|
+
const configPath = path.join(hqRoot, ".hq", "config.json");
|
|
1226
|
+
if (!fs.existsSync(configPath)) return undefined;
|
|
1227
|
+
try {
|
|
1228
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8")) as {
|
|
1229
|
+
activeCompany?: string;
|
|
1230
|
+
};
|
|
1231
|
+
return cfg.activeCompany;
|
|
1232
|
+
} catch {
|
|
1233
|
+
return undefined;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
571
1237
|
function formatBytes(bytes: number): string {
|
|
572
1238
|
if (bytes === 0) return "0 B";
|
|
573
1239
|
const units = ["B", "KB", "MB", "GB"];
|