@indigoai-us/hq-cli 5.59.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.
@@ -144,6 +144,7 @@ export function buildInstalledView(
144
144
  pack: InstalledPack,
145
145
  installedSources: Set<string>,
146
146
  checkUpdates: boolean,
147
+ latestProbe?: LatestResult,
147
148
  ): InstalledPackView {
148
149
  if (!pack.manifest) {
149
150
  return {
@@ -178,7 +179,7 @@ export function buildInstalledView(
178
179
 
179
180
  let updateAvailable: boolean | null = null;
180
181
  if (checkUpdates && m.source) {
181
- updateAvailable = resolveLatest(m.source, m.version).updateAvailable;
182
+ updateAvailable = latestProbe?.updateAvailable ?? null;
182
183
  }
183
184
 
184
185
  // US-005 — surface the pack's `initialization` block so the HQ Sync
@@ -214,15 +215,27 @@ export function buildInstalledView(
214
215
  };
215
216
  }
216
217
 
217
- function buildListView(hqRoot: string, checkUpdates: boolean, evalConditionals: boolean): PacksListView {
218
+ async function buildListView(
219
+ hqRoot: string,
220
+ checkUpdates: boolean,
221
+ evalConditionals: boolean,
222
+ refreshUpdates: boolean,
223
+ ): Promise<PacksListView> {
218
224
  const hqVersion = readHqVersion(hqRoot);
219
225
  const packs = listInstalledPacks(hqRoot);
220
226
  const catalog = readRecommendedPackages(hqRoot);
221
227
  const catalogSources = new Set(catalog.map((c) => c.source));
222
228
  const warnings: string[] = [];
223
229
 
224
- const installed = packs.map((p) =>
225
- buildInstalledView(hqRoot, hqVersion, p, catalogSources, checkUpdates),
230
+ const latestProbes = await Promise.all(
231
+ packs.map((p) =>
232
+ checkUpdates && p.manifest?.source
233
+ ? resolveLatest(p.manifest.source, p.manifest.version, { forceRefresh: refreshUpdates })
234
+ : Promise.resolve(undefined),
235
+ ),
236
+ );
237
+ const installed = packs.map((p, i) =>
238
+ buildInstalledView(hqRoot, hqVersion, p, catalogSources, checkUpdates, latestProbes[i]),
226
239
  );
227
240
  for (const p of installed) {
228
241
  if (p.error) warnings.push(`${p.name}: ${p.error}`);
@@ -303,6 +316,7 @@ interface UpdateCheck {
303
316
 
304
317
  interface UpdateOpts extends CommonOpts {
305
318
  checkOnly?: boolean;
319
+ refresh?: boolean;
306
320
  yes?: boolean;
307
321
  allowHooks?: boolean;
308
322
  allowMcp?: boolean;
@@ -334,7 +348,7 @@ async function runUpdate(name: string | undefined, opts: UpdateOpts): Promise<Up
334
348
  const probe: LatestResult =
335
349
  safeClassify(source) === 'marketplace'
336
350
  ? await resolveLatestMarketplace(source, m.version)
337
- : resolveLatest(source, m.version);
351
+ : await resolveLatest(source, m.version, { forceRefresh: true });
338
352
  const base: UpdateCheck = {
339
353
  name: pname,
340
354
  transport: probe.transport,
@@ -517,11 +531,17 @@ export function registerPacksCommand(parent: Command): void {
517
531
  .option('--json', 'Machine-readable JSON output')
518
532
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
519
533
  .option('--check-updates', 'Probe each pack for available updates (network I/O)')
534
+ .option('--refresh', 'Bypass cached update probes')
520
535
  .option('--eval-conditionals', 'Evaluate catalog conditional predicates (runs bash)')
521
536
  .action(
522
- async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean }) => {
537
+ async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean; refresh?: boolean }) => {
523
538
  try {
524
- const view = buildListView(resolveRoot(opts), !!opts.checkUpdates, !!opts.evalConditionals);
539
+ const view = await buildListView(
540
+ resolveRoot(opts),
541
+ !!opts.checkUpdates,
542
+ !!opts.evalConditionals,
543
+ !!opts.refresh,
544
+ );
525
545
  if (wantsJson(opts)) emitJson(view);
526
546
  else printListHuman(view);
527
547
  } catch (e) {
@@ -537,6 +557,7 @@ export function registerPacksCommand(parent: Command): void {
537
557
  .option('--json', 'Machine-readable JSON output')
538
558
  .option('--hq-root <path>', 'HQ root (default: auto-detect)')
539
559
  .option('--check-only', 'Report availability without installing')
560
+ .option('--refresh', 'Bypass cached update probes (update refreshes by default)')
540
561
  .option('-y, --yes', 'Non-interactive (implies --allow-hooks and --allow-mcp)')
541
562
  .option('--allow-hooks', 'Install pack hooks without prompting')
542
563
  .option('--allow-mcp', 'Register pack MCP servers without prompting')
@@ -264,6 +264,95 @@ describe("secrets sandbox", () => {
264
264
  expect(stdoutSpy).toHaveBeenCalledWith("token [REDACTED]\nAPI_KEY=[REDACTED]\n");
265
265
  });
266
266
 
267
+ it("terminates sandbox output with exactly one newline when output is present", async () => {
268
+ pollJobSpy.mockResolvedValueOnce({
269
+ jobId: "job_123",
270
+ status: "succeeded",
271
+ output: "123",
272
+ exitCode: 0,
273
+ success: true,
274
+ });
275
+
276
+ const program = buildProgram();
277
+ await program.parseAsync([
278
+ "node",
279
+ "hq",
280
+ "secrets",
281
+ "sandbox",
282
+ "--only",
283
+ "API_KEY",
284
+ "--",
285
+ "my-skill",
286
+ ]);
287
+
288
+ expect(stdoutSpy.mock.calls.flat().join("")).toBe("123\n");
289
+
290
+ stdoutSpy.mockClear();
291
+ pollJobSpy.mockResolvedValueOnce({
292
+ jobId: "job_123",
293
+ status: "succeeded",
294
+ output: "123\n",
295
+ exitCode: 0,
296
+ success: true,
297
+ });
298
+
299
+ await buildProgram().parseAsync([
300
+ "node",
301
+ "hq",
302
+ "secrets",
303
+ "sandbox",
304
+ "--only",
305
+ "API_KEY",
306
+ "--",
307
+ "my-skill",
308
+ ]);
309
+
310
+ expect(stdoutSpy.mock.calls.flat().join("")).toBe("123\n");
311
+
312
+ stdoutSpy.mockClear();
313
+ pollJobSpy.mockResolvedValueOnce({
314
+ jobId: "job_123",
315
+ status: "succeeded",
316
+ output: "",
317
+ exitCode: 0,
318
+ success: true,
319
+ });
320
+
321
+ await buildProgram().parseAsync([
322
+ "node",
323
+ "hq",
324
+ "secrets",
325
+ "sandbox",
326
+ "--only",
327
+ "API_KEY",
328
+ "--",
329
+ "my-skill",
330
+ ]);
331
+
332
+ expect(stdoutSpy).not.toHaveBeenCalled();
333
+
334
+ stdoutSpy.mockClear();
335
+ pollJobSpy.mockResolvedValueOnce({
336
+ jobId: "job_123",
337
+ status: "succeeded",
338
+ exitCode: 0,
339
+ success: true,
340
+ });
341
+
342
+ await buildProgram().parseAsync([
343
+ "node",
344
+ "hq",
345
+ "secrets",
346
+ "sandbox",
347
+ "--only",
348
+ "API_KEY",
349
+ "--",
350
+ "my-skill",
351
+ ]);
352
+
353
+ expect(stdoutSpy).not.toHaveBeenCalled();
354
+ });
355
+
267
356
  it("exits non-zero when the sandbox command fails", async () => {
268
357
  pollJobSpy.mockResolvedValueOnce({
269
358
  jobId: "job_123",
@@ -439,6 +528,324 @@ describe("US-003 — CLI refuses high-security secrets on local injection", () =
439
528
  });
440
529
  });
441
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
+
442
849
  describe("secrets generate-link", () => {
443
850
  it("mints one-time submission links for personal secrets", async () => {
444
851
  const program = buildProgram();