@indigoai-us/hq-cloud 5.23.0 → 5.25.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.
Files changed (36) hide show
  1. package/dist/bin/sync-runner.d.ts +58 -3
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +84 -2
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +90 -3
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +86 -20
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +332 -62
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +490 -6
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +48 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/personal-vault-exclusions.d.ts +128 -0
  21. package/dist/personal-vault-exclusions.d.ts.map +1 -0
  22. package/dist/personal-vault-exclusions.js +231 -0
  23. package/dist/personal-vault-exclusions.js.map +1 -0
  24. package/dist/personal-vault-exclusions.test.d.ts +22 -0
  25. package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
  26. package/dist/personal-vault-exclusions.test.js +198 -0
  27. package/dist/personal-vault-exclusions.test.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/bin/sync-runner.test.ts +113 -3
  30. package/src/bin/sync-runner.ts +125 -5
  31. package/src/cli/share.test.ts +585 -6
  32. package/src/cli/share.ts +461 -86
  33. package/src/cli/sync.ts +50 -0
  34. package/src/index.ts +10 -0
  35. package/src/personal-vault-exclusions.test.ts +256 -0
  36. package/src/personal-vault-exclusions.ts +277 -0
@@ -14,6 +14,10 @@
14
14
  * --company <slug-or-uid> Sync a single company (alternative to --companies)
15
15
  * --on-conflict <strategy> abort | overwrite | keep (default: abort)
16
16
  * --hq-root <path> Local HQ directory (default: $HOME/hq)
17
+ * --skip-personal Drop the personal target from the --companies
18
+ * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
19
+ * (either truthy disables personal sync). No-op
20
+ * outside --companies mode.
17
21
  * --json Ignored — ndjson on stdout is the default and
18
22
  * only output mode. Accepted for symmetry with the
19
23
  * AppBar's argv in case someone passes it.
@@ -120,6 +124,63 @@ const DEFAULT_VAULT_API_URL =
120
124
 
121
125
  const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
122
126
 
127
+ /**
128
+ * Delete-propagation policy honored by the push leg of bidirectional sync.
129
+ *
130
+ * Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
131
+ * one machine (Indigo / corey) ran the 5.24 code path through real syncs
132
+ * for a week without surfacing surprise behavior. Currency-gated does a
133
+ * per-file ETag HEAD before propagating any local-delete to S3: if the
134
+ * remote object's current ETag no longer matches the journal's last-
135
+ * recorded one, the delete is refused and the next pull leg re-pulls the
136
+ * file via the standard 3-way merge path. This is strictly safer than
137
+ * `owned-only` (which propagates any local-delete the journal can prove
138
+ * came from this device) — the only delete-class that changes behavior
139
+ * is "deleted locally + modified remotely by another device", which
140
+ * previously destroyed remote work and now becomes a pull-and-conflict.
141
+ *
142
+ * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
143
+ * also the rollback knob — anyone surprised by 5.25's flip can revert
144
+ * to `owned-only` without redeploying. `all` is the unsafe-mirror mode
145
+ * previously used by the runner pre-5.20 — included only as an
146
+ * emergency reconcile lever, not a recommended default.
147
+ */
148
+ export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
149
+
150
+ export function resolveDeletePolicy(): DeletePropagationPolicy {
151
+ const env = process.env.HQ_SYNC_DELETE_POLICY;
152
+ if (env === "owned-only" || env === "all" || env === "currency-gated") {
153
+ return env;
154
+ }
155
+ return "currency-gated";
156
+ }
157
+
158
+ /**
159
+ * Resolve whether to skip the personal target in a `--companies` fanout.
160
+ *
161
+ * Two inputs combine: the `--skip-personal` CLI flag (parsed into
162
+ * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
163
+ * being truthy skips the personal target — flag wins on conflict (CLI
164
+ * flag is the explicit-for-this-invocation knob, env is the persistent
165
+ * default usually set by the menubar in the spawned child process).
166
+ *
167
+ * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
168
+ * (including missing) is treated as falsy — same shape as classic
169
+ * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
170
+ *
171
+ * Use case: the menubar app exposes a "Sync personal vault" toggle in
172
+ * Settings (default ON, matching the auto-provisioning UX). When the user
173
+ * flips it off, the menubar spawns `hq sync` with this env set so the
174
+ * fanout drops the personal target before walking the user's entire HQ
175
+ * tree (a sync that would otherwise scan thousands of files, including
176
+ * the new personal-vault default exclusions, just to do nothing useful).
177
+ */
178
+ export function resolveSkipPersonal(flag: boolean): boolean {
179
+ if (flag) return true;
180
+ const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
181
+ return env === "1" || env === "true" || env === "yes";
182
+ }
183
+
123
184
  // Personal-vault scope (exclusion list + path computer) lives in
124
185
  // `../personal-vault.ts` so the `hq sync` CLI and this runner share the same
125
186
  // rules. Re-exported here for back-compat with any callers still importing
@@ -169,12 +230,19 @@ export type RunnerEvent =
169
230
  /**
170
231
  * Upload counters. Always emitted (0 when the run was pull-only) so
171
232
  * downstream consumers don't need to conditionally read the field.
172
- * Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
173
- * to the Rust struct is a follow-up when the UI needs to surface push
174
- * totals.
175
233
  */
176
234
  filesUploaded: number;
177
235
  bytesUploaded: number;
236
+ /**
237
+ * Push-side counters added in 5.25. Always emitted as numbers (0
238
+ * when no push leg ran). Tauri's `SyncCompleteEvent` carries them
239
+ * as Option<u32> for back-compat with <5.25 engines that don't
240
+ * include them; structural-typing-wise, the union just adds
241
+ * properties on top of `SyncResult`.
242
+ */
243
+ filesTombstoned: number;
244
+ filesRefusedStale: number;
245
+ filesExcludedByPolicy: number;
178
246
  } & SyncResult)
179
247
  | {
180
248
  type: "all-complete";
@@ -374,6 +442,13 @@ interface ParsedArgs {
374
442
  watch: boolean;
375
443
  /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
376
444
  pollRemoteMs?: number;
445
+ /**
446
+ * Drop the personal target from the fanout. Combined with the
447
+ * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
448
+ * truthy disables personal sync for this run. No-op outside `--companies`
449
+ * mode (single-company runs never visit the personal target).
450
+ */
451
+ skipPersonal: boolean;
377
452
  }
378
453
 
379
454
  function parseArgs(argv: string[]): ParsedArgs | { error: string } {
@@ -384,6 +459,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
384
459
  let direction: Direction = "pull";
385
460
  let watch = false;
386
461
  let pollRemoteMs: number | undefined;
462
+ let skipPersonal = false;
387
463
 
388
464
  for (let i = 0; i < argv.length; i++) {
389
465
  const arg = argv[i];
@@ -437,6 +513,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
437
513
  case "--json":
438
514
  // Accepted but ignored — ndjson is the only output mode.
439
515
  break;
516
+ case "--skip-personal":
517
+ // Drop the personal target from the fanout. No-op outside
518
+ // --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
519
+ // resolveSkipPersonal().
520
+ skipPersonal = true;
521
+ break;
440
522
  default:
441
523
  return { error: `Unknown argument: ${arg}` };
442
524
  }
@@ -452,7 +534,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
452
534
  return { error: "--poll-remote-ms requires --watch" };
453
535
  }
454
536
 
455
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
537
+ return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
456
538
  }
457
539
 
458
540
  // ---------------------------------------------------------------------------
@@ -670,7 +752,15 @@ export async function runRunner(
670
752
  plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
671
753
  }
672
754
 
673
- if (parsed.companies) {
755
+ if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
756
+ // Personal-target fanout slot. Skipped entirely when --skip-personal
757
+ // (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
758
+ // the rationale (menubar opt-out for users who only want company sync).
759
+ // When skipped, the fanout-plan event below carries only company
760
+ // memberships and no "personal" slug; downstream consumers (menubar
761
+ // workspaces row, status surfaces) should already tolerate that
762
+ // shape since pre-5.25 fanout often had it (a user with no person
763
+ // entity yet, or before the canonical-person-entity machinery landed).
674
764
  const persons = await client.entity.listByType("person");
675
765
  const pick = pickCanonicalPersonEntity(persons);
676
766
  if (pick?.bucketName) {
@@ -799,6 +889,9 @@ export async function runRunner(
799
889
  bytesUploaded: 0,
800
890
  filesSkipped: 0,
801
891
  filesDeleted: 0,
892
+ filesTombstoned: 0,
893
+ filesRefusedStale: 0,
894
+ filesExcludedByPolicy: 0,
802
895
  conflictPaths: [],
803
896
  aborted: false,
804
897
  };
@@ -834,7 +927,16 @@ export async function runRunner(
834
927
  // — DeleteObject writes a delete-marker, prior versions remain
835
928
  // recoverable). Without this, a deleted file resurfaces on the
836
929
  // next pull because the remote object is still listable.
930
+ //
931
+ // Policy default in 5.24 is `owned-only` (pre-5.24 behavior;
932
+ // preserved for the soak window). `HQ_SYNC_DELETE_POLICY` env
933
+ // can opt INTO the safer `currency-gated` (per-file HEAD + ETag
934
+ // verification) or the unsafe `all` (emergency reconcile only).
935
+ // Default flips to `currency-gated` in 5.25 after at least one
936
+ // machine has soaked the new path. Both personal and company
937
+ // targets use the same resolver — same engine, same flip.
837
938
  propagateDeletes: true,
939
+ propagateDeletePolicy: resolveDeletePolicy(),
838
940
  onEvent: tagAndEmit,
839
941
  ...(uploadAuthor ? { author: uploadAuthor } : {}),
840
942
  // Mirror the pull-side seam: only spread these for the personal
@@ -891,6 +993,17 @@ export async function runRunner(
891
993
  filesUploaded: pushResult.filesUploaded,
892
994
  bytesUploaded: pushResult.bytesUploaded,
893
995
  filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
996
+ // Push-side counters surfaced on `complete` so the menubar's
997
+ // `SyncCompleteEvent` (which carries them as Option<u32> for
998
+ // back-compat with pre-5.25 engines) can render the new totals.
999
+ // Always emitted as numbers (0 when no push leg ran) so Rust's
1000
+ // serde decodes them as `Some(0)` rather than `None` — distinct
1001
+ // from the legacy-engine `None` and useful when the UI wants to
1002
+ // distinguish "engine ran, nothing tombstoned" from "engine
1003
+ // didn't report".
1004
+ filesTombstoned: pushResult.filesTombstoned,
1005
+ filesRefusedStale: pushResult.filesRefusedStale,
1006
+ filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
894
1007
  // Sourced from the merged path list so push-side conflicts are
895
1008
  // counted too — `ShareResult` doesn't expose a numeric counter,
896
1009
  // and using `pullResult.conflicts` alone silently dropped any
@@ -928,6 +1041,13 @@ export async function runRunner(
928
1041
  filesUploaded: state.filesUploaded,
929
1042
  bytesUploaded: state.bytesUploaded,
930
1043
  filesSkipped: 0,
1044
+ // Mid-flight throw: we have no clean ShareResult to read these
1045
+ // from. Report 0 so the event shape stays stable; the partial
1046
+ // counts above already reflect what actually moved before the
1047
+ // throw.
1048
+ filesTombstoned: 0,
1049
+ filesRefusedStale: 0,
1050
+ filesExcludedByPolicy: 0,
931
1051
  conflicts: 0,
932
1052
  conflictPaths: [],
933
1053
  aborted: true,