@indigoai-us/hq-cli 5.61.0 → 5.62.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.d.ts +109 -0
- package/dist/commands/agents.js +385 -0
- package/dist/commands/db-migrate.d.ts +6 -0
- package/dist/commands/db-migrate.js +42 -0
- package/dist/commands/db-provision.d.ts +15 -0
- package/dist/commands/db-provision.js +78 -0
- package/dist/commands/db-sql.d.ts +9 -0
- package/dist/commands/db-sql.js +81 -0
- package/dist/commands/db-status.d.ts +7 -0
- package/dist/commands/db-status.js +70 -0
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +23 -0
- package/dist/commands/integrations.d.ts +78 -0
- package/dist/commands/integrations.js +309 -0
- package/dist/commands/members.js +4 -4
- package/dist/commands/outposts.d.ts +60 -0
- package/dist/commands/outposts.js +255 -0
- package/dist/commands/secrets.d.ts +8 -0
- package/dist/commands/secrets.js +23 -8
- package/dist/commands/skill.d.ts +153 -0
- package/dist/commands/skill.js +593 -0
- package/dist/commands/workers.d.ts +48 -0
- package/dist/commands/workers.js +229 -0
- package/dist/lib/db/control-plane.d.ts +45 -0
- package/dist/lib/db/control-plane.js +81 -0
- package/dist/lib/db/local.d.ts +49 -0
- package/dist/lib/db/local.js +106 -0
- package/dist/lib/db/migrate.d.ts +41 -0
- package/dist/lib/db/migrate.js +104 -0
- package/dist/lib/db/paths.d.ts +56 -0
- package/dist/lib/db/paths.js +103 -0
- package/dist/lib/db/remote-engine.d.ts +58 -0
- package/dist/lib/db/remote-engine.js +90 -0
- package/dist/lib/db/remote-sql.d.ts +22 -0
- package/dist/lib/db/remote-sql.js +39 -0
- package/dist/lib/db/sql.d.ts +49 -0
- package/dist/lib/db/sql.js +132 -0
- package/dist/main.js +27 -2
- package/dist/utils/cognito-session.js +3 -3
- package/dist/utils/sandbox-runner-client.js +3 -3
- package/package.json +9 -1
- package/pnpm-workspace.yaml +2 -0
- package/src/commands/agents.test.ts +297 -0
- package/src/commands/agents.ts +561 -0
- package/src/commands/db-migrate.ts +55 -0
- package/src/commands/db-provision.ts +102 -0
- package/src/commands/db-sql.ts +124 -0
- package/src/commands/db-status.ts +100 -0
- package/src/commands/db.ts +26 -0
- package/src/commands/integrations.test.ts +284 -0
- package/src/commands/integrations.ts +438 -0
- package/src/commands/members.ts +2 -2
- package/src/commands/outposts.test.ts +177 -0
- package/src/commands/outposts.ts +338 -0
- package/src/commands/secrets.parse-destination.test.ts +38 -0
- package/src/commands/secrets.test.ts +24 -0
- package/src/commands/secrets.ts +30 -10
- package/src/commands/skill.test.ts +770 -0
- package/src/commands/skill.ts +796 -0
- package/src/commands/workers.test.ts +158 -0
- package/src/commands/workers.ts +298 -0
- package/src/lib/db/control-plane.test.ts +59 -0
- package/src/lib/db/control-plane.ts +113 -0
- package/src/lib/db/local.test.ts +81 -0
- package/src/lib/db/local.ts +148 -0
- package/src/lib/db/migrate.test.ts +133 -0
- package/src/lib/db/migrate.ts +137 -0
- package/src/lib/db/paths.test.ts +112 -0
- package/src/lib/db/paths.ts +128 -0
- package/src/lib/db/remote-engine.test.ts +44 -0
- package/src/lib/db/remote-engine.ts +148 -0
- package/src/lib/db/remote-sql.test.ts +32 -0
- package/src/lib/db/remote-sql.ts +62 -0
- package/src/lib/db/sql.test.ts +106 -0
- package/src/lib/db/sql.ts +192 -0
- package/src/main.ts +31 -0
- package/src/utils/cognito-session.ts +1 -1
- package/src/utils/sandbox-runner-client.ts +1 -1
- package/test/commands/db-tenant-isolation.test.ts +94 -0
- package/test/commands/db.test.ts +85 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq outposts` — manage your personal HQ Outposts (EC2 boxes) from the
|
|
3
|
+
* terminal instead of the web console. Targets the hq-pro `/outpost/*`
|
|
4
|
+
* control plane on `DEFAULT_VAULT_API_URL` via the shared `vaultApiFetch`
|
|
5
|
+
* helper — the same routes the console's outpost panel calls.
|
|
6
|
+
*
|
|
7
|
+
* Outposts are PERSONAL / caller-scoped: hq-pro keys every `/outpost/*` route
|
|
8
|
+
* on the caller's Cognito sub, so there is no `--company`. `--id <outpostId>`
|
|
9
|
+
* selects a specific box (passed as the `outpostId` query param); when omitted
|
|
10
|
+
* hq-pro targets the caller's primary slot.
|
|
11
|
+
*
|
|
12
|
+
* Subcommands:
|
|
13
|
+
* hq outposts list — every Outpost you own (row summaries)
|
|
14
|
+
* hq outposts status [--id] — live detail for one box
|
|
15
|
+
* hq outposts codex-enable [--id] — enable / retry Codex on the box
|
|
16
|
+
* hq outposts login [--id] — request a fresh login URL
|
|
17
|
+
* hq outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
|
|
18
|
+
*
|
|
19
|
+
* NOTE: hq-pro exposes no rename or settings-mutation route for Outposts (the
|
|
20
|
+
* web console can't rename them either), so this CLI wraps only the lifecycle
|
|
21
|
+
* and status routes that exist. Renaming an Outpost is not a backend capability.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Command } from "commander";
|
|
25
|
+
import chalk from "chalk";
|
|
26
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
27
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
28
|
+
|
|
29
|
+
/** A non-2xx from the `/outpost/*` control plane. Carries status + `step`. */
|
|
30
|
+
export class OutpostHttpError extends Error {
|
|
31
|
+
status: number;
|
|
32
|
+
step?: string;
|
|
33
|
+
constructor(status: number, message: string, step?: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "OutpostHttpError";
|
|
36
|
+
this.status = status;
|
|
37
|
+
this.step = step;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Row summary from `GET /outpost/list`. */
|
|
42
|
+
export interface OutpostSummary {
|
|
43
|
+
outpostId: string;
|
|
44
|
+
state: string;
|
|
45
|
+
instanceName: string;
|
|
46
|
+
region: string;
|
|
47
|
+
agentRuntime: string;
|
|
48
|
+
platform: string;
|
|
49
|
+
createdAt: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Authenticated JSON round-trip against the outpost control plane. Throws
|
|
55
|
+
* `OutpostHttpError` on any non-2xx (never swallows — hq-never-swallow-errors),
|
|
56
|
+
* decoding hq-pro's `{ error | message, step }` envelope for the reason. The
|
|
57
|
+
* `step` is preserved so callers can recognise the `destroy` route's
|
|
58
|
+
* `teardown-incomplete` 409 (which means "retry", not "failed").
|
|
59
|
+
*/
|
|
60
|
+
export async function outpostRequest<T>(opts: {
|
|
61
|
+
token: string;
|
|
62
|
+
path: string;
|
|
63
|
+
method?: string;
|
|
64
|
+
query?: Record<string, string>;
|
|
65
|
+
}): Promise<T> {
|
|
66
|
+
const res = await vaultApiFetch(opts);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
69
|
+
error?: unknown;
|
|
70
|
+
message?: string;
|
|
71
|
+
step?: string;
|
|
72
|
+
};
|
|
73
|
+
const message =
|
|
74
|
+
typeof body.message === "string"
|
|
75
|
+
? body.message
|
|
76
|
+
: typeof body.error === "string"
|
|
77
|
+
? body.error
|
|
78
|
+
: res.statusText;
|
|
79
|
+
throw new OutpostHttpError(res.status, message, body.step);
|
|
80
|
+
}
|
|
81
|
+
return (await res.json()) as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function listOutposts(token: string): Promise<OutpostSummary[]> {
|
|
85
|
+
const data = await outpostRequest<{ outposts: OutpostSummary[] }>({
|
|
86
|
+
token,
|
|
87
|
+
path: "/outpost/list",
|
|
88
|
+
});
|
|
89
|
+
return data.outposts ?? [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getOutpostStatus(
|
|
93
|
+
token: string,
|
|
94
|
+
outpostId?: string,
|
|
95
|
+
): Promise<Record<string, unknown>> {
|
|
96
|
+
return outpostRequest({
|
|
97
|
+
token,
|
|
98
|
+
path: "/outpost/status",
|
|
99
|
+
query: outpostId ? { outpostId } : undefined,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function enableCodex(
|
|
104
|
+
token: string,
|
|
105
|
+
outpostId?: string,
|
|
106
|
+
): Promise<Record<string, unknown>> {
|
|
107
|
+
return outpostRequest({
|
|
108
|
+
token,
|
|
109
|
+
path: "/outpost/codex/enable",
|
|
110
|
+
method: "POST",
|
|
111
|
+
query: outpostId ? { outpostId } : undefined,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function regenerateLoginUrl(
|
|
116
|
+
token: string,
|
|
117
|
+
outpostId?: string,
|
|
118
|
+
): Promise<Record<string, unknown>> {
|
|
119
|
+
return outpostRequest({
|
|
120
|
+
token,
|
|
121
|
+
path: "/outpost/regenerate-login-url",
|
|
122
|
+
method: "POST",
|
|
123
|
+
query: outpostId ? { outpostId } : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function destroyOutpost(
|
|
128
|
+
token: string,
|
|
129
|
+
outpostId?: string,
|
|
130
|
+
): Promise<Record<string, unknown>> {
|
|
131
|
+
return outpostRequest({
|
|
132
|
+
token,
|
|
133
|
+
path: "/outpost/destroy",
|
|
134
|
+
method: "POST",
|
|
135
|
+
query: outpostId ? { outpostId } : undefined,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Command registration
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function fail(err: unknown): never {
|
|
144
|
+
if (err instanceof OutpostHttpError) {
|
|
145
|
+
console.error(chalk.red(err.message));
|
|
146
|
+
} else {
|
|
147
|
+
console.error(
|
|
148
|
+
chalk.red("Error:"),
|
|
149
|
+
err instanceof Error ? err.message : String(err),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Print a top-level object as `key: value`, JSON-ifying nested values. */
|
|
156
|
+
function printKeyValues(obj: Record<string, unknown>): void {
|
|
157
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
158
|
+
const rendered = v && typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
159
|
+
console.log(`${chalk.bold(k)}: ${rendered}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function registerOutpostsCommand(program: Command): void {
|
|
164
|
+
const outposts = program
|
|
165
|
+
.command("outposts")
|
|
166
|
+
.description("Manage your personal HQ Outposts (EC2 boxes)");
|
|
167
|
+
|
|
168
|
+
outposts
|
|
169
|
+
.command("list")
|
|
170
|
+
.description("List every Outpost you own")
|
|
171
|
+
.option("--json", "Emit raw JSON")
|
|
172
|
+
.action(async function (this: Command, opts: { json?: boolean }) {
|
|
173
|
+
try {
|
|
174
|
+
const token = await ensureCognitoToken();
|
|
175
|
+
const rows = await listOutposts(token);
|
|
176
|
+
if (opts.json) {
|
|
177
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (rows.length === 0) {
|
|
181
|
+
console.log(chalk.gray("You don't own any Outposts yet."));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const idW = Math.max(2, ...rows.map((r) => (r.outpostId ?? "").length));
|
|
185
|
+
const stateW = Math.max(5, ...rows.map((r) => (r.state ?? "").length));
|
|
186
|
+
const nameW = Math.max(
|
|
187
|
+
4,
|
|
188
|
+
...rows.map((r) => (r.instanceName ?? "").length),
|
|
189
|
+
);
|
|
190
|
+
const regionW = Math.max(6, ...rows.map((r) => (r.region ?? "").length));
|
|
191
|
+
const rtW = Math.max(
|
|
192
|
+
7,
|
|
193
|
+
...rows.map((r) => (r.agentRuntime ?? "").length),
|
|
194
|
+
);
|
|
195
|
+
console.log(
|
|
196
|
+
chalk.bold(
|
|
197
|
+
[
|
|
198
|
+
"ID".padEnd(idW),
|
|
199
|
+
"STATE".padEnd(stateW),
|
|
200
|
+
"INSTANCE".padEnd(nameW),
|
|
201
|
+
"REGION".padEnd(regionW),
|
|
202
|
+
"RUNTIME".padEnd(rtW),
|
|
203
|
+
"PLATFORM",
|
|
204
|
+
].join(" "),
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
for (const r of rows) {
|
|
208
|
+
console.log(
|
|
209
|
+
[
|
|
210
|
+
(r.outpostId ?? "").padEnd(idW),
|
|
211
|
+
(r.state ?? "").padEnd(stateW),
|
|
212
|
+
(r.instanceName ?? "").padEnd(nameW),
|
|
213
|
+
(r.region ?? "").padEnd(regionW),
|
|
214
|
+
(r.agentRuntime ?? "").padEnd(rtW),
|
|
215
|
+
r.platform ?? "",
|
|
216
|
+
].join(" "),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
fail(err);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
outposts
|
|
225
|
+
.command("status")
|
|
226
|
+
.description("Show live detail for one Outpost")
|
|
227
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
228
|
+
.option("--json", "Emit raw JSON")
|
|
229
|
+
.action(async function (
|
|
230
|
+
this: Command,
|
|
231
|
+
opts: { id?: string; json?: boolean },
|
|
232
|
+
) {
|
|
233
|
+
try {
|
|
234
|
+
const token = await ensureCognitoToken();
|
|
235
|
+
const status = await getOutpostStatus(token, opts.id);
|
|
236
|
+
if (opts.json) {
|
|
237
|
+
process.stdout.write(JSON.stringify(status, null, 2) + "\n");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
printKeyValues(status);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
fail(err);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
outposts
|
|
247
|
+
.command("codex-enable")
|
|
248
|
+
.description("Enable (or retry) Codex on an Outpost")
|
|
249
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
250
|
+
.option("--json", "Emit raw JSON")
|
|
251
|
+
.action(async function (
|
|
252
|
+
this: Command,
|
|
253
|
+
opts: { id?: string; json?: boolean },
|
|
254
|
+
) {
|
|
255
|
+
try {
|
|
256
|
+
const token = await ensureCognitoToken();
|
|
257
|
+
const result = await enableCodex(token, opts.id);
|
|
258
|
+
if (opts.json) {
|
|
259
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
console.log(chalk.green("Codex enablement requested for the Outpost."));
|
|
263
|
+
} catch (err) {
|
|
264
|
+
fail(err);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
outposts
|
|
269
|
+
.command("login")
|
|
270
|
+
.description("Request a fresh login URL for an Outpost")
|
|
271
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
272
|
+
.option("--json", "Emit raw JSON")
|
|
273
|
+
.action(async function (
|
|
274
|
+
this: Command,
|
|
275
|
+
opts: { id?: string; json?: boolean },
|
|
276
|
+
) {
|
|
277
|
+
try {
|
|
278
|
+
const token = await ensureCognitoToken();
|
|
279
|
+
const result = await regenerateLoginUrl(token, opts.id);
|
|
280
|
+
if (opts.json) {
|
|
281
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
console.log(
|
|
285
|
+
chalk.green(
|
|
286
|
+
"Login-URL regeneration requested. The box mints a fresh URL shortly — " +
|
|
287
|
+
"check `hq outposts status` to pick it up.",
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
fail(err);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
outposts
|
|
296
|
+
.command("destroy")
|
|
297
|
+
.description("Tear down (permanently destroy) an Outpost")
|
|
298
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
299
|
+
.option("--yes", "Confirm the irreversible teardown (required)")
|
|
300
|
+
.action(async function (
|
|
301
|
+
this: Command,
|
|
302
|
+
opts: { id?: string; yes?: boolean },
|
|
303
|
+
) {
|
|
304
|
+
const target = opts.id ?? "your primary Outpost";
|
|
305
|
+
if (!opts.yes) {
|
|
306
|
+
console.error(
|
|
307
|
+
chalk.yellow(
|
|
308
|
+
`This will permanently destroy ${target} and delete its cloud resources. ` +
|
|
309
|
+
`This cannot be undone.\n` +
|
|
310
|
+
`Re-run with --yes to confirm: hq outposts destroy${opts.id ? ` --id ${opts.id}` : ""} --yes`,
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const token = await ensureCognitoToken();
|
|
317
|
+
await destroyOutpost(token, opts.id);
|
|
318
|
+
console.log(chalk.green(`Destroyed ${target}.`));
|
|
319
|
+
} catch (err) {
|
|
320
|
+
// A 409 teardown-incomplete is not a failure — the gateway's 30s cap
|
|
321
|
+
// fired while the Lambda keeps working. The row is preserved and the
|
|
322
|
+
// operation is idempotent, so tell the caller to retry.
|
|
323
|
+
if (
|
|
324
|
+
err instanceof OutpostHttpError &&
|
|
325
|
+
(err.status === 409 || err.step === "teardown-incomplete")
|
|
326
|
+
) {
|
|
327
|
+
console.log(
|
|
328
|
+
chalk.yellow(
|
|
329
|
+
`Teardown still in progress for ${target} — this is expected for large boxes. ` +
|
|
330
|
+
`Re-run the same destroy command to finish (it's idempotent).`,
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
fail(err);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-landing hardening (secrets-proxy-per-secret-destination): `--destination`
|
|
3
|
+
* must reject an embedded `user:pass@host` userinfo segment EXPLICITLY rather
|
|
4
|
+
* than letting `new URL()` silently drop it and normalize to the bare host.
|
|
5
|
+
* Mirrors hq-pro's authoritative `validateDestinations` server-side check — the
|
|
6
|
+
* server re-validates and remains authoritative, but the CLI must not present a
|
|
7
|
+
* userinfo URL as an accepted destination.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { parseDestinationUrl } from "./secrets.js";
|
|
11
|
+
|
|
12
|
+
describe("parseDestinationUrl — userinfo hardening", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
// parseDestinationUrl logs the rejection reason to stderr; silence it so the
|
|
15
|
+
// test output stays clean while still exercising the real code path.
|
|
16
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
"https://user:pass@api.openai.com",
|
|
24
|
+
"https://user@api.openai.com",
|
|
25
|
+
"https://:pass@api.openai.com",
|
|
26
|
+
])("rejects a destination with embedded userinfo (%s)", (raw) => {
|
|
27
|
+
expect(parseDestinationUrl(raw).ok).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("still accepts a bare scheme+host HTTPS destination (guards against over-rejection)", () => {
|
|
31
|
+
const result = parseDestinationUrl("https://api.openai.com");
|
|
32
|
+
expect(result.ok).toBe(true);
|
|
33
|
+
if (result.ok) {
|
|
34
|
+
expect(result.url).toBe("https://api.openai.com");
|
|
35
|
+
expect(result.hostname).toBe("api.openai.com");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -162,6 +162,30 @@ describe("secrets sandbox", () => {
|
|
|
162
162
|
expect(stdoutSpy).toHaveBeenCalledWith("done\n");
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
it("accumulates repeated --only flags instead of silently keeping only the last", async () => {
|
|
166
|
+
const program = buildProgram();
|
|
167
|
+
await program.parseAsync([
|
|
168
|
+
"node",
|
|
169
|
+
"hq",
|
|
170
|
+
"secrets",
|
|
171
|
+
"sandbox",
|
|
172
|
+
"--company",
|
|
173
|
+
"acme",
|
|
174
|
+
"--only",
|
|
175
|
+
"API_KEY,OTHER_KEY",
|
|
176
|
+
"--only",
|
|
177
|
+
"THIRD_KEY",
|
|
178
|
+
"--",
|
|
179
|
+
"env",
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
183
|
+
companyUid: "prs_alice",
|
|
184
|
+
secretNames: ["API_KEY", "OTHER_KEY", "THIRD_KEY"],
|
|
185
|
+
command: "env",
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
165
189
|
it("requires --only", async () => {
|
|
166
190
|
const program = buildProgram();
|
|
167
191
|
|
package/src/commands/secrets.ts
CHANGED
|
@@ -338,7 +338,7 @@ function parseAuthStyle(authStyle: string): SecretInjectionRecipe | null {
|
|
|
338
338
|
// (src/vault-service/handlers/secrets.ts) so a malformed URL is caught
|
|
339
339
|
// locally with an actionable message rather than a round trip — the server
|
|
340
340
|
// re-validates and remains authoritative.
|
|
341
|
-
function parseDestinationUrl(
|
|
341
|
+
export function parseDestinationUrl(
|
|
342
342
|
raw: string,
|
|
343
343
|
): { ok: true; url: string; hostname: string } | { ok: false } {
|
|
344
344
|
let parsed: URL;
|
|
@@ -362,6 +362,17 @@ function parseDestinationUrl(
|
|
|
362
362
|
);
|
|
363
363
|
return { ok: false };
|
|
364
364
|
}
|
|
365
|
+
// Reject an embedded `user:pass@host` credential segment explicitly rather
|
|
366
|
+
// than letting `new URL()` silently drop it (mirrors hq-pro's authoritative
|
|
367
|
+
// `validateDestinations`; the server re-validates and remains authoritative).
|
|
368
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
369
|
+
console.error(
|
|
370
|
+
chalk.red(
|
|
371
|
+
`Invalid --destination '${raw}': must not contain embedded userinfo (user:pass@) credentials`,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
return { ok: false };
|
|
375
|
+
}
|
|
365
376
|
if (
|
|
366
377
|
(parsed.pathname !== "" && parsed.pathname !== "/") ||
|
|
367
378
|
parsed.search !== "" ||
|
|
@@ -427,8 +438,17 @@ async function buildSecretUsage(
|
|
|
427
438
|
};
|
|
428
439
|
}
|
|
429
440
|
|
|
430
|
-
|
|
431
|
-
|
|
441
|
+
// Commander collector for `--only`: accumulates across REPEATED flags instead
|
|
442
|
+
// of the last one silently winning, and each value may still be
|
|
443
|
+
// comma-separated. So `--only A,B`, `--only A --only B`, and
|
|
444
|
+
// `--only A,B --only C` all resolve to the full list.
|
|
445
|
+
export function collectSecretNames(value: string, previous?: string[]): string[] {
|
|
446
|
+
const parsed = value.split(",").map((k) => k.trim()).filter(Boolean);
|
|
447
|
+
return (previous ?? []).concat(parsed);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function parseSecretNameList(input: string[] | undefined): string[] {
|
|
451
|
+
const keys = input ?? [];
|
|
432
452
|
if (keys.length === 0) {
|
|
433
453
|
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
434
454
|
process.exit(1);
|
|
@@ -1447,11 +1467,11 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1447
1467
|
"--personal",
|
|
1448
1468
|
"Operate on the caller's personal vault (no sharing)",
|
|
1449
1469
|
)
|
|
1450
|
-
.option("--only <keys>", "
|
|
1470
|
+
.option("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
|
|
1451
1471
|
.allowUnknownOption(true)
|
|
1452
|
-
.action(async (opts: { company?: string; personal?: boolean; only?: string }, cmd: Command) => {
|
|
1472
|
+
.action(async (opts: { company?: string; personal?: boolean; only?: string[] }, cmd: Command) => {
|
|
1453
1473
|
try {
|
|
1454
|
-
if (!opts.only || opts.only.
|
|
1474
|
+
if (!opts.only || opts.only.length === 0) {
|
|
1455
1475
|
console.error(chalk.red("Error: --only is required and must name at least one secret."));
|
|
1456
1476
|
process.exit(1);
|
|
1457
1477
|
}
|
|
@@ -1501,10 +1521,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1501
1521
|
secrets
|
|
1502
1522
|
.command("exec")
|
|
1503
1523
|
.description("Run a command with secrets injected as env vars")
|
|
1504
|
-
.requiredOption("--only <keys>", "
|
|
1524
|
+
.requiredOption("--only <keys>", "Secret names to inject (comma-separated; may be repeated) (required)", collectSecretNames)
|
|
1505
1525
|
.option("--script <path>", "Attach local script identity for script-locked secrets")
|
|
1506
1526
|
.allowUnknownOption(true)
|
|
1507
|
-
.action(async (_opts: { only: string; script?: string }, cmd: Command) => {
|
|
1527
|
+
.action(async (_opts: { only: string[]; script?: string }, cmd: Command) => {
|
|
1508
1528
|
try {
|
|
1509
1529
|
const rawArgs = cmd.args;
|
|
1510
1530
|
const dashIndex = process.argv.indexOf("--");
|
|
@@ -1575,9 +1595,9 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1575
1595
|
secrets
|
|
1576
1596
|
.command("env")
|
|
1577
1597
|
.description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
|
|
1578
|
-
.requiredOption("--only <keys>", "
|
|
1598
|
+
.requiredOption("--only <keys>", "Secret names to print (comma-separated; may be repeated) (required)", collectSecretNames)
|
|
1579
1599
|
.option("--script <path>", "Attach local script identity for script-locked secrets")
|
|
1580
|
-
.action(async (opts: { only: string; script?: string }) => {
|
|
1600
|
+
.action(async (opts: { only: string[]; script?: string }) => {
|
|
1581
1601
|
try {
|
|
1582
1602
|
const redact = process.stdout.isTTY;
|
|
1583
1603
|
if (redact) {
|