@indigoai-us/hq-cli 5.75.0 → 5.77.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.
Files changed (49) hide show
  1. package/dist/commands/agents.js +8 -3
  2. package/dist/commands/files.d.ts +61 -0
  3. package/dist/commands/files.js +274 -0
  4. package/dist/commands/mcp-registration.d.ts +4 -5
  5. package/dist/commands/mcp-registration.js +5 -4
  6. package/dist/commands/outposts.d.ts +20 -4
  7. package/dist/commands/outposts.js +79 -10
  8. package/dist/commands/pack-install.d.ts +14 -17
  9. package/dist/commands/pack-install.js +53 -29
  10. package/dist/commands/pkg-install.js +3 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +9 -3
  13. package/dist/commands/secrets.js +189 -87
  14. package/dist/run/hq-plugin.js +94 -31
  15. package/dist/utils/billing-gate.d.ts +15 -0
  16. package/dist/utils/billing-gate.js +35 -0
  17. package/dist/utils/sandbox-runner-client.d.ts +1 -0
  18. package/dist/utils/sandbox-runner-client.js +1 -0
  19. package/dist/utils/secrets-cache.d.ts +4 -5
  20. package/dist/utils/secrets-cache.js +5 -8
  21. package/package.json +3 -2
  22. package/pnpm-workspace.yaml +2 -0
  23. package/src/commands/agents.test.ts +41 -0
  24. package/src/commands/agents.ts +7 -3
  25. package/src/commands/files-recovery.test.ts +361 -0
  26. package/src/commands/files.ts +410 -0
  27. package/src/commands/mcp-registration.ts +9 -9
  28. package/src/commands/outposts.test.ts +155 -24
  29. package/src/commands/outposts.ts +199 -45
  30. package/src/commands/pack-install-secret-authorization.test.ts +115 -0
  31. package/src/commands/pack-install.test.ts +5 -1
  32. package/src/commands/pack-install.ts +67 -29
  33. package/src/commands/pkg-install.ts +3 -1
  34. package/src/commands/run.test.ts +45 -0
  35. package/src/commands/run.ts +20 -4
  36. package/src/commands/secrets.test.ts +366 -25
  37. package/src/commands/secrets.ts +222 -96
  38. package/src/run/hq-plugin.test.ts +186 -10
  39. package/src/run/hq-plugin.ts +102 -32
  40. package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
  41. package/src/utils/billing-gate.ts +46 -0
  42. package/src/utils/pack-contributions.test.ts +90 -31
  43. package/src/utils/sandbox-runner-client.test.ts +28 -0
  44. package/src/utils/sandbox-runner-client.ts +2 -0
  45. package/src/utils/secrets-cache.ts +5 -8
  46. package/test/commands/signals.test.ts +2 -2
  47. package/test/commands/sources.test.ts +2 -2
  48. package/test/helpers/vault-service-mock.ts +76 -17
  49. package/test/sources-signals/smoke.test.ts +2 -2
@@ -45,8 +45,7 @@ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
45
45
  // The SSH fallback shells out to `ssh` via spawnSync — stub it so tests don't
46
46
  // touch a real box or need the ssh binary.
47
47
  vi.mock("node:child_process", async (importOriginal) => {
48
- const original =
49
- await importOriginal<typeof import("node:child_process")>();
48
+ const original = await importOriginal<typeof import("node:child_process")>();
50
49
  return { ...original, spawnSync: vi.fn() };
51
50
  });
52
51
 
@@ -173,7 +172,9 @@ describe("hq outposts codex-enable / login", () => {
173
172
  });
174
173
 
175
174
  it("login-code POSTs /outpost/login-code with the code in the body", async () => {
176
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
175
+ fetchSpy.mockResolvedValueOnce(
176
+ jsonResponse(200, { ok: true, userId: "u1" }),
177
+ );
177
178
  await run(["outposts", "login-code", "ABC-123"]);
178
179
  const [url, init] = fetchSpy.mock.calls[0];
179
180
  expect(String(url)).toContain("/outpost/login-code");
@@ -182,7 +183,9 @@ describe("hq outposts codex-enable / login", () => {
182
183
  });
183
184
 
184
185
  it("login-code targets a specific box via --id (outpostId query)", async () => {
185
- fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true, userId: "u1" }));
186
+ fetchSpy.mockResolvedValueOnce(
187
+ jsonResponse(200, { ok: true, userId: "u1" }),
188
+ );
186
189
  await run(["outposts", "login-code", "CODE", "--id", "3"]);
187
190
  const [url] = fetchSpy.mock.calls[0];
188
191
  expect(String(url)).toContain("outpostId=3");
@@ -197,7 +200,11 @@ describe("hq outposts codex-enable / login", () => {
197
200
 
198
201
  it("login-code surfaces an awaiting-login not-found error", async () => {
199
202
  fetchSpy.mockResolvedValueOnce(
200
- jsonResponse(404, { error: true, step: "not-found", message: "no outpost" }),
203
+ jsonResponse(404, {
204
+ error: true,
205
+ step: "not-found",
206
+ message: "no outpost",
207
+ }),
201
208
  );
202
209
  await expect(run(["outposts", "login-code", "CODE"])).rejects.toThrow(
203
210
  "process.exit(1)",
@@ -237,9 +244,9 @@ describe("hq outposts destroy", () => {
237
244
  fetchSpy.mockResolvedValueOnce(
238
245
  jsonResponse(500, { error: "boom", step: "teardown" }),
239
246
  );
240
- await expect(
241
- run(["outposts", "destroy", "--yes"]),
242
- ).rejects.toThrow("process.exit(1)");
247
+ await expect(run(["outposts", "destroy", "--yes"])).rejects.toThrow(
248
+ "process.exit(1)",
249
+ );
243
250
  });
244
251
  });
245
252
 
@@ -267,6 +274,68 @@ describe("hq outposts provision (billing gate)", () => {
267
274
  expect(sent.agentRuntime).toBe("codex");
268
275
  });
269
276
 
277
+ it("rejects an unknown --runtime before the charge gate and makes NO API call", async () => {
278
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
279
+ await expect(
280
+ run(["outposts", "provision", "--runtime", "codx", "--yes"]),
281
+ ).rejects.toThrow("process.exit(1)");
282
+ expect(fetchSpy).not.toHaveBeenCalled();
283
+ const printed = errSpy.mock.calls
284
+ .map((c) => c.map(String).join(" "))
285
+ .join("\n");
286
+ // A typo'd runtime must not silently resolve to claude — codex is the whole
287
+ // point of the flag, so a near-miss has to name itself.
288
+ expect(printed).toContain("codx");
289
+ expect(printed).toMatch(/claude/);
290
+ expect(printed).toMatch(/codex/);
291
+ });
292
+
293
+ it("accepts an explicit --runtime claude", async () => {
294
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { ok: true }));
295
+ await run(["outposts", "provision", "--runtime", "claude", "--yes"]);
296
+ const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
297
+ expect(sent.agentRuntime).toBe("claude");
298
+ });
299
+
300
+ it("explains the per-person cap on a 409 rather than printing a bare 'Conflict'", async () => {
301
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
302
+ // hq-pro's cap body carries `capped`/`limit`/`outposts` and NO
303
+ // `message`/`error` field, so the generic decoder falls through to
304
+ // res.statusText — the operator sees only "Conflict".
305
+ fetchSpy.mockResolvedValueOnce(
306
+ jsonResponse(409, {
307
+ capped: true,
308
+ userId: "user_test_123",
309
+ limit: 1,
310
+ outposts: [
311
+ {
312
+ outpostId: "primary",
313
+ state: "ready",
314
+ instanceName: "outpost-user_test_123",
315
+ region: "us-east-1",
316
+ agentRuntime: "claude",
317
+ platform: "ec2",
318
+ createdAt: "2026-07-01T00:00:00.000Z",
319
+ },
320
+ ],
321
+ }),
322
+ );
323
+
324
+ await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
325
+ "process.exit(1)",
326
+ );
327
+
328
+ const printed = errSpy.mock.calls
329
+ .map((c) => c.map(String).join(" "))
330
+ .join("\n");
331
+ expect(printed).not.toMatch(/^Conflict$/m);
332
+ expect(printed).toMatch(/limit/i);
333
+ // Name the box they already own, and reassure on the charge they were
334
+ // just warned about — the cap is checked BEFORE activation billing.
335
+ expect(printed).toContain("primary");
336
+ expect(printed).toMatch(/not been charged/i);
337
+ });
338
+
270
339
  it("mints a card-capture link on 402 billing_required and exits 1", async () => {
271
340
  fetchSpy
272
341
  .mockResolvedValueOnce(
@@ -286,9 +355,9 @@ describe("hq outposts provision (billing gate)", () => {
286
355
  jsonResponse(200, { url: "https://checkout.stripe.com/outpost" }),
287
356
  );
288
357
 
289
- await expect(
290
- run(["outposts", "provision", "--yes"]),
291
- ).rejects.toThrow("process.exit(1)");
358
+ await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
359
+ "process.exit(1)",
360
+ );
292
361
 
293
362
  expect(String(fetchSpy.mock.calls[1][0])).toContain(
294
363
  "/v1/billing/checkout/person",
@@ -296,6 +365,43 @@ describe("hq outposts provision (billing gate)", () => {
296
365
  const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
297
366
  expect(printed).toContain("https://checkout.stripe.com/outpost");
298
367
  });
368
+
369
+ it("surfaces the DECLINE reason on 402 payment_failed — never the 'No card on file' copy", async () => {
370
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
371
+ // hq-pro's payment_failed envelope: a card EXISTS and was declined. The
372
+ // server's `message` carries the friendly decline copy; telling this user
373
+ // "No card on file" sends them down the wrong remediation path entirely.
374
+ fetchSpy
375
+ .mockResolvedValueOnce(
376
+ jsonResponse(402, {
377
+ error: "payment required",
378
+ message: "Your card was declined. Try a different card or contact your bank.",
379
+ billing: {
380
+ status: "payment_failed",
381
+ setup: {
382
+ payerType: "person",
383
+ path: "/v1/billing/checkout/person",
384
+ method: "POST",
385
+ },
386
+ },
387
+ }),
388
+ )
389
+ .mockResolvedValueOnce(
390
+ jsonResponse(200, { url: "https://checkout.stripe.com/update-card" }),
391
+ );
392
+
393
+ await expect(run(["outposts", "provision", "--yes"])).rejects.toThrow(
394
+ "process.exit(1)",
395
+ );
396
+
397
+ const printed = [...errSpy.mock.calls, ...logSpy.mock.calls]
398
+ .map((c) => c.map(String).join(" "))
399
+ .join("\n");
400
+ expect(printed).toContain("declined");
401
+ expect(printed).not.toContain("No card on file");
402
+ // The card-capture link still surfaces — as the way to UPDATE the card.
403
+ expect(printed).toContain("https://checkout.stripe.com/update-card");
404
+ });
299
405
  });
300
406
 
301
407
  describe("hq outposts exec", () => {
@@ -394,12 +500,13 @@ describe("hq outposts exec", () => {
394
500
  jsonResponse(409, {
395
501
  error: true,
396
502
  step: "not-ready",
397
- message: 'outpost is "provisioning", not ready — it must be running to exec',
503
+ message:
504
+ 'outpost is "provisioning", not ready — it must be running to exec',
398
505
  }),
399
506
  );
400
- await expect(
401
- run(["outposts", "exec", "echo", "hi"]),
402
- ).rejects.toThrow("process.exit(1)");
507
+ await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
508
+ "process.exit(1)",
509
+ );
403
510
  });
404
511
 
405
512
  it("emits raw JSON with --json", async () => {
@@ -523,11 +630,15 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
523
630
 
524
631
  it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
525
632
  fetchSpy.mockResolvedValueOnce(
526
- jsonResponse(409, { error: true, step: "not-ready", message: "not ready" }),
633
+ jsonResponse(409, {
634
+ error: true,
635
+ step: "not-ready",
636
+ message: "not ready",
637
+ }),
638
+ );
639
+ await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
640
+ "process.exit(1)",
527
641
  );
528
- await expect(
529
- run(["outposts", "exec", "echo", "hi"]),
530
- ).rejects.toThrow("process.exit(1)");
531
642
  // Only the SSM call happened; no ssh-access mint, no ssh.
532
643
  expect(fetchSpy).toHaveBeenCalledTimes(1);
533
644
  expect(mockSpawnSync).not.toHaveBeenCalled();
@@ -711,7 +822,9 @@ describe("hq outposts asynchronous exec", () => {
711
822
  expect.stringContaining("/outpost/exec?outpostId=2"),
712
823
  expect.stringContaining("/outpost/exec?outpostId=2"),
713
824
  ]);
714
- expect(fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string))).toEqual([
825
+ expect(
826
+ fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string)),
827
+ ).toEqual([
715
828
  { mode: "stage" },
716
829
  { mode: "submit", command: "cd /tmp && run-worker" },
717
830
  { mode: "result", commandId: "cmd-1" },
@@ -738,7 +851,15 @@ describe("hq outposts asynchronous exec", () => {
738
851
  )
739
852
  .mockResolvedValueOnce(new Response(null, { status: 200 }));
740
853
 
741
- await run(["outposts", "exec-stage", "--id", "2", "--file", file, "--json"]);
854
+ await run([
855
+ "outposts",
856
+ "exec-stage",
857
+ "--id",
858
+ "2",
859
+ "--file",
860
+ file,
861
+ "--json",
862
+ ]);
742
863
 
743
864
  expect(fetchSpy).toHaveBeenCalledTimes(2);
744
865
  const [uploadUrl, uploadInit] = fetchSpy.mock.calls[1];
@@ -748,7 +869,9 @@ describe("hq outposts asynchronous exec", () => {
748
869
  "Content-Length": String(expectedBytes.byteLength),
749
870
  });
750
871
  expect(Buffer.from(uploadInit?.body as Uint8Array)).toEqual(expectedBytes);
751
- const printed = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
872
+ const printed = stdoutSpy.mock.calls
873
+ .map((call) => String(call[0]))
874
+ .join("");
752
875
  expect(JSON.parse(printed)).toEqual({
753
876
  key: "input-key",
754
877
  getUrl: "https://download.invalid/get",
@@ -828,6 +951,10 @@ describe("hq outposts asynchronous exec", () => {
828
951
  });
829
952
  });
830
953
 
954
+ // Spawns a real `bash` (execFileSync) to prove the round-trip end-to-end.
955
+ // Under the saturated full-suite parallel pool that spawn can exceed vitest's
956
+ // 5s default purely on scheduling contention; the extra budget covers that,
957
+ // not slow logic (isolated, this runs in milliseconds).
831
958
  it("preserves one command part verbatim and round-trips quoted bash argv", () => {
832
959
  const single = " printf 'already formed' ";
833
960
  expect(joinCommandParts([single])).toBe(single);
@@ -844,7 +971,7 @@ describe("hq outposts asynchronous exec", () => {
844
971
  expect(execFileSync("bash", ["-c", command], { encoding: "utf8" })).toBe(
845
972
  "marker\na b\nquote's\n",
846
973
  );
847
- });
974
+ }, 30_000);
848
975
 
849
976
  it("exec-result --wait retries a 429, polls until done, and prints the result contract", async () => {
850
977
  vi.useFakeTimers();
@@ -854,7 +981,11 @@ describe("hq outposts asynchronous exec", () => {
854
981
  .mockImplementation(() => true);
855
982
  fetchSpy
856
983
  .mockResolvedValueOnce(
857
- jsonResponse(429, { error: true, step: "rate", message: "slow down" }),
984
+ jsonResponse(429, {
985
+ error: true,
986
+ step: "rate",
987
+ message: "slow down",
988
+ }),
858
989
  )
859
990
  .mockResolvedValueOnce(
860
991
  jsonResponse(200, {