@indigoai-us/hq-cli 5.74.0 → 5.76.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/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- 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/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/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- 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/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
|
@@ -140,11 +140,10 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
|
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
142
|
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
143
|
-
* secrets-cache directory on disk.
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* unreadable (the desired graceful-deferral behavior — no scopes, no hits).
|
|
143
|
+
* secrets-cache directory on disk. Install-time MCP registration may use an
|
|
144
|
+
* exactly-one result as a scope hint before reauthorizing every value online; it
|
|
145
|
+
* never reads cached plaintext through this helper. Returns `[]` when the cache
|
|
146
|
+
* root is absent or unreadable.
|
|
148
147
|
*/
|
|
149
148
|
export function listSecretCacheScopes() {
|
|
150
149
|
try {
|
|
@@ -152,9 +151,7 @@ export function listSecretCacheScopes() {
|
|
|
152
151
|
.readdirSync(CACHE_DIR, { withFileTypes: true })
|
|
153
152
|
.filter((e) => e.isDirectory())
|
|
154
153
|
.map((e) => e.name)
|
|
155
|
-
|
|
156
|
-
// rejects anything with `/` or `..`, so this is belt-and-suspenders.
|
|
157
|
-
.filter((name) => !name.startsWith("."));
|
|
154
|
+
.filter((name) => /^(?:cmp|prs)_[A-Za-z0-9_-]+$/.test(name));
|
|
158
155
|
}
|
|
159
156
|
catch {
|
|
160
157
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.76.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"build": "node scripts/generate-dsn.mjs && tsc && node scripts/chmod-bins.mjs",
|
|
12
12
|
"prepublishOnly": "npm run build",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
+
"gen:scan-golden": "node scripts/generate-scan-packages-table.mjs > src/utils/__fixtures__/scan-packages.generated-block.sh",
|
|
14
15
|
"lint": "eslint .",
|
|
15
16
|
"test": "vitest run",
|
|
16
17
|
"test:db": "vitest run src/lib/db test/commands/db.test.ts test/commands/db-tenant-isolation.test.ts",
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
24
|
-
"@indigoai-us/hq-cloud": "^6.14.
|
|
25
|
+
"@indigoai-us/hq-cloud": "^6.14.14",
|
|
25
26
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
26
27
|
"@sentry/node": "^10.49.0",
|
|
27
28
|
"better-sqlite3": "^12.11.1",
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -1071,7 +1071,8 @@ function restoreSafely(realTarget: string, backupDir: string): boolean {
|
|
|
1071
1071
|
//
|
|
1072
1072
|
// SECRET SAFETY (the hard rule from the PRD authModel / US-002 AC):
|
|
1073
1073
|
// - `${secret:NAME}` resolves ONLY here, at emit, via the injected
|
|
1074
|
-
// {@link SecretResolver}
|
|
1074
|
+
// {@link SecretResolver}. Production injects values returned by a fresh
|
|
1075
|
+
// server authorization; the encrypted disk cache is never a value fallback.
|
|
1075
1076
|
// - The RESOLVED value lands in `~/.claude.json` — a 0600 user-global file that
|
|
1076
1077
|
// is NOT synced/committed — because the runtime needs the real header to work.
|
|
1077
1078
|
// - The resolved value is REDACTED from every return value and never echoed; we
|
|
@@ -1105,11 +1106,10 @@ export interface McpManifest {
|
|
|
1105
1106
|
/**
|
|
1106
1107
|
* Resolve a `${secret:NAME}` reference to its plaintext value, or return `null`
|
|
1107
1108
|
* when the secret is unavailable (un-minted / TTL-expired / no company context).
|
|
1108
|
-
* Production binds this to
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
* reference at all.
|
|
1109
|
+
* Production binds this to an in-memory map populated by a fresh vault `/load`
|
|
1110
|
+
* authorization response; tests inject a pure map. Returning `null` for a
|
|
1111
|
+
* referenced secret is a hard error at emit (we refuse to write a broken
|
|
1112
|
+
* header), distinct from a name with no reference at all.
|
|
1113
1113
|
*/
|
|
1114
1114
|
export type SecretResolver = (name: string) => string | null;
|
|
1115
1115
|
|
|
@@ -1146,9 +1146,9 @@ export function resolveSecretRefs(
|
|
|
1146
1146
|
const resolved = resolve(name);
|
|
1147
1147
|
if (resolved === null || resolved === undefined) {
|
|
1148
1148
|
throw new McpManifestError(
|
|
1149
|
-
`cannot resolve \${secret:${name}} at emit — the secret
|
|
1150
|
-
'
|
|
1151
|
-
'write a half-resolved header.',
|
|
1149
|
+
`cannot resolve \${secret:${name}} at emit — the secret was not available ` +
|
|
1150
|
+
'after online vault authorization (check login, company scope, and access, ' +
|
|
1151
|
+
'then re-install). HQ refuses to write a half-resolved header.',
|
|
1152
1152
|
);
|
|
1153
1153
|
}
|
|
1154
1154
|
if (resolved.length > 0) secretSink.add(resolved);
|
|
@@ -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",
|
|
@@ -394,12 +463,13 @@ describe("hq outposts exec", () => {
|
|
|
394
463
|
jsonResponse(409, {
|
|
395
464
|
error: true,
|
|
396
465
|
step: "not-ready",
|
|
397
|
-
message:
|
|
466
|
+
message:
|
|
467
|
+
'outpost is "provisioning", not ready — it must be running to exec',
|
|
398
468
|
}),
|
|
399
469
|
);
|
|
400
|
-
await expect(
|
|
401
|
-
|
|
402
|
-
)
|
|
470
|
+
await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
|
|
471
|
+
"process.exit(1)",
|
|
472
|
+
);
|
|
403
473
|
});
|
|
404
474
|
|
|
405
475
|
it("emits raw JSON with --json", async () => {
|
|
@@ -523,17 +593,155 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
|
523
593
|
|
|
524
594
|
it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
|
|
525
595
|
fetchSpy.mockResolvedValueOnce(
|
|
526
|
-
jsonResponse(409, {
|
|
596
|
+
jsonResponse(409, {
|
|
597
|
+
error: true,
|
|
598
|
+
step: "not-ready",
|
|
599
|
+
message: "not ready",
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
|
|
603
|
+
"process.exit(1)",
|
|
527
604
|
);
|
|
528
|
-
await expect(
|
|
529
|
-
run(["outposts", "exec", "echo", "hi"]),
|
|
530
|
-
).rejects.toThrow("process.exit(1)");
|
|
531
605
|
// Only the SSM call happened; no ssh-access mint, no ssh.
|
|
532
606
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
533
607
|
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
534
608
|
});
|
|
535
609
|
});
|
|
536
610
|
|
|
611
|
+
|
|
612
|
+
describe("hq outposts exec --async / --detach", () => {
|
|
613
|
+
afterEach(() => {
|
|
614
|
+
process.exitCode = undefined;
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("--detach submits with HQ-dir wrapper and prints commandId without waiting", async () => {
|
|
618
|
+
const stdoutSpy = vi
|
|
619
|
+
.spyOn(process.stdout, "write")
|
|
620
|
+
.mockImplementation(() => true);
|
|
621
|
+
fetchSpy.mockResolvedValueOnce(
|
|
622
|
+
jsonResponse(200, {
|
|
623
|
+
ok: true,
|
|
624
|
+
userId: "u",
|
|
625
|
+
outpostId: "3",
|
|
626
|
+
instanceId: "i-3",
|
|
627
|
+
commandId: "cmd-detach",
|
|
628
|
+
outputPrefix: "outpost-exec/u/3/n/out",
|
|
629
|
+
executionTimeoutSeconds: 172800,
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
await run(["outposts", "exec", "--detach", "--id", "3", "--json", "sleep", "999"]);
|
|
634
|
+
|
|
635
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
636
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
637
|
+
expect(String(url)).toContain("/outpost/exec");
|
|
638
|
+
expect(String(url)).toContain("outpostId=3");
|
|
639
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
640
|
+
mode: "submit",
|
|
641
|
+
command: withRemoteHqDir("'sleep' '999'"),
|
|
642
|
+
});
|
|
643
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
644
|
+
expect(printed).toContain('"commandId":"cmd-detach"');
|
|
645
|
+
// No second poll.
|
|
646
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("--async submits then polls until done and streams stdout", async () => {
|
|
650
|
+
const stdoutSpy = vi
|
|
651
|
+
.spyOn(process.stdout, "write")
|
|
652
|
+
.mockImplementation(() => true);
|
|
653
|
+
fetchSpy
|
|
654
|
+
.mockResolvedValueOnce(
|
|
655
|
+
jsonResponse(200, {
|
|
656
|
+
ok: true,
|
|
657
|
+
userId: "u",
|
|
658
|
+
outpostId: "primary",
|
|
659
|
+
instanceId: "i",
|
|
660
|
+
commandId: "cmd-async",
|
|
661
|
+
outputPrefix: "p",
|
|
662
|
+
executionTimeoutSeconds: 7200,
|
|
663
|
+
}),
|
|
664
|
+
)
|
|
665
|
+
.mockResolvedValueOnce(
|
|
666
|
+
jsonResponse(200, {
|
|
667
|
+
ok: true,
|
|
668
|
+
userId: "u",
|
|
669
|
+
outpostId: "primary",
|
|
670
|
+
status: "Success",
|
|
671
|
+
done: true,
|
|
672
|
+
exitCode: 0,
|
|
673
|
+
stdout: "finished\n",
|
|
674
|
+
stderr: "",
|
|
675
|
+
}),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
await run([
|
|
679
|
+
"outposts",
|
|
680
|
+
"exec",
|
|
681
|
+
"--async",
|
|
682
|
+
"--timeout-seconds",
|
|
683
|
+
"7200",
|
|
684
|
+
"echo",
|
|
685
|
+
"finished",
|
|
686
|
+
]);
|
|
687
|
+
|
|
688
|
+
// submit + one result poll (terminal on first poll)
|
|
689
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
690
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
691
|
+
mode: "submit",
|
|
692
|
+
command: withRemoteHqDir("'echo' 'finished'"),
|
|
693
|
+
timeoutSeconds: 7200,
|
|
694
|
+
});
|
|
695
|
+
expect(JSON.parse(fetchSpy.mock.calls[1][1]?.body as string)).toEqual({
|
|
696
|
+
mode: "result",
|
|
697
|
+
commandId: "cmd-async",
|
|
698
|
+
});
|
|
699
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
700
|
+
expect(printed).toContain("finished\n");
|
|
701
|
+
expect(process.exitCode).toBe(0);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("refuses --async and --detach together", async () => {
|
|
705
|
+
await expect(
|
|
706
|
+
run(["outposts", "exec", "--async", "--detach", "true"]),
|
|
707
|
+
).rejects.toThrow("process.exit(1)");
|
|
708
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("exec-submit forwards --timeout-seconds", async () => {
|
|
712
|
+
const stdoutSpy = vi
|
|
713
|
+
.spyOn(process.stdout, "write")
|
|
714
|
+
.mockImplementation(() => true);
|
|
715
|
+
fetchSpy.mockResolvedValueOnce(
|
|
716
|
+
jsonResponse(200, {
|
|
717
|
+
ok: true,
|
|
718
|
+
commandId: "cmd-sub",
|
|
719
|
+
executionTimeoutSeconds: 3600,
|
|
720
|
+
userId: "u",
|
|
721
|
+
outpostId: "primary",
|
|
722
|
+
instanceId: "i",
|
|
723
|
+
outputPrefix: "p",
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
await run([
|
|
727
|
+
"outposts",
|
|
728
|
+
"exec-submit",
|
|
729
|
+
"--timeout-seconds",
|
|
730
|
+
"3600",
|
|
731
|
+
"--json",
|
|
732
|
+
"sleep",
|
|
733
|
+
"10",
|
|
734
|
+
]);
|
|
735
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
736
|
+
mode: "submit",
|
|
737
|
+
command: "'sleep' '10'",
|
|
738
|
+
timeoutSeconds: 3600,
|
|
739
|
+
});
|
|
740
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
741
|
+
expect(printed).toContain("cmd-sub");
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
|
|
537
745
|
describe("hq outposts asynchronous exec", () => {
|
|
538
746
|
it("sends the stage, submit, and result mode request bodies", async () => {
|
|
539
747
|
fetchSpy
|
|
@@ -577,7 +785,9 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
577
785
|
expect.stringContaining("/outpost/exec?outpostId=2"),
|
|
578
786
|
expect.stringContaining("/outpost/exec?outpostId=2"),
|
|
579
787
|
]);
|
|
580
|
-
expect(
|
|
788
|
+
expect(
|
|
789
|
+
fetchSpy.mock.calls.map(([, init]) => JSON.parse(init?.body as string)),
|
|
790
|
+
).toEqual([
|
|
581
791
|
{ mode: "stage" },
|
|
582
792
|
{ mode: "submit", command: "cd /tmp && run-worker" },
|
|
583
793
|
{ mode: "result", commandId: "cmd-1" },
|
|
@@ -604,7 +814,15 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
604
814
|
)
|
|
605
815
|
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
606
816
|
|
|
607
|
-
await run([
|
|
817
|
+
await run([
|
|
818
|
+
"outposts",
|
|
819
|
+
"exec-stage",
|
|
820
|
+
"--id",
|
|
821
|
+
"2",
|
|
822
|
+
"--file",
|
|
823
|
+
file,
|
|
824
|
+
"--json",
|
|
825
|
+
]);
|
|
608
826
|
|
|
609
827
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
610
828
|
const [uploadUrl, uploadInit] = fetchSpy.mock.calls[1];
|
|
@@ -614,7 +832,9 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
614
832
|
"Content-Length": String(expectedBytes.byteLength),
|
|
615
833
|
});
|
|
616
834
|
expect(Buffer.from(uploadInit?.body as Uint8Array)).toEqual(expectedBytes);
|
|
617
|
-
const printed = stdoutSpy.mock.calls
|
|
835
|
+
const printed = stdoutSpy.mock.calls
|
|
836
|
+
.map((call) => String(call[0]))
|
|
837
|
+
.join("");
|
|
618
838
|
expect(JSON.parse(printed)).toEqual({
|
|
619
839
|
key: "input-key",
|
|
620
840
|
getUrl: "https://download.invalid/get",
|
|
@@ -694,6 +914,10 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
694
914
|
});
|
|
695
915
|
});
|
|
696
916
|
|
|
917
|
+
// Spawns a real `bash` (execFileSync) to prove the round-trip end-to-end.
|
|
918
|
+
// Under the saturated full-suite parallel pool that spawn can exceed vitest's
|
|
919
|
+
// 5s default purely on scheduling contention; the extra budget covers that,
|
|
920
|
+
// not slow logic (isolated, this runs in milliseconds).
|
|
697
921
|
it("preserves one command part verbatim and round-trips quoted bash argv", () => {
|
|
698
922
|
const single = " printf 'already formed' ";
|
|
699
923
|
expect(joinCommandParts([single])).toBe(single);
|
|
@@ -710,7 +934,7 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
710
934
|
expect(execFileSync("bash", ["-c", command], { encoding: "utf8" })).toBe(
|
|
711
935
|
"marker\na b\nquote's\n",
|
|
712
936
|
);
|
|
713
|
-
});
|
|
937
|
+
}, 30_000);
|
|
714
938
|
|
|
715
939
|
it("exec-result --wait retries a 429, polls until done, and prints the result contract", async () => {
|
|
716
940
|
vi.useFakeTimers();
|
|
@@ -720,7 +944,11 @@ describe("hq outposts asynchronous exec", () => {
|
|
|
720
944
|
.mockImplementation(() => true);
|
|
721
945
|
fetchSpy
|
|
722
946
|
.mockResolvedValueOnce(
|
|
723
|
-
jsonResponse(429, {
|
|
947
|
+
jsonResponse(429, {
|
|
948
|
+
error: true,
|
|
949
|
+
step: "rate",
|
|
950
|
+
message: "slow down",
|
|
951
|
+
}),
|
|
724
952
|
)
|
|
725
953
|
.mockResolvedValueOnce(
|
|
726
954
|
jsonResponse(200, {
|