@indigoai-us/hq-cli 5.18.3 → 5.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/commands/cloud.d.ts +35 -0
- package/dist/commands/cloud.js +82 -15
- package/dist/commands/files-browse.d.ts +42 -5
- package/dist/commands/files-browse.js +130 -26
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +98 -0
- package/src/commands/cloud.push-all.test.ts +75 -0
- package/src/commands/cloud.selectors.test.ts +53 -0
- package/src/commands/cloud.ts +112 -7
- package/src/commands/files-browse.test.ts +212 -0
- package/src/commands/files-browse.ts +219 -41
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.20.0] — 2026-05-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq sync {push,pull,now} --all --no-personal`** — skip the
|
|
10
|
+
canonical-person leg of the `--all` fanout (companies-only sync).
|
|
11
|
+
Mirrors the upstream `hq-sync-runner --skip-personal` flag added in
|
|
12
|
+
`@indigoai-us/hq-cloud@5.25.0`. Wired so the AppBar HQ Sync menubar
|
|
13
|
+
can drop personal sync via a toggle, and so CLI users can opt out
|
|
14
|
+
one-off. Plumbed through `pullAll` / `pushAll` / `runNowAll` via a
|
|
15
|
+
new `skipPersonal` option; outside `--all` mode the flag is a no-op.
|
|
16
|
+
- Bumps `@indigoai-us/hq-cloud` to `~5.25.0` (currency-gated personal-
|
|
17
|
+
vault default exclusions + skip-personal CLI/env surface).
|
|
18
|
+
|
|
5
19
|
## [5.18.3] — 2026-05-21
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -77,6 +77,17 @@ export interface PullAllOptions {
|
|
|
77
77
|
* legacy behavior for this run via `--mode-all`.
|
|
78
78
|
*/
|
|
79
79
|
modeAllOverride?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
82
|
+
* fanout only visits the caller's company memberships. Mirrors the
|
|
83
|
+
* `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
|
|
84
|
+
* `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
|
|
85
|
+
* mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
|
|
86
|
+
* CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
|
|
87
|
+
* menubar toggle (and CLI users opting out one-off) can drop the
|
|
88
|
+
* personal vault from the run without touching the rest of the plan.
|
|
89
|
+
*/
|
|
90
|
+
skipPersonal?: boolean;
|
|
80
91
|
}
|
|
81
92
|
export interface PullAllRow {
|
|
82
93
|
slug: string;
|
|
@@ -110,6 +121,13 @@ export interface PushAllOptions {
|
|
|
110
121
|
hqRoot: string;
|
|
111
122
|
onConflict?: ConflictStrategy;
|
|
112
123
|
message?: string;
|
|
124
|
+
/**
|
|
125
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
126
|
+
* fanout only visits the caller's company memberships. Symmetric with
|
|
127
|
+
* `PullAllOptions.skipPersonal`; surfaced on the CLI as
|
|
128
|
+
* `hq sync push --all --no-personal`.
|
|
129
|
+
*/
|
|
130
|
+
skipPersonal?: boolean;
|
|
113
131
|
}
|
|
114
132
|
export interface PushAllRow {
|
|
115
133
|
slug: string;
|
|
@@ -153,6 +171,23 @@ export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Pro
|
|
|
153
171
|
* no person entity (typically means they haven't run `hq onboard`).
|
|
154
172
|
*/
|
|
155
173
|
export declare function resolveCanonicalPersonUid(vaultClient: PullAllVaultClient): Promise<string>;
|
|
174
|
+
/**
|
|
175
|
+
* Refuse `hq sync push --personal <path>` — the combination silently
|
|
176
|
+
* bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
|
|
177
|
+
* `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
|
|
178
|
+
* `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
|
|
179
|
+
* incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
|
|
180
|
+
* objects to a personal vault before being killed. Cleanup required a
|
|
181
|
+
* hand-rolled S3 sweep. Closes hq-cli#25.
|
|
182
|
+
*
|
|
183
|
+
* Refusal — not silent filtering — is intentional: explicit is better than
|
|
184
|
+
* implicit guesswork, and the legitimate "I want to push a subset of my
|
|
185
|
+
* personal vault" use case has a clean workaround (drop `--personal`, the
|
|
186
|
+
* subset upload targets the active company via standard semantics).
|
|
187
|
+
*/
|
|
188
|
+
export declare function assertNoPersonalPositionalPaths(opts: {
|
|
189
|
+
personal?: boolean;
|
|
190
|
+
}, paths: string[] | undefined): void;
|
|
156
191
|
/**
|
|
157
192
|
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
158
193
|
* `--company` are mutually exclusive — at most one may be set per
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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]="bdaf154f-b17d-5482-9e4d-feaebe606a1b")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
@@ -60,7 +60,7 @@ export async function pullAll(options, deps) {
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
|
-
const personal = pickCanonicalPerson(persons);
|
|
63
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
64
64
|
if (personal) {
|
|
65
65
|
plan.push({
|
|
66
66
|
slug: "personal",
|
|
@@ -176,7 +176,7 @@ export async function pushAll(options, deps) {
|
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
-
const personal = pickCanonicalPerson(persons);
|
|
179
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
180
180
|
if (personal) {
|
|
181
181
|
plan.push({
|
|
182
182
|
slug: "personal",
|
|
@@ -234,6 +234,30 @@ export async function resolveCanonicalPersonUid(vaultClient) {
|
|
|
234
234
|
}
|
|
235
235
|
return pick.uid;
|
|
236
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Refuse `hq sync push --personal <path>` — the combination silently
|
|
239
|
+
* bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
|
|
240
|
+
* `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
|
|
241
|
+
* `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
|
|
242
|
+
* incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
|
|
243
|
+
* objects to a personal vault before being killed. Cleanup required a
|
|
244
|
+
* hand-rolled S3 sweep. Closes hq-cli#25.
|
|
245
|
+
*
|
|
246
|
+
* Refusal — not silent filtering — is intentional: explicit is better than
|
|
247
|
+
* implicit guesswork, and the legitimate "I want to push a subset of my
|
|
248
|
+
* personal vault" use case has a clean workaround (drop `--personal`, the
|
|
249
|
+
* subset upload targets the active company via standard semantics).
|
|
250
|
+
*/
|
|
251
|
+
export function assertNoPersonalPositionalPaths(opts, paths) {
|
|
252
|
+
if (opts.personal && paths && paths.length > 0) {
|
|
253
|
+
throw new Error("`--personal` cannot be combined with explicit [paths]: " +
|
|
254
|
+
"positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
|
|
255
|
+
"guard (skips .git/, companies/, repos/, workspace/), risking " +
|
|
256
|
+
"cross-scope upload of company data to the personal vault. " +
|
|
257
|
+
"Use bare `--personal` to push the whole personal scope, OR " +
|
|
258
|
+
"drop `--personal` to push specific paths to the active company.");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
237
261
|
/**
|
|
238
262
|
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
239
263
|
* `--company` are mutually exclusive — at most one may be set per
|
|
@@ -330,6 +354,12 @@ export function registerCloudCommands(program) {
|
|
|
330
354
|
"every top-level entry under <hq-root> minus the excluded set " +
|
|
331
355
|
"(.git, companies, repos, workspace) — same scope as `--all`'s " +
|
|
332
356
|
"personal slot. Mutually exclusive with --company and --all.")
|
|
357
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
358
|
+
"only push to the caller's company memberships. Mirrors the " +
|
|
359
|
+
"upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
|
|
360
|
+
"wired so the AppBar HQ Sync menubar can drop personal sync via " +
|
|
361
|
+
"a toggle, and so CLI users can opt out one-off. Ignored outside " +
|
|
362
|
+
"`--all`.")
|
|
333
363
|
.action(async (paths, options) => {
|
|
334
364
|
try {
|
|
335
365
|
assertSingleSelector(options, "push");
|
|
@@ -352,7 +382,13 @@ export function registerCloudCommands(program) {
|
|
|
352
382
|
"target instead.");
|
|
353
383
|
process.exit(1);
|
|
354
384
|
}
|
|
355
|
-
|
|
385
|
+
// `options.personal === false` happens when the user passed
|
|
386
|
+
// `--no-personal` (Commander's auto-negation of the `--personal`
|
|
387
|
+
// selector). In `--all` mode that means "skip the personal leg of
|
|
388
|
+
// the fanout" — wired through to `pushAll.skipPersonal`. Outside
|
|
389
|
+
// `--all` the flag has no effect (logged above as part of the
|
|
390
|
+
// option's help text).
|
|
391
|
+
await runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false);
|
|
356
392
|
return;
|
|
357
393
|
}
|
|
358
394
|
const jsonMode = options.json === true;
|
|
@@ -372,6 +408,8 @@ export function registerCloudCommands(program) {
|
|
|
372
408
|
"Cognito session, while --creds-from-stdin expects the caller " +
|
|
373
409
|
"to have already resolved entity + credentials. Pick one.");
|
|
374
410
|
}
|
|
411
|
+
// Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
|
|
412
|
+
assertNoPersonalPositionalPaths(options, paths);
|
|
375
413
|
log(chalk.bold("\nHQ Sync — Push"));
|
|
376
414
|
log(` HQ root: ${options.hqRoot}`);
|
|
377
415
|
// Resolve credentials. Two paths:
|
|
@@ -505,6 +543,10 @@ export function registerCloudCommands(program) {
|
|
|
505
543
|
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
506
544
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
507
545
|
"and --all.")
|
|
546
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
547
|
+
"only pull the caller's company memberships. Mirrors the upstream " +
|
|
548
|
+
"`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
|
|
549
|
+
"outside `--all`.")
|
|
508
550
|
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
509
551
|
"Has no effect today (default narrow-hint level is 'hint'); " +
|
|
510
552
|
"wired so future hq-core-staging releases can flip the default to " +
|
|
@@ -518,7 +560,10 @@ export function registerCloudCommands(program) {
|
|
|
518
560
|
process.exit(1);
|
|
519
561
|
}
|
|
520
562
|
if (options.all) {
|
|
521
|
-
|
|
563
|
+
// `options.personal === false` is Commander's auto-negation of
|
|
564
|
+
// `--personal`; in `--all` mode that means "drop the personal
|
|
565
|
+
// leg from the fanout" (see `--no-personal` option above).
|
|
566
|
+
await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false);
|
|
522
567
|
return;
|
|
523
568
|
}
|
|
524
569
|
if (options.personal) {
|
|
@@ -641,6 +686,10 @@ export function registerCloudCommands(program) {
|
|
|
641
686
|
"--personal.")
|
|
642
687
|
.option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
|
|
643
688
|
"Mutually exclusive with --company and --all.")
|
|
689
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of both the push " +
|
|
690
|
+
"and pull fanouts — only sync the caller's company memberships. " +
|
|
691
|
+
"Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
|
|
692
|
+
"(hq-cloud 5.25.0). Ignored outside `--all`.")
|
|
644
693
|
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
645
694
|
"No-op today; wired so future hq-core-staging releases can flip " +
|
|
646
695
|
"the default narrow-hint level to 'strict'.")
|
|
@@ -648,7 +697,11 @@ export function registerCloudCommands(program) {
|
|
|
648
697
|
try {
|
|
649
698
|
assertSingleSelector(options, "now");
|
|
650
699
|
if (options.all) {
|
|
651
|
-
|
|
700
|
+
// `options.personal === false` is Commander's auto-negation
|
|
701
|
+
// of `--personal`; in `--all` mode that means "drop the
|
|
702
|
+
// personal leg from both legs of the bidirectional fanout"
|
|
703
|
+
// (see `--no-personal` option above).
|
|
704
|
+
await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false);
|
|
652
705
|
return;
|
|
653
706
|
}
|
|
654
707
|
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
|
|
@@ -659,10 +712,14 @@ export function registerCloudCommands(program) {
|
|
|
659
712
|
}
|
|
660
713
|
});
|
|
661
714
|
}
|
|
662
|
-
async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
715
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
|
|
663
716
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
664
717
|
console.log(` HQ root: ${hqRoot}`);
|
|
665
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
718
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
719
|
+
if (skipPersonal) {
|
|
720
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
721
|
+
}
|
|
722
|
+
console.log("");
|
|
666
723
|
let result;
|
|
667
724
|
try {
|
|
668
725
|
const accessToken = await ensureCognitoToken();
|
|
@@ -686,6 +743,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
|
686
743
|
...(onConflict ? { onConflict } : {}),
|
|
687
744
|
narrowHintLevel: resolveBannerLevel(),
|
|
688
745
|
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
746
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
689
747
|
}, {
|
|
690
748
|
vaultClient: adapter,
|
|
691
749
|
sync: (opts) => sync({
|
|
@@ -759,10 +817,14 @@ async function runPullPersonal(hqRoot, onConflict) {
|
|
|
759
817
|
process.exit(1);
|
|
760
818
|
}
|
|
761
819
|
}
|
|
762
|
-
async function runPushAll(hqRoot, message, onConflict) {
|
|
820
|
+
async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
763
821
|
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
764
822
|
console.log(` HQ root: ${hqRoot}`);
|
|
765
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
823
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
824
|
+
if (skipPersonal) {
|
|
825
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
826
|
+
}
|
|
827
|
+
console.log("");
|
|
766
828
|
let result;
|
|
767
829
|
try {
|
|
768
830
|
const accessToken = await ensureCognitoToken();
|
|
@@ -785,6 +847,7 @@ async function runPushAll(hqRoot, message, onConflict) {
|
|
|
785
847
|
hqRoot,
|
|
786
848
|
...(onConflict ? { onConflict } : {}),
|
|
787
849
|
...(message ? { message } : {}),
|
|
850
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
788
851
|
}, {
|
|
789
852
|
vaultClient: adapter,
|
|
790
853
|
share: (opts) => share({
|
|
@@ -976,20 +1039,24 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
976
1039
|
process.exit(1);
|
|
977
1040
|
}
|
|
978
1041
|
}
|
|
979
|
-
async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
|
|
1042
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal) {
|
|
980
1043
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
981
1044
|
console.log(` HQ root: ${hqRoot}`);
|
|
982
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
1045
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1046
|
+
if (skipPersonal) {
|
|
1047
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
1048
|
+
}
|
|
1049
|
+
console.log("");
|
|
983
1050
|
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
984
1051
|
// so the per-target rendering, error isolation, and exit codes are
|
|
985
1052
|
// identical to running `push --all` then `pull --all` back-to-back.
|
|
986
1053
|
console.log(chalk.dim("→ push --all"));
|
|
987
|
-
await runPushAll(hqRoot, message, onConflict);
|
|
1054
|
+
await runPushAll(hqRoot, message, onConflict, skipPersonal);
|
|
988
1055
|
console.log(chalk.dim("\n→ pull --all"));
|
|
989
1056
|
// US-011: forward --mode-all so the strict refusal applies to the
|
|
990
1057
|
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
991
1058
|
// pull-side).
|
|
992
|
-
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
1059
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
|
|
993
1060
|
}
|
|
994
1061
|
/**
|
|
995
1062
|
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
@@ -1063,4 +1130,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
1063
1130
|
}
|
|
1064
1131
|
}
|
|
1065
1132
|
//# sourceMappingURL=cloud.js.map
|
|
1066
|
-
//# debugId=
|
|
1133
|
+
//# debugId=bdaf154f-b17d-5482-9e4d-feaebe606a1b
|
|
@@ -72,8 +72,17 @@ export type S3ClientFactory = (input: {
|
|
|
72
72
|
sessionToken: string;
|
|
73
73
|
};
|
|
74
74
|
}) => FilesBrowseS3Client;
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* ACL provenance for a single listed key.
|
|
77
|
+
* - `shared-with-you`: an explicit grant the caller holds covers the key.
|
|
78
|
+
* - `role-bypass`: the caller has no covering explicit grant, but
|
|
79
|
+
* owner/admin role widened the browse-vend policy to include it.
|
|
80
|
+
* - `personal-vault`: the key lives in the caller's own person-entity
|
|
81
|
+
* vault, where no grants graph applies — the caller is the only
|
|
82
|
+
* principal with access by construction. Emitted only when
|
|
83
|
+
* `runBrowse({ personalMode: true })`.
|
|
84
|
+
*/
|
|
85
|
+
export type AclSource = "shared-with-you" | "role-bypass" | "personal-vault";
|
|
77
86
|
export interface BrowseRow {
|
|
78
87
|
key: string;
|
|
79
88
|
size: number;
|
|
@@ -112,10 +121,30 @@ export declare function assertOutPathOutsideCompanies(outPath: string, hqRoot: s
|
|
|
112
121
|
*/
|
|
113
122
|
export declare function formatBrowseTable(rows: BrowseRow[]): string;
|
|
114
123
|
export interface RunBrowseInput {
|
|
115
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Vault path prefix.
|
|
126
|
+
* - Company mode (`personalMode: false | undefined`): must start with
|
|
127
|
+
* `companies/<slug>/`, e.g. `companies/indigo/scratch/`.
|
|
128
|
+
* - Personal mode (`personalMode: true`): bucket-relative; empty string
|
|
129
|
+
* lists the whole personal vault root.
|
|
130
|
+
*/
|
|
116
131
|
pathPrefix: string;
|
|
117
|
-
/** Caller-overridden company slug (defaults to slug parsed from path). */
|
|
132
|
+
/** Caller-overridden company slug (defaults to slug parsed from path). Ignored under `personalMode`. */
|
|
118
133
|
companySlug?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Personal-vault mode. Skips the `companies/<slug>/` path requirement,
|
|
136
|
+
* resolves the entity via `entity.get(personalUid)` instead of the
|
|
137
|
+
* company namespace, omits the explicit-grants fetch (no grants graph
|
|
138
|
+
* on a person bucket), and marks every row's `aclSource` as
|
|
139
|
+
* `"personal-vault"`. Closes hq-cli#26 (audit gap for personal vault).
|
|
140
|
+
*/
|
|
141
|
+
personalMode?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Canonical person-entity UID (e.g. `prs_…`). Required when
|
|
144
|
+
* `personalMode: true`; ignored otherwise. Caller resolves via
|
|
145
|
+
* `resolveCanonicalPersonUid` to keep this orchestrator pure.
|
|
146
|
+
*/
|
|
147
|
+
personalUid?: string;
|
|
119
148
|
vaultClient: FilesBrowseVaultClient;
|
|
120
149
|
s3Factory: S3ClientFactory;
|
|
121
150
|
region: string;
|
|
@@ -137,7 +166,11 @@ export interface RunBrowseResult {
|
|
|
137
166
|
*/
|
|
138
167
|
export declare function runBrowse(input: RunBrowseInput): Promise<RunBrowseResult>;
|
|
139
168
|
export interface RunCatInput {
|
|
140
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Single vault key.
|
|
171
|
+
* - Company mode: must be a `companies/<slug>/...` path.
|
|
172
|
+
* - Personal mode: bucket-relative, e.g. `.claude/CLAUDE.md`.
|
|
173
|
+
*/
|
|
141
174
|
key: string;
|
|
142
175
|
/**
|
|
143
176
|
* Where to write the body. `undefined` ⇒ stdout. Bright-line-guarded
|
|
@@ -146,6 +179,10 @@ export interface RunCatInput {
|
|
|
146
179
|
out?: string;
|
|
147
180
|
hqRoot: string;
|
|
148
181
|
companySlug?: string;
|
|
182
|
+
/** Personal-vault mode — see `RunBrowseInput.personalMode`. */
|
|
183
|
+
personalMode?: boolean;
|
|
184
|
+
/** Canonical person-entity UID; required when `personalMode: true`. */
|
|
185
|
+
personalUid?: string;
|
|
149
186
|
vaultClient: FilesBrowseVaultClient;
|
|
150
187
|
s3Factory: S3ClientFactory;
|
|
151
188
|
region: string;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* `pnpm.overrides` until that release ships to npm.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
!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]="
|
|
32
|
+
!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]="55a4ece7-6b33-5809-97c0-475c423bc012")}catch(e){}}();
|
|
33
33
|
import chalk from "chalk";
|
|
34
34
|
import * as fs from "node:fs";
|
|
35
35
|
import * as path from "node:path";
|
|
@@ -38,6 +38,7 @@ import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/clie
|
|
|
38
38
|
import { VaultClient, } from "@indigoai-us/hq-cloud";
|
|
39
39
|
import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
40
40
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
41
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
41
42
|
// ── Pure helpers ────────────────────────────────────────────────────────────
|
|
42
43
|
/**
|
|
43
44
|
* Parse the company slug from a vault prefix. Vault paths are anchored at
|
|
@@ -122,19 +123,43 @@ export function formatBrowseTable(rows) {
|
|
|
122
123
|
* Pure-ish: no console output, no process.exit — caller renders + exits.
|
|
123
124
|
*/
|
|
124
125
|
export async function runBrowse(input) {
|
|
125
|
-
const { pathPrefix, vaultClient, s3Factory, region } = input;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
const { pathPrefix, vaultClient, s3Factory, region, personalMode } = input;
|
|
127
|
+
// Branch by mode. Company mode parses slug from path and looks up by
|
|
128
|
+
// namespace; personal mode resolves the entity directly by the supplied
|
|
129
|
+
// person UID and skips the slug + grants machinery (a person bucket has
|
|
130
|
+
// no grants graph — the owner is the only principal). The vend call is
|
|
131
|
+
// identical for both modes once we have the entity in hand.
|
|
132
|
+
let bucket;
|
|
133
|
+
let entityUid;
|
|
134
|
+
if (personalMode) {
|
|
135
|
+
if (!input.personalUid) {
|
|
136
|
+
throw new Error("runBrowse: personalMode requires personalUid. Resolve via " +
|
|
137
|
+
"resolveCanonicalPersonUid() before calling.");
|
|
138
|
+
}
|
|
139
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
140
|
+
if (!entity.bucketName) {
|
|
141
|
+
throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
|
|
142
|
+
}
|
|
143
|
+
entityUid = entity.uid;
|
|
144
|
+
bucket = entity.bucketName;
|
|
130
145
|
}
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
else {
|
|
147
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(pathPrefix);
|
|
148
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
149
|
+
if (!entity) {
|
|
150
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
151
|
+
}
|
|
152
|
+
if (!entity.bucketName) {
|
|
153
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
154
|
+
}
|
|
155
|
+
entityUid = entity.uid;
|
|
156
|
+
bucket = entity.bucketName;
|
|
133
157
|
}
|
|
134
|
-
const companyUid = entity.uid;
|
|
135
|
-
const bucket = entity.bucketName;
|
|
136
158
|
// Distinct vend call from sync — `purpose: 'browse'` opts the request
|
|
137
|
-
// into the role-bypass-allowed code path on the server (US-009).
|
|
159
|
+
// into the role-bypass-allowed code path on the server (US-009). The
|
|
160
|
+
// personal mode vends against the person entity which is owner-only by
|
|
161
|
+
// construction; the vend response shape is identical so downstream
|
|
162
|
+
// S3Client construction doesn't branch.
|
|
138
163
|
const vend = await vaultClient.vend({
|
|
139
164
|
paths: [pathPrefix],
|
|
140
165
|
operations: "read-only",
|
|
@@ -149,8 +174,12 @@ export async function runBrowse(input) {
|
|
|
149
174
|
},
|
|
150
175
|
});
|
|
151
176
|
// Pull the caller's explicit-grant graph once so per-key classification
|
|
152
|
-
// is O(grants) without N round-trips.
|
|
153
|
-
|
|
177
|
+
// is O(grants) without N round-trips. Skipped in personal mode — the
|
|
178
|
+
// grants graph is a company concept; a person bucket marks every row
|
|
179
|
+
// as `"personal-vault"` directly.
|
|
180
|
+
const grants = personalMode
|
|
181
|
+
? []
|
|
182
|
+
: await vaultClient.listMyExplicitGrants(entityUid);
|
|
154
183
|
const rows = [];
|
|
155
184
|
let continuationToken;
|
|
156
185
|
do {
|
|
@@ -169,7 +198,7 @@ export async function runBrowse(input) {
|
|
|
169
198
|
key: obj.Key,
|
|
170
199
|
size: obj.Size ?? 0,
|
|
171
200
|
lastModified: obj.LastModified,
|
|
172
|
-
aclSource: classifyAclSource(obj.Key, grants),
|
|
201
|
+
aclSource: personalMode ? "personal-vault" : classifyAclSource(obj.Key, grants),
|
|
173
202
|
});
|
|
174
203
|
}
|
|
175
204
|
continuationToken = resp.NextContinuationToken ?? undefined;
|
|
@@ -182,20 +211,37 @@ export async function runBrowse(input) {
|
|
|
182
211
|
* containment guard). Refuses ahead of any I/O when `--out` is unsafe.
|
|
183
212
|
*/
|
|
184
213
|
export async function runCat(input) {
|
|
185
|
-
const { key, vaultClient, s3Factory, region, hqRoot } = input;
|
|
186
|
-
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
214
|
+
const { key, vaultClient, s3Factory, region, hqRoot, personalMode } = input;
|
|
187
215
|
// Acceptance 5: refuse BEFORE vending — no point pulling credentials
|
|
188
216
|
// for a request we're already going to abort.
|
|
189
217
|
let absOut;
|
|
190
218
|
if (input.out !== undefined) {
|
|
191
219
|
absOut = assertOutPathOutsideCompanies(input.out, hqRoot);
|
|
192
220
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
221
|
+
// Same branch logic as runBrowse — see that function's doc-block for
|
|
222
|
+
// the personal-vs-company rationale.
|
|
223
|
+
let bucket;
|
|
224
|
+
if (personalMode) {
|
|
225
|
+
if (!input.personalUid) {
|
|
226
|
+
throw new Error("runCat: personalMode requires personalUid. Resolve via " +
|
|
227
|
+
"resolveCanonicalPersonUid() before calling.");
|
|
228
|
+
}
|
|
229
|
+
const entity = await vaultClient.entity.get(input.personalUid);
|
|
230
|
+
if (!entity.bucketName) {
|
|
231
|
+
throw new Error(`Personal entity '${input.personalUid}' has no provisioned bucket.`);
|
|
232
|
+
}
|
|
233
|
+
bucket = entity.bucketName;
|
|
196
234
|
}
|
|
197
|
-
|
|
198
|
-
|
|
235
|
+
else {
|
|
236
|
+
const slug = input.companySlug ?? parseCompanySlugFromPath(key);
|
|
237
|
+
const entity = await vaultClient.entity.findInMyNamespace("company", slug);
|
|
238
|
+
if (!entity) {
|
|
239
|
+
throw new Error(`No company found for slug '${slug}' in your namespace. Confirm you have an active membership.`);
|
|
240
|
+
}
|
|
241
|
+
if (!entity.bucketName) {
|
|
242
|
+
throw new Error(`Company '${slug}' (${entity.uid}) has no provisioned bucket.`);
|
|
243
|
+
}
|
|
244
|
+
bucket = entity.bucketName;
|
|
199
245
|
}
|
|
200
246
|
const vend = await vaultClient.vend({
|
|
201
247
|
paths: [key],
|
|
@@ -210,7 +256,7 @@ export async function runCat(input) {
|
|
|
210
256
|
sessionToken: vend.credentials.sessionToken,
|
|
211
257
|
},
|
|
212
258
|
});
|
|
213
|
-
const resp = (await s3.send(new GetObjectCommand({ Bucket:
|
|
259
|
+
const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
|
|
214
260
|
if (!resp.Body) {
|
|
215
261
|
throw new Error(`GetObject for '${key}' returned no body.`);
|
|
216
262
|
}
|
|
@@ -247,15 +293,47 @@ const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, cre
|
|
|
247
293
|
*/
|
|
248
294
|
export function registerFilesBrowseCommands(filesCmd) {
|
|
249
295
|
filesCmd
|
|
250
|
-
.command("browse
|
|
251
|
-
.description("List vault objects under
|
|
296
|
+
.command("browse [path]")
|
|
297
|
+
.description("List vault objects under [path] without syncing them locally. Uses the browse-vend path (role-bypass allowed). Pass --personal to browse the caller's personal vault; otherwise [path] must start with companies/<slug>/.")
|
|
252
298
|
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
|
|
299
|
+
.option("--personal", "Browse the caller's canonical personal vault. [path] is treated as " +
|
|
300
|
+
"bucket-relative (omit it to list the vault root). Mutually exclusive " +
|
|
301
|
+
"with --company.")
|
|
253
302
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
254
303
|
.action(async (pathArg, options) => {
|
|
255
304
|
try {
|
|
305
|
+
if (options.personal && options.company) {
|
|
306
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
307
|
+
}
|
|
256
308
|
const accessToken = await ensureCognitoToken();
|
|
257
309
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
258
310
|
const client = new VaultClient(vaultConfig);
|
|
311
|
+
if (options.personal) {
|
|
312
|
+
// Personal-vault path. Resolve the caller's canonical person
|
|
313
|
+
// entity once; the orchestrator does the bucket lookup + vend.
|
|
314
|
+
// Empty [path] → list bucket root.
|
|
315
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
316
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
317
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
318
|
+
getEntity: async () => null,
|
|
319
|
+
});
|
|
320
|
+
const result = await runBrowse({
|
|
321
|
+
pathPrefix: pathArg ?? "",
|
|
322
|
+
personalMode: true,
|
|
323
|
+
personalUid,
|
|
324
|
+
vaultClient: client,
|
|
325
|
+
s3Factory: defaultS3Factory,
|
|
326
|
+
region: DEFAULT_COGNITO.region,
|
|
327
|
+
});
|
|
328
|
+
console.log(formatBrowseTable(result.rows));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Company path. [path] is required here — the slug parse needs it.
|
|
332
|
+
if (!pathArg) {
|
|
333
|
+
throw new Error("browse: [path] is required when --personal is not set. " +
|
|
334
|
+
"Pass a companies/<slug>/... path, or add --personal to " +
|
|
335
|
+
"browse your personal vault.");
|
|
336
|
+
}
|
|
259
337
|
// Resolve slug — CLI flag wins, otherwise parse from path arg.
|
|
260
338
|
const slug = options.company ?? parseCompanySlugFromPath(pathArg);
|
|
261
339
|
// If the user passed `--company` AND the path doesn't begin with
|
|
@@ -301,15 +379,41 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
301
379
|
});
|
|
302
380
|
filesCmd
|
|
303
381
|
.command("cat <path>")
|
|
304
|
-
.description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path.")
|
|
382
|
+
.description("Stream a single vault object to stdout (or --out <file>) without syncing it. Uses the browse-vend path. Pass --personal to read from the caller's personal vault.")
|
|
305
383
|
.option("--out <file>", "Write the object body to <file> instead of stdout. Refused under <hqRoot>/companies/.")
|
|
306
384
|
.option("--company <slug>", "Company slug (defaults to the slug parsed from <path>)")
|
|
385
|
+
.option("--personal", "Read from the caller's canonical personal vault. <path> is treated as " +
|
|
386
|
+
"bucket-relative. Mutually exclusive with --company.")
|
|
307
387
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
308
388
|
.action(async (keyArg, options) => {
|
|
309
389
|
try {
|
|
390
|
+
if (options.personal && options.company) {
|
|
391
|
+
throw new Error("--personal and --company are mutually exclusive. Pick one.");
|
|
392
|
+
}
|
|
310
393
|
const accessToken = await ensureCognitoToken();
|
|
311
394
|
const vaultConfig = buildVaultConfig(accessToken);
|
|
312
395
|
const client = new VaultClient(vaultConfig);
|
|
396
|
+
if (options.personal) {
|
|
397
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
398
|
+
listMyMemberships: () => client.listMyMemberships(),
|
|
399
|
+
listPersonEntities: () => client.entity.listByType("person"),
|
|
400
|
+
getEntity: async () => null,
|
|
401
|
+
});
|
|
402
|
+
const result = await runCat({
|
|
403
|
+
key: keyArg,
|
|
404
|
+
out: options.out,
|
|
405
|
+
hqRoot: options.hqRoot,
|
|
406
|
+
personalMode: true,
|
|
407
|
+
personalUid,
|
|
408
|
+
vaultClient: client,
|
|
409
|
+
s3Factory: defaultS3Factory,
|
|
410
|
+
region: DEFAULT_COGNITO.region,
|
|
411
|
+
});
|
|
412
|
+
if (result.destination.kind === "file") {
|
|
413
|
+
console.error(chalk.green("✓"), `Wrote ${result.bytesWritten} bytes to ${result.destination.absPath}`);
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
313
417
|
const slug = options.company ?? parseCompanySlugFromPath(keyArg);
|
|
314
418
|
if (options.company !== undefined) {
|
|
315
419
|
const fromPath = (() => {
|
|
@@ -345,4 +449,4 @@ export function registerFilesBrowseCommands(filesCmd) {
|
|
|
345
449
|
});
|
|
346
450
|
}
|
|
347
451
|
//# sourceMappingURL=files-browse.js.map
|
|
348
|
-
//# debugId=
|
|
452
|
+
//# debugId=55a4ece7-6b33-5809-97c0-475c423bc012
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — 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": "
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.25.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|