@indigoai-us/hq-cli 5.68.2 → 5.70.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/CHANGELOG.md +30 -0
- package/dist/commands/channels.js +6 -3
- package/dist/commands/dm.d.ts +76 -0
- package/dist/commands/dm.js +317 -3
- package/dist/commands/outposts.d.ts +16 -1
- package/dist/commands/outposts.js +230 -3
- package/dist/main.js +6 -2
- package/dist/utils/cli-telemetry.d.ts +6 -0
- package/dist/utils/cli-telemetry.js +60 -0
- package/dist/utils/cognito-session.d.ts +2 -1
- package/dist/utils/cognito-session.js +5 -2
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +3 -2
- package/package.json +1 -1
- package/src/commands/channels.ts +4 -1
- package/src/commands/dm.test.ts +265 -0
- package/src/commands/dm.ts +449 -1
- package/src/commands/outposts-self-deploy.test.ts +243 -0
- package/src/commands/outposts.ts +346 -1
- package/src/main.ts +5 -0
- package/src/utils/cli-telemetry.test.ts +153 -0
- package/src/utils/cli-telemetry.ts +61 -0
- package/src/utils/cognito-session.ts +4 -0
- package/src/utils/vault-api.test.ts +18 -0
- package/src/utils/vault-api.ts +2 -0
package/src/commands/outposts.ts
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Each case does vi.resetModules() + a dynamic import() to reset the module-scoped
|
|
4
|
+
// once-per-process memoization; the cold transform/import is slow, so raise the
|
|
5
|
+
// per-test timeout well above the default 5s.
|
|
6
|
+
vi.setConfig({ testTimeout: 30_000 });
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
browserLogin: vi.fn(),
|
|
10
|
+
isExpiring: vi.fn(),
|
|
11
|
+
isMachineIdentity: vi.fn(),
|
|
12
|
+
loadCachedTokens: vi.fn(),
|
|
13
|
+
refreshTokens: vi.fn(),
|
|
14
|
+
vaultApiFetch: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
|
|
19
|
+
return { ...actual, ...mocks };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock("./vault-api.js", () => ({
|
|
23
|
+
vaultApiFetch: mocks.vaultApiFetch,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../cli-version.js", () => ({
|
|
27
|
+
CLI_NAME: "@indigoai-us/hq-cli",
|
|
28
|
+
CLI_VERSION: "5.68.1",
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const HUMAN_TOKENS = {
|
|
32
|
+
accessToken: "human-access-token",
|
|
33
|
+
idToken: "human-id-token",
|
|
34
|
+
refreshToken: "human-refresh-token",
|
|
35
|
+
expiresAt: Date.now() + 3_600_000,
|
|
36
|
+
tokenType: "Bearer" as const,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const MACHINE_TOKENS = {
|
|
40
|
+
...HUMAN_TOKENS,
|
|
41
|
+
accessToken: "machine-access-token",
|
|
42
|
+
idToken: "machine-id-token",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function loadEmitter() {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
return import("./cli-telemetry.js");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.resetAllMocks();
|
|
52
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
53
|
+
mocks.isExpiring.mockReturnValue(false);
|
|
54
|
+
mocks.vaultApiFetch.mockResolvedValue(new Response(null, { status: 202 }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("emitCliSessionStarted", () => {
|
|
62
|
+
it("posts the privacy-safe event with a valid cached human token", async () => {
|
|
63
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
64
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
65
|
+
|
|
66
|
+
await emitCliSessionStarted();
|
|
67
|
+
|
|
68
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
69
|
+
const request = mocks.vaultApiFetch.mock.calls[0]?.[0];
|
|
70
|
+
expect(request).toMatchObject({
|
|
71
|
+
token: HUMAN_TOKENS.accessToken,
|
|
72
|
+
path: "/v1/telemetry/events",
|
|
73
|
+
method: "POST",
|
|
74
|
+
signal: expect.any(AbortSignal),
|
|
75
|
+
});
|
|
76
|
+
expect(request.body.events).toHaveLength(1);
|
|
77
|
+
expect(request.body.events[0]).toEqual({
|
|
78
|
+
eventName: "cli_session_started",
|
|
79
|
+
app: "hq-cli",
|
|
80
|
+
source: "cli",
|
|
81
|
+
occurredAt: expect.any(String),
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
properties: { version: "5.68.1" },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses the cached ID token for a machine identity", async () => {
|
|
88
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
89
|
+
mocks.loadCachedTokens.mockReturnValue(MACHINE_TOKENS);
|
|
90
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
91
|
+
|
|
92
|
+
await emitCliSessionStarted();
|
|
93
|
+
|
|
94
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledWith(
|
|
95
|
+
expect.objectContaining({ token: MACHINE_TOKENS.idToken }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
["is absent", null, false],
|
|
101
|
+
["is expiring", HUMAN_TOKENS, true],
|
|
102
|
+
])(
|
|
103
|
+
"does not emit or refresh/login when the cached token %s",
|
|
104
|
+
async (_description, cachedTokens, expiring) => {
|
|
105
|
+
mocks.loadCachedTokens.mockReturnValue(cachedTokens);
|
|
106
|
+
mocks.isExpiring.mockReturnValue(expiring);
|
|
107
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
108
|
+
|
|
109
|
+
await emitCliSessionStarted();
|
|
110
|
+
|
|
111
|
+
expect(mocks.vaultApiFetch).not.toHaveBeenCalled();
|
|
112
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
113
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
it("swallows a non-2xx telemetry response", async () => {
|
|
118
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
119
|
+
mocks.vaultApiFetch.mockResolvedValue(new Response(null, { status: 503 }));
|
|
120
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
121
|
+
|
|
122
|
+
await expect(emitCliSessionStarted()).resolves.toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("aborts a slow telemetry request without failing the command", async () => {
|
|
126
|
+
vi.useFakeTimers();
|
|
127
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
128
|
+
mocks.vaultApiFetch.mockImplementation(
|
|
129
|
+
({ signal }: { signal: AbortSignal }) =>
|
|
130
|
+
new Promise((_resolve, reject) => {
|
|
131
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), {
|
|
132
|
+
once: true,
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
137
|
+
|
|
138
|
+
const emission = emitCliSessionStarted();
|
|
139
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
140
|
+
|
|
141
|
+
await expect(emission).resolves.toBeUndefined();
|
|
142
|
+
expect(mocks.vaultApiFetch.mock.calls[0]?.[0].signal.aborted).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("posts at most once when the root hook is invoked repeatedly", async () => {
|
|
146
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
147
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
148
|
+
|
|
149
|
+
await Promise.all([emitCliSessionStarted(), emitCliSessionStarted()]);
|
|
150
|
+
|
|
151
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
2
|
+
import {
|
|
3
|
+
isExpiring,
|
|
4
|
+
isMachineIdentity,
|
|
5
|
+
loadCachedTokens,
|
|
6
|
+
} from "./cognito-session.js";
|
|
7
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
8
|
+
|
|
9
|
+
const TELEMETRY_TIMEOUT_MS = 1_200;
|
|
10
|
+
|
|
11
|
+
let cliSessionStartedPromise: Promise<void> | undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Emit the authenticated CLI session signal using an already-cached token.
|
|
15
|
+
* This deliberately never refreshes a session or opens a browser.
|
|
16
|
+
*/
|
|
17
|
+
export function emitCliSessionStarted(): Promise<void> {
|
|
18
|
+
cliSessionStartedPromise ??= emitCachedCliSessionStarted();
|
|
19
|
+
return cliSessionStartedPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function emitCachedCliSessionStarted(): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const cached = loadCachedTokens();
|
|
25
|
+
if (!cached || isExpiring(cached, 120)) return;
|
|
26
|
+
|
|
27
|
+
const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
|
|
28
|
+
if (!token) return;
|
|
29
|
+
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const response = await vaultApiFetch({
|
|
35
|
+
token,
|
|
36
|
+
path: "/v1/telemetry/events",
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: {
|
|
39
|
+
events: [
|
|
40
|
+
{
|
|
41
|
+
eventName: "cli_session_started",
|
|
42
|
+
app: "hq-cli",
|
|
43
|
+
source: "cli",
|
|
44
|
+
occurredAt: new Date().toISOString(),
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
properties: { version: CLI_VERSION },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) return;
|
|
53
|
+
} catch {
|
|
54
|
+
// Telemetry is best effort and must never affect a CLI command.
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Cached-token reads are also best effort (for example, a malformed cache).
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
} from "@indigoai-us/hq-cloud";
|
|
39
39
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
40
40
|
|
|
41
|
+
// Re-export the cached-session primitives for callers that must deliberately
|
|
42
|
+
// avoid the refresh/login behavior in the higher-level helpers below.
|
|
43
|
+
export { isExpiring, isMachineIdentity, loadCachedTokens };
|
|
44
|
+
|
|
41
45
|
export const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
42
46
|
region: process.env.AWS_REGION ?? "us-east-1",
|
|
43
47
|
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-prod",
|
|
@@ -229,3 +229,21 @@ describe('vaultApiFetch breadcrumb URL sanitization', () => {
|
|
|
229
229
|
expect(errorCrumb.data?.url).toContain('?<redacted>');
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('vaultApiFetch abort signals', () => {
|
|
234
|
+
it('passes an optional abort signal to fetch', async () => {
|
|
235
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
|
|
238
|
+
await vaultApiFetch({
|
|
239
|
+
token: 'tok',
|
|
240
|
+
path: '/v1/telemetry/events',
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
245
|
+
expect.any(String),
|
|
246
|
+
expect.objectContaining({ signal: controller.signal }),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface VaultApiOptions {
|
|
|
7
7
|
method?: string;
|
|
8
8
|
body?: Record<string, unknown>;
|
|
9
9
|
query?: Record<string, string>;
|
|
10
|
+
signal?: AbortSignal;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
@@ -31,6 +32,7 @@ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
|
31
32
|
'Content-Type': 'application/json',
|
|
32
33
|
},
|
|
33
34
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
35
|
+
signal: opts.signal,
|
|
34
36
|
});
|
|
35
37
|
if (!response.ok) {
|
|
36
38
|
Sentry.addBreadcrumb({
|