@indigoai-us/hq-cli 5.60.0 → 5.61.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/dist/commands/pack-install.d.ts +7 -1
- package/dist/commands/pack-install.js +86 -15
- package/dist/commands/packs.d.ts +2 -1
- package/dist/commands/packs.js +13 -8
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +129 -5
- package/dist/index.d.ts +5 -3
- package/dist/index.js +14 -240
- package/dist/main.d.ts +7 -0
- package/dist/main.js +247 -0
- package/dist/utils/sandbox-runner-client.d.ts +13 -0
- package/dist/utils/sandbox-runner-client.js +82 -5
- package/dist/utils/version-check.d.ts +6 -0
- package/dist/utils/version-check.js +78 -2
- package/package.json +1 -1
- package/src/commands/pack-install.ts +115 -18
- package/src/commands/pack-update-cache.test.ts +149 -0
- package/src/commands/packs.ts +28 -7
- package/src/commands/secrets.test.ts +318 -0
- package/src/commands/secrets.ts +198 -4
- package/src/index.test.ts +32 -0
- package/src/index.ts +11 -274
- package/src/main.ts +283 -0
- package/src/utils/sandbox-runner-client.test.ts +128 -0
- package/src/utils/sandbox-runner-client.ts +99 -3
- package/src/utils/version-check.test.ts +30 -0
- package/src/utils/version-check.ts +72 -0
|
@@ -528,6 +528,324 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
|
|
|
528
528
|
});
|
|
529
529
|
});
|
|
530
530
|
|
|
531
|
+
// secrets-proxy-per-secret-destination US-006: hq-cli creation-side parity.
|
|
532
|
+
// `hq secrets set NAME --high-security --destination <url> [--auth-style ...]`
|
|
533
|
+
// POSTs `highSecurity`/`destinations`/`injection` to the vault API — the CLI
|
|
534
|
+
// counterpart to the console create-form (US-005) and the server contract
|
|
535
|
+
// (US-001/US-004). Value input is exercised via --from-stdin (unaffected by
|
|
536
|
+
// this story) so these tests isolate the new flag-handling/validation.
|
|
537
|
+
describe("US-006 — hq secrets set --high-security --destination parity", () => {
|
|
538
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
539
|
+
let stdinIsTTYSpy: MockInstance<() => boolean> | undefined;
|
|
540
|
+
|
|
541
|
+
beforeEach(() => {
|
|
542
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
543
|
+
throw new Error("__exit__");
|
|
544
|
+
}) as never);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
afterEach(() => {
|
|
548
|
+
stdinIsTTYSpy?.mockRestore();
|
|
549
|
+
stdinIsTTYSpy = undefined;
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Drives the value through the existing --from-stdin path (unchanged by
|
|
553
|
+
// this story) by feeding a piped-stdin double: mocks isTTY=false and
|
|
554
|
+
// replaces the data/end listeners `readFromPipedStdin` attaches.
|
|
555
|
+
function stubPipedStdin(value: string): void {
|
|
556
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
557
|
+
configurable: true,
|
|
558
|
+
value: false,
|
|
559
|
+
});
|
|
560
|
+
vi.spyOn(process.stdin, "on").mockImplementation(((
|
|
561
|
+
event: string,
|
|
562
|
+
cb: (...args: unknown[]) => void,
|
|
563
|
+
) => {
|
|
564
|
+
if (event === "data") cb(value);
|
|
565
|
+
if (event === "end") cb();
|
|
566
|
+
return process.stdin;
|
|
567
|
+
}) as never);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
it("with no flags, the POST body is byte-for-byte unchanged (just name+value)", async () => {
|
|
571
|
+
stubPipedStdin("plain-value");
|
|
572
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
573
|
+
|
|
574
|
+
const program = buildProgram();
|
|
575
|
+
await program.parseAsync([
|
|
576
|
+
"node",
|
|
577
|
+
"hq",
|
|
578
|
+
"secrets",
|
|
579
|
+
"set",
|
|
580
|
+
"MY_KEY",
|
|
581
|
+
"--from-stdin",
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
585
|
+
token: "test-token",
|
|
586
|
+
path: "/secrets/prs_alice",
|
|
587
|
+
method: "POST",
|
|
588
|
+
body: { name: "MY_KEY", value: "plain-value" },
|
|
589
|
+
});
|
|
590
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("--high-security with a KNOWN destination auto-resolves the recipe (no --auth-style needed)", async () => {
|
|
594
|
+
stubPipedStdin("sk-secret-value");
|
|
595
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
596
|
+
|
|
597
|
+
const program = buildProgram();
|
|
598
|
+
await program.parseAsync([
|
|
599
|
+
"node",
|
|
600
|
+
"hq",
|
|
601
|
+
"secrets",
|
|
602
|
+
"set",
|
|
603
|
+
"OPENAI_KEY",
|
|
604
|
+
"--from-stdin",
|
|
605
|
+
"--high-security",
|
|
606
|
+
"--destination",
|
|
607
|
+
"https://api.openai.com",
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
611
|
+
token: "test-token",
|
|
612
|
+
path: "/secrets/prs_alice",
|
|
613
|
+
method: "POST",
|
|
614
|
+
body: {
|
|
615
|
+
name: "OPENAI_KEY",
|
|
616
|
+
value: "sk-secret-value",
|
|
617
|
+
highSecurity: true,
|
|
618
|
+
destinations: ["https://api.openai.com"],
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
622
|
+
expect(logSpy.mock.calls.flat().join(" ")).toMatch(/high-security/i);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("--auth-style bearer maps to {header: authorization, scheme: bearer}", async () => {
|
|
626
|
+
stubPipedStdin("bearer-token-value");
|
|
627
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
628
|
+
|
|
629
|
+
const program = buildProgram();
|
|
630
|
+
await program.parseAsync([
|
|
631
|
+
"node",
|
|
632
|
+
"hq",
|
|
633
|
+
"secrets",
|
|
634
|
+
"set",
|
|
635
|
+
"SOME_BEARER_KEY",
|
|
636
|
+
"--from-stdin",
|
|
637
|
+
"--high-security",
|
|
638
|
+
"--destination",
|
|
639
|
+
"https://api.example.com",
|
|
640
|
+
"--auth-style",
|
|
641
|
+
"bearer",
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
645
|
+
token: "test-token",
|
|
646
|
+
path: "/secrets/prs_alice",
|
|
647
|
+
method: "POST",
|
|
648
|
+
body: {
|
|
649
|
+
name: "SOME_BEARER_KEY",
|
|
650
|
+
value: "bearer-token-value",
|
|
651
|
+
highSecurity: true,
|
|
652
|
+
destinations: ["https://api.example.com"],
|
|
653
|
+
injection: { header: "authorization", scheme: "bearer" },
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("--auth-style x-api-key maps to {header: x-api-key, scheme: raw}", async () => {
|
|
659
|
+
stubPipedStdin("raw-key-value");
|
|
660
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
661
|
+
|
|
662
|
+
const program = buildProgram();
|
|
663
|
+
await program.parseAsync([
|
|
664
|
+
"node",
|
|
665
|
+
"hq",
|
|
666
|
+
"secrets",
|
|
667
|
+
"set",
|
|
668
|
+
"SOME_RAW_KEY",
|
|
669
|
+
"--from-stdin",
|
|
670
|
+
"--high-security",
|
|
671
|
+
"--destination",
|
|
672
|
+
"https://api.example.com",
|
|
673
|
+
"--auth-style",
|
|
674
|
+
"x-api-key",
|
|
675
|
+
]);
|
|
676
|
+
|
|
677
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
678
|
+
token: "test-token",
|
|
679
|
+
path: "/secrets/prs_alice",
|
|
680
|
+
method: "POST",
|
|
681
|
+
body: {
|
|
682
|
+
name: "SOME_RAW_KEY",
|
|
683
|
+
value: "raw-key-value",
|
|
684
|
+
highSecurity: true,
|
|
685
|
+
destinations: ["https://api.example.com"],
|
|
686
|
+
injection: { header: "x-api-key", scheme: "raw" },
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("--auth-style header:NAME maps to a custom raw header", async () => {
|
|
692
|
+
stubPipedStdin("custom-header-value");
|
|
693
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(jsonRes({ ok: true }));
|
|
694
|
+
|
|
695
|
+
const program = buildProgram();
|
|
696
|
+
await program.parseAsync([
|
|
697
|
+
"node",
|
|
698
|
+
"hq",
|
|
699
|
+
"secrets",
|
|
700
|
+
"set",
|
|
701
|
+
"CUSTOM_HEADER_KEY",
|
|
702
|
+
"--from-stdin",
|
|
703
|
+
"--high-security",
|
|
704
|
+
"--destination",
|
|
705
|
+
"https://api.example.com",
|
|
706
|
+
"--auth-style",
|
|
707
|
+
"header:X-Custom-Key",
|
|
708
|
+
]);
|
|
709
|
+
|
|
710
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
711
|
+
token: "test-token",
|
|
712
|
+
path: "/secrets/prs_alice",
|
|
713
|
+
method: "POST",
|
|
714
|
+
body: {
|
|
715
|
+
name: "CUSTOM_HEADER_KEY",
|
|
716
|
+
value: "custom-header-value",
|
|
717
|
+
highSecurity: true,
|
|
718
|
+
destinations: ["https://api.example.com"],
|
|
719
|
+
injection: { header: "X-Custom-Key", scheme: "raw" },
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("--high-security with an UNKNOWN host and no --auth-style errors clearly, client-side (no request sent)", async () => {
|
|
725
|
+
const program = buildProgram();
|
|
726
|
+
await expect(
|
|
727
|
+
program.parseAsync([
|
|
728
|
+
"node",
|
|
729
|
+
"hq",
|
|
730
|
+
"secrets",
|
|
731
|
+
"set",
|
|
732
|
+
"UNKNOWN_HOST_KEY",
|
|
733
|
+
"--from-stdin",
|
|
734
|
+
"--high-security",
|
|
735
|
+
"--destination",
|
|
736
|
+
"https://api.unknown-vendor.example",
|
|
737
|
+
]),
|
|
738
|
+
).rejects.toThrow("__exit__");
|
|
739
|
+
|
|
740
|
+
expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
|
|
741
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
742
|
+
expect(errText).toMatch(/unknown destination host/i);
|
|
743
|
+
expect(errText).toMatch(/--auth-style/);
|
|
744
|
+
// Client-side rejection — never reaches the network.
|
|
745
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("--high-security with no --destination errors clearly (non-zero exit, no request sent)", async () => {
|
|
749
|
+
const program = buildProgram();
|
|
750
|
+
await expect(
|
|
751
|
+
program.parseAsync([
|
|
752
|
+
"node",
|
|
753
|
+
"hq",
|
|
754
|
+
"secrets",
|
|
755
|
+
"set",
|
|
756
|
+
"MISSING_DEST_KEY",
|
|
757
|
+
"--from-stdin",
|
|
758
|
+
"--high-security",
|
|
759
|
+
]),
|
|
760
|
+
).rejects.toThrow("__exit__");
|
|
761
|
+
|
|
762
|
+
expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
|
|
763
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
764
|
+
expect(errText).toMatch(/--high-security/);
|
|
765
|
+
expect(errText).toMatch(/--destination/);
|
|
766
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("--destination without --high-security errors clearly (non-zero exit, no request sent)", async () => {
|
|
770
|
+
const program = buildProgram();
|
|
771
|
+
await expect(
|
|
772
|
+
program.parseAsync([
|
|
773
|
+
"node",
|
|
774
|
+
"hq",
|
|
775
|
+
"secrets",
|
|
776
|
+
"set",
|
|
777
|
+
"STRAY_DEST_KEY",
|
|
778
|
+
"--from-stdin",
|
|
779
|
+
"--destination",
|
|
780
|
+
"https://api.openai.com",
|
|
781
|
+
]),
|
|
782
|
+
).rejects.toThrow("__exit__");
|
|
783
|
+
|
|
784
|
+
expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
|
|
785
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("an invalid --destination URL (path segment) errors clearly, client-side", async () => {
|
|
789
|
+
const program = buildProgram();
|
|
790
|
+
await expect(
|
|
791
|
+
program.parseAsync([
|
|
792
|
+
"node",
|
|
793
|
+
"hq",
|
|
794
|
+
"secrets",
|
|
795
|
+
"set",
|
|
796
|
+
"BAD_URL_KEY",
|
|
797
|
+
"--from-stdin",
|
|
798
|
+
"--high-security",
|
|
799
|
+
"--destination",
|
|
800
|
+
"https://api.openai.com/v1",
|
|
801
|
+
]),
|
|
802
|
+
).rejects.toThrow("__exit__");
|
|
803
|
+
|
|
804
|
+
expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
|
|
805
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
806
|
+
expect(errText).toMatch(/no path, query, or fragment/i);
|
|
807
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it("surfaces the server's rejection message when the backend 400s (e.g. unknown host drift)", async () => {
|
|
811
|
+
stubPipedStdin("value");
|
|
812
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
813
|
+
jsonRes(
|
|
814
|
+
{
|
|
815
|
+
error:
|
|
816
|
+
"unknown destination host api.newer-vendor.com: provide an injection recipe (header + scheme)",
|
|
817
|
+
},
|
|
818
|
+
400,
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
const program = buildProgram();
|
|
823
|
+
// A host absent from the CLI's own known-set copy but WOULD be flagged
|
|
824
|
+
// client-side too (this asserts the server-error surfacing path itself
|
|
825
|
+
// stays intact, using --auth-style to bypass the client-side check and
|
|
826
|
+
// reach the network).
|
|
827
|
+
await expect(
|
|
828
|
+
program.parseAsync([
|
|
829
|
+
"node",
|
|
830
|
+
"hq",
|
|
831
|
+
"secrets",
|
|
832
|
+
"set",
|
|
833
|
+
"DRIFT_KEY",
|
|
834
|
+
"--from-stdin",
|
|
835
|
+
"--high-security",
|
|
836
|
+
"--destination",
|
|
837
|
+
"https://api.newer-vendor.com",
|
|
838
|
+
"--auth-style",
|
|
839
|
+
"bearer",
|
|
840
|
+
]),
|
|
841
|
+
).rejects.toThrow("__exit__");
|
|
842
|
+
|
|
843
|
+
expect(exitSpy.mock.calls[0]?.[0]).toBe(1);
|
|
844
|
+
const errText = errSpy.mock.calls.flat().join(" ");
|
|
845
|
+
expect(errText).toMatch(/unknown destination host/i);
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
531
849
|
describe("secrets generate-link", () => {
|
|
532
850
|
it("mints one-time submission links for personal secrets", async () => {
|
|
533
851
|
const program = buildProgram();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -279,6 +279,110 @@ function describeSecretAclPrincipal(principal: SecretAclPrincipal): string {
|
|
|
279
279
|
: principal.granteeId;
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
// secrets-proxy-per-secret-destination US-006: an InjectionRecipe as sent on
|
|
283
|
+
// the create/update POST body. Mirrors hq-pro's `InjectionRecipe`
|
|
284
|
+
// (src/vault-service/handlers/secrets.ts) — kept as a local shape (not
|
|
285
|
+
// imported) since the CLI does not depend on the hq-pro package.
|
|
286
|
+
export interface SecretInjectionRecipe {
|
|
287
|
+
header: string;
|
|
288
|
+
scheme: "raw" | "bearer";
|
|
289
|
+
extraHeaders?: Record<string, string>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Mirrors hq-pro's server-authoritative KNOWN_DESTINATION_REGISTRY
|
|
293
|
+
// (src/vault-service/handlers/destination-registry.ts) — a KNOWN host's
|
|
294
|
+
// --auth-style is optional because the server resolves the recipe itself.
|
|
295
|
+
// This client-side copy exists purely so an UNKNOWN host with no
|
|
296
|
+
// --auth-style can be rejected immediately with a clear, actionable message
|
|
297
|
+
// instead of a round trip; the server remains the authoritative validator
|
|
298
|
+
// (this list drifting stale merely means one extra CLI round trip, not a
|
|
299
|
+
// security gap — the server still 400s an unrecognized host with no recipe).
|
|
300
|
+
const KNOWN_DESTINATION_HOSTS = new Set([
|
|
301
|
+
"api.anthropic.com",
|
|
302
|
+
"api.openai.com",
|
|
303
|
+
"api.stripe.com",
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
// Parses `--auth-style` into the InjectionRecipe shape the server expects.
|
|
307
|
+
// Returns `null` (with a printed error) for an unrecognized value.
|
|
308
|
+
function parseAuthStyle(authStyle: string): SecretInjectionRecipe | null {
|
|
309
|
+
if (authStyle === "bearer") {
|
|
310
|
+
return { header: "authorization", scheme: "bearer" };
|
|
311
|
+
}
|
|
312
|
+
if (authStyle === "x-api-key") {
|
|
313
|
+
return { header: "x-api-key", scheme: "raw" };
|
|
314
|
+
}
|
|
315
|
+
const headerMatch = authStyle.match(/^header:(.+)$/);
|
|
316
|
+
if (headerMatch) {
|
|
317
|
+
const headerName = headerMatch[1].trim();
|
|
318
|
+
if (!headerName) {
|
|
319
|
+
console.error(
|
|
320
|
+
chalk.red(
|
|
321
|
+
`Invalid --auth-style 'header:': must name a header, e.g. header:X-Custom-Key`,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
return { header: headerName, scheme: "raw" };
|
|
327
|
+
}
|
|
328
|
+
console.error(
|
|
329
|
+
chalk.red(
|
|
330
|
+
`Invalid --auth-style '${authStyle}': must be one of bearer, x-api-key, or header:NAME`,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Validates `--destination` is a bare HTTPS scheme+host URL (no path, query,
|
|
337
|
+
// port). Mirrors hq-pro's `validateDestinations` server-side check
|
|
338
|
+
// (src/vault-service/handlers/secrets.ts) so a malformed URL is caught
|
|
339
|
+
// locally with an actionable message rather than a round trip — the server
|
|
340
|
+
// re-validates and remains authoritative.
|
|
341
|
+
function parseDestinationUrl(
|
|
342
|
+
raw: string,
|
|
343
|
+
): { ok: true; url: string; hostname: string } | { ok: false } {
|
|
344
|
+
let parsed: URL;
|
|
345
|
+
try {
|
|
346
|
+
parsed = new URL(raw);
|
|
347
|
+
} catch {
|
|
348
|
+
console.error(
|
|
349
|
+
chalk.red(`Invalid --destination '${raw}': must be a valid URL`),
|
|
350
|
+
);
|
|
351
|
+
return { ok: false };
|
|
352
|
+
}
|
|
353
|
+
if (parsed.protocol !== "https:") {
|
|
354
|
+
console.error(
|
|
355
|
+
chalk.red(`Invalid --destination '${raw}': must use https://`),
|
|
356
|
+
);
|
|
357
|
+
return { ok: false };
|
|
358
|
+
}
|
|
359
|
+
if (!parsed.hostname) {
|
|
360
|
+
console.error(
|
|
361
|
+
chalk.red(`Invalid --destination '${raw}': missing hostname`),
|
|
362
|
+
);
|
|
363
|
+
return { ok: false };
|
|
364
|
+
}
|
|
365
|
+
if (
|
|
366
|
+
(parsed.pathname !== "" && parsed.pathname !== "/") ||
|
|
367
|
+
parsed.search !== "" ||
|
|
368
|
+
parsed.hash !== ""
|
|
369
|
+
) {
|
|
370
|
+
console.error(
|
|
371
|
+
chalk.red(
|
|
372
|
+
`Invalid --destination '${raw}': must be a bare scheme+host URL with no path, query, or fragment (e.g. https://api.openai.com)`,
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
return { ok: false };
|
|
376
|
+
}
|
|
377
|
+
if (parsed.port !== "") {
|
|
378
|
+
console.error(
|
|
379
|
+
chalk.red(`Invalid --destination '${raw}': must not specify a port`),
|
|
380
|
+
);
|
|
381
|
+
return { ok: false };
|
|
382
|
+
}
|
|
383
|
+
return { ok: true, url: `https://${parsed.hostname}`, hostname: parsed.hostname };
|
|
384
|
+
}
|
|
385
|
+
|
|
282
386
|
function normalizeSecretTier(tier?: string): SecretTier {
|
|
283
387
|
return tier === "sensitive" || tier === "nuclear" ? tier : "standard";
|
|
284
388
|
}
|
|
@@ -572,13 +676,87 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
572
676
|
.command("set <name>")
|
|
573
677
|
.description("Create or update a secret")
|
|
574
678
|
.option("--from-stdin", "Read secret value from piped stdin")
|
|
575
|
-
.
|
|
679
|
+
.option(
|
|
680
|
+
"--high-security",
|
|
681
|
+
"Mark the secret high-security: it can never be revealed or injected locally, only used through the HQ secret proxy (requires --destination)",
|
|
682
|
+
)
|
|
683
|
+
.option(
|
|
684
|
+
"--destination <https-url>",
|
|
685
|
+
"Approved scheme+host HTTPS URL the proxy may forward this secret to (e.g. https://api.openai.com); required with --high-security",
|
|
686
|
+
)
|
|
687
|
+
.option(
|
|
688
|
+
"--auth-style <style>",
|
|
689
|
+
"How the proxy attaches the key upstream: bearer | x-api-key | header:NAME. Optional for known destinations (auto-resolved server-side); required for unknown ones",
|
|
690
|
+
)
|
|
691
|
+
.action(async (
|
|
692
|
+
name: string,
|
|
693
|
+
opts: {
|
|
694
|
+
fromStdin?: boolean;
|
|
695
|
+
highSecurity?: boolean;
|
|
696
|
+
destination?: string;
|
|
697
|
+
authStyle?: string;
|
|
698
|
+
},
|
|
699
|
+
) => {
|
|
576
700
|
try {
|
|
577
701
|
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
578
702
|
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
|
|
579
703
|
process.exit(1);
|
|
580
704
|
}
|
|
581
705
|
|
|
706
|
+
// secrets-proxy-per-secret-destination US-006: --high-security marks
|
|
707
|
+
// the secret so it can only ever be used through the server-side
|
|
708
|
+
// proxy (never revealed/injected locally — that refusal is the
|
|
709
|
+
// pre-existing consumption-side behavior in `get`/`exec`/`env` above,
|
|
710
|
+
// unchanged by this story). It REQUIRES a --destination: the proxy
|
|
711
|
+
// (hq-pro US-002) fails closed with no destination configured, so
|
|
712
|
+
// catching the missing pin here is a clear, immediate CLI error
|
|
713
|
+
// rather than a deferred proxy-time failure.
|
|
714
|
+
let destinations: string[] | undefined;
|
|
715
|
+
let injection: SecretInjectionRecipe | undefined;
|
|
716
|
+
if (opts.highSecurity) {
|
|
717
|
+
if (!opts.destination) {
|
|
718
|
+
console.error(
|
|
719
|
+
chalk.red(
|
|
720
|
+
"Error: --high-security requires --destination <https-url> (e.g. --destination https://api.openai.com).",
|
|
721
|
+
),
|
|
722
|
+
);
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const destResult = parseDestinationUrl(opts.destination);
|
|
727
|
+
if (!destResult.ok) {
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
destinations = [destResult.url];
|
|
731
|
+
|
|
732
|
+
if (opts.authStyle) {
|
|
733
|
+
const recipe = parseAuthStyle(opts.authStyle);
|
|
734
|
+
if (!recipe) {
|
|
735
|
+
process.exit(1);
|
|
736
|
+
}
|
|
737
|
+
injection = recipe;
|
|
738
|
+
} else if (!KNOWN_DESTINATION_HOSTS.has(destResult.hostname)) {
|
|
739
|
+
// Unknown host + no explicit recipe: the server would reject this
|
|
740
|
+
// 400 anyway (US-004 registry lookup only, never guesses) — fail
|
|
741
|
+
// fast locally with an actionable message instead of a round trip.
|
|
742
|
+
console.error(
|
|
743
|
+
chalk.red(
|
|
744
|
+
`Error: unknown destination host '${destResult.hostname}' — provide --auth-style <bearer|x-api-key|header:NAME> (known hosts auto-resolve: ${[...KNOWN_DESTINATION_HOSTS].join(", ")}).`,
|
|
745
|
+
),
|
|
746
|
+
);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
}
|
|
749
|
+
// Known host + no --auth-style: leave `injection` undefined so the
|
|
750
|
+
// server (US-004) auto-resolves the recipe from its registry.
|
|
751
|
+
} else if (opts.destination || opts.authStyle) {
|
|
752
|
+
console.error(
|
|
753
|
+
chalk.red(
|
|
754
|
+
"Error: --destination/--auth-style require --high-security.",
|
|
755
|
+
),
|
|
756
|
+
);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
|
|
582
760
|
let value: string;
|
|
583
761
|
if (opts.fromStdin) {
|
|
584
762
|
if (process.stdin.isTTY) {
|
|
@@ -617,19 +795,35 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
617
795
|
token,
|
|
618
796
|
path: `/secrets/${encodeURIComponent(companyUid)}`,
|
|
619
797
|
method: "POST",
|
|
620
|
-
body: {
|
|
798
|
+
body: {
|
|
799
|
+
name,
|
|
800
|
+
value,
|
|
801
|
+
// Only present when --high-security was passed — an ordinary
|
|
802
|
+
// `set` with no flags sends exactly `{ name, value }`, byte-for-
|
|
803
|
+
// byte unchanged from before this story.
|
|
804
|
+
...(opts.highSecurity ? { highSecurity: true } : {}),
|
|
805
|
+
...(destinations ? { destinations } : {}),
|
|
806
|
+
...(injection ? { injection } : {}),
|
|
807
|
+
},
|
|
621
808
|
});
|
|
622
809
|
|
|
623
810
|
if (!res.ok) {
|
|
624
|
-
const body = await res.json().catch(() => ({}))
|
|
811
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
625
812
|
console.error(
|
|
626
|
-
chalk.red(`Failed to set secret: ${(body
|
|
813
|
+
chalk.red(`Failed to set secret: ${extractApiMessage(body, res.statusText)}`),
|
|
627
814
|
);
|
|
628
815
|
process.exit(1);
|
|
629
816
|
}
|
|
630
817
|
|
|
631
818
|
removeCacheEntry(companyUid, name);
|
|
632
819
|
console.log(chalk.green(formatSecretSaved(name, scopeLabel)));
|
|
820
|
+
if (opts.highSecurity) {
|
|
821
|
+
console.log(
|
|
822
|
+
chalk.dim(
|
|
823
|
+
` High-security: destination pinned to ${destinations?.[0]}. This value can never be revealed or injected locally — only used through the HQ secret proxy.`,
|
|
824
|
+
),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
633
827
|
} catch (err) {
|
|
634
828
|
console.error(
|
|
635
829
|
chalk.red("Error:"),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("./cli-version.js", () => ({
|
|
4
|
+
CLI_VERSION: "5.60.0",
|
|
5
|
+
CLI_NAME: "@indigoai-us/hq-cli",
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("./node-preflight.js", () => ({}));
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("bin bootstrap", () => {
|
|
16
|
+
it("answers --version without importing the command graph", async () => {
|
|
17
|
+
const originalArgv = process.argv;
|
|
18
|
+
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
19
|
+
const runCli = vi.fn();
|
|
20
|
+
vi.doMock("./main.js", () => ({ runCli }));
|
|
21
|
+
process.argv = ["node", "hq", "--version"];
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await import("./index.js");
|
|
25
|
+
} finally {
|
|
26
|
+
process.argv = originalArgv;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(write).toHaveBeenCalledWith("5.60.0\n");
|
|
30
|
+
expect(runCli).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
});
|