@indigoai-us/hq-cli 5.38.2 → 5.39.2

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.39.0]
6
+
7
+ ### Added
8
+
9
+ - **`hq secrets exists <name>` — HEAD existence probe (HQ-4H).** Checks whether a
10
+ secret exists without fetching or decrypting its value, so optional-credential
11
+ readers can HEAD-first instead of blind-`GET`ting:
12
+ `hq secrets exists FOO && hq secrets get FOO --reveal`. A missing secret no
13
+ longer fires a spurious server-side `Secret not found` Sentry warning (HQ-4H:
14
+ 706 nameless events / 0 users) — a `HEAD` is by contract an expected-absence
15
+ probe, so a not-found is its normal answer and is not captured. Exit codes are
16
+ built for shell chaining: `0` present, `1` absent (the normal "no", not an
17
+ error), `2` real failure (auth/network/permission) so `&&` chains never
18
+ mistake an outage for "absent and proceed". `--quiet` suppresses the
19
+ present/absent line. Backed by the new hq-pro `HEAD
20
+ /secrets/{companyUid}/name/{proxy+}` route. Callers that genuinely *require* a
21
+ secret keep `GET`ting directly — their 404s still surface.
22
+
5
23
  ## [5.38.2]
6
24
 
7
25
  ### Fixed
@@ -13,7 +13,7 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { Command } from "commander";
16
- import { type ConflictStrategy, type MembershipSyncConfig } from "@indigoai-us/hq-cloud";
16
+ import { type ConflictStrategy, type MembershipSyncConfig, type SyncMode, type PullScope, type ExplicitGrant } from "@indigoai-us/hq-cloud";
17
17
  import { type BannerLevel } from "../lib/narrow-hint-banner.js";
18
18
  export interface PullAllVaultClient {
19
19
  listMyMemberships(): Promise<Array<{
@@ -38,6 +38,12 @@ export interface PullAllVaultClient {
38
38
  * applicable.
39
39
  */
40
40
  getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
41
+ /**
42
+ * Caller's explicit grants for a company — consumed by `resolvePullScope`
43
+ * to build a `shared`-mode prefix set. Optional so legacy adapters degrade
44
+ * to `all` (the safe direction).
45
+ */
46
+ listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
41
47
  }
42
48
  export interface SyncCallOptions {
43
49
  company: string;
@@ -45,6 +51,16 @@ export interface SyncCallOptions {
45
51
  onConflict?: ConflictStrategy;
46
52
  personalMode?: boolean;
47
53
  journalSlug?: string;
54
+ /**
55
+ * Effective pull scope (DEV-1768). When set, `sync()` materializes only the
56
+ * in-scope keys and scope-shrinks the rest — instead of defaulting to
57
+ * `syncMode: "all"` and stamping an all-mode PullRecord that wedges the next
58
+ * menubar sync. Resolved per-company by the shared `resolvePullScope`.
59
+ */
60
+ syncMode?: SyncMode;
61
+ prefixSet?: string[];
62
+ /** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
63
+ forceScopeShrink?: boolean;
48
64
  }
49
65
  export interface SyncCallResult {
50
66
  filesDownloaded: number;
@@ -57,6 +73,13 @@ export interface SyncCallResult {
57
73
  export interface PullAllDeps {
58
74
  vaultClient: PullAllVaultClient;
59
75
  sync: (options: SyncCallOptions) => Promise<SyncCallResult>;
76
+ /**
77
+ * Resolve a company's effective pull scope (DEV-1768). Injected so `pullAll`
78
+ * stays pure/testable; the real entry point wires it to the shared
79
+ * `resolvePullScope` over a live `VaultClient`. When absent, the per-company
80
+ * leg falls back to `all` (legacy behavior) — only the real path injects it.
81
+ */
82
+ resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
60
83
  }
61
84
  export interface PullAllOptions {
62
85
  hqRoot: string;
@@ -88,6 +111,12 @@ export interface PullAllOptions {
88
111
  * personal vault from the run without touching the rest of the plan.
89
112
  */
90
113
  skipPersonal?: boolean;
114
+ /**
115
+ * Forward `--force-scope-shrink` to each company leg: when a scope shrink
116
+ * would un-track locally-modified files, proceed anyway (dirty files KEPT on
117
+ * disk, only un-tracked). Makes the foreground "block" advice followable.
118
+ */
119
+ forceScopeShrink?: boolean;
91
120
  }
92
121
  export interface PullAllRow {
93
122
  slug: string;
@@ -13,11 +13,11 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
 
16
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="1164bdd1-84e4-5bc5-80a8-6888a6f4313c")}catch(e){}}();
16
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="59cfec0e-76a2-5beb-8eb6-80a129dd62c6")}catch(e){}}();
17
17
  import chalk from "chalk";
18
18
  import * as fs from "fs";
19
19
  import * as path from "path";
20
- import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, } from "@indigoai-us/hq-cloud";
20
+ import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
21
21
  import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
22
22
  import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
23
23
  /**
@@ -115,14 +115,30 @@ export async function pullAll(options, deps) {
115
115
  };
116
116
  for (const entry of plan) {
117
117
  result.attempted += 1;
118
- // US-011: resolve the membership's effective sync mode so we can
119
- // either nudge an all-mode owner toward `hq sync narrow` OR refuse
120
- // the leg outright when strict-mode is on and the operator didn't
121
- // pass `--mode-all`. Sync-config lookup is best-effort a 404 or
122
- // network blip should never block the sync itself, so we fall back
123
- // to syncMode='all' (the legacy default) and skip the banner.
118
+ // Resolve the membership's effective sync scope. DEV-1768: this must drive
119
+ // the actual pull (mode + prefixSet), not just the narrow-hint banner — the
120
+ // old code resolved the mode for the banner and then called sync() with NO
121
+ // scope, so every CLI pull ran `syncMode: "all"` and stamped an all-mode
122
+ // PullRecord that wedged the next menubar sync (all→shared scope-shrink).
123
+ // The shared `resolvePullScope` (via deps.resolveScope) degrades to `all`
124
+ // on any failure, so a transient blip never narrows scope.
124
125
  let resolvedMode;
125
- if (entry.membershipKey && getSyncConfig) {
126
+ if (entry.companyUid && deps.resolveScope) {
127
+ try {
128
+ const scope = await deps.resolveScope(entry.companyUid, entry.slug);
129
+ resolvedMode = scope.syncMode;
130
+ entry.syncOptions.syncMode = scope.syncMode;
131
+ if (scope.prefixSet !== undefined) {
132
+ entry.syncOptions.prefixSet = scope.prefixSet;
133
+ }
134
+ }
135
+ catch {
136
+ resolvedMode = undefined;
137
+ }
138
+ }
139
+ else if (entry.membershipKey && getSyncConfig) {
140
+ // Fallback when no scope resolver is injected (legacy/test paths):
141
+ // banner-only mode resolution, preserving the pre-DEV-1768 behavior.
126
142
  try {
127
143
  const cfg = await getSyncConfig(entry.membershipKey);
128
144
  resolvedMode = cfg.syncMode;
@@ -131,6 +147,9 @@ export async function pullAll(options, deps) {
131
147
  resolvedMode = undefined;
132
148
  }
133
149
  }
150
+ if (options.forceScopeShrink && entry.companyUid) {
151
+ entry.syncOptions.forceScopeShrink = true;
152
+ }
134
153
  if (resolvedMode === "all" &&
135
154
  isStrictRefusal(resolvedMode, narrowHintLevel) &&
136
155
  !options.modeAllOverride &&
@@ -358,6 +377,61 @@ export async function resolvePerCompanyPullPlan(client, targetCompany) {
358
377
  }
359
378
  return { resolvedCompanyUid: undefined, resolvedMode: undefined };
360
379
  }
380
+ /** Read the active company slug/uid from `.hq/config.json` (best-effort). */
381
+ function readActiveCompany(hqRoot) {
382
+ try {
383
+ const cfg = JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8"));
384
+ return typeof cfg?.activeCompany === "string" ? cfg.activeCompany : undefined;
385
+ }
386
+ catch {
387
+ return undefined;
388
+ }
389
+ }
390
+ /**
391
+ * Resolve the effective PULL scope (DEV-1768) for a single foreground pull
392
+ * (`hq sync pull` / `hq sync now`), so the membership's REAL syncMode +
393
+ * prefixSet are threaded into `sync()`. Without this the CLI ran `syncMode:
394
+ * "all"` and stamped an all-mode PullRecord that wedged the next menubar sync.
395
+ *
396
+ * Delegates to the shared `resolvePullScope` (same resolver the runner uses),
397
+ * which degrades to `all` on any failure — so a transient blip never narrows
398
+ * scope. Returns `undefined` only when no company can be resolved at all (then
399
+ * `sync()` resolves the active company itself and pulls `all`, as before).
400
+ */
401
+ async function resolveCliPullScope(client, companyRef, hqRoot) {
402
+ const ref = companyRef ?? readActiveCompany(hqRoot);
403
+ if (!ref)
404
+ return undefined;
405
+ // Map ref (slug OR uid) → { uid, slug }: resolvePullScope needs the slug to
406
+ // normalize slug-anchored grant paths in shared mode.
407
+ let companyUid = ref;
408
+ let slug = ref;
409
+ try {
410
+ const memberships = await client.listMyMemberships();
411
+ const direct = memberships.find((m) => m.companyUid === ref || m.membershipKey === ref);
412
+ if (direct) {
413
+ companyUid = direct.companyUid;
414
+ const ent = await client.entity.get(direct.companyUid).catch(() => null);
415
+ if (ent?.slug)
416
+ slug = ent.slug;
417
+ }
418
+ else {
419
+ for (const m of memberships) {
420
+ const ent = await client.entity.get(m.companyUid).catch(() => null);
421
+ if (ent?.slug === ref) {
422
+ companyUid = m.companyUid;
423
+ slug = ent.slug;
424
+ break;
425
+ }
426
+ }
427
+ }
428
+ }
429
+ catch {
430
+ // Can't resolve the membership set — degrade to no explicit scope.
431
+ return undefined;
432
+ }
433
+ return resolvePullScope(client, companyUid, slug, hqRoot);
434
+ }
361
435
  export function registerCloudCommands(program) {
362
436
  program
363
437
  .command("push")
@@ -585,6 +659,10 @@ export function registerCloudCommands(program) {
585
659
  "Has no effect today (default narrow-hint level is 'hint'); " +
586
660
  "wired so future hq-core-staging releases can flip the default to " +
587
661
  "'strict' without re-touching this command.")
662
+ .option("--force-scope-shrink", "When a scope shrink would un-track locally-modified files (e.g. after " +
663
+ "narrowing from all → shared), proceed anyway. Dirty files are KEPT on " +
664
+ "disk and only un-tracked from sync; clean out-of-scope files are " +
665
+ "quarantined under .hq/scope-quarantine/ (recoverable).")
588
666
  .action(async (options) => {
589
667
  try {
590
668
  assertSingleSelector(options, "pull");
@@ -597,7 +675,7 @@ export function registerCloudCommands(program) {
597
675
  // `options.personal === false` is Commander's auto-negation of
598
676
  // `--personal`; in `--all` mode that means "drop the personal
599
677
  // leg from the fanout" (see `--no-personal` option above).
600
- await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false);
678
+ await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true);
601
679
  return;
602
680
  }
603
681
  if (options.personal) {
@@ -617,7 +695,12 @@ export function registerCloudCommands(program) {
617
695
  // paths. Failure to resolve degrades silently — pull still works,
618
696
  // banner just stays quiet (same as the catch in runPullAll).
619
697
  const narrowHintLevel = resolveBannerLevel();
620
- const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(new VaultClient(vaultConfig), options.company);
698
+ const pullClient = new VaultClient(vaultConfig);
699
+ const { resolvedCompanyUid, resolvedMode } = await resolvePerCompanyPullPlan(pullClient, options.company);
700
+ // DEV-1768: resolve the REAL effective scope (mode + prefixSet) and
701
+ // thread it into the pull below — not just the banner. Best-effort;
702
+ // degrades to "all" inside the resolver on any failure.
703
+ const pullScope = await resolveCliPullScope(pullClient, options.company, options.hqRoot);
621
704
  // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
622
705
  // Default banner level is 'hint' which never triggers refusal —
623
706
  // wired now so future hq-core-staging releases can flip the
@@ -641,6 +724,13 @@ export function registerCloudCommands(program) {
641
724
  onConflict: options.onConflict,
642
725
  vaultConfig,
643
726
  hqRoot: options.hqRoot,
727
+ ...(pullScope?.syncMode !== undefined
728
+ ? { syncMode: pullScope.syncMode }
729
+ : {}),
730
+ ...(pullScope?.prefixSet !== undefined
731
+ ? { prefixSet: pullScope.prefixSet }
732
+ : {}),
733
+ ...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
644
734
  });
645
735
  if (result.aborted) {
646
736
  console.log(chalk.yellow(`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
@@ -756,6 +846,9 @@ export function registerCloudCommands(program) {
756
846
  .option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
757
847
  "No-op today; wired so future hq-core-staging releases can flip " +
758
848
  "the default narrow-hint level to 'strict'.")
849
+ .option("--force-scope-shrink", "When a scope shrink would un-track locally-modified files, proceed " +
850
+ "anyway. Dirty files are KEPT on disk (only un-tracked); clean " +
851
+ "out-of-scope files are quarantined under .hq/scope-quarantine/.")
759
852
  .action(async (options) => {
760
853
  try {
761
854
  assertSingleSelector(options, "now");
@@ -764,10 +857,10 @@ export function registerCloudCommands(program) {
764
857
  // of `--personal`; in `--all` mode that means "drop the
765
858
  // personal leg from both legs of the bidirectional fanout"
766
859
  // (see `--no-personal` option above).
767
- await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false);
860
+ await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true);
768
861
  return;
769
862
  }
770
- await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
863
+ await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true, options.forceScopeShrink === true);
771
864
  }
772
865
  catch (err) {
773
866
  console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
@@ -775,7 +868,7 @@ export function registerCloudCommands(program) {
775
868
  }
776
869
  });
777
870
  }
778
- async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
871
+ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
779
872
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
780
873
  console.log(` HQ root: ${hqRoot}`);
781
874
  console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
@@ -800,6 +893,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
800
893
  }
801
894
  },
802
895
  getMembershipSyncConfig: (id) => realClient.getMembershipSyncConfig(id),
896
+ listMyExplicitGrants: (companyUid) => realClient.listMyExplicitGrants(companyUid),
803
897
  };
804
898
  result = await pullAll({
805
899
  hqRoot,
@@ -807,8 +901,13 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
807
901
  narrowHintLevel: resolveBannerLevel(),
808
902
  ...(modeAllOverride ? { modeAllOverride: true } : {}),
809
903
  ...(skipPersonal ? { skipPersonal: true } : {}),
904
+ ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
810
905
  }, {
811
906
  vaultClient: adapter,
907
+ // DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
908
+ // via the shared resolver, so the actual pull is scoped — the CLI no
909
+ // longer seeds an all-mode PullRecord that wedges the menubar runner.
910
+ resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
812
911
  sync: (opts) => sync({
813
912
  company: opts.company,
814
913
  hqRoot: opts.hqRoot,
@@ -820,6 +919,9 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
820
919
  ...(opts.journalSlug !== undefined
821
920
  ? { journalSlug: opts.journalSlug }
822
921
  : {}),
922
+ ...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
923
+ ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
924
+ ...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
823
925
  }),
824
926
  });
825
927
  }
@@ -964,7 +1066,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
964
1066
  if (errored > 0)
965
1067
  process.exit(1);
966
1068
  }
967
- async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride) {
1069
+ async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride, forceScopeShrink) {
968
1070
  console.log(chalk.bold("\nHQ Sync — Now"));
969
1071
  console.log(` HQ root: ${hqRoot}`);
970
1072
  console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
@@ -1073,6 +1175,18 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1073
1175
  "to migrate, or re-run with --mode-all."));
1074
1176
  process.exit(1);
1075
1177
  }
1178
+ // DEV-1768: resolve the membership's real scope and thread it into the
1179
+ // pull leg, so `hq sync now` stops seeding all-mode PullRecords. Personal
1180
+ // targets have no membership scope — they stay full ("all").
1181
+ let pullScope;
1182
+ if (!personalMode) {
1183
+ try {
1184
+ pullScope = await resolveCliPullScope(new VaultClient(vaultConfig), targetCompany, hqRoot);
1185
+ }
1186
+ catch {
1187
+ pullScope = undefined;
1188
+ }
1189
+ }
1076
1190
  console.log(chalk.dim(" → pull leg"));
1077
1191
  const pullResult = await sync({
1078
1192
  company: targetCompany,
@@ -1081,6 +1195,13 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1081
1195
  ...(onConflict ? { onConflict } : {}),
1082
1196
  ...(personalMode ? { personalMode: true } : {}),
1083
1197
  ...(journalSlug !== undefined ? { journalSlug } : {}),
1198
+ ...(pullScope?.syncMode !== undefined
1199
+ ? { syncMode: pullScope.syncMode }
1200
+ : {}),
1201
+ ...(pullScope?.prefixSet !== undefined
1202
+ ? { prefixSet: pullScope.prefixSet }
1203
+ : {}),
1204
+ ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
1084
1205
  });
1085
1206
  console.log(` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
1086
1207
  `${pullResult.filesDownloaded} downloaded, ${pullResult.filesSkipped} skipped, ` +
@@ -1106,7 +1227,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1106
1227
  process.exit(1);
1107
1228
  }
1108
1229
  }
1109
- async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal) {
1230
+ async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
1110
1231
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
1111
1232
  console.log(` HQ root: ${hqRoot}`);
1112
1233
  console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
@@ -1123,7 +1244,7 @@ async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPerso
1123
1244
  // US-011: forward --mode-all so the strict refusal applies to the
1124
1245
  // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1125
1246
  // pull-side).
1126
- await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
1247
+ await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink);
1127
1248
  }
1128
1249
  /**
1129
1250
  * Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
@@ -1209,4 +1330,4 @@ function resolveUploadAuthorFromCache() {
1209
1330
  }
1210
1331
  }
1211
1332
  //# sourceMappingURL=cloud.js.map
1212
- //# debugId=1164bdd1-84e4-5bc5-80a8-6888a6f4313c
1333
+ //# debugId=59cfec0e-76a2-5beb-8eb6-80a129dd62c6
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b75c8495-7e0e-53f8-a0d2-c92a993e6492")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import * as readline from "node:readline";
5
5
  import { spawn } from "node:child_process";
@@ -227,6 +227,48 @@ export function registerSecretsCommand(program) {
227
227
  process.exit(1);
228
228
  }
229
229
  });
230
+ // HQ-4H: existence probe via the HEAD route. Optional-credential readers
231
+ // should HEAD-first so a missing secret no longer fires a spurious GET-404
232
+ // Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
233
+ // A HEAD never decrypts or reveals the value.
234
+ //
235
+ // Exit codes are designed for shell chaining:
236
+ // 0 → secret exists (200)
237
+ // 1 → secret is absent (404) — the normal "no" answer, NOT an error
238
+ // 2 → a real failure (auth/network/permission) — distinct from absence so
239
+ // `&&` chains don't mistake an outage for "absent and proceed"
240
+ secrets
241
+ .command("exists <name>")
242
+ .description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
243
+ .option("--quiet", "Suppress the present/absent line (use the exit code only)")
244
+ .action(async (name, opts) => {
245
+ try {
246
+ const token = await ensureCognitoToken();
247
+ const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
248
+ const res = await vaultApiFetch({
249
+ token,
250
+ method: "HEAD",
251
+ path: buildSecretNamePath(companyUid, name),
252
+ });
253
+ if (res.status === 200) {
254
+ if (!opts.quiet)
255
+ console.log(chalk.green(`exists: ${name}`));
256
+ process.exit(0);
257
+ }
258
+ if (res.status === 404) {
259
+ if (!opts.quiet)
260
+ console.log(chalk.dim(`absent: ${name}`));
261
+ process.exit(1);
262
+ }
263
+ // Any other status (401/403/5xx) is a real failure, not an absence.
264
+ console.error(chalk.red(`Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`));
265
+ process.exit(2);
266
+ }
267
+ catch (err) {
268
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
269
+ process.exit(2);
270
+ }
271
+ });
230
272
  secrets
231
273
  .command("list")
232
274
  .description("List all secrets for the company (including nested path-based names)")
@@ -712,4 +754,4 @@ export function registerSecretsCommand(program) {
712
754
  });
713
755
  }
714
756
  //# sourceMappingURL=secrets.js.map
715
- //# debugId=b75c8495-7e0e-53f8-a0d2-c92a993e6492
757
+ //# debugId=7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.38.2",
3
+ "version": "5.39.2",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "^6.5.0",
18
+ "@indigoai-us/hq-cloud": "^6.6.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -154,6 +154,70 @@ describe("pullAll", () => {
154
154
  ]);
155
155
  });
156
156
 
157
+ // ── 1b. DEV-1768: real scope is threaded into the actual pull ─────────────
158
+
159
+ it("threads the resolved syncMode + prefixSet into sync() (no all-mode seed)", async () => {
160
+ const vaultClient = makeVaultClient({
161
+ memberships: [{ companyUid: "cmp_a" }],
162
+ entitiesBySlug: { cmp_a: { slug: "acme" } },
163
+ });
164
+ const sync = makeSyncSpy();
165
+ const resolveScope = vi.fn(async (_uid: string, _slug: string) => ({
166
+ syncMode: "shared" as const,
167
+ prefixSet: ["knowledge/", "policies/"],
168
+ }));
169
+
170
+ await pullAll(
171
+ { hqRoot: "/tmp/hq" },
172
+ { vaultClient, sync: sync.fn, resolveScope },
173
+ );
174
+
175
+ // Resolver invoked with the company's UID + resolved slug.
176
+ expect(resolveScope).toHaveBeenCalledWith("cmp_a", "acme");
177
+ // ...and the result is forwarded to the ACTUAL pull, not just the banner.
178
+ const companyCall = sync.calls.find((c) => c.company === "cmp_a");
179
+ expect(companyCall?.syncMode).toBe("shared");
180
+ expect(companyCall?.prefixSet).toEqual(["knowledge/", "policies/"]);
181
+ });
182
+
183
+ it("passes no prefixSet for an all-mode membership (full pull preserved)", async () => {
184
+ const vaultClient = makeVaultClient({
185
+ memberships: [{ companyUid: "cmp_a" }],
186
+ entitiesBySlug: { cmp_a: { slug: "acme" } },
187
+ });
188
+ const sync = makeSyncSpy();
189
+ const resolveScope = vi.fn(async () => ({ syncMode: "all" as const }));
190
+
191
+ await pullAll(
192
+ { hqRoot: "/tmp/hq" },
193
+ { vaultClient, sync: sync.fn, resolveScope },
194
+ );
195
+
196
+ const companyCall = sync.calls.find((c) => c.company === "cmp_a");
197
+ expect(companyCall?.syncMode).toBe("all");
198
+ expect(companyCall?.prefixSet).toBeUndefined();
199
+ });
200
+
201
+ it("forwards forceScopeShrink to company legs when set", async () => {
202
+ const vaultClient = makeVaultClient({
203
+ memberships: [{ companyUid: "cmp_a" }],
204
+ entitiesBySlug: { cmp_a: { slug: "acme" } },
205
+ });
206
+ const sync = makeSyncSpy();
207
+ const resolveScope = vi.fn(async () => ({
208
+ syncMode: "shared" as const,
209
+ prefixSet: ["knowledge/"],
210
+ }));
211
+
212
+ await pullAll(
213
+ { hqRoot: "/tmp/hq", forceScopeShrink: true },
214
+ { vaultClient, sync: sync.fn, resolveScope },
215
+ );
216
+
217
+ const companyCall = sync.calls.find((c) => c.company === "cmp_a");
218
+ expect(companyCall?.forceScopeShrink).toBe(true);
219
+ });
220
+
157
221
  // ── 2. Conflict strategy passthrough ──────────────────────────────────────
158
222
 
159
223
  it("forwards --on-conflict to every sync() call", async () => {
@@ -27,9 +27,13 @@ import {
27
27
  VaultClient,
28
28
  computePersonalVaultPaths,
29
29
  PERSONAL_VAULT_JOURNAL_SLUG,
30
+ resolvePullScope,
30
31
  type ConflictStrategy,
31
32
  type EntityContext,
32
33
  type MembershipSyncConfig,
34
+ type SyncMode,
35
+ type PullScope,
36
+ type ExplicitGrant,
33
37
  type SyncProgressEvent,
34
38
  type UploadAuthor,
35
39
  } from "@indigoai-us/hq-cloud";
@@ -111,6 +115,12 @@ export interface PullAllVaultClient {
111
115
  getMembershipSyncConfig?: (
112
116
  membershipId: string,
113
117
  ) => Promise<MembershipSyncConfig>;
118
+ /**
119
+ * Caller's explicit grants for a company — consumed by `resolvePullScope`
120
+ * to build a `shared`-mode prefix set. Optional so legacy adapters degrade
121
+ * to `all` (the safe direction).
122
+ */
123
+ listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
114
124
  }
115
125
 
116
126
  export interface SyncCallOptions {
@@ -119,6 +129,16 @@ export interface SyncCallOptions {
119
129
  onConflict?: ConflictStrategy;
120
130
  personalMode?: boolean;
121
131
  journalSlug?: string;
132
+ /**
133
+ * Effective pull scope (DEV-1768). When set, `sync()` materializes only the
134
+ * in-scope keys and scope-shrinks the rest — instead of defaulting to
135
+ * `syncMode: "all"` and stamping an all-mode PullRecord that wedges the next
136
+ * menubar sync. Resolved per-company by the shared `resolvePullScope`.
137
+ */
138
+ syncMode?: SyncMode;
139
+ prefixSet?: string[];
140
+ /** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
141
+ forceScopeShrink?: boolean;
122
142
  }
123
143
 
124
144
  export interface SyncCallResult {
@@ -133,6 +153,13 @@ export interface SyncCallResult {
133
153
  export interface PullAllDeps {
134
154
  vaultClient: PullAllVaultClient;
135
155
  sync: (options: SyncCallOptions) => Promise<SyncCallResult>;
156
+ /**
157
+ * Resolve a company's effective pull scope (DEV-1768). Injected so `pullAll`
158
+ * stays pure/testable; the real entry point wires it to the shared
159
+ * `resolvePullScope` over a live `VaultClient`. When absent, the per-company
160
+ * leg falls back to `all` (legacy behavior) — only the real path injects it.
161
+ */
162
+ resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
136
163
  }
137
164
 
138
165
  export interface PullAllOptions {
@@ -165,6 +192,12 @@ export interface PullAllOptions {
165
192
  * personal vault from the run without touching the rest of the plan.
166
193
  */
167
194
  skipPersonal?: boolean;
195
+ /**
196
+ * Forward `--force-scope-shrink` to each company leg: when a scope shrink
197
+ * would un-track locally-modified files, proceed anyway (dirty files KEPT on
198
+ * disk, only un-tracked). Makes the foreground "block" advice followable.
199
+ */
200
+ forceScopeShrink?: boolean;
168
201
  }
169
202
 
170
203
  export interface PullAllRow {
@@ -331,14 +364,28 @@ export async function pullAll(
331
364
  for (const entry of plan) {
332
365
  result.attempted += 1;
333
366
 
334
- // US-011: resolve the membership's effective sync mode so we can
335
- // either nudge an all-mode owner toward `hq sync narrow` OR refuse
336
- // the leg outright when strict-mode is on and the operator didn't
337
- // pass `--mode-all`. Sync-config lookup is best-effort a 404 or
338
- // network blip should never block the sync itself, so we fall back
339
- // to syncMode='all' (the legacy default) and skip the banner.
367
+ // Resolve the membership's effective sync scope. DEV-1768: this must drive
368
+ // the actual pull (mode + prefixSet), not just the narrow-hint banner — the
369
+ // old code resolved the mode for the banner and then called sync() with NO
370
+ // scope, so every CLI pull ran `syncMode: "all"` and stamped an all-mode
371
+ // PullRecord that wedged the next menubar sync (all→shared scope-shrink).
372
+ // The shared `resolvePullScope` (via deps.resolveScope) degrades to `all`
373
+ // on any failure, so a transient blip never narrows scope.
340
374
  let resolvedMode: MembershipSyncConfig["syncMode"] | undefined;
341
- if (entry.membershipKey && getSyncConfig) {
375
+ if (entry.companyUid && deps.resolveScope) {
376
+ try {
377
+ const scope = await deps.resolveScope(entry.companyUid, entry.slug);
378
+ resolvedMode = scope.syncMode;
379
+ entry.syncOptions.syncMode = scope.syncMode;
380
+ if (scope.prefixSet !== undefined) {
381
+ entry.syncOptions.prefixSet = scope.prefixSet;
382
+ }
383
+ } catch {
384
+ resolvedMode = undefined;
385
+ }
386
+ } else if (entry.membershipKey && getSyncConfig) {
387
+ // Fallback when no scope resolver is injected (legacy/test paths):
388
+ // banner-only mode resolution, preserving the pre-DEV-1768 behavior.
342
389
  try {
343
390
  const cfg = await getSyncConfig(entry.membershipKey);
344
391
  resolvedMode = cfg.syncMode;
@@ -346,6 +393,9 @@ export async function pullAll(
346
393
  resolvedMode = undefined;
347
394
  }
348
395
  }
396
+ if (options.forceScopeShrink && entry.companyUid) {
397
+ entry.syncOptions.forceScopeShrink = true;
398
+ }
349
399
 
350
400
  if (
351
401
  resolvedMode === "all" &&
@@ -637,6 +687,66 @@ export async function resolvePerCompanyPullPlan(
637
687
  return { resolvedCompanyUid: undefined, resolvedMode: undefined };
638
688
  }
639
689
 
690
+ /** Read the active company slug/uid from `.hq/config.json` (best-effort). */
691
+ function readActiveCompany(hqRoot: string): string | undefined {
692
+ try {
693
+ const cfg = JSON.parse(
694
+ fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf-8"),
695
+ ) as { activeCompany?: unknown };
696
+ return typeof cfg?.activeCompany === "string" ? cfg.activeCompany : undefined;
697
+ } catch {
698
+ return undefined;
699
+ }
700
+ }
701
+
702
+ /**
703
+ * Resolve the effective PULL scope (DEV-1768) for a single foreground pull
704
+ * (`hq sync pull` / `hq sync now`), so the membership's REAL syncMode +
705
+ * prefixSet are threaded into `sync()`. Without this the CLI ran `syncMode:
706
+ * "all"` and stamped an all-mode PullRecord that wedged the next menubar sync.
707
+ *
708
+ * Delegates to the shared `resolvePullScope` (same resolver the runner uses),
709
+ * which degrades to `all` on any failure — so a transient blip never narrows
710
+ * scope. Returns `undefined` only when no company can be resolved at all (then
711
+ * `sync()` resolves the active company itself and pulls `all`, as before).
712
+ */
713
+ async function resolveCliPullScope(
714
+ client: VaultClient,
715
+ companyRef: string | undefined,
716
+ hqRoot: string,
717
+ ): Promise<PullScope | undefined> {
718
+ const ref = companyRef ?? readActiveCompany(hqRoot);
719
+ if (!ref) return undefined;
720
+ // Map ref (slug OR uid) → { uid, slug }: resolvePullScope needs the slug to
721
+ // normalize slug-anchored grant paths in shared mode.
722
+ let companyUid = ref;
723
+ let slug = ref;
724
+ try {
725
+ const memberships = await client.listMyMemberships();
726
+ const direct = memberships.find(
727
+ (m) => m.companyUid === ref || m.membershipKey === ref,
728
+ );
729
+ if (direct) {
730
+ companyUid = direct.companyUid;
731
+ const ent = await client.entity.get(direct.companyUid).catch(() => null);
732
+ if (ent?.slug) slug = ent.slug;
733
+ } else {
734
+ for (const m of memberships) {
735
+ const ent = await client.entity.get(m.companyUid).catch(() => null);
736
+ if (ent?.slug === ref) {
737
+ companyUid = m.companyUid;
738
+ slug = ent.slug;
739
+ break;
740
+ }
741
+ }
742
+ }
743
+ } catch {
744
+ // Can't resolve the membership set — degrade to no explicit scope.
745
+ return undefined;
746
+ }
747
+ return resolvePullScope(client, companyUid, slug, hqRoot);
748
+ }
749
+
640
750
  export function registerCloudCommands(program: Command): void {
641
751
  program
642
752
  .command("push")
@@ -970,6 +1080,13 @@ export function registerCloudCommands(program: Command): void {
970
1080
  "wired so future hq-core-staging releases can flip the default to " +
971
1081
  "'strict' without re-touching this command.",
972
1082
  )
1083
+ .option(
1084
+ "--force-scope-shrink",
1085
+ "When a scope shrink would un-track locally-modified files (e.g. after " +
1086
+ "narrowing from all → shared), proceed anyway. Dirty files are KEPT on " +
1087
+ "disk and only un-tracked from sync; clean out-of-scope files are " +
1088
+ "quarantined under .hq/scope-quarantine/ (recoverable).",
1089
+ )
973
1090
  .action(
974
1091
  async (
975
1092
  options: CommonSyncOptions & {
@@ -977,6 +1094,7 @@ export function registerCloudCommands(program: Command): void {
977
1094
  all?: boolean;
978
1095
  personal?: boolean;
979
1096
  modeAll?: boolean;
1097
+ forceScopeShrink?: boolean;
980
1098
  },
981
1099
  ) => {
982
1100
  try {
@@ -997,6 +1115,7 @@ export function registerCloudCommands(program: Command): void {
997
1115
  options.onConflict,
998
1116
  options.modeAll === true,
999
1117
  options.personal === false,
1118
+ options.forceScopeShrink === true,
1000
1119
  );
1001
1120
  return;
1002
1121
  }
@@ -1019,11 +1138,17 @@ export function registerCloudCommands(program: Command): void {
1019
1138
  // paths. Failure to resolve degrades silently — pull still works,
1020
1139
  // banner just stays quiet (same as the catch in runPullAll).
1021
1140
  const narrowHintLevel: BannerLevel = resolveBannerLevel();
1141
+ const pullClient = new VaultClient(vaultConfig);
1022
1142
  const { resolvedCompanyUid, resolvedMode } =
1023
- await resolvePerCompanyPullPlan(
1024
- new VaultClient(vaultConfig),
1025
- options.company,
1026
- );
1143
+ await resolvePerCompanyPullPlan(pullClient, options.company);
1144
+ // DEV-1768: resolve the REAL effective scope (mode + prefixSet) and
1145
+ // thread it into the pull below — not just the banner. Best-effort;
1146
+ // degrades to "all" inside the resolver on any failure.
1147
+ const pullScope = await resolveCliPullScope(
1148
+ pullClient,
1149
+ options.company,
1150
+ options.hqRoot,
1151
+ );
1027
1152
 
1028
1153
  // Strict-mode refusal: matches runPullAll + runNowSingle behavior.
1029
1154
  // Default banner level is 'hint' which never triggers refusal —
@@ -1055,6 +1180,13 @@ export function registerCloudCommands(program: Command): void {
1055
1180
  onConflict: options.onConflict,
1056
1181
  vaultConfig,
1057
1182
  hqRoot: options.hqRoot,
1183
+ ...(pullScope?.syncMode !== undefined
1184
+ ? { syncMode: pullScope.syncMode }
1185
+ : {}),
1186
+ ...(pullScope?.prefixSet !== undefined
1187
+ ? { prefixSet: pullScope.prefixSet }
1188
+ : {}),
1189
+ ...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
1058
1190
  });
1059
1191
 
1060
1192
  if (result.aborted) {
@@ -1236,6 +1368,12 @@ export function registerCloudCommands(program: Command): void {
1236
1368
  "No-op today; wired so future hq-core-staging releases can flip " +
1237
1369
  "the default narrow-hint level to 'strict'.",
1238
1370
  )
1371
+ .option(
1372
+ "--force-scope-shrink",
1373
+ "When a scope shrink would un-track locally-modified files, proceed " +
1374
+ "anyway. Dirty files are KEPT on disk (only un-tracked); clean " +
1375
+ "out-of-scope files are quarantined under .hq/scope-quarantine/.",
1376
+ )
1239
1377
  .action(
1240
1378
  async (
1241
1379
  options: CommonSyncOptions & {
@@ -1244,6 +1382,7 @@ export function registerCloudCommands(program: Command): void {
1244
1382
  all?: boolean;
1245
1383
  personal?: boolean;
1246
1384
  modeAll?: boolean;
1385
+ forceScopeShrink?: boolean;
1247
1386
  },
1248
1387
  ) => {
1249
1388
  try {
@@ -1259,6 +1398,7 @@ export function registerCloudCommands(program: Command): void {
1259
1398
  options.onConflict,
1260
1399
  options.modeAll === true,
1261
1400
  options.personal === false,
1401
+ options.forceScopeShrink === true,
1262
1402
  );
1263
1403
  return;
1264
1404
  }
@@ -1269,6 +1409,7 @@ export function registerCloudCommands(program: Command): void {
1269
1409
  options.message,
1270
1410
  options.onConflict,
1271
1411
  options.modeAll === true,
1412
+ options.forceScopeShrink === true,
1272
1413
  );
1273
1414
  } catch (err) {
1274
1415
  console.error(
@@ -1286,6 +1427,7 @@ async function runPullAll(
1286
1427
  onConflict?: ConflictStrategy,
1287
1428
  modeAllOverride?: boolean,
1288
1429
  skipPersonal?: boolean,
1430
+ forceScopeShrink?: boolean,
1289
1431
  ): Promise<void> {
1290
1432
  console.log(chalk.bold("\nHQ Sync — Pull (all)"));
1291
1433
  console.log(` HQ root: ${hqRoot}`);
@@ -1313,6 +1455,8 @@ async function runPullAll(
1313
1455
  },
1314
1456
  getMembershipSyncConfig: (id: string) =>
1315
1457
  realClient.getMembershipSyncConfig(id),
1458
+ listMyExplicitGrants: (companyUid: string) =>
1459
+ realClient.listMyExplicitGrants(companyUid),
1316
1460
  };
1317
1461
 
1318
1462
  result = await pullAll(
@@ -1322,9 +1466,15 @@ async function runPullAll(
1322
1466
  narrowHintLevel: resolveBannerLevel(),
1323
1467
  ...(modeAllOverride ? { modeAllOverride: true } : {}),
1324
1468
  ...(skipPersonal ? { skipPersonal: true } : {}),
1469
+ ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
1325
1470
  },
1326
1471
  {
1327
1472
  vaultClient: adapter,
1473
+ // DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
1474
+ // via the shared resolver, so the actual pull is scoped — the CLI no
1475
+ // longer seeds an all-mode PullRecord that wedges the menubar runner.
1476
+ resolveScope: (companyUid: string, slug: string) =>
1477
+ resolvePullScope(realClient, companyUid, slug, hqRoot),
1328
1478
  sync: (opts) =>
1329
1479
  sync({
1330
1480
  company: opts.company,
@@ -1337,6 +1487,9 @@ async function runPullAll(
1337
1487
  ...(opts.journalSlug !== undefined
1338
1488
  ? { journalSlug: opts.journalSlug }
1339
1489
  : {}),
1490
+ ...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
1491
+ ...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
1492
+ ...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
1340
1493
  }),
1341
1494
  },
1342
1495
  );
@@ -1530,6 +1683,7 @@ async function runNowSingle(
1530
1683
  message?: string,
1531
1684
  onConflict?: ConflictStrategy,
1532
1685
  modeAllOverride?: boolean,
1686
+ forceScopeShrink?: boolean,
1533
1687
  ): Promise<void> {
1534
1688
  console.log(chalk.bold("\nHQ Sync — Now"));
1535
1689
  console.log(` HQ root: ${hqRoot}`);
@@ -1657,6 +1811,22 @@ async function runNowSingle(
1657
1811
  process.exit(1);
1658
1812
  }
1659
1813
 
1814
+ // DEV-1768: resolve the membership's real scope and thread it into the
1815
+ // pull leg, so `hq sync now` stops seeding all-mode PullRecords. Personal
1816
+ // targets have no membership scope — they stay full ("all").
1817
+ let pullScope: PullScope | undefined;
1818
+ if (!personalMode) {
1819
+ try {
1820
+ pullScope = await resolveCliPullScope(
1821
+ new VaultClient(vaultConfig),
1822
+ targetCompany,
1823
+ hqRoot,
1824
+ );
1825
+ } catch {
1826
+ pullScope = undefined;
1827
+ }
1828
+ }
1829
+
1660
1830
  console.log(chalk.dim(" → pull leg"));
1661
1831
  const pullResult = await sync({
1662
1832
  company: targetCompany,
@@ -1665,6 +1835,13 @@ async function runNowSingle(
1665
1835
  ...(onConflict ? { onConflict } : {}),
1666
1836
  ...(personalMode ? { personalMode: true } : {}),
1667
1837
  ...(journalSlug !== undefined ? { journalSlug } : {}),
1838
+ ...(pullScope?.syncMode !== undefined
1839
+ ? { syncMode: pullScope.syncMode }
1840
+ : {}),
1841
+ ...(pullScope?.prefixSet !== undefined
1842
+ ? { prefixSet: pullScope.prefixSet }
1843
+ : {}),
1844
+ ...(forceScopeShrink ? { forceScopeShrink: true } : {}),
1668
1845
  });
1669
1846
  console.log(
1670
1847
  ` ${pullResult.aborted ? chalk.yellow("⚠") : chalk.green("✓")} ` +
@@ -1704,6 +1881,7 @@ async function runNowAll(
1704
1881
  onConflict?: ConflictStrategy,
1705
1882
  modeAllOverride?: boolean,
1706
1883
  skipPersonal?: boolean,
1884
+ forceScopeShrink?: boolean,
1707
1885
  ): Promise<void> {
1708
1886
  console.log(chalk.bold("\nHQ Sync — Now (all)"));
1709
1887
  console.log(` HQ root: ${hqRoot}`);
@@ -1722,7 +1900,13 @@ async function runNowAll(
1722
1900
  // US-011: forward --mode-all so the strict refusal applies to the
1723
1901
  // pull leg (push doesn't need a narrow-hint — the narrow ritual is
1724
1902
  // pull-side).
1725
- await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
1903
+ await runPullAll(
1904
+ hqRoot,
1905
+ onConflict,
1906
+ modeAllOverride,
1907
+ skipPersonal,
1908
+ forceScopeShrink,
1909
+ );
1726
1910
  }
1727
1911
 
1728
1912
  /**
@@ -62,6 +62,71 @@ function buildProgram(): Command {
62
62
  return program;
63
63
  }
64
64
 
65
+ // HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
66
+ // codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
67
+ // terminal exit doesn't kill the runner; we assert the code it requested.
68
+ describe("secrets exists (HQ-4H HEAD probe)", () => {
69
+ let exitSpy: MockInstance<typeof process.exit>;
70
+
71
+ beforeEach(() => {
72
+ // Throw so the command stops at its first process.exit (the real runtime
73
+ // terminates there). The action's own try/catch re-invokes exit(2) on the
74
+ // thrown sentinel, so we assert on the FIRST recorded exit code — the one
75
+ // the command actually intended — not the last.
76
+ exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
77
+ throw new Error("__exit__");
78
+ }) as never);
79
+ });
80
+
81
+ async function firstExitCode(name: string): Promise<number | undefined> {
82
+ const program = buildProgram();
83
+ try {
84
+ await program.parseAsync(["node", "hq", "secrets", "exists", name]);
85
+ } catch {
86
+ // sentinel(s) from the exit spy
87
+ }
88
+ return exitSpy.mock.calls[0]?.[0] as number | undefined;
89
+ }
90
+
91
+ it("issues a HEAD and intends exit 0 when the secret exists (200)", async () => {
92
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
93
+ new Response(null, { status: 200 }),
94
+ );
95
+
96
+ const code = await firstExitCode("MY_KEY");
97
+
98
+ expect(vaultApiFetch).toHaveBeenCalledWith({
99
+ token: "test-token",
100
+ method: "HEAD",
101
+ path: "/secrets/prs_alice/name/MY_KEY",
102
+ });
103
+ expect(code).toBe(0);
104
+ });
105
+
106
+ it("intends exit 1 (absent, not an error) on 404", async () => {
107
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
108
+ new Response(
109
+ JSON.stringify({ error: "Secret not found", name: "MISSING" }),
110
+ { status: 404, headers: { "Content-Type": "application/json" } },
111
+ ),
112
+ );
113
+
114
+ const code = await firstExitCode("MISSING");
115
+
116
+ expect(code).toBe(1);
117
+ });
118
+
119
+ it("intends exit 2 (real failure, distinct from absence) on a 5xx", async () => {
120
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
121
+ new Response("boom", { status: 503 }),
122
+ );
123
+
124
+ const code = await firstExitCode("MY_KEY");
125
+
126
+ expect(code).toBe(2);
127
+ });
128
+ });
129
+
65
130
  describe("secrets generate-link", () => {
66
131
  it("mints one-time submission links for personal secrets", async () => {
67
132
  const program = buildProgram();
@@ -294,6 +294,58 @@ export function registerSecretsCommand(program: Command): void {
294
294
  }
295
295
  });
296
296
 
297
+ // HQ-4H: existence probe via the HEAD route. Optional-credential readers
298
+ // should HEAD-first so a missing secret no longer fires a spurious GET-404
299
+ // Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
300
+ // A HEAD never decrypts or reveals the value.
301
+ //
302
+ // Exit codes are designed for shell chaining:
303
+ // 0 → secret exists (200)
304
+ // 1 → secret is absent (404) — the normal "no" answer, NOT an error
305
+ // 2 → a real failure (auth/network/permission) — distinct from absence so
306
+ // `&&` chains don't mistake an outage for "absent and proceed"
307
+ secrets
308
+ .command("exists <name>")
309
+ .description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
310
+ .option("--quiet", "Suppress the present/absent line (use the exit code only)")
311
+ .action(async (name: string, opts: { quiet?: boolean }) => {
312
+ try {
313
+ const token = await ensureCognitoToken();
314
+ const companyUid = await getEntityUid(
315
+ token,
316
+ scopeOpts(secrets.opts()),
317
+ );
318
+
319
+ const res = await vaultApiFetch({
320
+ token,
321
+ method: "HEAD",
322
+ path: buildSecretNamePath(companyUid, name),
323
+ });
324
+
325
+ if (res.status === 200) {
326
+ if (!opts.quiet) console.log(chalk.green(`exists: ${name}`));
327
+ process.exit(0);
328
+ }
329
+ if (res.status === 404) {
330
+ if (!opts.quiet) console.log(chalk.dim(`absent: ${name}`));
331
+ process.exit(1);
332
+ }
333
+ // Any other status (401/403/5xx) is a real failure, not an absence.
334
+ console.error(
335
+ chalk.red(
336
+ `Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`,
337
+ ),
338
+ );
339
+ process.exit(2);
340
+ } catch (err) {
341
+ console.error(
342
+ chalk.red("Error:"),
343
+ err instanceof Error ? err.message : String(err),
344
+ );
345
+ process.exit(2);
346
+ }
347
+ });
348
+
297
349
  secrets
298
350
  .command("list")
299
351
  .description("List all secrets for the company (including nested path-based names)")