@indigoai-us/hq-cli 5.65.0 → 5.66.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/outposts.d.ts +29 -0
- package/dist/commands/outposts.js +124 -20
- package/package.json +1 -1
- package/src/commands/outposts.test.ts +120 -0
- package/src/commands/outposts.ts +149 -17
|
@@ -94,5 +94,34 @@ export interface OutpostExecResult {
|
|
|
94
94
|
* `step` distinguishes not-ready / platform-unsupported / timeout / ssm.
|
|
95
95
|
*/
|
|
96
96
|
export declare function execOutpost(token: string, command: string, outpostId?: string): Promise<OutpostExecResult>;
|
|
97
|
+
/** SSH connection details vended by `POST /outpost/ssh-access`. */
|
|
98
|
+
export interface OutpostSshAccess {
|
|
99
|
+
outpostId: string;
|
|
100
|
+
platform: "ec2" | "lightsail";
|
|
101
|
+
host: string;
|
|
102
|
+
port: number;
|
|
103
|
+
username: string;
|
|
104
|
+
/** PEM private key for the box. SENSITIVE — never printed or logged. */
|
|
105
|
+
privateKey: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
109
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
110
|
+
* `OutpostHttpError` on a non-2xx.
|
|
111
|
+
*/
|
|
112
|
+
export declare function getOutpostSshAccess(token: string, outpostId?: string): Promise<OutpostSshAccess>;
|
|
113
|
+
/**
|
|
114
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
115
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
116
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
117
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
118
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function execViaSsh(access: OutpostSshAccess, command: string): {
|
|
121
|
+
stdout: string;
|
|
122
|
+
stderr: string;
|
|
123
|
+
exitCode: number | null;
|
|
124
|
+
error?: string;
|
|
125
|
+
};
|
|
97
126
|
export declare function registerOutpostsCommand(program: Command): void;
|
|
98
127
|
//# sourceMappingURL=outposts.d.ts.map
|
|
@@ -21,8 +21,13 @@
|
|
|
21
21
|
* and status routes that exist. Renaming an Outpost is not a backend capability.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
24
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="951a0bc6-7dc3-5b10-b8f6-728df1074817")}catch(e){}}();
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
26
31
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
27
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
28
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -133,6 +138,72 @@ export async function execOutpost(token, command, outpostId) {
|
|
|
133
138
|
query: outpostId ? { outpostId } : undefined,
|
|
134
139
|
});
|
|
135
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
143
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
144
|
+
* `OutpostHttpError` on a non-2xx.
|
|
145
|
+
*/
|
|
146
|
+
export async function getOutpostSshAccess(token, outpostId) {
|
|
147
|
+
return outpostRequest({
|
|
148
|
+
token,
|
|
149
|
+
path: "/outpost/ssh-access",
|
|
150
|
+
method: "POST",
|
|
151
|
+
body: {},
|
|
152
|
+
query: outpostId ? { outpostId } : undefined,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
157
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
158
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
159
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
160
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
161
|
+
*/
|
|
162
|
+
export function execViaSsh(access, command) {
|
|
163
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
|
|
164
|
+
const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
|
|
165
|
+
const knownHosts = path.join(dir, "known_hosts");
|
|
166
|
+
try {
|
|
167
|
+
fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
const result = spawnSync("ssh", [
|
|
171
|
+
"-i",
|
|
172
|
+
keyPath,
|
|
173
|
+
"-p",
|
|
174
|
+
String(access.port),
|
|
175
|
+
"-o",
|
|
176
|
+
"BatchMode=yes",
|
|
177
|
+
"-o",
|
|
178
|
+
"StrictHostKeyChecking=accept-new",
|
|
179
|
+
"-o",
|
|
180
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
181
|
+
"-o",
|
|
182
|
+
"ConnectTimeout=15",
|
|
183
|
+
"-o",
|
|
184
|
+
"LogLevel=ERROR",
|
|
185
|
+
`${access.username}@${access.host}`,
|
|
186
|
+
command,
|
|
187
|
+
], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
|
|
188
|
+
if (result.error) {
|
|
189
|
+
const hint = result.error.code === "ENOENT"
|
|
190
|
+
? "the `ssh` client is not installed or not on PATH"
|
|
191
|
+
: result.error.message;
|
|
192
|
+
return { stdout: "", stderr: "", exitCode: null, error: hint };
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
stdout: result.stdout ?? "",
|
|
196
|
+
stderr: result.stderr ?? "",
|
|
197
|
+
exitCode: result.status,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function ensureTrailingNewline(s) {
|
|
205
|
+
return s.endsWith("\n") ? s : s + "\n";
|
|
206
|
+
}
|
|
136
207
|
// ---------------------------------------------------------------------------
|
|
137
208
|
// Command registration
|
|
138
209
|
// ---------------------------------------------------------------------------
|
|
@@ -294,27 +365,60 @@ export function registerOutpostsCommand(program) {
|
|
|
294
365
|
process.exit(1);
|
|
295
366
|
}
|
|
296
367
|
const token = await ensureCognitoToken();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
368
|
+
try {
|
|
369
|
+
const result = await execOutpost(token, command, opts.id);
|
|
370
|
+
if (opts.json) {
|
|
371
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Stream the remote streams to ours so the command feels local.
|
|
375
|
+
if (result.stdout)
|
|
376
|
+
process.stdout.write(result.stdout);
|
|
377
|
+
if (result.stderr)
|
|
378
|
+
process.stderr.write(result.stderr);
|
|
379
|
+
if (result.truncated) {
|
|
380
|
+
console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
|
|
381
|
+
}
|
|
382
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
383
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
384
|
+
}
|
|
310
385
|
}
|
|
311
|
-
|
|
312
|
-
|
|
386
|
+
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
387
|
+
process.exitCode =
|
|
388
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
// Lightsail boxes have no SSM agent — transparently fall back to SSH.
|
|
392
|
+
if (err instanceof OutpostHttpError &&
|
|
393
|
+
err.step === "platform-unsupported") {
|
|
394
|
+
const access = await getOutpostSshAccess(token, opts.id);
|
|
395
|
+
const ssh = execViaSsh(access, command);
|
|
396
|
+
if (ssh.error) {
|
|
397
|
+
console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
if (opts.json) {
|
|
401
|
+
process.stdout.write(JSON.stringify({
|
|
402
|
+
via: "ssh",
|
|
403
|
+
platform: access.platform,
|
|
404
|
+
host: access.host,
|
|
405
|
+
exitCode: ssh.exitCode,
|
|
406
|
+
stdout: ssh.stdout,
|
|
407
|
+
stderr: ssh.stderr,
|
|
408
|
+
}, null, 2) + "\n");
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
if (ssh.stdout)
|
|
412
|
+
process.stdout.write(ssh.stdout);
|
|
413
|
+
if (ssh.stderr)
|
|
414
|
+
process.stderr.write(ssh.stderr);
|
|
415
|
+
}
|
|
416
|
+
process.exitCode =
|
|
417
|
+
typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
|
|
418
|
+
return;
|
|
313
419
|
}
|
|
420
|
+
throw err;
|
|
314
421
|
}
|
|
315
|
-
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
316
|
-
process.exitCode =
|
|
317
|
-
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
318
422
|
}
|
|
319
423
|
catch (err) {
|
|
320
424
|
fail(err);
|
|
@@ -392,4 +496,4 @@ export function registerOutpostsCommand(program) {
|
|
|
392
496
|
});
|
|
393
497
|
}
|
|
394
498
|
//# sourceMappingURL=outposts.js.map
|
|
395
|
-
//# debugId=
|
|
499
|
+
//# debugId=951a0bc6-7dc3-5b10-b8f6-728df1074817
|
package/package.json
CHANGED
|
@@ -42,9 +42,20 @@ vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
|
42
42
|
};
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
// The SSH fallback shells out to `ssh` via spawnSync — stub it so tests don't
|
|
46
|
+
// touch a real box or need the ssh binary.
|
|
47
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
48
|
+
const original =
|
|
49
|
+
await importOriginal<typeof import("node:child_process")>();
|
|
50
|
+
return { ...original, spawnSync: vi.fn() };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
import { spawnSync } from "node:child_process";
|
|
45
54
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
46
55
|
import { registerOutpostsCommand } from "./outposts.js";
|
|
47
56
|
|
|
57
|
+
const mockSpawnSync = vi.mocked(spawnSync);
|
|
58
|
+
|
|
48
59
|
function jsonResponse(status: number, body: unknown): Response {
|
|
49
60
|
return new Response(JSON.stringify(body), {
|
|
50
61
|
status,
|
|
@@ -354,3 +365,112 @@ describe("hq outposts exec", () => {
|
|
|
354
365
|
expect(printed).toContain('"commandId": "cmd-3"');
|
|
355
366
|
});
|
|
356
367
|
});
|
|
368
|
+
|
|
369
|
+
describe("hq outposts exec — Lightsail SSH fallback", () => {
|
|
370
|
+
afterEach(() => {
|
|
371
|
+
process.exitCode = undefined;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
function platformUnsupported(): Response {
|
|
375
|
+
return jsonResponse(409, {
|
|
376
|
+
error: true,
|
|
377
|
+
step: "platform-unsupported",
|
|
378
|
+
message: "remote exec requires the EC2 platform",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sshAccess(): Response {
|
|
383
|
+
return jsonResponse(200, {
|
|
384
|
+
ok: true,
|
|
385
|
+
outpostId: "primary",
|
|
386
|
+
platform: "lightsail",
|
|
387
|
+
host: "52.2.2.2",
|
|
388
|
+
port: 22,
|
|
389
|
+
username: "ec2-user",
|
|
390
|
+
privateKey: "-----BEGIN KEY-----\nx\n-----END KEY-----\n",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
it("falls back to SSH when the box is Lightsail, streams stdout, propagates exit", async () => {
|
|
395
|
+
const stdoutSpy = vi
|
|
396
|
+
.spyOn(process.stdout, "write")
|
|
397
|
+
.mockImplementation(() => true);
|
|
398
|
+
fetchSpy
|
|
399
|
+
.mockResolvedValueOnce(platformUnsupported()) // /outpost/exec (SSM) → 409
|
|
400
|
+
.mockResolvedValueOnce(sshAccess()); // /outpost/ssh-access → 200
|
|
401
|
+
mockSpawnSync.mockReturnValue({
|
|
402
|
+
status: 0,
|
|
403
|
+
stdout: "lightsail-out\n",
|
|
404
|
+
stderr: "",
|
|
405
|
+
signal: null,
|
|
406
|
+
output: [],
|
|
407
|
+
pid: 1,
|
|
408
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
409
|
+
|
|
410
|
+
// Flags meant for the remote command go after `--` (same as the SSM path).
|
|
411
|
+
await run(["outposts", "exec", "--", "uname", "-a"]);
|
|
412
|
+
|
|
413
|
+
// Second HTTP call is the ssh-access mint.
|
|
414
|
+
expect(String(fetchSpy.mock.calls[1][0])).toContain("/outpost/ssh-access");
|
|
415
|
+
// ssh was invoked with the box coordinates and the command as the last arg.
|
|
416
|
+
const args = mockSpawnSync.mock.calls[0][1] as string[];
|
|
417
|
+
expect(mockSpawnSync.mock.calls[0][0]).toBe("ssh");
|
|
418
|
+
expect(args).toContain("ec2-user@52.2.2.2");
|
|
419
|
+
expect(args[args.length - 1]).toBe("uname -a");
|
|
420
|
+
expect(args).toContain("BatchMode=yes");
|
|
421
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
422
|
+
expect(printed).toContain("lightsail-out\n");
|
|
423
|
+
expect(process.exitCode).toBe(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("propagates a non-zero SSH exit code", async () => {
|
|
427
|
+
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
428
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
429
|
+
fetchSpy
|
|
430
|
+
.mockResolvedValueOnce(platformUnsupported())
|
|
431
|
+
.mockResolvedValueOnce(sshAccess());
|
|
432
|
+
mockSpawnSync.mockReturnValue({
|
|
433
|
+
status: 7,
|
|
434
|
+
stdout: "",
|
|
435
|
+
stderr: "boom\n",
|
|
436
|
+
signal: null,
|
|
437
|
+
output: [],
|
|
438
|
+
pid: 1,
|
|
439
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
440
|
+
await run(["outposts", "exec", "false"]);
|
|
441
|
+
expect(process.exitCode).toBe(7);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("exits 1 with a clear message when the ssh client is missing", async () => {
|
|
445
|
+
fetchSpy
|
|
446
|
+
.mockResolvedValueOnce(platformUnsupported())
|
|
447
|
+
.mockResolvedValueOnce(sshAccess());
|
|
448
|
+
const enoent = Object.assign(new Error("spawnSync ssh ENOENT"), {
|
|
449
|
+
code: "ENOENT",
|
|
450
|
+
});
|
|
451
|
+
mockSpawnSync.mockReturnValue({
|
|
452
|
+
error: enoent,
|
|
453
|
+
status: null,
|
|
454
|
+
stdout: "",
|
|
455
|
+
stderr: "",
|
|
456
|
+
signal: null,
|
|
457
|
+
output: [],
|
|
458
|
+
pid: 0,
|
|
459
|
+
} as unknown as ReturnType<typeof spawnSync>);
|
|
460
|
+
await expect(run(["outposts", "exec", "echo", "hi"])).rejects.toThrow(
|
|
461
|
+
"process.exit(1)",
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("does NOT fall back for a non-platform error (e.g. not-ready)", async () => {
|
|
466
|
+
fetchSpy.mockResolvedValueOnce(
|
|
467
|
+
jsonResponse(409, { error: true, step: "not-ready", message: "not ready" }),
|
|
468
|
+
);
|
|
469
|
+
await expect(
|
|
470
|
+
run(["outposts", "exec", "echo", "hi"]),
|
|
471
|
+
).rejects.toThrow("process.exit(1)");
|
|
472
|
+
// Only the SSM call happened; no ssh-access mint, no ssh.
|
|
473
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
474
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
475
|
+
});
|
|
476
|
+
});
|
package/src/commands/outposts.ts
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
import { Command } from "commander";
|
|
25
25
|
import chalk from "chalk";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
26
31
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
27
32
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
28
33
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -222,6 +227,96 @@ export async function execOutpost(
|
|
|
222
227
|
});
|
|
223
228
|
}
|
|
224
229
|
|
|
230
|
+
/** SSH connection details vended by `POST /outpost/ssh-access`. */
|
|
231
|
+
export interface OutpostSshAccess {
|
|
232
|
+
outpostId: string;
|
|
233
|
+
platform: "ec2" | "lightsail";
|
|
234
|
+
host: string;
|
|
235
|
+
port: number;
|
|
236
|
+
username: string;
|
|
237
|
+
/** PEM private key for the box. SENSITIVE — never printed or logged. */
|
|
238
|
+
privateKey: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Fetch SSH connection info + key for the caller's Outpost and open the caller's
|
|
243
|
+
* IP on the box's SSH port. Used to reach a Lightsail box (no SSM). Throws
|
|
244
|
+
* `OutpostHttpError` on a non-2xx.
|
|
245
|
+
*/
|
|
246
|
+
export async function getOutpostSshAccess(
|
|
247
|
+
token: string,
|
|
248
|
+
outpostId?: string,
|
|
249
|
+
): Promise<OutpostSshAccess> {
|
|
250
|
+
return outpostRequest({
|
|
251
|
+
token,
|
|
252
|
+
path: "/outpost/ssh-access",
|
|
253
|
+
method: "POST",
|
|
254
|
+
body: {},
|
|
255
|
+
query: outpostId ? { outpostId } : undefined,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run `command` on the box over SSH using vended access details. Writes the
|
|
261
|
+
* private key to a locked-down temp file, runs a non-interactive `ssh`, and
|
|
262
|
+
* returns stdout/stderr/exitCode. The key file + a throwaway known_hosts file
|
|
263
|
+
* are always cleaned up; the key is never printed. `exitCode` is `null` only
|
|
264
|
+
* when `ssh` itself couldn't run (e.g. binary missing) — surfaced via `error`.
|
|
265
|
+
*/
|
|
266
|
+
export function execViaSsh(
|
|
267
|
+
access: OutpostSshAccess,
|
|
268
|
+
command: string,
|
|
269
|
+
): { stdout: string; stderr: string; exitCode: number | null; error?: string } {
|
|
270
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-outpost-ssh-"));
|
|
271
|
+
const keyPath = path.join(dir, `id_${randomBytes(6).toString("hex")}`);
|
|
272
|
+
const knownHosts = path.join(dir, "known_hosts");
|
|
273
|
+
try {
|
|
274
|
+
fs.writeFileSync(keyPath, ensureTrailingNewline(access.privateKey), {
|
|
275
|
+
mode: 0o600,
|
|
276
|
+
});
|
|
277
|
+
const result = spawnSync(
|
|
278
|
+
"ssh",
|
|
279
|
+
[
|
|
280
|
+
"-i",
|
|
281
|
+
keyPath,
|
|
282
|
+
"-p",
|
|
283
|
+
String(access.port),
|
|
284
|
+
"-o",
|
|
285
|
+
"BatchMode=yes",
|
|
286
|
+
"-o",
|
|
287
|
+
"StrictHostKeyChecking=accept-new",
|
|
288
|
+
"-o",
|
|
289
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
290
|
+
"-o",
|
|
291
|
+
"ConnectTimeout=15",
|
|
292
|
+
"-o",
|
|
293
|
+
"LogLevel=ERROR",
|
|
294
|
+
`${access.username}@${access.host}`,
|
|
295
|
+
command,
|
|
296
|
+
],
|
|
297
|
+
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
|
298
|
+
);
|
|
299
|
+
if (result.error) {
|
|
300
|
+
const hint =
|
|
301
|
+
(result.error as NodeJS.ErrnoException).code === "ENOENT"
|
|
302
|
+
? "the `ssh` client is not installed or not on PATH"
|
|
303
|
+
: result.error.message;
|
|
304
|
+
return { stdout: "", stderr: "", exitCode: null, error: hint };
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
stdout: result.stdout ?? "",
|
|
308
|
+
stderr: result.stderr ?? "",
|
|
309
|
+
exitCode: result.status,
|
|
310
|
+
};
|
|
311
|
+
} finally {
|
|
312
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function ensureTrailingNewline(s: string): string {
|
|
317
|
+
return s.endsWith("\n") ? s : s + "\n";
|
|
318
|
+
}
|
|
319
|
+
|
|
225
320
|
// ---------------------------------------------------------------------------
|
|
226
321
|
// Command registration
|
|
227
322
|
// ---------------------------------------------------------------------------
|
|
@@ -423,27 +518,64 @@ export function registerOutpostsCommand(program: Command): void {
|
|
|
423
518
|
process.exit(1);
|
|
424
519
|
}
|
|
425
520
|
const token = await ensureCognitoToken();
|
|
426
|
-
const result = await execOutpost(token, command, opts.id);
|
|
427
521
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
522
|
+
try {
|
|
523
|
+
const result = await execOutpost(token, command, opts.id);
|
|
524
|
+
if (opts.json) {
|
|
525
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
526
|
+
} else {
|
|
527
|
+
// Stream the remote streams to ours so the command feels local.
|
|
528
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
529
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
530
|
+
if (result.truncated) {
|
|
531
|
+
console.error(
|
|
532
|
+
chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
536
|
+
console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
|
|
537
|
+
}
|
|
439
538
|
}
|
|
440
|
-
|
|
441
|
-
|
|
539
|
+
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
540
|
+
process.exitCode =
|
|
541
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
// Lightsail boxes have no SSM agent — transparently fall back to SSH.
|
|
544
|
+
if (
|
|
545
|
+
err instanceof OutpostHttpError &&
|
|
546
|
+
err.step === "platform-unsupported"
|
|
547
|
+
) {
|
|
548
|
+
const access = await getOutpostSshAccess(token, opts.id);
|
|
549
|
+
const ssh = execViaSsh(access, command);
|
|
550
|
+
if (ssh.error) {
|
|
551
|
+
console.error(chalk.red(`Could not run the command over SSH: ${ssh.error}`));
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
if (opts.json) {
|
|
555
|
+
process.stdout.write(
|
|
556
|
+
JSON.stringify(
|
|
557
|
+
{
|
|
558
|
+
via: "ssh",
|
|
559
|
+
platform: access.platform,
|
|
560
|
+
host: access.host,
|
|
561
|
+
exitCode: ssh.exitCode,
|
|
562
|
+
stdout: ssh.stdout,
|
|
563
|
+
stderr: ssh.stderr,
|
|
564
|
+
},
|
|
565
|
+
null,
|
|
566
|
+
2,
|
|
567
|
+
) + "\n",
|
|
568
|
+
);
|
|
569
|
+
} else {
|
|
570
|
+
if (ssh.stdout) process.stdout.write(ssh.stdout);
|
|
571
|
+
if (ssh.stderr) process.stderr.write(ssh.stderr);
|
|
572
|
+
}
|
|
573
|
+
process.exitCode =
|
|
574
|
+
typeof ssh.exitCode === "number" ? ssh.exitCode : 1;
|
|
575
|
+
return;
|
|
442
576
|
}
|
|
577
|
+
throw err;
|
|
443
578
|
}
|
|
444
|
-
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
445
|
-
process.exitCode =
|
|
446
|
-
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
447
579
|
} catch (err) {
|
|
448
580
|
fail(err);
|
|
449
581
|
}
|