@indigoai-us/hq-cli 5.18.2 → 5.19.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 +43 -0
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +28 -2
- package/dist/commands/files-browse.d.ts +42 -5
- package/dist/commands/files-browse.js +130 -26
- package/dist/commands/members.d.ts +17 -0
- package/dist/commands/members.js +53 -11
- package/package.json +1 -1
- package/src/commands/cloud.selectors.test.ts +53 -0
- package/src/commands/cloud.ts +31 -0
- package/src/commands/files-browse.test.ts +212 -0
- package/src/commands/files-browse.ts +219 -41
- package/src/commands/members.test.ts +53 -0
- package/src/commands/members.ts +69 -9
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { describe, expect, it, vi } from "vitest";
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
assertNoPersonalPositionalPaths,
|
|
14
15
|
assertSingleSelector,
|
|
15
16
|
resolveCanonicalPersonUid,
|
|
16
17
|
type PullAllVaultClient,
|
|
@@ -76,6 +77,58 @@ describe("assertSingleSelector", () => {
|
|
|
76
77
|
});
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
// ── assertNoPersonalPositionalPaths (hq-cli#25) ─────────────────────────────
|
|
81
|
+
//
|
|
82
|
+
// The combination `--personal <path>` silently bypasses
|
|
83
|
+
// PERSONAL_VAULT_EXCLUDED_TOP_LEVEL (which is only applied by
|
|
84
|
+
// computePersonalVaultPaths). Real incident: 196 companies/{slug}/** objects
|
|
85
|
+
// uploaded to a personal vault. Refusal forces the operator to drop one of
|
|
86
|
+
// the two — either `--personal` (full vault scope) or the positional paths
|
|
87
|
+
// (specific subset against the active company).
|
|
88
|
+
|
|
89
|
+
describe("assertNoPersonalPositionalPaths", () => {
|
|
90
|
+
it("accepts --personal alone (no positional paths)", () => {
|
|
91
|
+
expect(() => assertNoPersonalPositionalPaths({ personal: true }, undefined)).not.toThrow();
|
|
92
|
+
expect(() => assertNoPersonalPositionalPaths({ personal: true }, [])).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts positional paths without --personal", () => {
|
|
96
|
+
expect(() => assertNoPersonalPositionalPaths({ personal: false }, ["./scratch"])).not.toThrow();
|
|
97
|
+
expect(() => assertNoPersonalPositionalPaths({}, ["./scratch"])).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts neither --personal nor positional paths", () => {
|
|
101
|
+
expect(() => assertNoPersonalPositionalPaths({}, undefined)).not.toThrow();
|
|
102
|
+
expect(() => assertNoPersonalPositionalPaths({}, [])).not.toThrow();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("throws when --personal combines with one positional path", () => {
|
|
106
|
+
expect(() => assertNoPersonalPositionalPaths({ personal: true }, ["./scratch"])).toThrow(
|
|
107
|
+
/--personal.*cannot be combined with explicit \[paths\]/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("throws when --personal combines with multiple positional paths", () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
assertNoPersonalPositionalPaths({ personal: true }, ["./a", "./b", "./c"]),
|
|
114
|
+
).toThrow(/PERSONAL_VAULT_EXCLUDED_TOP_LEVEL/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("error message names the dangerous prefixes so the operator understands the risk", () => {
|
|
118
|
+
let caught: Error | null = null;
|
|
119
|
+
try {
|
|
120
|
+
assertNoPersonalPositionalPaths({ personal: true }, ["/Users/corey/Documents/HQ"]);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
caught = e as Error;
|
|
123
|
+
}
|
|
124
|
+
expect(caught).not.toBeNull();
|
|
125
|
+
expect(caught!.message).toContain(".git/");
|
|
126
|
+
expect(caught!.message).toContain("companies/");
|
|
127
|
+
expect(caught!.message).toContain("repos/");
|
|
128
|
+
expect(caught!.message).toContain("workspace/");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
79
132
|
// ── resolveCanonicalPersonUid ──────────────────────────────────────────────
|
|
80
133
|
|
|
81
134
|
function makeClient(persons: Array<{
|
package/src/commands/cloud.ts
CHANGED
|
@@ -451,6 +451,35 @@ export async function resolveCanonicalPersonUid(
|
|
|
451
451
|
return pick.uid;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Refuse `hq sync push --personal <path>` — the combination silently
|
|
456
|
+
* bypasses `PERSONAL_VAULT_EXCLUDED_TOP_LEVEL` (which is only applied by
|
|
457
|
+
* `computePersonalVaultPaths`), risking cross-scope upload of `companies/`,
|
|
458
|
+
* `repos/`, `workspace/`, or `.git/` content to the personal vault. Real
|
|
459
|
+
* incident (2026-05-21): a single command uploaded 196 `companies/{slug}/**`
|
|
460
|
+
* objects to a personal vault before being killed. Cleanup required a
|
|
461
|
+
* hand-rolled S3 sweep. Closes hq-cli#25.
|
|
462
|
+
*
|
|
463
|
+
* Refusal — not silent filtering — is intentional: explicit is better than
|
|
464
|
+
* implicit guesswork, and the legitimate "I want to push a subset of my
|
|
465
|
+
* personal vault" use case has a clean workaround (drop `--personal`, the
|
|
466
|
+
* subset upload targets the active company via standard semantics).
|
|
467
|
+
*/
|
|
468
|
+
export function assertNoPersonalPositionalPaths(opts: {
|
|
469
|
+
personal?: boolean;
|
|
470
|
+
}, paths: string[] | undefined): void {
|
|
471
|
+
if (opts.personal && paths && paths.length > 0) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
"`--personal` cannot be combined with explicit [paths]: " +
|
|
474
|
+
"positional paths bypass the PERSONAL_VAULT_EXCLUDED_TOP_LEVEL " +
|
|
475
|
+
"guard (skips .git/, companies/, repos/, workspace/), risking " +
|
|
476
|
+
"cross-scope upload of company data to the personal vault. " +
|
|
477
|
+
"Use bare `--personal` to push the whole personal scope, OR " +
|
|
478
|
+
"drop `--personal` to push specific paths to the active company.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
454
483
|
/**
|
|
455
484
|
* Refuse ambiguous selector combinations. `--all`, `--personal`, and
|
|
456
485
|
* `--company` are mutually exclusive — at most one may be set per
|
|
@@ -675,6 +704,8 @@ export function registerCloudCommands(program: Command): void {
|
|
|
675
704
|
"to have already resolved entity + credentials. Pick one.",
|
|
676
705
|
);
|
|
677
706
|
}
|
|
707
|
+
// Closes hq-cli#25 — see `assertNoPersonalPositionalPaths` doc-block.
|
|
708
|
+
assertNoPersonalPositionalPaths(options, paths);
|
|
678
709
|
|
|
679
710
|
log(chalk.bold("\nHQ Sync — Push"));
|
|
680
711
|
log(` HQ root: ${options.hqRoot}`);
|
|
@@ -387,6 +387,151 @@ describe("runBrowse", () => {
|
|
|
387
387
|
}),
|
|
388
388
|
).rejects.toThrow(/No company found for slug 'notmine'/);
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
392
|
+
//
|
|
393
|
+
// Personal mode resolves the entity via `entity.get(personalUid)` (skipping
|
|
394
|
+
// the company-namespace lookup), omits the explicit-grants fetch, and tags
|
|
395
|
+
// every row with `aclSource: "personal-vault"`. The path arg is bucket-
|
|
396
|
+
// relative — companies/<slug>/ prefix is NOT required (and would be
|
|
397
|
+
// incorrect, since the person bucket is owner-only with no companies/
|
|
398
|
+
// subtree).
|
|
399
|
+
|
|
400
|
+
it("personalMode: resolves entity via entity.get(personalUid), skips namespace lookup", async () => {
|
|
401
|
+
const { client, spies } = makeStubVaultClient({
|
|
402
|
+
entity: {
|
|
403
|
+
uid: "prs_test",
|
|
404
|
+
slug: "personal",
|
|
405
|
+
bucketName: "hq-vault-prs-test",
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const { factory } = makeStubS3Factory({
|
|
409
|
+
listResponses: [
|
|
410
|
+
{
|
|
411
|
+
Contents: [{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() }],
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const result = await runBrowse({
|
|
417
|
+
pathPrefix: ".claude/",
|
|
418
|
+
personalMode: true,
|
|
419
|
+
personalUid: "prs_test",
|
|
420
|
+
vaultClient: client,
|
|
421
|
+
s3Factory: factory,
|
|
422
|
+
region: "us-east-1",
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(result.rows).toHaveLength(1);
|
|
426
|
+
expect(result.rows[0].key).toBe(".claude/CLAUDE.md");
|
|
427
|
+
// findInMyNamespace is the company-mode lookup — must not be touched.
|
|
428
|
+
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
429
|
+
// Grants graph is a company concept — must not be fetched.
|
|
430
|
+
expect(spies.listMyExplicitGrants).not.toHaveBeenCalled();
|
|
431
|
+
// Vend still issued for browse purpose, no policy difference.
|
|
432
|
+
expect(spies.vend).toHaveBeenCalledWith(
|
|
433
|
+
expect.objectContaining({ purpose: "browse", operations: "read-only" }),
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("personalMode: empty pathPrefix lists the bucket root", async () => {
|
|
438
|
+
const { client } = makeStubVaultClient({
|
|
439
|
+
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
440
|
+
});
|
|
441
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
442
|
+
listResponses: [
|
|
443
|
+
{
|
|
444
|
+
Contents: [
|
|
445
|
+
{ Key: ".claude/CLAUDE.md", Size: 100, LastModified: new Date() },
|
|
446
|
+
{ Key: "core/policies/foo.md", Size: 50, LastModified: new Date() },
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const result = await runBrowse({
|
|
453
|
+
pathPrefix: "",
|
|
454
|
+
personalMode: true,
|
|
455
|
+
personalUid: "prs_test",
|
|
456
|
+
vaultClient: client,
|
|
457
|
+
s3Factory: factory,
|
|
458
|
+
region: "us-east-1",
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
expect(result.rows).toHaveLength(2);
|
|
462
|
+
// ListObjectsV2 issued with an empty Prefix means "list everything".
|
|
463
|
+
const listCmd = sendSpy.mock.calls[0][0] as ListObjectsV2Command;
|
|
464
|
+
expect(listCmd.input.Prefix).toBe("");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("personalMode: every row is tagged aclSource='personal-vault'", async () => {
|
|
468
|
+
const { client } = makeStubVaultClient({
|
|
469
|
+
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
470
|
+
// Note: even if grants WERE present, personalMode would skip the
|
|
471
|
+
// lookup entirely — no chance of accidentally tagging a personal
|
|
472
|
+
// key as `shared-with-you`.
|
|
473
|
+
grants: [fakeGrant("companies/indigo/")],
|
|
474
|
+
});
|
|
475
|
+
const { factory } = makeStubS3Factory({
|
|
476
|
+
listResponses: [
|
|
477
|
+
{
|
|
478
|
+
Contents: [
|
|
479
|
+
{ Key: ".claude/CLAUDE.md", Size: 1, LastModified: new Date() },
|
|
480
|
+
{ Key: "core/policies/_digest.md", Size: 2, LastModified: new Date() },
|
|
481
|
+
{ Key: "personal/notes.md", Size: 3, LastModified: new Date() },
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const result = await runBrowse({
|
|
488
|
+
pathPrefix: "",
|
|
489
|
+
personalMode: true,
|
|
490
|
+
personalUid: "prs_test",
|
|
491
|
+
vaultClient: client,
|
|
492
|
+
s3Factory: factory,
|
|
493
|
+
region: "us-east-1",
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
for (const row of result.rows) {
|
|
497
|
+
expect(row.aclSource).toBe("personal-vault");
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("personalMode: throws when personalUid is missing", async () => {
|
|
502
|
+
const { client } = makeStubVaultClient({});
|
|
503
|
+
const { factory } = makeStubS3Factory({});
|
|
504
|
+
|
|
505
|
+
await expect(
|
|
506
|
+
runBrowse({
|
|
507
|
+
pathPrefix: "",
|
|
508
|
+
personalMode: true,
|
|
509
|
+
// personalUid intentionally omitted
|
|
510
|
+
vaultClient: client,
|
|
511
|
+
s3Factory: factory,
|
|
512
|
+
region: "us-east-1",
|
|
513
|
+
}),
|
|
514
|
+
).rejects.toThrow(/personalMode requires personalUid/);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("personalMode: throws when entity has no provisioned bucket", async () => {
|
|
518
|
+
// Force entity.get to return a bucket-less entity.
|
|
519
|
+
const { client } = makeStubVaultClient({
|
|
520
|
+
entity: { uid: "prs_test", slug: "personal" },
|
|
521
|
+
});
|
|
522
|
+
const { factory } = makeStubS3Factory({});
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
runBrowse({
|
|
526
|
+
pathPrefix: "",
|
|
527
|
+
personalMode: true,
|
|
528
|
+
personalUid: "prs_test",
|
|
529
|
+
vaultClient: client,
|
|
530
|
+
s3Factory: factory,
|
|
531
|
+
region: "us-east-1",
|
|
532
|
+
}),
|
|
533
|
+
).rejects.toThrow(/no provisioned bucket/);
|
|
534
|
+
});
|
|
390
535
|
});
|
|
391
536
|
|
|
392
537
|
// ── runCat ──────────────────────────────────────────────────────────────────
|
|
@@ -472,4 +617,71 @@ describe("runCat", () => {
|
|
|
472
617
|
}),
|
|
473
618
|
).rejects.toThrow(/Expected a path starting with 'companies\//);
|
|
474
619
|
});
|
|
620
|
+
|
|
621
|
+
// ── personalMode (hq-cli#26) ──────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
it("personalMode: streams from the person bucket, no slug parse on the key", async () => {
|
|
624
|
+
const { client, spies } = makeStubVaultClient({
|
|
625
|
+
entity: { uid: "prs_test", slug: "personal", bucketName: "hq-vault-prs-test" },
|
|
626
|
+
});
|
|
627
|
+
const body = new Readable({
|
|
628
|
+
read() {
|
|
629
|
+
this.push("hello personal");
|
|
630
|
+
this.push(null);
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
const { factory, sendSpy } = makeStubS3Factory({
|
|
634
|
+
getResponse: { Body: body as unknown as GetObjectCommandOutput["Body"] },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// The key has no `companies/<slug>/` prefix — would normally fail
|
|
638
|
+
// parseCompanySlugFromPath. Under personalMode, that parse is skipped.
|
|
639
|
+
const sink = new Writable({
|
|
640
|
+
write(_chunk, _enc, cb) {
|
|
641
|
+
cb();
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
const result = await runCat({
|
|
645
|
+
key: ".claude/CLAUDE.md",
|
|
646
|
+
personalMode: true,
|
|
647
|
+
personalUid: "prs_test",
|
|
648
|
+
vaultClient: client,
|
|
649
|
+
s3Factory: factory,
|
|
650
|
+
region: "us-east-1",
|
|
651
|
+
hqRoot: tmpRoot,
|
|
652
|
+
stdout: sink,
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
expect(result.destination.kind).toBe("stdout");
|
|
656
|
+
expect(spies.findInMyNamespace).not.toHaveBeenCalled();
|
|
657
|
+
// Vend issued against the bucket-relative key, browse purpose.
|
|
658
|
+
expect(spies.vend).toHaveBeenCalledWith(
|
|
659
|
+
expect.objectContaining({
|
|
660
|
+
paths: [".claude/CLAUDE.md"],
|
|
661
|
+
purpose: "browse",
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
664
|
+
// GetObject targeted the person bucket.
|
|
665
|
+
const getCmd = sendSpy.mock.calls.find(
|
|
666
|
+
(c) => c[0] instanceof GetObjectCommand,
|
|
667
|
+
)?.[0] as GetObjectCommand;
|
|
668
|
+
expect(getCmd.input.Bucket).toBe("hq-vault-prs-test");
|
|
669
|
+
expect(getCmd.input.Key).toBe(".claude/CLAUDE.md");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("personalMode: throws when personalUid is missing", async () => {
|
|
673
|
+
const { client } = makeStubVaultClient({});
|
|
674
|
+
const { factory } = makeStubS3Factory({});
|
|
675
|
+
await expect(
|
|
676
|
+
runCat({
|
|
677
|
+
key: ".claude/CLAUDE.md",
|
|
678
|
+
personalMode: true,
|
|
679
|
+
// personalUid omitted
|
|
680
|
+
vaultClient: client,
|
|
681
|
+
s3Factory: factory,
|
|
682
|
+
region: "us-east-1",
|
|
683
|
+
hqRoot: tmpRoot,
|
|
684
|
+
}),
|
|
685
|
+
).rejects.toThrow(/personalMode requires personalUid/);
|
|
686
|
+
});
|
|
475
687
|
});
|