@indigoai-us/hq-cli 5.73.1 → 5.75.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/integrations.d.ts +10 -1
- package/dist/commands/integrations.js +42 -9
- package/dist/commands/outposts.d.ts +10 -7
- package/dist/commands/outposts.js +137 -12
- package/dist/main.js +21 -0
- package/dist/utils/auth-error.d.ts +16 -0
- package/dist/utils/auth-error.js +39 -0
- package/dist/utils/expected-cli-error.d.ts +15 -0
- package/dist/utils/expected-cli-error.js +29 -0
- package/dist/utils/vault-api.js +17 -4
- package/package.json +1 -1
- package/src/commands/integrations.test.ts +231 -0
- package/src/commands/integrations.ts +44 -1
- package/src/commands/outposts.test.ts +137 -0
- package/src/commands/outposts.ts +215 -13
- package/src/main.ts +19 -0
- package/src/utils/auth-error.test.ts +40 -0
- package/src/utils/auth-error.ts +42 -0
- package/src/utils/expected-cli-error.test.ts +28 -0
- package/src/utils/expected-cli-error.ts +39 -0
- package/src/utils/vault-api.test.ts +63 -0
- package/src/utils/vault-api.ts +17 -4
|
@@ -34,6 +34,7 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
37
|
+
import { AuthError, isAuthError } from "../utils/auth-error.js";
|
|
37
38
|
import {
|
|
38
39
|
IntegrationsCliError,
|
|
39
40
|
queuedOutcome,
|
|
@@ -258,6 +259,16 @@ describe("hq integrations call", () => {
|
|
|
258
259
|
).rejects.toThrow(/--args must be a JSON object/);
|
|
259
260
|
expect(vaultApiFetchMock).not.toHaveBeenCalled();
|
|
260
261
|
});
|
|
262
|
+
|
|
263
|
+
it("marks non-object --args as expected", async () => {
|
|
264
|
+
try {
|
|
265
|
+
await runCli(["integrations", "call", "t", "--provider", "linear", "--args", "[1]"]);
|
|
266
|
+
throw new Error("expected runCli to throw");
|
|
267
|
+
} catch (err) {
|
|
268
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
269
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
261
272
|
});
|
|
262
273
|
|
|
263
274
|
describe("hq integrations approve", () => {
|
|
@@ -282,3 +293,223 @@ describe("hq integrations approve", () => {
|
|
|
282
293
|
expect(logged()).toContain("Approved");
|
|
283
294
|
});
|
|
284
295
|
});
|
|
296
|
+
|
|
297
|
+
describe("expected integrations errors", () => {
|
|
298
|
+
const linear = {
|
|
299
|
+
id: "acct_1",
|
|
300
|
+
provider: "factory:linear",
|
|
301
|
+
status: "connected",
|
|
302
|
+
};
|
|
303
|
+
const notion = {
|
|
304
|
+
id: "acct_2",
|
|
305
|
+
provider: "factory:notion",
|
|
306
|
+
status: "connected",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
it("marks a non-owner 403 approve as expected (skips Sentry) and preserves the message", async () => {
|
|
310
|
+
vaultApiFetchMock
|
|
311
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
312
|
+
.mockResolvedValueOnce(
|
|
313
|
+
jsonResponse(
|
|
314
|
+
{ error: "Only a company owner can approve or reject queued integration writes" },
|
|
315
|
+
403,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await runCli(["integrations", "approve", "cq_123", "--provider", "linear"]);
|
|
321
|
+
throw new Error("expected runCli to throw");
|
|
322
|
+
} catch (err) {
|
|
323
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
324
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
325
|
+
expect((err as Error).message).toContain("Only a company owner");
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("still captures a genuine server 500 on approve (expected === false)", async () => {
|
|
330
|
+
vaultApiFetchMock
|
|
331
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
332
|
+
.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
await runCli(["integrations", "approve", "cq_123", "--provider", "linear"]);
|
|
336
|
+
throw new Error("expected runCli to throw");
|
|
337
|
+
} catch (err) {
|
|
338
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
339
|
+
expect((err as IntegrationsCliError).expected).toBe(false);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("marks reject 403 as expected too", async () => {
|
|
344
|
+
vaultApiFetchMock
|
|
345
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
346
|
+
.mockResolvedValueOnce(
|
|
347
|
+
jsonResponse(
|
|
348
|
+
{ error: "Only a company owner can approve or reject queued integration writes" },
|
|
349
|
+
403,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await runCli(["integrations", "reject", "cq_123", "--provider", "linear"]);
|
|
355
|
+
throw new Error("expected runCli to throw");
|
|
356
|
+
} catch (err) {
|
|
357
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
358
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
expect(vaultApiFetchMock.mock.calls[1]![0].path).toBe(
|
|
362
|
+
"/v1/integrations/confirm/cq_123/reject",
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("marks local connection selection usage errors as expected", () => {
|
|
367
|
+
for (const fn of [
|
|
368
|
+
() => selectConnection([], {}),
|
|
369
|
+
() => selectConnection([linear, notion], {}),
|
|
370
|
+
() => selectConnection([linear], { provider: "jira" }),
|
|
371
|
+
]) {
|
|
372
|
+
try {
|
|
373
|
+
fn();
|
|
374
|
+
throw new Error("expected selectConnection to throw");
|
|
375
|
+
} catch (err) {
|
|
376
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
377
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// HQ-CLI-9: `hq integrations call get_board_info --provider monday …` hit a 401
|
|
384
|
+
// on POST /v1/integrations/mcp while the caller's HQ session was rejected. The
|
|
385
|
+
// old `callGateway` threw a plain, opaque `IntegrationsCliError("Integration
|
|
386
|
+
// gateway request failed (HTTP 401).")` that shipped to Sentry as an
|
|
387
|
+
// unactionable fatal. A gateway 401 is an expired/missing session — the same
|
|
388
|
+
// expected auth state the vault company-resolution paths already raise as
|
|
389
|
+
// `AuthError` (HQ-CLI-8). The fix routes every integration-gateway 401 through
|
|
390
|
+
// that typed AuthError so the user gets one actionable "run `hq login`" message
|
|
391
|
+
// and Sentry is skipped, while genuine 4xx/5xx faults keep their behavior.
|
|
392
|
+
describe("integration gateway 401 → AuthError (HQ-CLI-9)", () => {
|
|
393
|
+
it("throws an actionable AuthError (not an opaque IntegrationsCliError) when callGateway 401s", async () => {
|
|
394
|
+
vaultApiFetchMock
|
|
395
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
396
|
+
.mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
|
|
397
|
+
|
|
398
|
+
const err = await runCli([
|
|
399
|
+
"integrations",
|
|
400
|
+
"call",
|
|
401
|
+
"get_board_info",
|
|
402
|
+
"--provider",
|
|
403
|
+
"linear",
|
|
404
|
+
"--args",
|
|
405
|
+
"{}",
|
|
406
|
+
]).then(
|
|
407
|
+
() => {
|
|
408
|
+
throw new Error("expected runCli to throw");
|
|
409
|
+
},
|
|
410
|
+
(e: unknown) => e,
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(isAuthError(err)).toBe(true);
|
|
414
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
415
|
+
expect((err as Error).message).toMatch(/hq login/);
|
|
416
|
+
// The gateway request was actually attempted (connections + gateway call).
|
|
417
|
+
expect(vaultApiFetchMock.mock.calls[1]![0].path).toBe("/v1/integrations/mcp");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("throws an AuthError when the admin (fetchConnections) call 401s", async () => {
|
|
421
|
+
vaultApiFetchMock.mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
|
|
422
|
+
|
|
423
|
+
const err = await runCli(["integrations", "list"]).then(
|
|
424
|
+
() => {
|
|
425
|
+
throw new Error("expected runCli to throw");
|
|
426
|
+
},
|
|
427
|
+
(e: unknown) => e,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
expect(isAuthError(err)).toBe(true);
|
|
431
|
+
expect((err as Error).message).toMatch(/hq login/);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("throws an AuthError when an approve confirm call 401s", async () => {
|
|
435
|
+
vaultApiFetchMock
|
|
436
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
437
|
+
.mockResolvedValueOnce(jsonResponse({ error: "Unauthorized" }, 401));
|
|
438
|
+
|
|
439
|
+
const err = await runCli([
|
|
440
|
+
"integrations",
|
|
441
|
+
"approve",
|
|
442
|
+
"cq_123",
|
|
443
|
+
"--provider",
|
|
444
|
+
"linear",
|
|
445
|
+
]).then(
|
|
446
|
+
() => {
|
|
447
|
+
throw new Error("expected runCli to throw");
|
|
448
|
+
},
|
|
449
|
+
(e: unknown) => e,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
expect(isAuthError(err)).toBe(true);
|
|
453
|
+
expect((err as Error).message).toMatch(/hq login/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Scope guards: only a 401 becomes an AuthError. A genuine server 500 still
|
|
457
|
+
// reports (expected === false), and a provider-level JSON-RPC error (HTTP 200
|
|
458
|
+
// with `message.error`) stays an IntegrationsCliError — so real faults and
|
|
459
|
+
// upstream tool errors are never misclassified as an auth state.
|
|
460
|
+
it("keeps a gateway 500 as a reporting IntegrationsCliError (not an AuthError)", async () => {
|
|
461
|
+
vaultApiFetchMock
|
|
462
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
463
|
+
.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
|
|
464
|
+
|
|
465
|
+
const err = await runCli([
|
|
466
|
+
"integrations",
|
|
467
|
+
"call",
|
|
468
|
+
"get_board_info",
|
|
469
|
+
"--provider",
|
|
470
|
+
"linear",
|
|
471
|
+
"--args",
|
|
472
|
+
"{}",
|
|
473
|
+
]).then(
|
|
474
|
+
() => {
|
|
475
|
+
throw new Error("expected runCli to throw");
|
|
476
|
+
},
|
|
477
|
+
(e: unknown) => e,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
expect(isAuthError(err)).toBe(false);
|
|
481
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
482
|
+
expect((err as IntegrationsCliError).expected).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("keeps an upstream provider error (HTTP 200 message.error) as an IntegrationsCliError", async () => {
|
|
486
|
+
vaultApiFetchMock
|
|
487
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
488
|
+
.mockResolvedValueOnce(
|
|
489
|
+
jsonResponse({
|
|
490
|
+
jsonrpc: "2.0",
|
|
491
|
+
id: "x",
|
|
492
|
+
error: { code: -32050, message: "monday rejected the board id" },
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const err = await runCli([
|
|
497
|
+
"integrations",
|
|
498
|
+
"call",
|
|
499
|
+
"get_board_info",
|
|
500
|
+
"--provider",
|
|
501
|
+
"linear",
|
|
502
|
+
"--args",
|
|
503
|
+
"{}",
|
|
504
|
+
]).then(
|
|
505
|
+
() => {
|
|
506
|
+
throw new Error("expected runCli to throw");
|
|
507
|
+
},
|
|
508
|
+
(e: unknown) => e,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
expect(isAuthError(err)).toBe(false);
|
|
512
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
513
|
+
expect((err as Error).message).toMatch(/monday rejected the board id/);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -31,6 +31,7 @@ import { Command } from "commander";
|
|
|
31
31
|
import chalk from "chalk";
|
|
32
32
|
import { ensureCognitoIdToken } from "../utils/cognito-session.js";
|
|
33
33
|
import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
34
|
+
import { AuthError } from "../utils/auth-error.js";
|
|
34
35
|
|
|
35
36
|
interface AdminConnection {
|
|
36
37
|
id: string;
|
|
@@ -50,12 +51,43 @@ interface GatewayMessage {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export class IntegrationsCliError extends Error {
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* True when the error is the caller's request/state/permission (a client 4xx
|
|
56
|
+
* or a local input/usage error) rather than an hq-cli defect. Expected errors
|
|
57
|
+
* are printed to the user but skipped for Sentry capture (HQ-CLI-6). Defaults
|
|
58
|
+
* to false so an unclassified error still reaches Sentry.
|
|
59
|
+
*/
|
|
60
|
+
readonly expected: boolean;
|
|
61
|
+
|
|
62
|
+
constructor(message: string, opts: { expected?: boolean } = {}) {
|
|
54
63
|
super(message);
|
|
55
64
|
this.name = "IntegrationsCliError";
|
|
65
|
+
this.expected = opts.expected ?? false;
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A client 4xx is the caller's request/state/permission (bad params, stale
|
|
71
|
+
* queueId, a non-owner approving) — expected and user-facing, not a bug. A 5xx
|
|
72
|
+
* (or a 2xx protocol violation) is a genuine server/unknown fault worth a Sentry
|
|
73
|
+
* crash report.
|
|
74
|
+
*/
|
|
75
|
+
function isClientError(status: number): boolean {
|
|
76
|
+
return status >= 400 && status < 500;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A 401 from ANY integration-gateway vault call means the caller's HQ session
|
|
80
|
+
// is expired or missing — an expected auth state fixed by `hq login`, not an
|
|
81
|
+
// hq-cli defect. Raise the same typed AuthError the vault company-resolution
|
|
82
|
+
// paths use (HQ-CLI-8) so the top-level handler prints one actionable message
|
|
83
|
+
// and skips Sentry, instead of surfacing the opaque, unactionable
|
|
84
|
+
// "Integration gateway request failed (HTTP 401)" that shipped as a fatal from
|
|
85
|
+
// `callGateway` (HQ-CLI-9). Non-401 statuses keep their existing behavior:
|
|
86
|
+
// other 4xx stay expected client errors, 5xx still report.
|
|
87
|
+
function raiseIfUnauthorized(res: Response): void {
|
|
88
|
+
if (res.status === 401) throw new AuthError();
|
|
89
|
+
}
|
|
90
|
+
|
|
59
91
|
/** "factory:linear" → "linear"; mirrors hq-pro's factoryToolPrefix. */
|
|
60
92
|
export function toolPrefixForProvider(provider: string): string {
|
|
61
93
|
return provider
|
|
@@ -75,9 +107,11 @@ export async function fetchConnections(
|
|
|
75
107
|
query: { companyUid },
|
|
76
108
|
});
|
|
77
109
|
if (!res.ok) {
|
|
110
|
+
raiseIfUnauthorized(res);
|
|
78
111
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
79
112
|
throw new IntegrationsCliError(
|
|
80
113
|
body.error ?? `Failed to list integrations (HTTP ${res.status})`,
|
|
114
|
+
{ expected: isClientError(res.status) },
|
|
81
115
|
);
|
|
82
116
|
}
|
|
83
117
|
const data = (await res.json()) as { connections?: AdminConnection[] };
|
|
@@ -99,6 +133,7 @@ export function selectConnection(
|
|
|
99
133
|
if (!match) {
|
|
100
134
|
throw new IntegrationsCliError(
|
|
101
135
|
`No connection '${opts.connection}'. Run \`hq integrations list\` to see connected apps.`,
|
|
136
|
+
{ expected: true },
|
|
102
137
|
);
|
|
103
138
|
}
|
|
104
139
|
return match;
|
|
@@ -116,6 +151,7 @@ export function selectConnection(
|
|
|
116
151
|
throw new IntegrationsCliError(
|
|
117
152
|
`No connected app matches '${opts.provider}'.` +
|
|
118
153
|
(available ? ` Connected: ${available}.` : " Nothing is connected yet — connect apps on the console Integrations page."),
|
|
154
|
+
{ expected: true },
|
|
119
155
|
);
|
|
120
156
|
}
|
|
121
157
|
return match;
|
|
@@ -124,11 +160,13 @@ export function selectConnection(
|
|
|
124
160
|
if (active.length === 0) {
|
|
125
161
|
throw new IntegrationsCliError(
|
|
126
162
|
"No connected apps yet. Connect one on the console Integrations page, then retry.",
|
|
163
|
+
{ expected: true },
|
|
127
164
|
);
|
|
128
165
|
}
|
|
129
166
|
throw new IntegrationsCliError(
|
|
130
167
|
`Multiple apps are connected — pick one with --provider:\n` +
|
|
131
168
|
active.map((c) => ` --provider ${c.provider.replace(/^factory:/, "")}`).join("\n"),
|
|
169
|
+
{ expected: true },
|
|
132
170
|
);
|
|
133
171
|
}
|
|
134
172
|
|
|
@@ -149,8 +187,10 @@ export async function callGateway(
|
|
|
149
187
|
});
|
|
150
188
|
const message = (await res.json().catch(() => null)) as GatewayMessage | null;
|
|
151
189
|
if (!res.ok || !message) {
|
|
190
|
+
raiseIfUnauthorized(res);
|
|
152
191
|
throw new IntegrationsCliError(
|
|
153
192
|
`Integration gateway request failed (HTTP ${res.status}).`,
|
|
193
|
+
{ expected: isClientError(res.status) },
|
|
154
194
|
);
|
|
155
195
|
}
|
|
156
196
|
if (message.error) {
|
|
@@ -331,6 +371,7 @@ export function registerIntegrationsCommand(program: Command): void {
|
|
|
331
371
|
} catch {
|
|
332
372
|
throw new IntegrationsCliError(
|
|
333
373
|
`--args must be a JSON object, e.g. --args '{"assignee":"me"}'`,
|
|
374
|
+
{ expected: true },
|
|
334
375
|
);
|
|
335
376
|
}
|
|
336
377
|
const token = await ensureCognitoIdToken();
|
|
@@ -416,8 +457,10 @@ export function registerIntegrationsCommand(program: Command): void {
|
|
|
416
457
|
error?: string;
|
|
417
458
|
};
|
|
418
459
|
if (!res.ok) {
|
|
460
|
+
raiseIfUnauthorized(res);
|
|
419
461
|
throw new IntegrationsCliError(
|
|
420
462
|
body.error ?? `${decision} failed (HTTP ${res.status})`,
|
|
463
|
+
{ expected: isClientError(res.status) },
|
|
421
464
|
);
|
|
422
465
|
}
|
|
423
466
|
if (opts.json) {
|
|
@@ -305,6 +305,9 @@ describe("hq outposts exec", () => {
|
|
|
305
305
|
|
|
306
306
|
it("wraps the command to run from the box's HQ folder, then runs it", () => {
|
|
307
307
|
const wrapped = withRemoteHqDir("pwd");
|
|
308
|
+
// SSM may invoke the command as root with HOME unset. Seed a real home so
|
|
309
|
+
// tools such as gh do not write machine state into the HQ checkout.
|
|
310
|
+
expect(wrapped).toContain('export HOME="${HOME:-/root}"');
|
|
308
311
|
// cd into $HOME/hq (SSH/login user) …
|
|
309
312
|
expect(wrapped).toContain('cd "$HOME/hq"');
|
|
310
313
|
// … falling back to ec2-user's home for the SSM/root path (no $HOME) …
|
|
@@ -531,6 +534,140 @@ describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
|
531
534
|
});
|
|
532
535
|
});
|
|
533
536
|
|
|
537
|
+
|
|
538
|
+
describe("hq outposts exec --async / --detach", () => {
|
|
539
|
+
afterEach(() => {
|
|
540
|
+
process.exitCode = undefined;
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("--detach submits with HQ-dir wrapper and prints commandId without waiting", async () => {
|
|
544
|
+
const stdoutSpy = vi
|
|
545
|
+
.spyOn(process.stdout, "write")
|
|
546
|
+
.mockImplementation(() => true);
|
|
547
|
+
fetchSpy.mockResolvedValueOnce(
|
|
548
|
+
jsonResponse(200, {
|
|
549
|
+
ok: true,
|
|
550
|
+
userId: "u",
|
|
551
|
+
outpostId: "3",
|
|
552
|
+
instanceId: "i-3",
|
|
553
|
+
commandId: "cmd-detach",
|
|
554
|
+
outputPrefix: "outpost-exec/u/3/n/out",
|
|
555
|
+
executionTimeoutSeconds: 172800,
|
|
556
|
+
}),
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
await run(["outposts", "exec", "--detach", "--id", "3", "--json", "sleep", "999"]);
|
|
560
|
+
|
|
561
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
562
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
563
|
+
expect(String(url)).toContain("/outpost/exec");
|
|
564
|
+
expect(String(url)).toContain("outpostId=3");
|
|
565
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
566
|
+
mode: "submit",
|
|
567
|
+
command: withRemoteHqDir("'sleep' '999'"),
|
|
568
|
+
});
|
|
569
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
570
|
+
expect(printed).toContain('"commandId":"cmd-detach"');
|
|
571
|
+
// No second poll.
|
|
572
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("--async submits then polls until done and streams stdout", async () => {
|
|
576
|
+
const stdoutSpy = vi
|
|
577
|
+
.spyOn(process.stdout, "write")
|
|
578
|
+
.mockImplementation(() => true);
|
|
579
|
+
fetchSpy
|
|
580
|
+
.mockResolvedValueOnce(
|
|
581
|
+
jsonResponse(200, {
|
|
582
|
+
ok: true,
|
|
583
|
+
userId: "u",
|
|
584
|
+
outpostId: "primary",
|
|
585
|
+
instanceId: "i",
|
|
586
|
+
commandId: "cmd-async",
|
|
587
|
+
outputPrefix: "p",
|
|
588
|
+
executionTimeoutSeconds: 7200,
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
.mockResolvedValueOnce(
|
|
592
|
+
jsonResponse(200, {
|
|
593
|
+
ok: true,
|
|
594
|
+
userId: "u",
|
|
595
|
+
outpostId: "primary",
|
|
596
|
+
status: "Success",
|
|
597
|
+
done: true,
|
|
598
|
+
exitCode: 0,
|
|
599
|
+
stdout: "finished\n",
|
|
600
|
+
stderr: "",
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
await run([
|
|
605
|
+
"outposts",
|
|
606
|
+
"exec",
|
|
607
|
+
"--async",
|
|
608
|
+
"--timeout-seconds",
|
|
609
|
+
"7200",
|
|
610
|
+
"echo",
|
|
611
|
+
"finished",
|
|
612
|
+
]);
|
|
613
|
+
|
|
614
|
+
// submit + one result poll (terminal on first poll)
|
|
615
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
616
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
617
|
+
mode: "submit",
|
|
618
|
+
command: withRemoteHqDir("'echo' 'finished'"),
|
|
619
|
+
timeoutSeconds: 7200,
|
|
620
|
+
});
|
|
621
|
+
expect(JSON.parse(fetchSpy.mock.calls[1][1]?.body as string)).toEqual({
|
|
622
|
+
mode: "result",
|
|
623
|
+
commandId: "cmd-async",
|
|
624
|
+
});
|
|
625
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
626
|
+
expect(printed).toContain("finished\n");
|
|
627
|
+
expect(process.exitCode).toBe(0);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("refuses --async and --detach together", async () => {
|
|
631
|
+
await expect(
|
|
632
|
+
run(["outposts", "exec", "--async", "--detach", "true"]),
|
|
633
|
+
).rejects.toThrow("process.exit(1)");
|
|
634
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("exec-submit forwards --timeout-seconds", async () => {
|
|
638
|
+
const stdoutSpy = vi
|
|
639
|
+
.spyOn(process.stdout, "write")
|
|
640
|
+
.mockImplementation(() => true);
|
|
641
|
+
fetchSpy.mockResolvedValueOnce(
|
|
642
|
+
jsonResponse(200, {
|
|
643
|
+
ok: true,
|
|
644
|
+
commandId: "cmd-sub",
|
|
645
|
+
executionTimeoutSeconds: 3600,
|
|
646
|
+
userId: "u",
|
|
647
|
+
outpostId: "primary",
|
|
648
|
+
instanceId: "i",
|
|
649
|
+
outputPrefix: "p",
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
await run([
|
|
653
|
+
"outposts",
|
|
654
|
+
"exec-submit",
|
|
655
|
+
"--timeout-seconds",
|
|
656
|
+
"3600",
|
|
657
|
+
"--json",
|
|
658
|
+
"sleep",
|
|
659
|
+
"10",
|
|
660
|
+
]);
|
|
661
|
+
expect(JSON.parse(fetchSpy.mock.calls[0][1]?.body as string)).toEqual({
|
|
662
|
+
mode: "submit",
|
|
663
|
+
command: "'sleep' '10'",
|
|
664
|
+
timeoutSeconds: 3600,
|
|
665
|
+
});
|
|
666
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
667
|
+
expect(printed).toContain("cmd-sub");
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
|
|
534
671
|
describe("hq outposts asynchronous exec", () => {
|
|
535
672
|
it("sends the stage, submit, and result mode request bodies", async () => {
|
|
536
673
|
fetchSpy
|