@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.
- package/dist/commands/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +79 -10
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +155 -24
- package/src/commands/outposts.ts +199 -45
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- 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(
|
|
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(
|
|
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, {
|
|
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
|
-
|
|
242
|
-
)
|
|
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
|
-
|
|
291
|
-
)
|
|
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:
|
|
503
|
+
message:
|
|
504
|
+
'outpost is "provisioning", not ready — it must be running to exec',
|
|
398
505
|
}),
|
|
399
506
|
);
|
|
400
|
-
await expect(
|
|
401
|
-
|
|
402
|
-
)
|
|
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, {
|
|
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(
|
|
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([
|
|
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
|
|
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, {
|
|
984
|
+
jsonResponse(429, {
|
|
985
|
+
error: true,
|
|
986
|
+
step: "rate",
|
|
987
|
+
message: "slow down",
|
|
988
|
+
}),
|
|
858
989
|
)
|
|
859
990
|
.mockResolvedValueOnce(
|
|
860
991
|
jsonResponse(200, {
|