@indigoai-us/hq-cli 5.12.5 → 5.13.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.
@@ -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
- const targetPaths =
256
- paths && paths.length > 0 ? paths : [process.cwd()];
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: options.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>. Ignores --company.",
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"];