@indigoai-us/hq-cli 5.74.0 → 5.76.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/mcp-registration.d.ts +4 -5
- package/dist/commands/mcp-registration.js +5 -4
- package/dist/commands/outposts.d.ts +23 -5
- package/dist/commands/outposts.js +207 -14
- 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/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/mcp-registration.ts +9 -9
- package/src/commands/outposts.test.ts +252 -24
- package/src/commands/outposts.ts +405 -50
- 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/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
|
@@ -41,23 +41,51 @@ import {
|
|
|
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,
|
|
@@ -268,6 +299,8 @@ export interface OutpostExecSubmission {
|
|
|
268
299
|
instanceId: string;
|
|
269
300
|
commandId: string;
|
|
270
301
|
outputPrefix: string;
|
|
302
|
+
/** Shell budget applied server-side (AWS-RunShellScript executionTimeout). */
|
|
303
|
+
executionTimeoutSeconds?: number;
|
|
271
304
|
}
|
|
272
305
|
|
|
273
306
|
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
@@ -300,12 +333,17 @@ export async function submitExec(
|
|
|
300
333
|
token: string,
|
|
301
334
|
command: string,
|
|
302
335
|
outpostId?: string,
|
|
336
|
+
timeoutSeconds?: number,
|
|
303
337
|
): Promise<OutpostExecSubmission> {
|
|
304
338
|
return outpostRequest({
|
|
305
339
|
token,
|
|
306
340
|
path: "/outpost/exec",
|
|
307
341
|
method: "POST",
|
|
308
|
-
body: {
|
|
342
|
+
body: {
|
|
343
|
+
mode: "submit",
|
|
344
|
+
command,
|
|
345
|
+
...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
|
|
346
|
+
},
|
|
309
347
|
query: outpostId ? { outpostId } : undefined,
|
|
310
348
|
});
|
|
311
349
|
}
|
|
@@ -473,6 +511,31 @@ function ensureTrailingNewline(s: string): string {
|
|
|
473
511
|
// Command registration
|
|
474
512
|
// ---------------------------------------------------------------------------
|
|
475
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
|
+
|
|
476
539
|
function fail(err: unknown): never {
|
|
477
540
|
if (err instanceof OutpostHttpError) {
|
|
478
541
|
console.error(chalk.red(err.message));
|
|
@@ -506,8 +569,7 @@ export interface SelfDeployDependencies {
|
|
|
506
569
|
) => ReturnType<typeof spawnSync>;
|
|
507
570
|
readTextFile: (file: string) => string;
|
|
508
571
|
loadCachedTokens: () =>
|
|
509
|
-
|
|
510
|
-
| undefined;
|
|
572
|
+
{ refreshToken?: string; idToken?: string } | undefined;
|
|
511
573
|
getUid: () => number | undefined;
|
|
512
574
|
isStdinTty: () => boolean;
|
|
513
575
|
confirm: () => Promise<boolean>;
|
|
@@ -588,10 +650,9 @@ function hqIdentityFromSession(session: {
|
|
|
588
650
|
try {
|
|
589
651
|
const payload = session.idToken.split(".")[1];
|
|
590
652
|
if (!payload) return "your cached HQ session";
|
|
591
|
-
const claims = JSON.parse(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
>;
|
|
653
|
+
const claims = JSON.parse(
|
|
654
|
+
Buffer.from(payload, "base64url").toString("utf8"),
|
|
655
|
+
) as Record<string, unknown>;
|
|
595
656
|
for (const key of [
|
|
596
657
|
"email",
|
|
597
658
|
"preferred_username",
|
|
@@ -691,9 +752,10 @@ function runPrivileged(
|
|
|
691
752
|
runChecked(deps, "sudo", [command, ...args], description, options);
|
|
692
753
|
}
|
|
693
754
|
|
|
694
|
-
function selfDeployPreflight(
|
|
695
|
-
|
|
696
|
-
|
|
755
|
+
function selfDeployPreflight(deps: SelfDeployDependencies): {
|
|
756
|
+
refreshToken?: string;
|
|
757
|
+
idToken?: string;
|
|
758
|
+
} {
|
|
697
759
|
let osRelease: string;
|
|
698
760
|
try {
|
|
699
761
|
osRelease = deps.readTextFile("/etc/os-release");
|
|
@@ -733,7 +795,9 @@ function selfDeployPreflight(
|
|
|
733
795
|
|
|
734
796
|
const session = deps.loadCachedTokens();
|
|
735
797
|
if (!session?.refreshToken) {
|
|
736
|
-
throw selfDeployError(
|
|
798
|
+
throw selfDeployError(
|
|
799
|
+
"no HQ login session was found. Run `hq login`, then re-run this command.",
|
|
800
|
+
);
|
|
737
801
|
}
|
|
738
802
|
return session;
|
|
739
803
|
}
|
|
@@ -756,7 +820,9 @@ async function selfDeployOutpost(
|
|
|
756
820
|
),
|
|
757
821
|
);
|
|
758
822
|
if (!deps.isStdinTty()) {
|
|
759
|
-
throw new Error(
|
|
823
|
+
throw new Error(
|
|
824
|
+
"Confirmation requires a TTY. Pass --yes to continue non-interactively.",
|
|
825
|
+
);
|
|
760
826
|
}
|
|
761
827
|
if (!(await deps.confirm())) {
|
|
762
828
|
throw new Error("Self-deploy cancelled.");
|
|
@@ -799,12 +865,7 @@ async function selfDeployOutpost(
|
|
|
799
865
|
stdio: ["pipe", "ignore", "pipe"],
|
|
800
866
|
},
|
|
801
867
|
);
|
|
802
|
-
runPrivileged(
|
|
803
|
-
deps,
|
|
804
|
-
"systemctl",
|
|
805
|
-
["daemon-reload"],
|
|
806
|
-
"Reloading systemd",
|
|
807
|
-
);
|
|
868
|
+
runPrivileged(deps, "systemctl", ["daemon-reload"], "Reloading systemd");
|
|
808
869
|
runPrivileged(
|
|
809
870
|
deps,
|
|
810
871
|
"systemctl",
|
|
@@ -812,9 +873,19 @@ async function selfDeployOutpost(
|
|
|
812
873
|
"Enabling outpost-sync.service",
|
|
813
874
|
);
|
|
814
875
|
|
|
815
|
-
console.log(
|
|
816
|
-
|
|
817
|
-
|
|
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
|
+
);
|
|
818
889
|
}
|
|
819
890
|
|
|
820
891
|
// ---------------------------------------------------------------------------
|
|
@@ -933,7 +1004,9 @@ function runBestEffort(
|
|
|
933
1004
|
return false;
|
|
934
1005
|
}
|
|
935
1006
|
if (!result || result.error || result.status !== 0) {
|
|
936
|
-
console.warn(
|
|
1007
|
+
console.warn(
|
|
1008
|
+
`replica-sync: ${description} did not complete cleanly — continuing.`,
|
|
1009
|
+
);
|
|
937
1010
|
return false;
|
|
938
1011
|
}
|
|
939
1012
|
return true;
|
|
@@ -954,7 +1027,17 @@ function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
|
|
|
954
1027
|
const authed = runBestEffort(
|
|
955
1028
|
deps,
|
|
956
1029
|
"hq",
|
|
957
|
-
[
|
|
1030
|
+
[
|
|
1031
|
+
"secrets",
|
|
1032
|
+
"--personal",
|
|
1033
|
+
"exec",
|
|
1034
|
+
"--only",
|
|
1035
|
+
"GITHUB_TOKEN",
|
|
1036
|
+
"--",
|
|
1037
|
+
"bash",
|
|
1038
|
+
"-c",
|
|
1039
|
+
ghLogin,
|
|
1040
|
+
],
|
|
958
1041
|
"GitHub auth via vault",
|
|
959
1042
|
);
|
|
960
1043
|
if (authed) {
|
|
@@ -971,7 +1054,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
|
|
|
971
1054
|
try {
|
|
972
1055
|
manifestText = deps.readTextFile(manifestPath);
|
|
973
1056
|
} catch {
|
|
974
|
-
console.log(
|
|
1057
|
+
console.log(
|
|
1058
|
+
"replica-sync: no personal/data/repos.yaml — skipping repo clone.",
|
|
1059
|
+
);
|
|
975
1060
|
return;
|
|
976
1061
|
}
|
|
977
1062
|
|
|
@@ -983,7 +1068,9 @@ function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
|
|
|
983
1068
|
|
|
984
1069
|
const missing = repos.filter(
|
|
985
1070
|
(repo) =>
|
|
986
|
-
!deps.pathExists(
|
|
1071
|
+
!deps.pathExists(
|
|
1072
|
+
path.join(hqRoot, "repos", repo.visibility, repo.name, ".git"),
|
|
1073
|
+
),
|
|
987
1074
|
);
|
|
988
1075
|
if (missing.length === 0) {
|
|
989
1076
|
console.log("replica-sync: all repos already present.");
|
|
@@ -1018,7 +1105,9 @@ async function replicaSyncOutpost(
|
|
|
1018
1105
|
// signed in has nothing to pull — exit cleanly so the timer stays green.
|
|
1019
1106
|
const session = deps.loadCachedTokens();
|
|
1020
1107
|
if (!session?.refreshToken) {
|
|
1021
|
-
console.log(
|
|
1108
|
+
console.log(
|
|
1109
|
+
"replica-sync: no HQ session on this box yet — nothing to replicate.",
|
|
1110
|
+
);
|
|
1022
1111
|
return;
|
|
1023
1112
|
}
|
|
1024
1113
|
|
|
@@ -1034,7 +1123,15 @@ async function replicaSyncOutpost(
|
|
|
1034
1123
|
runBestEffort(
|
|
1035
1124
|
deps,
|
|
1036
1125
|
"hq",
|
|
1037
|
-
[
|
|
1126
|
+
[
|
|
1127
|
+
"sync",
|
|
1128
|
+
"pull",
|
|
1129
|
+
"--personal",
|
|
1130
|
+
"--hq-root",
|
|
1131
|
+
hqRoot,
|
|
1132
|
+
"--on-conflict",
|
|
1133
|
+
"keep",
|
|
1134
|
+
],
|
|
1038
1135
|
"personal vault pull",
|
|
1039
1136
|
);
|
|
1040
1137
|
|
|
@@ -1081,7 +1178,11 @@ export function registerOutpostsCommand(
|
|
|
1081
1178
|
.description(
|
|
1082
1179
|
"Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
|
|
1083
1180
|
)
|
|
1084
|
-
.option(
|
|
1181
|
+
.option(
|
|
1182
|
+
"--hq-root <path>",
|
|
1183
|
+
"HQ root to replicate into",
|
|
1184
|
+
deps.defaultHqRoot(),
|
|
1185
|
+
)
|
|
1085
1186
|
.action(async (opts: { hqRoot?: string }) => {
|
|
1086
1187
|
// Best-effort background job: never hard-fail the systemd unit on a
|
|
1087
1188
|
// transient step. Swallow, log, and exit 0.
|
|
@@ -1098,7 +1199,10 @@ export function registerOutpostsCommand(
|
|
|
1098
1199
|
.command("provision")
|
|
1099
1200
|
.alias("create")
|
|
1100
1201
|
.description("Provision a new Outpost ($80/month — requires --yes)")
|
|
1101
|
-
.option(
|
|
1202
|
+
.option(
|
|
1203
|
+
"--runtime <runtime>",
|
|
1204
|
+
"Agent runtime: claude | codex (default claude)",
|
|
1205
|
+
)
|
|
1102
1206
|
.option("--disk <gb>", "Root disk size in GB (EC2 only)")
|
|
1103
1207
|
.option(
|
|
1104
1208
|
"--client-ip <ip>",
|
|
@@ -1107,20 +1211,43 @@ export function registerOutpostsCommand(
|
|
|
1107
1211
|
.option("--yes", "Confirm the $80/month charge (required to provision)")
|
|
1108
1212
|
.action(async function (
|
|
1109
1213
|
this: Command,
|
|
1110
|
-
opts: {
|
|
1214
|
+
opts: {
|
|
1215
|
+
runtime?: string;
|
|
1216
|
+
disk?: string;
|
|
1217
|
+
clientIp?: string;
|
|
1218
|
+
yes?: boolean;
|
|
1219
|
+
},
|
|
1111
1220
|
) {
|
|
1112
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
|
+
}
|
|
1113
1236
|
const agentRuntime =
|
|
1114
1237
|
opts.runtime === "codex"
|
|
1115
1238
|
? "codex"
|
|
1116
|
-
: opts.runtime
|
|
1239
|
+
: opts.runtime === "claude"
|
|
1117
1240
|
? "claude"
|
|
1118
1241
|
: undefined;
|
|
1119
1242
|
let diskSizeGb: number | undefined;
|
|
1120
1243
|
if (opts.disk !== undefined) {
|
|
1121
1244
|
diskSizeGb = Number(opts.disk);
|
|
1122
1245
|
if (!Number.isFinite(diskSizeGb) || diskSizeGb <= 0) {
|
|
1123
|
-
console.error(
|
|
1246
|
+
console.error(
|
|
1247
|
+
chalk.red(
|
|
1248
|
+
`Invalid --disk '${opts.disk}': must be a positive number of GB.`,
|
|
1249
|
+
),
|
|
1250
|
+
);
|
|
1124
1251
|
process.exit(1);
|
|
1125
1252
|
}
|
|
1126
1253
|
}
|
|
@@ -1137,7 +1264,9 @@ export function registerOutpostsCommand(
|
|
|
1137
1264
|
const refreshToken = loadCachedTokens()?.refreshToken;
|
|
1138
1265
|
if (!refreshToken) {
|
|
1139
1266
|
console.error(
|
|
1140
|
-
chalk.red(
|
|
1267
|
+
chalk.red(
|
|
1268
|
+
"No cached session found — run `hq login` first, then re-run.",
|
|
1269
|
+
),
|
|
1141
1270
|
);
|
|
1142
1271
|
process.exit(1);
|
|
1143
1272
|
}
|
|
@@ -1162,6 +1291,16 @@ export function registerOutpostsCommand(
|
|
|
1162
1291
|
await surfaceBillingRequired(token, err.billing);
|
|
1163
1292
|
process.exit(1);
|
|
1164
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);
|
|
1302
|
+
process.exit(1);
|
|
1303
|
+
}
|
|
1165
1304
|
throw err;
|
|
1166
1305
|
}
|
|
1167
1306
|
} catch (err) {
|
|
@@ -1191,7 +1330,10 @@ export function registerOutpostsCommand(
|
|
|
1191
1330
|
4,
|
|
1192
1331
|
...rows.map((r) => (r.instanceName ?? "").length),
|
|
1193
1332
|
);
|
|
1194
|
-
const regionW = Math.max(
|
|
1333
|
+
const regionW = Math.max(
|
|
1334
|
+
6,
|
|
1335
|
+
...rows.map((r) => (r.region ?? "").length),
|
|
1336
|
+
);
|
|
1195
1337
|
const rtW = Math.max(
|
|
1196
1338
|
7,
|
|
1197
1339
|
...rows.map((r) => (r.agentRuntime ?? "").length),
|
|
@@ -1250,26 +1392,180 @@ export function registerOutpostsCommand(
|
|
|
1250
1392
|
outposts
|
|
1251
1393
|
.command("exec <command...>")
|
|
1252
1394
|
.description(
|
|
1253
|
-
"Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command)"
|
|
1395
|
+
"Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
|
|
1396
|
+
"Default is synchronous (API Gateway ~20s cap). Use --async for long jobs, or --detach to print the commandId and return immediately.",
|
|
1254
1397
|
)
|
|
1255
1398
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1399
|
+
.option(
|
|
1400
|
+
"--async",
|
|
1401
|
+
"Submit via the async transport and wait for completion (bypasses the ~20s sync cap; shell budget defaults to 48h)",
|
|
1402
|
+
)
|
|
1403
|
+
.option(
|
|
1404
|
+
"--detach",
|
|
1405
|
+
"Submit via the async transport, print commandId, and return immediately (pair with `hq outposts exec-result --wait`)",
|
|
1406
|
+
)
|
|
1407
|
+
.option(
|
|
1408
|
+
"--timeout-seconds <n>",
|
|
1409
|
+
"Async shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800). Implies --async unless --detach is set.",
|
|
1410
|
+
(v: string) => {
|
|
1411
|
+
const n = Number(v);
|
|
1412
|
+
if (!Number.isInteger(n)) {
|
|
1413
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1414
|
+
}
|
|
1415
|
+
return n;
|
|
1416
|
+
},
|
|
1417
|
+
)
|
|
1256
1418
|
.option("--json", "Emit raw JSON")
|
|
1257
1419
|
.action(async function (
|
|
1258
1420
|
this: Command,
|
|
1259
1421
|
commandParts: string[],
|
|
1260
|
-
opts: {
|
|
1422
|
+
opts: {
|
|
1423
|
+
id?: string;
|
|
1424
|
+
json?: boolean;
|
|
1425
|
+
async?: boolean;
|
|
1426
|
+
detach?: boolean;
|
|
1427
|
+
timeoutSeconds?: number;
|
|
1428
|
+
},
|
|
1261
1429
|
) {
|
|
1262
1430
|
try {
|
|
1263
1431
|
const command = joinCommandParts(commandParts);
|
|
1264
1432
|
if (!command.trim()) {
|
|
1265
|
-
console.error(
|
|
1433
|
+
console.error(
|
|
1434
|
+
chalk.red("No command given. Usage: hq outposts exec -- <command>"),
|
|
1435
|
+
);
|
|
1436
|
+
process.exit(1);
|
|
1437
|
+
}
|
|
1438
|
+
if (opts.async && opts.detach) {
|
|
1439
|
+
console.error(
|
|
1440
|
+
chalk.red("Use either --async (submit + wait) or --detach (submit only), not both."),
|
|
1441
|
+
);
|
|
1266
1442
|
process.exit(1);
|
|
1267
1443
|
}
|
|
1444
|
+
// --timeout-seconds only applies to the async path; bare use implies --async.
|
|
1445
|
+
const useAsync =
|
|
1446
|
+
Boolean(opts.async) ||
|
|
1447
|
+
Boolean(opts.detach) ||
|
|
1448
|
+
opts.timeoutSeconds !== undefined;
|
|
1449
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1450
|
+
if (
|
|
1451
|
+
!Number.isInteger(opts.timeoutSeconds) ||
|
|
1452
|
+
opts.timeoutSeconds < 1 ||
|
|
1453
|
+
opts.timeoutSeconds > 172_800
|
|
1454
|
+
) {
|
|
1455
|
+
console.error(
|
|
1456
|
+
chalk.red(
|
|
1457
|
+
"--timeout-seconds must be an integer between 1 and 172800 (48h, the AWS-RunShellScript max)",
|
|
1458
|
+
),
|
|
1459
|
+
);
|
|
1460
|
+
process.exit(1);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1268
1463
|
const token = await ensureCognitoToken();
|
|
1269
1464
|
|
|
1270
1465
|
// Run from the box's HQ folder by default (works over both SSM and SSH).
|
|
1271
1466
|
const remoteCommand = withRemoteHqDir(command);
|
|
1272
1467
|
|
|
1468
|
+
if (useAsync) {
|
|
1469
|
+
try {
|
|
1470
|
+
const submitted = await submitExec(
|
|
1471
|
+
token,
|
|
1472
|
+
remoteCommand,
|
|
1473
|
+
opts.id,
|
|
1474
|
+
opts.timeoutSeconds,
|
|
1475
|
+
);
|
|
1476
|
+
if (opts.detach) {
|
|
1477
|
+
const output = {
|
|
1478
|
+
commandId: submitted.commandId,
|
|
1479
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1480
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1481
|
+
: opts.timeoutSeconds !== undefined
|
|
1482
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1483
|
+
: {}),
|
|
1484
|
+
};
|
|
1485
|
+
if (opts.json) {
|
|
1486
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1487
|
+
} else {
|
|
1488
|
+
printKeyValues(output);
|
|
1489
|
+
console.error(
|
|
1490
|
+
chalk.dim(
|
|
1491
|
+
"Submitted. Poll with: hq outposts exec-result --command-id " +
|
|
1492
|
+
submitted.commandId +
|
|
1493
|
+
(opts.id ? ` --id ${opts.id}` : "") +
|
|
1494
|
+
" --wait",
|
|
1495
|
+
),
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// --async (or --timeout-seconds without --detach): wait for terminal.
|
|
1502
|
+
if (!opts.json) {
|
|
1503
|
+
console.error(
|
|
1504
|
+
chalk.dim(
|
|
1505
|
+
`Submitted ${submitted.commandId}; waiting for completion…`,
|
|
1506
|
+
),
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
const result = await waitForExecResult(
|
|
1510
|
+
token,
|
|
1511
|
+
submitted.commandId,
|
|
1512
|
+
opts.id,
|
|
1513
|
+
);
|
|
1514
|
+
if (opts.json) {
|
|
1515
|
+
process.stdout.write(
|
|
1516
|
+
JSON.stringify(
|
|
1517
|
+
{
|
|
1518
|
+
commandId: submitted.commandId,
|
|
1519
|
+
done: result.done,
|
|
1520
|
+
status: result.status,
|
|
1521
|
+
exitCode: result.exitCode ?? null,
|
|
1522
|
+
stdout: result.stdout ?? "",
|
|
1523
|
+
stderr: result.stderr ?? "",
|
|
1524
|
+
truncated: result.truncated ?? false,
|
|
1525
|
+
},
|
|
1526
|
+
null,
|
|
1527
|
+
2,
|
|
1528
|
+
) + "\n",
|
|
1529
|
+
);
|
|
1530
|
+
} else {
|
|
1531
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1532
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1533
|
+
if (result.truncated) {
|
|
1534
|
+
console.error(
|
|
1535
|
+
chalk.yellow(
|
|
1536
|
+
"(output truncated — redirect to a file on the box for full output)",
|
|
1537
|
+
),
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
if (result.status !== "Success" && result.exitCode === null) {
|
|
1541
|
+
console.error(
|
|
1542
|
+
chalk.yellow(`(command ended with SSM status: ${result.status})`),
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
process.exitCode =
|
|
1547
|
+
typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
1548
|
+
return;
|
|
1549
|
+
} catch (err) {
|
|
1550
|
+
// Async requires EC2/SSM. Lightsail has no async channel — refuse
|
|
1551
|
+
// rather than silently falling back to a live SSH hold, which is
|
|
1552
|
+
// the exact timeout failure mode --async is meant to escape.
|
|
1553
|
+
if (
|
|
1554
|
+
err instanceof OutpostHttpError &&
|
|
1555
|
+
err.step === "platform-unsupported"
|
|
1556
|
+
) {
|
|
1557
|
+
console.error(
|
|
1558
|
+
chalk.red(
|
|
1559
|
+
"Async exec requires an EC2 Outpost (SSM). This box is Lightsail — " +
|
|
1560
|
+
"re-provision on EC2, or run a short sync command / SSH session instead.",
|
|
1561
|
+
),
|
|
1562
|
+
);
|
|
1563
|
+
process.exit(1);
|
|
1564
|
+
}
|
|
1565
|
+
throw err;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1273
1569
|
try {
|
|
1274
1570
|
const result = await execOutpost(token, remoteCommand, opts.id);
|
|
1275
1571
|
if (opts.json) {
|
|
@@ -1280,11 +1576,17 @@ export function registerOutpostsCommand(
|
|
|
1280
1576
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
1281
1577
|
if (result.truncated) {
|
|
1282
1578
|
console.error(
|
|
1283
|
-
chalk.yellow(
|
|
1579
|
+
chalk.yellow(
|
|
1580
|
+
"(output truncated by SSM's inline limit — redirect to a file on the box for full output)",
|
|
1581
|
+
),
|
|
1284
1582
|
);
|
|
1285
1583
|
}
|
|
1286
1584
|
if (result.status !== "Success" && result.exitCode === null) {
|
|
1287
|
-
console.error(
|
|
1585
|
+
console.error(
|
|
1586
|
+
chalk.yellow(
|
|
1587
|
+
`(command ended with SSM status: ${result.status})`,
|
|
1588
|
+
),
|
|
1589
|
+
);
|
|
1288
1590
|
}
|
|
1289
1591
|
}
|
|
1290
1592
|
// Propagate the remote exit code so `hq outposts exec -- false` exits 1.
|
|
@@ -1299,7 +1601,9 @@ export function registerOutpostsCommand(
|
|
|
1299
1601
|
const access = await getOutpostSshAccess(token, opts.id);
|
|
1300
1602
|
const ssh = execViaSsh(access, remoteCommand);
|
|
1301
1603
|
if (ssh.error) {
|
|
1302
|
-
console.error(
|
|
1604
|
+
console.error(
|
|
1605
|
+
chalk.red(`Could not run the command over SSH: ${ssh.error}`),
|
|
1606
|
+
);
|
|
1303
1607
|
process.exit(1);
|
|
1304
1608
|
}
|
|
1305
1609
|
if (opts.json) {
|
|
@@ -1369,25 +1673,69 @@ export function registerOutpostsCommand(
|
|
|
1369
1673
|
|
|
1370
1674
|
outposts
|
|
1371
1675
|
.command("exec-submit <command...>")
|
|
1372
|
-
.description(
|
|
1676
|
+
.description(
|
|
1677
|
+
"Submit an asynchronous shell command to an Outpost (returns immediately with commandId; shell budget defaults to 48h)",
|
|
1678
|
+
)
|
|
1373
1679
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1680
|
+
.option(
|
|
1681
|
+
"--timeout-seconds <n>",
|
|
1682
|
+
"Shell budget in seconds (AWS-RunShellScript executionTimeout; 1..172800)",
|
|
1683
|
+
(v: string) => {
|
|
1684
|
+
const n = Number(v);
|
|
1685
|
+
if (!Number.isInteger(n)) {
|
|
1686
|
+
throw new Error("--timeout-seconds must be an integer");
|
|
1687
|
+
}
|
|
1688
|
+
return n;
|
|
1689
|
+
},
|
|
1690
|
+
)
|
|
1374
1691
|
.option("--json", "Emit raw JSON")
|
|
1375
1692
|
.action(async function (
|
|
1376
1693
|
this: Command,
|
|
1377
1694
|
commandParts: string[],
|
|
1378
|
-
opts: { id?: string; json?: boolean },
|
|
1695
|
+
opts: { id?: string; json?: boolean; timeoutSeconds?: number },
|
|
1379
1696
|
) {
|
|
1380
1697
|
try {
|
|
1381
1698
|
const command = joinCommandParts(commandParts);
|
|
1382
1699
|
if (!command.trim()) {
|
|
1383
1700
|
console.error(
|
|
1384
|
-
chalk.red(
|
|
1701
|
+
chalk.red(
|
|
1702
|
+
"No command given. Usage: hq outposts exec-submit -- <command>",
|
|
1703
|
+
),
|
|
1385
1704
|
);
|
|
1386
1705
|
process.exit(1);
|
|
1387
1706
|
}
|
|
1707
|
+
if (opts.timeoutSeconds !== undefined) {
|
|
1708
|
+
if (
|
|
1709
|
+
!Number.isInteger(opts.timeoutSeconds) ||
|
|
1710
|
+
opts.timeoutSeconds < 1 ||
|
|
1711
|
+
opts.timeoutSeconds > 172_800
|
|
1712
|
+
) {
|
|
1713
|
+
console.error(
|
|
1714
|
+
chalk.red(
|
|
1715
|
+
"--timeout-seconds must be an integer between 1 and 172800 (48h)",
|
|
1716
|
+
),
|
|
1717
|
+
);
|
|
1718
|
+
process.exit(1);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1388
1721
|
const token = await ensureCognitoToken();
|
|
1389
|
-
|
|
1390
|
-
|
|
1722
|
+
// exec-submit is the raw fire-and-forget path — do NOT wrap with
|
|
1723
|
+
// withRemoteHqDir here (callers that want the HQ cwd use `exec --async`
|
|
1724
|
+
// or prefix their own cd). Matches the existing contract.
|
|
1725
|
+
const submitted = await submitExec(
|
|
1726
|
+
token,
|
|
1727
|
+
command,
|
|
1728
|
+
opts.id,
|
|
1729
|
+
opts.timeoutSeconds,
|
|
1730
|
+
);
|
|
1731
|
+
const output = {
|
|
1732
|
+
commandId: submitted.commandId,
|
|
1733
|
+
...(submitted.executionTimeoutSeconds !== undefined
|
|
1734
|
+
? { executionTimeoutSeconds: submitted.executionTimeoutSeconds }
|
|
1735
|
+
: opts.timeoutSeconds !== undefined
|
|
1736
|
+
? { executionTimeoutSeconds: opts.timeoutSeconds }
|
|
1737
|
+
: {}),
|
|
1738
|
+
};
|
|
1391
1739
|
if (opts.json) {
|
|
1392
1740
|
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1393
1741
|
} else {
|
|
@@ -1401,7 +1749,10 @@ export function registerOutpostsCommand(
|
|
|
1401
1749
|
outposts
|
|
1402
1750
|
.command("exec-result")
|
|
1403
1751
|
.description("Fetch the result of an asynchronous Outpost command")
|
|
1404
|
-
.requiredOption(
|
|
1752
|
+
.requiredOption(
|
|
1753
|
+
"--command-id <commandId>",
|
|
1754
|
+
"Command id returned by exec-submit",
|
|
1755
|
+
)
|
|
1405
1756
|
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1406
1757
|
.option("--wait", "Poll until the command reaches a terminal state")
|
|
1407
1758
|
.option("--json", "Emit raw JSON")
|
|
@@ -1504,7 +1855,11 @@ export function registerOutpostsCommand(
|
|
|
1504
1855
|
try {
|
|
1505
1856
|
const trimmed = code.trim();
|
|
1506
1857
|
if (!trimmed) {
|
|
1507
|
-
console.error(
|
|
1858
|
+
console.error(
|
|
1859
|
+
chalk.red(
|
|
1860
|
+
"Provide the sign-in code: hq outposts login-code <code>",
|
|
1861
|
+
),
|
|
1862
|
+
);
|
|
1508
1863
|
process.exit(1);
|
|
1509
1864
|
}
|
|
1510
1865
|
const token = await ensureCognitoToken();
|