@indigoai-us/hq-cli 5.75.0 → 5.77.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/agents.js +8 -3
- package/dist/commands/files.d.ts +61 -0
- package/dist/commands/files.js +274 -0
- package/dist/commands/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +20 -4
- package/dist/commands/outposts.js +79 -10
- package/dist/commands/pack-install.d.ts +14 -17
- package/dist/commands/pack-install.js +53 -29
- package/dist/commands/pkg-install.js +3 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +9 -3
- package/dist/commands/secrets.js +189 -87
- package/dist/run/hq-plugin.js +94 -31
- package/dist/utils/billing-gate.d.ts +15 -0
- package/dist/utils/billing-gate.js +35 -0
- package/dist/utils/sandbox-runner-client.d.ts +1 -0
- package/dist/utils/sandbox-runner-client.js +1 -0
- package/dist/utils/secrets-cache.d.ts +4 -5
- package/dist/utils/secrets-cache.js +5 -8
- package/package.json +3 -2
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +41 -0
- package/src/commands/agents.ts +7 -3
- package/src/commands/files-recovery.test.ts +361 -0
- package/src/commands/files.ts +410 -0
- package/src/commands/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +155 -24
- package/src/commands/outposts.ts +199 -45
- package/src/commands/pack-install-secret-authorization.test.ts +115 -0
- package/src/commands/pack-install.test.ts +5 -1
- package/src/commands/pack-install.ts +67 -29
- package/src/commands/pkg-install.ts +3 -1
- package/src/commands/run.test.ts +45 -0
- package/src/commands/run.ts +20 -4
- package/src/commands/secrets.test.ts +366 -25
- package/src/commands/secrets.ts +222 -96
- package/src/run/hq-plugin.test.ts +186 -10
- package/src/run/hq-plugin.ts +102 -32
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +23 -0
- package/src/utils/billing-gate.ts +46 -0
- package/src/utils/pack-contributions.test.ts +90 -31
- package/src/utils/sandbox-runner-client.test.ts +28 -0
- package/src/utils/sandbox-runner-client.ts +2 -0
- package/src/utils/secrets-cache.ts +5 -8
- package/test/commands/signals.test.ts +2 -2
- package/test/commands/sources.test.ts +2 -2
- package/test/helpers/vault-service-mock.ts +76 -17
- package/test/sources-signals/smoke.test.ts +2 -2
package/src/commands/outposts.ts
CHANGED
|
@@ -37,27 +37,55 @@ import {
|
|
|
37
37
|
OUTPOST_PRICE_CENTS,
|
|
38
38
|
confirmChargeOrExit,
|
|
39
39
|
parseBillingPayload,
|
|
40
|
-
|
|
40
|
+
surfaceBillingBlocked,
|
|
41
41
|
type BillingErrorPayload,
|
|
42
42
|
} from "../utils/billing-gate.js";
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* hq-pro's per-person cap envelope on a `409` provision block. Unlike every
|
|
46
|
+
* other `/outpost/*` failure this body carries NO `message`/`error` field —
|
|
47
|
+
* only the cap facts — so it has to be decoded structurally or the reason
|
|
48
|
+
* degrades to a bare `res.statusText` ("Conflict").
|
|
49
|
+
*/
|
|
50
|
+
export interface OutpostCappedPayload {
|
|
51
|
+
limit: number;
|
|
52
|
+
outposts: OutpostSummary[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
|
|
56
|
+
export function parseCappedPayload(
|
|
57
|
+
body: unknown,
|
|
58
|
+
): OutpostCappedPayload | undefined {
|
|
59
|
+
if (!body || typeof body !== "object") return undefined;
|
|
60
|
+
const b = body as { capped?: unknown; limit?: unknown; outposts?: unknown };
|
|
61
|
+
if (b.capped !== true) return undefined;
|
|
62
|
+
return {
|
|
63
|
+
limit: typeof b.limit === "number" ? b.limit : 0,
|
|
64
|
+
outposts: Array.isArray(b.outposts) ? (b.outposts as OutpostSummary[]) : [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
/** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
|
|
45
69
|
export class OutpostHttpError extends Error {
|
|
46
70
|
status: number;
|
|
47
71
|
step?: string;
|
|
48
72
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
49
73
|
billing?: BillingErrorPayload;
|
|
74
|
+
/** hq-pro's cap envelope on a `409` provision block. */
|
|
75
|
+
capped?: OutpostCappedPayload;
|
|
50
76
|
constructor(
|
|
51
77
|
status: number,
|
|
52
78
|
message: string,
|
|
53
79
|
step?: string,
|
|
54
80
|
billing?: BillingErrorPayload,
|
|
81
|
+
capped?: OutpostCappedPayload,
|
|
55
82
|
) {
|
|
56
83
|
super(message);
|
|
57
84
|
this.name = "OutpostHttpError";
|
|
58
85
|
this.status = status;
|
|
59
86
|
this.step = step;
|
|
60
87
|
this.billing = billing;
|
|
88
|
+
this.capped = capped;
|
|
61
89
|
}
|
|
62
90
|
}
|
|
63
91
|
|
|
@@ -105,6 +133,7 @@ export async function outpostRequest<T>(opts: {
|
|
|
105
133
|
message,
|
|
106
134
|
body.step,
|
|
107
135
|
parseBillingPayload(body),
|
|
136
|
+
parseCappedPayload(body),
|
|
108
137
|
);
|
|
109
138
|
}
|
|
110
139
|
return (await res.json()) as T;
|
|
@@ -113,9 +142,11 @@ export async function outpostRequest<T>(opts: {
|
|
|
113
142
|
/**
|
|
114
143
|
* Provision the caller's Outpost. Sends the cached Cognito refresh token so the
|
|
115
144
|
* box can authenticate AS the caller (the same body the console's
|
|
116
|
-
* `provisionMyOutpost` sends).
|
|
117
|
-
* per-person cap gets
|
|
118
|
-
*
|
|
145
|
+
* `provisionMyOutpost` sends). No duplicate is ever created: a caller already at
|
|
146
|
+
* their per-person cap gets a `409` whose body lists their existing boxes —
|
|
147
|
+
* thrown here as an `OutpostHttpError` carrying `capped` (hq-pro checks the cap
|
|
148
|
+
* BEFORE activation billing, so a capped call is never charged). The refresh
|
|
149
|
+
* token is sent over HTTPS and NEVER printed.
|
|
119
150
|
*/
|
|
120
151
|
export async function provisionOutpost(
|
|
121
152
|
token: string,
|
|
@@ -480,6 +511,31 @@ function ensureTrailingNewline(s: string): string {
|
|
|
480
511
|
// Command registration
|
|
481
512
|
// ---------------------------------------------------------------------------
|
|
482
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Explain a `409` per-person cap. hq-pro checks the cap BEFORE activation
|
|
516
|
+
* billing, so nothing was charged — worth saying, since the caller just
|
|
517
|
+
* confirmed a recurring charge to get here.
|
|
518
|
+
*/
|
|
519
|
+
function surfaceOutpostCapped(capped: OutpostCappedPayload): void {
|
|
520
|
+
const owned = capped.outposts.length;
|
|
521
|
+
console.error(
|
|
522
|
+
chalk.yellow(
|
|
523
|
+
`You're already at your Outpost limit (${owned} of ${capped.limit}). ` +
|
|
524
|
+
`No new box was provisioned and you have not been charged.`,
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
for (const o of capped.outposts) {
|
|
528
|
+
const detail = [o.state, o.instanceName, o.region]
|
|
529
|
+
.filter(Boolean)
|
|
530
|
+
.join(" ");
|
|
531
|
+
console.error(` ${o.outpostId} ${detail}`);
|
|
532
|
+
}
|
|
533
|
+
console.error(chalk.dim("Inspect it: hq outposts status"));
|
|
534
|
+
console.error(
|
|
535
|
+
chalk.dim("Or tear it down first: hq outposts destroy --id <id> --yes"),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
483
539
|
function fail(err: unknown): never {
|
|
484
540
|
if (err instanceof OutpostHttpError) {
|
|
485
541
|
console.error(chalk.red(err.message));
|
|
@@ -513,8 +569,7 @@ export interface SelfDeployDependencies {
|
|
|
513
569
|
) => ReturnType<typeof spawnSync>;
|
|
514
570
|
readTextFile: (file: string) => string;
|
|
515
571
|
loadCachedTokens: () =>
|
|
516
|
-
|
|
517
|
-
| undefined;
|
|
572
|
+
{ refreshToken?: string; idToken?: string } | undefined;
|
|
518
573
|
getUid: () => number | undefined;
|
|
519
574
|
isStdinTty: () => boolean;
|
|
520
575
|
confirm: () => Promise<boolean>;
|
|
@@ -595,10 +650,9 @@ function hqIdentityFromSession(session: {
|
|
|
595
650
|
try {
|
|
596
651
|
const payload = session.idToken.split(".")[1];
|
|
597
652
|
if (!payload) return "your cached HQ session";
|
|
598
|
-
const claims = JSON.parse(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
>;
|
|
653
|
+
const claims = JSON.parse(
|
|
654
|
+
Buffer.from(payload, "base64url").toString("utf8"),
|
|
655
|
+
) as Record<string, unknown>;
|
|
602
656
|
for (const key of [
|
|
603
657
|
"email",
|
|
604
658
|
"preferred_username",
|
|
@@ -698,9 +752,10 @@ function runPrivileged(
|
|
|
698
752
|
runChecked(deps, "sudo", [command, ...args], description, options);
|
|
699
753
|
}
|
|
700
754
|
|
|
701
|
-
function selfDeployPreflight(
|
|
702
|
-
|
|
703
|
-
|
|
755
|
+
function selfDeployPreflight(deps: SelfDeployDependencies): {
|
|
756
|
+
refreshToken?: string;
|
|
757
|
+
idToken?: string;
|
|
758
|
+
} {
|
|
704
759
|
let osRelease: string;
|
|
705
760
|
try {
|
|
706
761
|
osRelease = deps.readTextFile("/etc/os-release");
|
|
@@ -740,7 +795,9 @@ function selfDeployPreflight(
|
|
|
740
795
|
|
|
741
796
|
const session = deps.loadCachedTokens();
|
|
742
797
|
if (!session?.refreshToken) {
|
|
743
|
-
throw selfDeployError(
|
|
798
|
+
throw selfDeployError(
|
|
799
|
+
"no HQ login session was found. Run `hq login`, then re-run this command.",
|
|
800
|
+
);
|
|
744
801
|
}
|
|
745
802
|
return session;
|
|
746
803
|
}
|
|
@@ -763,7 +820,9 @@ async function selfDeployOutpost(
|
|
|
763
820
|
),
|
|
764
821
|
);
|
|
765
822
|
if (!deps.isStdinTty()) {
|
|
766
|
-
throw new Error(
|
|
823
|
+
throw new Error(
|
|
824
|
+
"Confirmation requires a TTY. Pass --yes to continue non-interactively.",
|
|
825
|
+
);
|
|
767
826
|
}
|
|
768
827
|
if (!(await deps.confirm())) {
|
|
769
828
|
throw new Error("Self-deploy cancelled.");
|
|
@@ -806,12 +865,7 @@ async function selfDeployOutpost(
|
|
|
806
865
|
stdio: ["pipe", "ignore", "pipe"],
|
|
807
866
|
},
|
|
808
867
|
);
|
|
809
|
-
runPrivileged(
|
|
810
|
-
deps,
|
|
811
|
-
"systemctl",
|
|
812
|
-
["daemon-reload"],
|
|
813
|
-
"Reloading systemd",
|
|
814
|
-
);
|
|
868
|
+
runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
|
|
815
869
|
runPrivileged(
|
|
816
870
|
deps,
|
|
817
871
|
"systemctl",
|
|
@@ -819,9 +873,19 @@ async function selfDeployOutpost(
|
|
|
819
873
|
"Enabling outpost-sync.service",
|
|
820
874
|
);
|
|
821
875
|
|
|
822
|
-
console.log(
|
|
823
|
-
|
|
824
|
-
|
|
876
|
+
console.log(
|
|
877
|
+
chalk.green(
|
|
878
|
+
"This box is now a self-hosted HQ outpost and will sync all your company vaults continuously.",
|
|
879
|
+
),
|
|
880
|
+
);
|
|
881
|
+
console.log(
|
|
882
|
+
chalk.dim("Check it with: systemctl status outpost-sync.service"),
|
|
883
|
+
);
|
|
884
|
+
console.log(
|
|
885
|
+
chalk.dim(
|
|
886
|
+
"It is unregistered and unmanaged by hq-pro (no console entry or remote management).",
|
|
887
|
+
),
|
|
888
|
+
);
|
|
825
889
|
}
|
|
826
890
|
|
|
827
891
|
// ---------------------------------------------------------------------------
|
|
@@ -940,7 +1004,9 @@ function runBestEffort(
|
|
|
940
1004
|
return false;
|
|
941
1005
|
}
|
|
942
1006
|
if (!result || result.error || result.status !== 0) {
|
|
943
|
-
console.warn(
|
|
1007
|
+
console.warn(
|
|
1008
|
+
`replica-sync: ${description} did not complete cleanly — continuing.`,
|
|
1009
|
+
);
|
|
944
1010
|
return false;
|
|
945
1011
|
}
|
|
946
1012
|
return true;
|
|
@@ -961,7 +1027,17 @@ function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
|
|
|
961
1027
|
const authed = runBestEffort(
|
|
962
1028
|
deps,
|
|
963
1029
|
"hq",
|
|
964
|
-
[
|
|
1030
|
+
[
|
|
1031
|
+
"secrets",
|
|
1032
|
+
"--personal",
|
|
1033
|
+
"exec",
|
|
1034
|
+
"--only",
|
|
1035
|
+
"GITHUB_TOKEN",
|
|
1036
|
+
"--",
|
|
1037
|
+
"bash",
|
|
1038
|
+
"-c",
|
|
1039
|
+
ghLogin,
|
|
1040
|
+
],
|
|
965
1041
|
"GitHub auth via vault",
|
|
966
1042
|
);
|
|
967
1043
|
if (authed) {
|
|
@@ -978,7 +1054,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
|
|
|
978
1054
|
try {
|
|
979
1055
|
manifestText = deps.readTextFile(manifestPath);
|
|
980
1056
|
} catch {
|
|
981
|
-
console.log(
|
|
1057
|
+
console.log(
|
|
1058
|
+
"replica-sync: no personal/data/repos.yaml — skipping repo clone.",
|
|
1059
|
+
);
|
|
982
1060
|
return;
|
|
983
1061
|
}
|
|
984
1062
|
|
|
@@ -990,7 +1068,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
|
|
|
990
1068
|
|
|
991
1069
|
const missing = repos.filter(
|
|
992
1070
|
(repo) =>
|
|
993
|
-
!deps.pathExists(
|
|
1071
|
+
!deps.pathExists(
|
|
1072
|
+
path.join(hqRoot, "repos", repo.visibility, repo.name, ".git"),
|
|
1073
|
+
),
|
|
994
1074
|
);
|
|
995
1075
|
if (missing.length === 0) {
|
|
996
1076
|
console.log("replica-sync: all repos already present.");
|
|
@@ -1025,7 +1105,9 @@ async function replicaSyncOutpost(
|
|
|
1025
1105
|
// signed in has nothing to pull — exit cleanly so the timer stays green.
|
|
1026
1106
|
const session = deps.loadCachedTokens();
|
|
1027
1107
|
if (!session?.refreshToken) {
|
|
1028
|
-
console.log(
|
|
1108
|
+
console.log(
|
|
1109
|
+
"replica-sync: no HQ session on this box yet — nothing to replicate.",
|
|
1110
|
+
);
|
|
1029
1111
|
return;
|
|
1030
1112
|
}
|
|
1031
1113
|
|
|
@@ -1041,7 +1123,15 @@ async function replicaSyncOutpost(
|
|
|
1041
1123
|
runBestEffort(
|
|
1042
1124
|
deps,
|
|
1043
1125
|
"hq",
|
|
1044
|
-
[
|
|
1126
|
+
[
|
|
1127
|
+
"sync",
|
|
1128
|
+
"pull",
|
|
1129
|
+
"--personal",
|
|
1130
|
+
"--hq-root",
|
|
1131
|
+
hqRoot,
|
|
1132
|
+
"--on-conflict",
|
|
1133
|
+
"keep",
|
|
1134
|
+
],
|
|
1045
1135
|
"personal vault pull",
|
|
1046
1136
|
);
|
|
1047
1137
|
|
|
@@ -1088,7 +1178,11 @@ export function registerOutpostsCommand(
|
|
|
1088
1178
|
.description(
|
|
1089
1179
|
"Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
|
|
1090
1180
|
)
|
|
1091
|
-
.option(
|
|
1181
|
+
.option(
|
|
1182
|
+
"--hq-root <path>",
|
|
1183
|
+
"HQ root to replicate into",
|
|
1184
|
+
deps.defaultHqRoot(),
|
|
1185
|
+
)
|
|
1092
1186
|
.action(async (opts: { hqRoot?: string }) => {
|
|
1093
1187
|
// Best-effort background job: never hard-fail the systemd unit on a
|
|
1094
1188
|
// transient step. Swallow, log, and exit 0.
|
|
@@ -1105,7 +1199,10 @@ export function registerOutpostsCommand(
|
|
|
1105
1199
|
.command("provision")
|
|
1106
1200
|
.alias("create")
|
|
1107
1201
|
.description("Provision a new Outpost ($80/month — requires --yes)")
|
|
1108
|
-
.option(
|
|
1202
|
+
.option(
|
|
1203
|
+
"--runtime <runtime>",
|
|
1204
|
+
"Agent runtime: claude | codex (default claude)",
|
|
1205
|
+
)
|
|
1109
1206
|
.option("--disk <gb>", "Root disk size in GB (EC2 only)")
|
|
1110
1207
|
.option(
|
|
1111
1208
|
"--client-ip <ip>",
|
|
@@ -1114,20 +1211,43 @@ export function registerOutpostsCommand(
|
|
|
1114
1211
|
.option("--yes", "Confirm the $80/month charge (required to provision)")
|
|
1115
1212
|
.action(async function (
|
|
1116
1213
|
this: Command,
|
|
1117
|
-
opts: {
|
|
1214
|
+
opts: {
|
|
1215
|
+
runtime?: string;
|
|
1216
|
+
disk?: string;
|
|
1217
|
+
clientIp?: string;
|
|
1218
|
+
yes?: boolean;
|
|
1219
|
+
},
|
|
1118
1220
|
) {
|
|
1119
1221
|
try {
|
|
1222
|
+
// Reject an unrecognized runtime rather than resolving it to claude:
|
|
1223
|
+
// `--runtime codx` would otherwise hand back a silently Claude box.
|
|
1224
|
+
if (
|
|
1225
|
+
opts.runtime !== undefined &&
|
|
1226
|
+
opts.runtime !== "claude" &&
|
|
1227
|
+
opts.runtime !== "codex"
|
|
1228
|
+
) {
|
|
1229
|
+
console.error(
|
|
1230
|
+
chalk.red(
|
|
1231
|
+
`Invalid --runtime '${opts.runtime}': must be 'claude' or 'codex'.`,
|
|
1232
|
+
),
|
|
1233
|
+
);
|
|
1234
|
+
process.exit(1);
|
|
1235
|
+
}
|
|
1120
1236
|
const agentRuntime =
|
|
1121
1237
|
opts.runtime === "codex"
|
|
1122
1238
|
? "codex"
|
|
1123
|
-
: opts.runtime
|
|
1239
|
+
: opts.runtime === "claude"
|
|
1124
1240
|
? "claude"
|
|
1125
1241
|
: undefined;
|
|
1126
1242
|
let diskSizeGb: number | undefined;
|
|
1127
1243
|
if (opts.disk !== undefined) {
|
|
1128
1244
|
diskSizeGb = Number(opts.disk);
|
|
1129
1245
|
if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
|
|
1130
|
-
console.error(
|
|
1246
|
+
console.error(
|
|
1247
|
+
chalk.red(
|
|
1248
|
+
`Invalid --disk '${opts.disk}': must be a positive number of GB.`,
|
|
1249
|
+
),
|
|
1250
|
+
);
|
|
1131
1251
|
process.exit(1);
|
|
1132
1252
|
}
|
|
1133
1253
|
}
|
|
@@ -1144,7 +1264,9 @@ export function registerOutpostsCommand(
|
|
|
1144
1264
|
const refreshToken = loadCachedTokens()?.refreshToken;
|
|
1145
1265
|
if (!refreshToken) {
|
|
1146
1266
|
console.error(
|
|
1147
|
-
chalk.red(
|
|
1267
|
+
chalk.red(
|
|
1268
|
+
"No cached session found — run `hq login` first, then re-run.",
|
|
1269
|
+
),
|
|
1148
1270
|
);
|
|
1149
1271
|
process.exit(1);
|
|
1150
1272
|
}
|
|
@@ -1166,7 +1288,17 @@ export function registerOutpostsCommand(
|
|
|
1166
1288
|
err.status === 402 &&
|
|
1167
1289
|
err.billing
|
|
1168
1290
|
) {
|
|
1169
|
-
await
|
|
1291
|
+
await surfaceBillingBlocked(token, err.billing, err.message);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|
|
1294
|
+
// Already at the per-person cap → say so and name the box they own,
|
|
1295
|
+
// not a bare "Conflict".
|
|
1296
|
+
if (
|
|
1297
|
+
err instanceof OutpostHttpError &&
|
|
1298
|
+
err.status === 409 &&
|
|
1299
|
+
err.capped
|
|
1300
|
+
) {
|
|
1301
|
+
surfaceOutpostCapped(err.capped);
|
|
1170
1302
|
process.exit(1);
|
|
1171
1303
|
}
|
|
1172
1304
|
throw err;
|
|
@@ -1198,7 +1330,10 @@ export function registerOutpostsCommand(
|
|
|
1198
1330
|
4,
|
|
1199
1331
|
...rows.map((r) => (r.instanceName ?? "").length),
|
|
1200
1332
|
);
|
|
1201
|
-
const regionW = Math.max(
|
|
1333
|
+
const regionW = Math.max(
|
|
1334
|
+
6,
|
|
1335
|
+
...rows.map((r) => (r.region ?? "").length),
|
|
1336
|
+
);
|
|
1202
1337
|
const rtW = Math.max(
|
|
1203
1338
|
7,
|
|
1204
1339
|
...rows.map((r) => (r.agentRuntime ?? "").length),
|
|
@@ -1295,7 +1430,9 @@ export function registerOutpostsCommand(
|
|
|
1295
1430
|
try {
|
|
1296
1431
|
const command = joinCommandParts(commandParts);
|
|
1297
1432
|
if (!command.trim()) {
|
|
1298
|
-
console.error(
|
|
1433
|
+
console.error(
|
|
1434
|
+
chalk.red("No command given. Usage: hq outposts exec -- <command>"),
|
|
1435
|
+
);
|
|
1299
1436
|
process.exit(1);
|
|
1300
1437
|
}
|
|
1301
1438
|
if (opts.async && opts.detach) {
|
|
@@ -1439,11 +1576,17 @@ export function registerOutpostsCommand(
|
|
|
1439
1576
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
1440
1577
|
if (result.truncated) {
|
|
1441
1578
|
console.error(
|
|
1442
|
-
chalk.yellow(
|
|
1579
|
+
chalk.yellow(
|
|
1580
|
+
"(output truncated by SSM's inline limit — redirect to a file on the box for full output)",
|
|
1581
|
+
),
|
|
1443
1582
|
);
|
|
1444
1583
|
}
|
|
1445
1584
|
if (result.status !== "Success" && result.exitCode === null) {
|
|
1446
|
-
console.error(
|
|
1585
|
+
console.error(
|
|
1586
|
+
chalk.yellow(
|
|
1587
|
+
`(command ended with SSM status: ${result.status})`,
|
|
1588
|
+
),
|
|
1589
|
+
);
|
|
1447
1590
|
}
|
|
1448
1591
|
}
|
|
1449
1592
|
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
@@ -1458,7 +1601,9 @@ export function registerOutpostsCommand(
|
|
|
1458
1601
|
const access = await getOutpostSshAccess(token, opts.id);
|
|
1459
1602
|
const ssh = execViaSsh(access, remoteCommand);
|
|
1460
1603
|
if (ssh.error) {
|
|
1461
|
-
console.error(
|
|
1604
|
+
console.error(
|
|
1605
|
+
chalk.red(`Could not run the command over SSH: ${ssh.error}`),
|
|
1606
|
+
);
|
|
1462
1607
|
process.exit(1);
|
|
1463
1608
|
}
|
|
1464
1609
|
if (opts.json) {
|
|
@@ -1553,7 +1698,9 @@ export function registerOutpostsCommand(
|
|
|
1553
1698
|
const command = joinCommandParts(commandParts);
|
|
1554
1699
|
if (!command.trim()) {
|
|
1555
1700
|
console.error(
|
|
1556
|
-
chalk.red(
|
|
1701
|
+
chalk.red(
|
|
1702
|
+
"No command given. Usage: hq outposts exec-submit -- <command>",
|
|
1703
|
+
),
|
|
1557
1704
|
);
|
|
1558
1705
|
process.exit(1);
|
|
1559
1706
|
}
|
|
@@ -1602,7 +1749,10 @@ export function registerOutpostsCommand(
|
|
|
1602
1749
|
outposts
|
|
1603
1750
|
.command("exec-result")
|
|
1604
1751
|
.description("Fetch the result of an asynchronous Outpost command")
|
|
1605
|
-
.requiredOption(
|
|
1752
|
+
.requiredOption(
|
|
1753
|
+
"--command-id <commandId>",
|
|
1754
|
+
"Command id returned by exec-submit",
|
|
1755
|
+
)
|
|
1606
1756
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1607
1757
|
.option("--wait", "Poll until the command reaches a terminal state")
|
|
1608
1758
|
.option("--json", "Emit raw JSON")
|
|
@@ -1705,7 +1855,11 @@ export function registerOutpostsCommand(
|
|
|
1705
1855
|
try {
|
|
1706
1856
|
const trimmed = code.trim();
|
|
1707
1857
|
if (!trimmed) {
|
|
1708
|
-
console.error(
|
|
1858
|
+
console.error(
|
|
1859
|
+
chalk.red(
|
|
1860
|
+
"Provide the sign-in code: hq outposts login-code <code>",
|
|
1861
|
+
),
|
|
1862
|
+
);
|
|
1709
1863
|
process.exit(1);
|
|
1710
1864
|
}
|
|
1711
1865
|
const token = await ensureCognitoToken();
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('../utils/cognito-session.js', () => ({
|
|
4
|
+
ensureCognitoToken: vi.fn(async () => 'test-token'),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('../utils/vault-api.js', () => ({
|
|
8
|
+
getCompanyUid: vi.fn(async () => 'cmp_selected'),
|
|
9
|
+
vaultApiFetchPublic: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../utils/secrets-cache.js', () => ({
|
|
13
|
+
listSecretCacheScopes: vi.fn(() => []),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('./secrets.js', () => ({
|
|
17
|
+
loadRevealedSecrets: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { ensureCognitoToken } from '../utils/cognito-session.js';
|
|
21
|
+
import { getCompanyUid } from '../utils/vault-api.js';
|
|
22
|
+
import { listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
23
|
+
import { loadRevealedSecrets } from './secrets.js';
|
|
24
|
+
import { makeInstallSecretResolver } from './pack-install.js';
|
|
25
|
+
|
|
26
|
+
describe('install-time MCP secret authorization', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
vi.mocked(ensureCognitoToken).mockResolvedValue('test-token');
|
|
30
|
+
vi.mocked(getCompanyUid).mockResolvedValue('cmp_selected');
|
|
31
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('uses a fresh server response for one unambiguous cached scope', async () => {
|
|
35
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
|
|
36
|
+
vi.mocked(loadRevealedSecrets).mockResolvedValue(
|
|
37
|
+
new Map([['API_KEY', 'fresh-server-value']]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const resolve = await makeInstallSecretResolver(['API_KEY']);
|
|
41
|
+
|
|
42
|
+
expect(loadRevealedSecrets).toHaveBeenCalledWith(
|
|
43
|
+
'test-token',
|
|
44
|
+
'cmp_cached',
|
|
45
|
+
['API_KEY'],
|
|
46
|
+
);
|
|
47
|
+
expect(resolve('API_KEY')).toBe('fresh-server-value');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('fails closed without reading a cached value when scopes are ambiguous', async () => {
|
|
51
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_one', 'cmp_two']);
|
|
52
|
+
|
|
53
|
+
const resolve = await makeInstallSecretResolver(['API_KEY']);
|
|
54
|
+
|
|
55
|
+
expect(loadRevealedSecrets).not.toHaveBeenCalled();
|
|
56
|
+
expect(resolve('API_KEY')).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses an explicit company to resolve and authorize the target scope', async () => {
|
|
60
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_one', 'cmp_two']);
|
|
61
|
+
vi.mocked(loadRevealedSecrets).mockResolvedValue(
|
|
62
|
+
new Map([['API_KEY', 'selected-company-value']]),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const resolve = await makeInstallSecretResolver(['API_KEY'], 'indigo');
|
|
66
|
+
|
|
67
|
+
expect(getCompanyUid).toHaveBeenCalledWith('test-token', 'indigo');
|
|
68
|
+
expect(loadRevealedSecrets).toHaveBeenCalledWith(
|
|
69
|
+
'test-token',
|
|
70
|
+
'cmp_selected',
|
|
71
|
+
['API_KEY'],
|
|
72
|
+
);
|
|
73
|
+
expect(resolve('API_KEY')).toBe('selected-company-value');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each([
|
|
77
|
+
['forbidden', new Error('Forbidden')],
|
|
78
|
+
['not found', new Error('Secret not found')],
|
|
79
|
+
['offline', new TypeError('fetch failed: ENETUNREACH')],
|
|
80
|
+
['timeout', new DOMException('The operation was aborted due to timeout', 'TimeoutError')],
|
|
81
|
+
])('defers registration when online authorization is %s', async (_label, failure) => {
|
|
82
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
|
|
83
|
+
vi.mocked(loadRevealedSecrets).mockRejectedValue(failure);
|
|
84
|
+
|
|
85
|
+
const resolve = await makeInstallSecretResolver(['API_KEY']);
|
|
86
|
+
|
|
87
|
+
expect(resolve('API_KEY')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not reuse an earlier authorized value after the server revokes access', async () => {
|
|
91
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
|
|
92
|
+
vi.mocked(loadRevealedSecrets)
|
|
93
|
+
.mockResolvedValueOnce(new Map([['API_KEY', 'initial-authorized-value']]))
|
|
94
|
+
.mockRejectedValueOnce(new Error('No read permission'));
|
|
95
|
+
|
|
96
|
+
const initiallyAuthorized = await makeInstallSecretResolver(['API_KEY']);
|
|
97
|
+
const afterRevocation = await makeInstallSecretResolver(['API_KEY']);
|
|
98
|
+
|
|
99
|
+
expect(initiallyAuthorized('API_KEY')).toBe('initial-authorized-value');
|
|
100
|
+
expect(afterRevocation('API_KEY')).toBeNull();
|
|
101
|
+
expect(loadRevealedSecrets).toHaveBeenCalledTimes(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('fails closed when no non-interactive HQ session is available', async () => {
|
|
105
|
+
vi.mocked(ensureCognitoToken).mockRejectedValue(
|
|
106
|
+
new Error('No valid HQ session'),
|
|
107
|
+
);
|
|
108
|
+
vi.mocked(listSecretCacheScopes).mockReturnValue(['cmp_cached']);
|
|
109
|
+
|
|
110
|
+
const resolve = await makeInstallSecretResolver(['API_KEY']);
|
|
111
|
+
|
|
112
|
+
expect(loadRevealedSecrets).not.toHaveBeenCalled();
|
|
113
|
+
expect(resolve('API_KEY')).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -127,6 +127,10 @@ describe('pack-install: install path layout', () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
// ---- 3. scan-packages.sh resolution -------------------------------------
|
|
130
|
+
// Spawns a real `bash` subprocess (spawnSync), which under the saturated
|
|
131
|
+
// full-suite parallel pool can take well past vitest's 5s default. The work
|
|
132
|
+
// is a trivial `touch`; the extra budget is for scheduling contention, not
|
|
133
|
+
// slow logic. Isolated, this runs in milliseconds.
|
|
130
134
|
it('runScanPackages invokes core/scripts/scan-packages.sh when present', () => {
|
|
131
135
|
const scriptDir = path.join(hqRoot, 'core', 'scripts');
|
|
132
136
|
fs.mkdirSync(scriptDir, { recursive: true });
|
|
@@ -142,7 +146,7 @@ describe('pack-install: install path layout', () => {
|
|
|
142
146
|
runScanPackages(hqRoot);
|
|
143
147
|
|
|
144
148
|
expect(fs.existsSync(sentinel)).toBe(true);
|
|
145
|
-
});
|
|
149
|
+
}, 30_000);
|
|
146
150
|
|
|
147
151
|
it('runScanPackages skips with a dim warning when core/scripts/scan-packages.sh is absent', () => {
|
|
148
152
|
// No script anywhere — runScanPackages must not throw, must not invoke
|