@indigoai-us/hq-cli 5.68.1 → 5.69.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.
@@ -0,0 +1,243 @@
1
+ import { Command } from "commander";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ it,
8
+ vi,
9
+ type MockInstance,
10
+ } from "vitest";
11
+ import {
12
+ registerOutpostsCommand,
13
+ type SelfDeployDependencies,
14
+ } from "./outposts.js";
15
+
16
+ type SpawnCall = {
17
+ command: string;
18
+ args: string[];
19
+ options: Parameters<SelfDeployDependencies["spawnSync"]>[2];
20
+ };
21
+
22
+ const AL2023 = 'ID="amzn"\nVERSION_ID="2023"\n';
23
+ const SECRET = "refresh-token-that-must-never-be-printed";
24
+
25
+ function successfulSpawn(stdout = "") {
26
+ return {
27
+ status: 0,
28
+ stdout,
29
+ stderr: "",
30
+ error: undefined,
31
+ } as ReturnType<SelfDeployDependencies["spawnSync"]>;
32
+ }
33
+
34
+ function selfDeployDependencies(input: {
35
+ osRelease?: string;
36
+ uname?: string;
37
+ systemd?: boolean;
38
+ sudo?: boolean;
39
+ session?: { refreshToken?: string; idToken?: string } | undefined;
40
+ stdinTty?: boolean;
41
+ uid?: number;
42
+ } = {}): { deps: Partial<SelfDeployDependencies>; calls: SpawnCall[] } {
43
+ const calls: SpawnCall[] = [];
44
+ const deps: Partial<SelfDeployDependencies> = {
45
+ readTextFile: () => input.osRelease ?? AL2023,
46
+ loadCachedTokens: () => input.session ?? { refreshToken: SECRET },
47
+ getUid: () => input.uid ?? 1000,
48
+ isStdinTty: () => input.stdinTty ?? true,
49
+ confirm: async () => true,
50
+ defaultHqRoot: () => "/home/ec2-user/hq",
51
+ invokingUser: () => "ec2-user",
52
+ spawnSync: ((command, args, options) => {
53
+ calls.push({ command, args, options });
54
+ if (command === "uname") return successfulSpawn(input.uname ?? "x86_64\n");
55
+ if (command === "systemctl" && args[0] === "--version") {
56
+ return input.systemd === false
57
+ ? { ...successfulSpawn(), status: 1 }
58
+ : successfulSpawn("systemd 252\n");
59
+ }
60
+ if (command === "sudo" && args[0] === "-n") {
61
+ return input.sudo === false ? { ...successfulSpawn(), status: 1 } : successfulSpawn();
62
+ }
63
+ return successfulSpawn();
64
+ }) as SelfDeployDependencies["spawnSync"],
65
+ };
66
+ return { deps, calls };
67
+ }
68
+
69
+ function buildProgram(deps: Partial<SelfDeployDependencies>): Command {
70
+ const program = new Command();
71
+ program.name("hq").exitOverride();
72
+ registerOutpostsCommand(program, deps);
73
+ return program;
74
+ }
75
+
76
+ async function run(
77
+ deps: Partial<SelfDeployDependencies>,
78
+ args: string[],
79
+ ): Promise<void> {
80
+ await buildProgram(deps).parseAsync(["node", "hq", ...args]);
81
+ }
82
+
83
+ let exitSpy: MockInstance<typeof process.exit>;
84
+ let logSpy: MockInstance<typeof console.log>;
85
+ let errorSpy: MockInstance<typeof console.error>;
86
+ let fetchSpy: MockInstance<typeof fetch>;
87
+
88
+ beforeEach(() => {
89
+ exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number) => {
90
+ throw new Error(`process.exit(${code})`);
91
+ }) as unknown as MockInstance<typeof process.exit>;
92
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
93
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
94
+ fetchSpy = vi.spyOn(globalThis, "fetch");
95
+ });
96
+
97
+ afterEach(() => {
98
+ vi.restoreAllMocks();
99
+ });
100
+
101
+ function printed(): string {
102
+ return [
103
+ ...logSpy.mock.calls.map((call) => call.map(String).join(" ")),
104
+ ...errorSpy.mock.calls.map((call) => call.map(String).join(" ")),
105
+ ].join("\n");
106
+ }
107
+
108
+ function installedCommands(calls: SpawnCall[]): SpawnCall[] {
109
+ return calls.filter(
110
+ (call) =>
111
+ call.command === "hq" ||
112
+ (call.command === "sudo" &&
113
+ ["tee", "chmod", "systemctl"].includes(call.args[0] ?? "")),
114
+ );
115
+ }
116
+
117
+ describe("hq outposts self-deploy", () => {
118
+ it("is hidden from outposts help but remains invokable", async () => {
119
+ const { deps, calls } = selfDeployDependencies();
120
+ const program = buildProgram(deps);
121
+ const outposts = program.commands.find((command) => command.name() === "outposts");
122
+
123
+ expect(outposts?.helpInformation()).not.toContain("self-deploy");
124
+
125
+ await run(deps, ["outposts", "self-deploy", "--yes"]);
126
+ expect(calls).toContainEqual(
127
+ expect.objectContaining({
128
+ command: "hq",
129
+ args: ["rescue", "--hq-root", "/home/ec2-user/hq", "--yes"],
130
+ }),
131
+ );
132
+ });
133
+
134
+ it("rejects non-Amazon Linux 2023 before installing anything", async () => {
135
+ const { deps, calls } = selfDeployDependencies({
136
+ osRelease: 'ID="ubuntu"\nVERSION_ID="24.04"\n',
137
+ });
138
+
139
+ await expect(run(deps, ["outposts", "self-deploy", "--yes"])).rejects.toThrow(
140
+ "process.exit(1)",
141
+ );
142
+ expect(printed()).toContain("Amazon Linux 2023 x86_64");
143
+ expect(installedCommands(calls)).toEqual([]);
144
+ });
145
+
146
+ it("rejects a host without systemd before installing anything", async () => {
147
+ const { deps, calls } = selfDeployDependencies({ systemd: false });
148
+
149
+ await expect(run(deps, ["outposts", "self-deploy", "--yes"])).rejects.toThrow(
150
+ "process.exit(1)",
151
+ );
152
+ expect(printed()).toContain("systemd is required");
153
+ expect(installedCommands(calls)).toEqual([]);
154
+ });
155
+
156
+ it("requires an existing hq login before installing anything", async () => {
157
+ const { deps, calls } = selfDeployDependencies({ session: undefined });
158
+ deps.loadCachedTokens = () => undefined;
159
+
160
+ await expect(run(deps, ["outposts", "self-deploy", "--yes"])).rejects.toThrow(
161
+ "process.exit(1)",
162
+ );
163
+ expect(printed()).toContain("hq login");
164
+ expect(installedCommands(calls)).toEqual([]);
165
+ });
166
+
167
+ it("aborts without a TTY or --yes before running install commands", async () => {
168
+ const idToken = `header.${Buffer.from(JSON.stringify({ email: "person@example.com" })).toString("base64url")}.signature`;
169
+ const { deps, calls } = selfDeployDependencies({
170
+ stdinTty: false,
171
+ session: { refreshToken: SECRET, idToken },
172
+ });
173
+
174
+ await expect(run(deps, ["outposts", "self-deploy"])).rejects.toThrow(
175
+ "process.exit(1)",
176
+ );
177
+ expect(printed()).toContain("HQ identity: person@example.com");
178
+ expect(printed()).toContain("SELF-HOSTED HQ outpost");
179
+ expect(printed()).toMatch(/pass --yes/i);
180
+ expect(installedCommands(calls)).toEqual([]);
181
+ });
182
+
183
+ it("installs the all-company watch sync service locally with --yes", async () => {
184
+ const { deps, calls } = selfDeployDependencies();
185
+
186
+ await run(deps, ["outposts", "self-deploy", "--yes"]);
187
+
188
+ expect(calls).toContainEqual(
189
+ expect.objectContaining({
190
+ command: "hq",
191
+ args: ["rescue", "--hq-root", "/home/ec2-user/hq", "--yes"],
192
+ }),
193
+ );
194
+ const serviceWrite = calls.find(
195
+ (call) =>
196
+ call.command === "sudo" &&
197
+ call.args[0] === "tee" &&
198
+ call.args[1] === "/etc/systemd/system/outpost-sync.service",
199
+ );
200
+ const serviceContents = String(
201
+ (serviceWrite?.options as { input?: unknown } | undefined)?.input,
202
+ );
203
+ expect(serviceContents).toContain("User=ec2-user");
204
+ expect(serviceContents).toContain("WorkingDirectory=\"/home/ec2-user/hq\"");
205
+ expect(serviceContents).toContain("ExecStart=/usr/local/bin/outpost-sync.sh");
206
+ expect(serviceContents).toContain("Restart=always");
207
+
208
+ const scriptWrite = calls.find(
209
+ (call) =>
210
+ call.command === "sudo" &&
211
+ call.args[0] === "tee" &&
212
+ call.args[1] === "/usr/local/bin/outpost-sync.sh",
213
+ );
214
+ const scriptContents = String(
215
+ (scriptWrite?.options as { input?: unknown } | undefined)?.input,
216
+ );
217
+ expect(scriptContents).toContain("hq auth refresh");
218
+ expect(scriptContents).toContain("hq-sync-runner");
219
+ expect(scriptContents).toContain("--companies");
220
+ expect(scriptContents).toContain("--watch");
221
+ expect(scriptContents).toContain("--event-push");
222
+ expect(scriptContents).toContain("--poll-remote-ms 60000");
223
+ expect(calls).toContainEqual(
224
+ expect.objectContaining({
225
+ command: "sudo",
226
+ args: ["systemctl", "enable", "--now", "outpost-sync.service"],
227
+ }),
228
+ );
229
+ });
230
+
231
+ it("never exposes session secrets or makes hq-pro requests", async () => {
232
+ const { deps, calls } = selfDeployDependencies({
233
+ session: { refreshToken: SECRET },
234
+ });
235
+
236
+ await run(deps, ["outposts", "self-deploy", "--yes"]);
237
+
238
+ expect(JSON.stringify(calls)).not.toContain(SECRET);
239
+ expect(printed()).not.toContain(SECRET);
240
+ expect(fetchSpy).not.toHaveBeenCalled();
241
+ expect(exitSpy).not.toHaveBeenCalled();
242
+ });
243
+ });
@@ -27,6 +27,7 @@ import { spawnSync } from "node:child_process";
27
27
  import * as fs from "node:fs";
28
28
  import * as os from "node:os";
29
29
  import * as path from "node:path";
30
+ import * as readline from "node:readline";
30
31
  import { randomBytes } from "node:crypto";
31
32
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
32
33
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -360,11 +361,355 @@ function printKeyValues(obj: Record<string, unknown>): void {
360
361
  }
361
362
  }
362
363
 
363
- export function registerOutpostsCommand(program: Command): void {
364
+ // ---------------------------------------------------------------------------
365
+ // Hidden local self-deploy command
366
+ // ---------------------------------------------------------------------------
367
+
368
+ /** The small local-environment surface used by `outposts self-deploy`. */
369
+ export interface SelfDeployDependencies {
370
+ spawnSync: (
371
+ command: string,
372
+ args: string[],
373
+ options?: Parameters<typeof spawnSync>[2],
374
+ ) => ReturnType<typeof spawnSync>;
375
+ readTextFile: (file: string) => string;
376
+ loadCachedTokens: () =>
377
+ | { refreshToken?: string; idToken?: string }
378
+ | undefined;
379
+ getUid: () => number | undefined;
380
+ isStdinTty: () => boolean;
381
+ confirm: () => Promise<boolean>;
382
+ defaultHqRoot: () => string;
383
+ invokingUser: () => string;
384
+ }
385
+
386
+ const defaultSelfDeployDependencies: SelfDeployDependencies = {
387
+ spawnSync: (command, args, options) => spawnSync(command, args, options),
388
+ readTextFile: (file) => fs.readFileSync(file, "utf8"),
389
+ loadCachedTokens: () => loadCachedTokens() ?? undefined,
390
+ getUid: () => process.getuid?.(),
391
+ isStdinTty: () => process.stdin.isTTY === true,
392
+ confirm: async () => {
393
+ const rl = readline.createInterface({
394
+ input: process.stdin,
395
+ output: process.stdout,
396
+ });
397
+ return new Promise((resolve) => {
398
+ rl.question("", (answer) => {
399
+ rl.close();
400
+ resolve(/^y(es)?$/i.test(answer.trim()));
401
+ });
402
+ });
403
+ },
404
+ defaultHqRoot: () => process.env.HQ_ROOT ?? path.join(os.homedir(), "hq"),
405
+ invokingUser: () =>
406
+ process.env.SUDO_USER ?? process.env.USER ?? os.userInfo().username,
407
+ };
408
+
409
+ function selfDeployError(message: string): Error {
410
+ return new Error(`Self-deploy preflight failed: ${message}`);
411
+ }
412
+
413
+ function commandSucceeded(
414
+ deps: SelfDeployDependencies,
415
+ command: string,
416
+ args: string[],
417
+ ): boolean {
418
+ try {
419
+ const result = deps.spawnSync(command, args, { encoding: "utf8" });
420
+ return !!result && !result.error && result.status === 0;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ function commandOutput(
427
+ deps: SelfDeployDependencies,
428
+ command: string,
429
+ args: string[],
430
+ ): string | undefined {
431
+ try {
432
+ const result = deps.spawnSync(command, args, { encoding: "utf8" });
433
+ if (!result || result.error || result.status !== 0) return undefined;
434
+ return String(result.stdout ?? "").trim();
435
+ } catch {
436
+ return undefined;
437
+ }
438
+ }
439
+
440
+ function parseOsRelease(source: string): Map<string, string> {
441
+ const values = new Map<string, string>();
442
+ for (const line of source.split("\n")) {
443
+ const match = /^([A-Z_]+)=(.*)$/.exec(line);
444
+ if (!match) continue;
445
+ const [, key, rawValue] = match;
446
+ values.set(key, rawValue.replace(/^['"]|['"]$/g, ""));
447
+ }
448
+ return values;
449
+ }
450
+
451
+ function hqIdentityFromSession(session: {
452
+ refreshToken?: string;
453
+ idToken?: string;
454
+ }): string {
455
+ if (!session.idToken) return "your cached HQ session";
456
+ try {
457
+ const payload = session.idToken.split(".")[1];
458
+ if (!payload) return "your cached HQ session";
459
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<
460
+ string,
461
+ unknown
462
+ >;
463
+ for (const key of [
464
+ "email",
465
+ "preferred_username",
466
+ "cognito:username",
467
+ "username",
468
+ "sub",
469
+ ]) {
470
+ const value = claims[key];
471
+ if (typeof value === "string" && value) return value;
472
+ }
473
+ } catch {
474
+ // A cache that has a refresh token remains valid for this local setup. The
475
+ // identity banner is informational, so never expose a token parse failure.
476
+ }
477
+ return "your cached HQ session";
478
+ }
479
+
480
+ function bashSingleQuote(value: string): string {
481
+ return `'${value.replace(/'/g, "'\\''")}'`;
482
+ }
483
+
484
+ function systemdQuoted(value: string): string {
485
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
486
+ }
487
+
488
+ function renderSelfDeploySyncScript(hqRoot: string): string {
489
+ return `#!/usr/bin/env bash
490
+ set -u
491
+ # Persistent all-membership sync for a locally self-hosted HQ outpost. The
492
+ # watch runner event-pushes local changes and polls remote changes every minute.
493
+ HQ_ROOT=${bashSingleQuote(hqRoot)}
494
+ cd "$HQ_ROOT"
495
+ while true; do
496
+ hq auth refresh || echo "[outpost-sync] auth refresh failed, continuing"
497
+ npx -y --package=@indigoai-us/hq-cloud@latest hq-sync-runner \\
498
+ --companies \\
499
+ --direction both \\
500
+ --on-conflict keep \\
501
+ --hq-root "$HQ_ROOT" \\
502
+ --watch \\
503
+ --event-push \\
504
+ --poll-remote-ms 60000
505
+ echo "[outpost-sync] watch runner exited; restarting in 10 seconds"
506
+ sleep 10
507
+ done
508
+ `;
509
+ }
510
+
511
+ function renderSelfDeployService(hqRoot: string, user: string): string {
512
+ return `[Unit]
513
+ After=network-online.target
514
+ Wants=network-online.target
515
+
516
+ [Service]
517
+ User=${user}
518
+ WorkingDirectory=${systemdQuoted(hqRoot)}
519
+ ExecStart=/usr/local/bin/outpost-sync.sh
520
+ Restart=always
521
+ RestartSec=10s
522
+
523
+ [Install]
524
+ WantedBy=multi-user.target
525
+ `;
526
+ }
527
+
528
+ function runChecked(
529
+ deps: SelfDeployDependencies,
530
+ command: string,
531
+ args: string[],
532
+ description: string,
533
+ options?: Parameters<typeof spawnSync>[2],
534
+ ): void {
535
+ let result: ReturnType<typeof spawnSync>;
536
+ try {
537
+ result = deps.spawnSync(command, args, options);
538
+ } catch {
539
+ throw new Error(`${description} could not be started.`);
540
+ }
541
+ if (!result || result.error || result.status !== 0) {
542
+ // Do not include child process output here: even though this command never
543
+ // passes credentials to children, it keeps the error path secret-safe.
544
+ throw new Error(`${description} failed. Resolve the error and try again.`);
545
+ }
546
+ }
547
+
548
+ function runPrivileged(
549
+ deps: SelfDeployDependencies,
550
+ command: string,
551
+ args: string[],
552
+ description: string,
553
+ options?: Parameters<typeof spawnSync>[2],
554
+ ): void {
555
+ if (deps.getUid() === 0) {
556
+ runChecked(deps, command, args, description, options);
557
+ return;
558
+ }
559
+ runChecked(deps, "sudo", [command, ...args], description, options);
560
+ }
561
+
562
+ function selfDeployPreflight(
563
+ deps: SelfDeployDependencies,
564
+ ): { refreshToken?: string; idToken?: string } {
565
+ let osRelease: string;
566
+ try {
567
+ osRelease = deps.readTextFile("/etc/os-release");
568
+ } catch {
569
+ throw selfDeployError(
570
+ "could not read /etc/os-release. This command only supports Amazon Linux 2023 x86_64.",
571
+ );
572
+ }
573
+ const release = parseOsRelease(osRelease);
574
+ if (
575
+ release.get("ID") !== "amzn" ||
576
+ !release.get("VERSION_ID")?.startsWith("2023")
577
+ ) {
578
+ throw selfDeployError(
579
+ "this command only supports Amazon Linux 2023 x86_64. Use an Amazon Linux 2023 EC2 instance.",
580
+ );
581
+ }
582
+
583
+ const architecture = commandOutput(deps, "uname", ["-m"]);
584
+ if (architecture !== "x86_64") {
585
+ throw selfDeployError(
586
+ "this command only supports x86_64 EC2 instances. Use an Amazon Linux 2023 x86_64 instance.",
587
+ );
588
+ }
589
+
590
+ if (!commandSucceeded(deps, "systemctl", ["--version"])) {
591
+ throw selfDeployError(
592
+ "systemd is required but `systemctl --version` failed. Run this on an Amazon Linux 2023 EC2 host with systemd.",
593
+ );
594
+ }
595
+
596
+ if (deps.getUid() !== 0 && !commandSucceeded(deps, "sudo", ["-n", "true"])) {
597
+ throw selfDeployError(
598
+ "root or passwordless sudo is required. Configure passwordless sudo or run this command as root.",
599
+ );
600
+ }
601
+
602
+ const session = deps.loadCachedTokens();
603
+ if (!session?.refreshToken) {
604
+ throw selfDeployError("no HQ login session was found. Run `hq login`, then re-run this command.");
605
+ }
606
+ return session;
607
+ }
608
+
609
+ async function selfDeployOutpost(
610
+ opts: { yes?: boolean; hqRoot?: string },
611
+ deps: SelfDeployDependencies,
612
+ ): Promise<void> {
613
+ const session = selfDeployPreflight(deps);
614
+ const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
615
+ const user = deps.invokingUser();
616
+
617
+ if (!opts.yes) {
618
+ console.log(`HQ identity: ${hqIdentityFromSession(session)}`);
619
+ console.log(
620
+ chalk.yellow(
621
+ "This machine will run as a SELF-HOSTED HQ outpost under YOUR identity, continuously syncing ALL your company vaults. " +
622
+ "It is NOT registered with or managed by hq-pro (no console entry, no remote management, no metering). " +
623
+ "Anyone with root on this box can act as you. Continue? [y/N]",
624
+ ),
625
+ );
626
+ if (!deps.isStdinTty()) {
627
+ throw new Error("Confirmation requires a TTY. Pass --yes to continue non-interactively.");
628
+ }
629
+ if (!(await deps.confirm())) {
630
+ throw new Error("Self-deploy cancelled.");
631
+ }
632
+ }
633
+
634
+ runChecked(
635
+ deps,
636
+ "hq",
637
+ ["rescue", "--hq-root", hqRoot, "--yes"],
638
+ "HQ kernel rescue",
639
+ { stdio: "inherit" },
640
+ );
641
+
642
+ runPrivileged(
643
+ deps,
644
+ "tee",
645
+ ["/usr/local/bin/outpost-sync.sh"],
646
+ "Writing /usr/local/bin/outpost-sync.sh",
647
+ {
648
+ encoding: "utf8",
649
+ input: renderSelfDeploySyncScript(hqRoot),
650
+ stdio: ["pipe", "ignore", "pipe"],
651
+ },
652
+ );
653
+ runPrivileged(
654
+ deps,
655
+ "chmod",
656
+ ["+x", "/usr/local/bin/outpost-sync.sh"],
657
+ "Making /usr/local/bin/outpost-sync.sh executable",
658
+ );
659
+ runPrivileged(
660
+ deps,
661
+ "tee",
662
+ ["/etc/systemd/system/outpost-sync.service"],
663
+ "Writing /etc/systemd/system/outpost-sync.service",
664
+ {
665
+ encoding: "utf8",
666
+ input: renderSelfDeployService(hqRoot, user),
667
+ stdio: ["pipe", "ignore", "pipe"],
668
+ },
669
+ );
670
+ runPrivileged(
671
+ deps,
672
+ "systemctl",
673
+ ["daemon-reload"],
674
+ "Reloading systemd",
675
+ );
676
+ runPrivileged(
677
+ deps,
678
+ "systemctl",
679
+ ["enable", "--now", "outpost-sync.service"],
680
+ "Enabling outpost-sync.service",
681
+ );
682
+
683
+ console.log(chalk.green("This box is now a self-hosted HQ outpost and will sync all your company vaults continuously."));
684
+ console.log(chalk.dim("Check it with: systemctl status outpost-sync.service"));
685
+ console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
686
+ }
687
+
688
+ export function registerOutpostsCommand(
689
+ program: Command,
690
+ selfDeployOverrides: Partial<SelfDeployDependencies> = {},
691
+ ): void {
692
+ const selfDeployDependencies: SelfDeployDependencies = {
693
+ ...defaultSelfDeployDependencies,
694
+ ...selfDeployOverrides,
695
+ };
364
696
  const outposts = program
365
697
  .command("outposts")
366
698
  .description("Manage your personal HQ Outposts (EC2 boxes)");
367
699
 
700
+ outposts
701
+ .command("self-deploy", { hidden: true })
702
+ .description("Configure this EC2 host as a locally self-hosted HQ outpost")
703
+ .option("--yes", "Skip the self-hosting confirmation")
704
+ .option("--hq-root <path>", "HQ root to sync", selfDeployDependencies.defaultHqRoot())
705
+ .action(async (opts: { yes?: boolean; hqRoot?: string }) => {
706
+ try {
707
+ await selfDeployOutpost(opts, selfDeployDependencies);
708
+ } catch (err) {
709
+ fail(err);
710
+ }
711
+ });
712
+
368
713
  outposts
369
714
  .command("provision")
370
715
  .alias("create")
package/src/main.ts CHANGED
@@ -71,6 +71,7 @@ import {
71
71
  shouldSkipGate,
72
72
  } from "./utils/version-gate.js";
73
73
  import { CLI_VERSION } from "./cli-version.js";
74
+ import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
74
75
 
75
76
  // Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
76
77
  // the pipe early. This covers the ASYNC path — an 'error' event emitted on the
@@ -264,6 +265,10 @@ registerOutpostsCommand(program);
264
265
  // provisioning gate for agents & Outposts.
265
266
  registerBillingCommand(program);
266
267
 
268
+ program.hook("preAction", async () => {
269
+ await emitCliSessionStarted();
270
+ });
271
+
267
272
  export async function runCli(): Promise<void> {
268
273
  try {
269
274
  Sentry.addBreadcrumb({