@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.
@@ -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]="067e20d6-0132-565a-8a22-9c27520669d2")}catch(e){}}();
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
- const result = await execOutpost(token, command, opts.id);
298
- if (opts.json) {
299
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
300
- }
301
- else {
302
- // Stream the remote streams to ours so the command feels local, then
303
- // exit with the remote exit code.
304
- if (result.stdout)
305
- process.stdout.write(result.stdout);
306
- if (result.stderr)
307
- process.stderr.write(result.stderr);
308
- if (result.truncated) {
309
- console.error(chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"));
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
- if (result.status !== "Success" && result.exitCode === null) {
312
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
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=067e20d6-0132-565a-8a22-9c27520669d2
499
+ //# debugId=951a0bc6-7dc3-5b10-b8f6-728df1074817
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.65.0",
3
+ "version": "5.66.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
+ });
@@ -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
- if (opts.json) {
429
- process.stdout.write(JSON.stringify(result, null, 2) + "\n");
430
- } else {
431
- // Stream the remote streams to ours so the command feels local, then
432
- // exit with the remote exit code.
433
- if (result.stdout) process.stdout.write(result.stdout);
434
- if (result.stderr) process.stderr.write(result.stderr);
435
- if (result.truncated) {
436
- console.error(
437
- chalk.yellow("(output truncated by SSM's inline limit — redirect to a file on the box for full output)"),
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
- if (result.status !== "Success" && result.exitCode === null) {
441
- console.error(chalk.yellow(`(command ended with SSM status: ${result.status})`));
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
  }