@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 +18 -0
- package/dist/commands/cloud.d.ts +30 -1
- package/dist/commands/cloud.js +139 -18
- package/dist/commands/secrets.js +44 -2
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +64 -0
- package/src/commands/cloud.ts +196 -12
- package/src/commands/secrets.test.ts +65 -0
- package/src/commands/secrets.ts +52 -0
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
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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]="
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
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.
|
|
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
|
|
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=
|
|
1333
|
+
//# debugId=59cfec0e-76a2-5beb-8eb6-80a129dd62c6
|
package/dist/commands/secrets.js
CHANGED
|
@@ -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]="
|
|
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=
|
|
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.
|
|
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.
|
|
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 () => {
|
package/src/commands/cloud.ts
CHANGED
|
@@ -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
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
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.
|
|
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
|
-
|
|
1025
|
-
|
|
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(
|
|
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();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -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)")
|