@indigoai-us/hq-cli 5.47.1 → 5.47.3
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 +16 -0
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +93 -46
- package/package.json +2 -2
- package/src/commands/secrets.test.ts +128 -1
- package/src/commands/secrets.ts +107 -56
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`hq secrets exec` / `hq secrets env` load via the batch endpoint, killing
|
|
8
|
+
the spurious "Secret not found" Sentry warning (HQ-4H).** Both commands used
|
|
9
|
+
to fetch each `--only` key with a single-secret GET
|
|
10
|
+
(`/secrets/{uid}/name/{name}?reveal=true`). Every GET that 404s makes
|
|
11
|
+
vault-service capture an (unattributed) `"Secret not found"` warning — the
|
|
12
|
+
right signal for `hq secrets get <name>` (a caller that genuinely needed
|
|
13
|
+
THAT secret), but pure noise for a multi-key load where a missing / renamed
|
|
14
|
+
/ rotated key is a per-key outcome the command already reports cleanly. They
|
|
15
|
+
now use the batch-load endpoint (`POST /secrets/{uid}/load`), which returns
|
|
16
|
+
`200` with a per-name `errors[]` array and never captures — the same path
|
|
17
|
+
`hq run` already uses (which is why `hq run` never tripped HQ-4H). Identical
|
|
18
|
+
UX and error text; `hq secrets get` is unchanged (its 404 is a genuine
|
|
19
|
+
signal and stays captured).
|
|
20
|
+
|
|
5
21
|
## [5.44.0]
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -2,5 +2,6 @@ import { Command } from "commander";
|
|
|
2
2
|
import { vaultApiFetch, getCompanyUid, getEntityUid } from "../utils/vault-api.js";
|
|
3
3
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
4
4
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
5
|
+
export declare function loadRevealedSecrets(token: string, companyUid: string, keys: string[]): Promise<Map<string, string>>;
|
|
5
6
|
export declare function registerSecretsCommand(program: Command): void;
|
|
6
7
|
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="bdef03fd-2f30-5034-8f32-23a5eb77cc3d")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -124,6 +124,81 @@ function promptSecretInteractively() {
|
|
|
124
124
|
process.stdin.resume();
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
|
+
// Maximum names per batch-load request. Mirrors hq-pro's MAX_BATCH_NAMES so a
|
|
128
|
+
// large --only list is chunked client-side rather than 400'd whole by the
|
|
129
|
+
// server (the legacy per-key GET path had no such cap).
|
|
130
|
+
const MAX_BATCH_NAMES = 100;
|
|
131
|
+
// HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
|
|
132
|
+
// (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
|
|
133
|
+
//
|
|
134
|
+
// Every single-secret GET that 404s makes vault-service emit an unattributed
|
|
135
|
+
// "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
|
|
136
|
+
// the response boundary (hq-pro `_error-response.ts`). That capture is the
|
|
137
|
+
// right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
|
|
138
|
+
// secret and it was missing"), but it is pure noise for a multi-key load where
|
|
139
|
+
// a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
|
|
140
|
+
// report cleanly to the user. The batch-load endpoint returns 200 with a
|
|
141
|
+
// per-name `errors[]` array and never captures — the same path `hq run` uses,
|
|
142
|
+
// which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
|
|
143
|
+
// `env` through it stops those callers from emitting the warning while keeping
|
|
144
|
+
// identical UX and error text.
|
|
145
|
+
//
|
|
146
|
+
// Cache-first (so warm keys cost no request), chunked at MAX_BATCH_NAMES, and
|
|
147
|
+
// throws on the FIRST unresolved key with the same `Failed to fetch secret
|
|
148
|
+
// '<k>': <reason>` shape the per-key GET path used — never swallows a failure.
|
|
149
|
+
export async function loadRevealedSecrets(token, companyUid, keys) {
|
|
150
|
+
const resolved = new Map();
|
|
151
|
+
const missing = [];
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const cached = readCache(companyUid, key);
|
|
154
|
+
if (cached !== null) {
|
|
155
|
+
resolved.set(key, cached);
|
|
156
|
+
}
|
|
157
|
+
else if (!missing.includes(key)) {
|
|
158
|
+
missing.push(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (let i = 0; i < missing.length; i += MAX_BATCH_NAMES) {
|
|
162
|
+
const chunk = missing.slice(i, i + MAX_BATCH_NAMES);
|
|
163
|
+
const res = await vaultApiFetch({
|
|
164
|
+
token,
|
|
165
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: { names: chunk },
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const body = (await res.json().catch(() => ({})));
|
|
171
|
+
throw new Error(`Failed to batch-load secrets: ${body.error ?? res.statusText}`);
|
|
172
|
+
}
|
|
173
|
+
const data = (await res.json());
|
|
174
|
+
for (const s of data.secrets ?? []) {
|
|
175
|
+
if (s.value == null) {
|
|
176
|
+
throw new Error(`Secret '${s.name}' has no value (reveal may not be permitted).`);
|
|
177
|
+
}
|
|
178
|
+
writeCache(companyUid, s.name, s.value);
|
|
179
|
+
resolved.set(s.name, s.value);
|
|
180
|
+
}
|
|
181
|
+
const errorsByName = new Map();
|
|
182
|
+
for (const e of data.errors ?? []) {
|
|
183
|
+
errorsByName.set(e.name, { code: e.code, message: e.message });
|
|
184
|
+
}
|
|
185
|
+
// Any requested key in this chunk the server did not return is a per-key
|
|
186
|
+
// failure — surface it with the same prefix the single-GET path used so
|
|
187
|
+
// callers (and scripts grepping stderr) see no behavior change.
|
|
188
|
+
for (const key of chunk) {
|
|
189
|
+
if (resolved.has(key))
|
|
190
|
+
continue;
|
|
191
|
+
const err = errorsByName.get(key);
|
|
192
|
+
const reason = err?.code === "not_found"
|
|
193
|
+
? "Secret not found"
|
|
194
|
+
: err?.code === "forbidden"
|
|
195
|
+
? err.message ?? "No read permission"
|
|
196
|
+
: err?.message ?? err?.code ?? "not returned by vault";
|
|
197
|
+
throw new Error(`Failed to fetch secret '${key}': ${reason}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return resolved;
|
|
201
|
+
}
|
|
127
202
|
export function registerSecretsCommand(program) {
|
|
128
203
|
const secrets = program
|
|
129
204
|
.command("secrets")
|
|
@@ -403,29 +478,15 @@ export function registerSecretsCommand(program) {
|
|
|
403
478
|
}
|
|
404
479
|
const token = await ensureCognitoToken();
|
|
405
480
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
406
|
-
const revealed = await
|
|
407
|
-
const cached = readCache(companyUid, key);
|
|
408
|
-
if (cached !== null) {
|
|
409
|
-
return { key, value: cached };
|
|
410
|
-
}
|
|
411
|
-
const res = await vaultApiFetch({
|
|
412
|
-
token,
|
|
413
|
-
path: buildSecretNamePath(companyUid, key),
|
|
414
|
-
query: { reveal: "true" },
|
|
415
|
-
});
|
|
416
|
-
if (!res.ok) {
|
|
417
|
-
const body = await res.json().catch(() => ({}));
|
|
418
|
-
throw new Error(`Failed to fetch secret '${key}': ${body.error ?? res.statusText}`);
|
|
419
|
-
}
|
|
420
|
-
const data = (await res.json());
|
|
421
|
-
if (data.secret.value == null) {
|
|
422
|
-
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
423
|
-
}
|
|
424
|
-
writeCache(companyUid, key, data.secret.value);
|
|
425
|
-
return { key, value: data.secret.value };
|
|
426
|
-
}));
|
|
481
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys);
|
|
427
482
|
const secretEnv = {};
|
|
428
|
-
for (const
|
|
483
|
+
for (const key of keys) {
|
|
484
|
+
const value = revealed.get(key);
|
|
485
|
+
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
486
|
+
// unreachable — guard rather than inject an `undefined` into the env.
|
|
487
|
+
if (value === undefined) {
|
|
488
|
+
throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
|
|
489
|
+
}
|
|
429
490
|
secretEnv[key] = value;
|
|
430
491
|
}
|
|
431
492
|
const [childCmd, ...childCmdArgs] = childArgs;
|
|
@@ -472,28 +533,14 @@ export function registerSecretsCommand(program) {
|
|
|
472
533
|
}
|
|
473
534
|
const token = await ensureCognitoToken();
|
|
474
535
|
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
475
|
-
const revealed = await
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
query: { reveal: "true" },
|
|
484
|
-
});
|
|
485
|
-
if (!res.ok) {
|
|
486
|
-
const body = await res.json().catch(() => ({}));
|
|
487
|
-
throw new Error(`Failed to fetch secret '${key}': ${body.error ?? res.statusText}`);
|
|
488
|
-
}
|
|
489
|
-
const data = (await res.json());
|
|
490
|
-
if (data.secret.value == null) {
|
|
491
|
-
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
492
|
-
}
|
|
493
|
-
writeCache(companyUid, key, data.secret.value);
|
|
494
|
-
return { key, value: data.secret.value };
|
|
495
|
-
}));
|
|
496
|
-
for (const { key, value } of revealed) {
|
|
536
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys);
|
|
537
|
+
for (const key of keys) {
|
|
538
|
+
const value = revealed.get(key);
|
|
539
|
+
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
540
|
+
// unreachable — guard rather than print an `undefined` export line.
|
|
541
|
+
if (value === undefined) {
|
|
542
|
+
throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
|
|
543
|
+
}
|
|
497
544
|
const out = redact ? "[REDACTED]" : value;
|
|
498
545
|
process.stdout.write(`export ${key}=${shellSingleQuote(out)}\n`);
|
|
499
546
|
}
|
|
@@ -754,4 +801,4 @@ export function registerSecretsCommand(program) {
|
|
|
754
801
|
});
|
|
755
802
|
}
|
|
756
803
|
//# sourceMappingURL=secrets.js.map
|
|
757
|
-
//# debugId=
|
|
804
|
+
//# debugId=bdef03fd-2f30-5034-8f32-23a5eb77cc3d
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.47.
|
|
3
|
+
"version": "5.47.3",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "^6.11.
|
|
18
|
+
"@indigoai-us/hq-cloud": "^6.11.1",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -34,9 +34,21 @@ vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
|
34
34
|
};
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
vi.mock("../utils/secrets-cache.js", async (importOriginal) => {
|
|
38
|
+
const original = (await importOriginal()) as Record<string, unknown>;
|
|
39
|
+
return {
|
|
40
|
+
...original,
|
|
41
|
+
// Default: cold cache (every key is a miss → forces a batch-load request).
|
|
42
|
+
// Tests that exercise the warm path override readCache per-case.
|
|
43
|
+
readCache: vi.fn(() => null),
|
|
44
|
+
writeCache: vi.fn(() => undefined),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
import { Command } from "commander";
|
|
38
|
-
import { registerSecretsCommand } from "./secrets.js";
|
|
49
|
+
import { registerSecretsCommand, loadRevealedSecrets } from "./secrets.js";
|
|
39
50
|
import { getEntityUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
51
|
+
import { readCache, writeCache } from "../utils/secrets-cache.js";
|
|
40
52
|
|
|
41
53
|
let logSpy: MockInstance<typeof console.log>;
|
|
42
54
|
let errSpy: MockInstance<typeof console.error>;
|
|
@@ -159,3 +171,118 @@ describe("secrets generate-link", () => {
|
|
|
159
171
|
expect(errSpy).not.toHaveBeenCalled();
|
|
160
172
|
});
|
|
161
173
|
});
|
|
174
|
+
|
|
175
|
+
// HQ-4H — `hq secrets exec`/`env` load secrets through the BATCH-LOAD endpoint
|
|
176
|
+
// (`POST /secrets/{uid}/load`), which returns 200 + per-name `errors[]` and
|
|
177
|
+
// never fires the server's "Secret not found" Sentry warning. The old per-key
|
|
178
|
+
// path single-GET'd `/secrets/{uid}/name/{name}?reveal=true`, and EVERY such
|
|
179
|
+
// 404 made vault-service capture an (unattributable) warning. These tests pin
|
|
180
|
+
// that the caller no longer touches the capturing GET route — the differential
|
|
181
|
+
// at the client boundary that "stops the caller from emitting the error event".
|
|
182
|
+
describe("secrets exec/env batch-load (HQ-4H)", () => {
|
|
183
|
+
function jsonRes(body: unknown, status = 200): Response {
|
|
184
|
+
return new Response(JSON.stringify(body), {
|
|
185
|
+
status,
|
|
186
|
+
headers: { "Content-Type": "application/json" },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A path that contains the single-secret GET segment — the route whose 404
|
|
191
|
+
// fires the capture. The whole fix is that we never call it from exec/env.
|
|
192
|
+
const CAPTURING_GET = expect.objectContaining({
|
|
193
|
+
path: expect.stringContaining("/name/"),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
beforeEach(() => {
|
|
197
|
+
vi.mocked(readCache).mockReturnValue(null);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("loads via /load (POST) and never the capturing single-secret GET", async () => {
|
|
201
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
202
|
+
jsonRes({
|
|
203
|
+
secrets: [
|
|
204
|
+
{ name: "MY_KEY", value: "v1" },
|
|
205
|
+
{ name: "OTHER", value: "v2" },
|
|
206
|
+
],
|
|
207
|
+
errors: [],
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const out = await loadRevealedSecrets("test-token", "prs_alice", [
|
|
212
|
+
"MY_KEY",
|
|
213
|
+
"OTHER",
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
expect(out.get("MY_KEY")).toBe("v1");
|
|
217
|
+
expect(out.get("OTHER")).toBe("v2");
|
|
218
|
+
expect(vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
220
|
+
token: "test-token",
|
|
221
|
+
path: "/secrets/prs_alice/load",
|
|
222
|
+
method: "POST",
|
|
223
|
+
body: { names: ["MY_KEY", "OTHER"] },
|
|
224
|
+
});
|
|
225
|
+
// The crux: the capturing GET route is never touched.
|
|
226
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
|
|
227
|
+
expect(writeCache).toHaveBeenCalledWith("prs_alice", "MY_KEY", "v1");
|
|
228
|
+
expect(writeCache).toHaveBeenCalledWith("prs_alice", "OTHER", "v2");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("a missing key fails cleanly WITHOUT hitting the GET-404 capture path", async () => {
|
|
232
|
+
// Batch-load reports absence as a 200 body `errors[]` entry — no 404, no
|
|
233
|
+
// server capture. The old code GET'd `/name/MISSING` → 404 → warning.
|
|
234
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
235
|
+
jsonRes({
|
|
236
|
+
secrets: [],
|
|
237
|
+
errors: [{ name: "MISSING", code: "not_found" }],
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
loadRevealedSecrets("test-token", "prs_alice", ["MISSING"]),
|
|
243
|
+
).rejects.toThrow("Failed to fetch secret 'MISSING': Secret not found");
|
|
244
|
+
|
|
245
|
+
expect(vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
246
|
+
expect(vaultApiFetch).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({ path: "/secrets/prs_alice/load", method: "POST" }),
|
|
248
|
+
);
|
|
249
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("a forbidden key surfaces as a clean per-key error", async () => {
|
|
253
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
254
|
+
jsonRes({
|
|
255
|
+
secrets: [],
|
|
256
|
+
errors: [
|
|
257
|
+
{ name: "LOCKED", code: "forbidden", message: "No read permission" },
|
|
258
|
+
],
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
await expect(
|
|
263
|
+
loadRevealedSecrets("test-token", "prs_alice", ["LOCKED"]),
|
|
264
|
+
).rejects.toThrow("Failed to fetch secret 'LOCKED': No read permission");
|
|
265
|
+
expect(vaultApiFetch).not.toHaveBeenCalledWith(CAPTURING_GET);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("warm-cache keys cost no request at all", async () => {
|
|
269
|
+
vi.mocked(readCache).mockReturnValue("cached-value");
|
|
270
|
+
|
|
271
|
+
const out = await loadRevealedSecrets("test-token", "prs_alice", [
|
|
272
|
+
"CACHED",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
expect(out.get("CACHED")).toBe("cached-value");
|
|
276
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("a non-2xx batch response throws the batch-load error", async () => {
|
|
280
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
281
|
+
jsonRes({ error: "Internal server error" }, 500),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
loadRevealedSecrets("test-token", "prs_alice", ["K"]),
|
|
286
|
+
).rejects.toThrow("Failed to batch-load secrets: Internal server error");
|
|
287
|
+
});
|
|
288
|
+
});
|
package/src/commands/secrets.ts
CHANGED
|
@@ -152,6 +152,97 @@ function promptSecretInteractively(): Promise<string> {
|
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Maximum names per batch-load request. Mirrors hq-pro's MAX_BATCH_NAMES so a
|
|
156
|
+
// large --only list is chunked client-side rather than 400'd whole by the
|
|
157
|
+
// server (the legacy per-key GET path had no such cap).
|
|
158
|
+
const MAX_BATCH_NAMES = 100;
|
|
159
|
+
|
|
160
|
+
// HQ-4H — load + decrypt secrets through the BATCH-LOAD endpoint
|
|
161
|
+
// (`POST /secrets/{companyUid}/load`) instead of one single-secret GET per key.
|
|
162
|
+
//
|
|
163
|
+
// Every single-secret GET that 404s makes vault-service emit an unattributed
|
|
164
|
+
// "Secret not found" Sentry *warning* — each `response(404, …)` is captured at
|
|
165
|
+
// the response boundary (hq-pro `_error-response.ts`). That capture is the
|
|
166
|
+
// right signal for `hq secrets get <name>` ("a caller genuinely needed THIS
|
|
167
|
+
// secret and it was missing"), but it is pure noise for a multi-key load where
|
|
168
|
+
// a missing / renamed / rotated key is a per-key outcome `exec`/`env` already
|
|
169
|
+
// report cleanly to the user. The batch-load endpoint returns 200 with a
|
|
170
|
+
// per-name `errors[]` array and never captures — the same path `hq run` uses,
|
|
171
|
+
// which is exactly why `hq run` never contributed to HQ-4H. Routing `exec` and
|
|
172
|
+
// `env` through it stops those callers from emitting the warning while keeping
|
|
173
|
+
// identical UX and error text.
|
|
174
|
+
//
|
|
175
|
+
// Cache-first (so warm keys cost no request), chunked at MAX_BATCH_NAMES, and
|
|
176
|
+
// throws on the FIRST unresolved key with the same `Failed to fetch secret
|
|
177
|
+
// '<k>': <reason>` shape the per-key GET path used — never swallows a failure.
|
|
178
|
+
export async function loadRevealedSecrets(
|
|
179
|
+
token: string,
|
|
180
|
+
companyUid: string,
|
|
181
|
+
keys: string[],
|
|
182
|
+
): Promise<Map<string, string>> {
|
|
183
|
+
const resolved = new Map<string, string>();
|
|
184
|
+
const missing: string[] = [];
|
|
185
|
+
for (const key of keys) {
|
|
186
|
+
const cached = readCache(companyUid, key);
|
|
187
|
+
if (cached !== null) {
|
|
188
|
+
resolved.set(key, cached);
|
|
189
|
+
} else if (!missing.includes(key)) {
|
|
190
|
+
missing.push(key);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < missing.length; i += MAX_BATCH_NAMES) {
|
|
195
|
+
const chunk = missing.slice(i, i + MAX_BATCH_NAMES);
|
|
196
|
+
const res = await vaultApiFetch({
|
|
197
|
+
token,
|
|
198
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
199
|
+
method: "POST",
|
|
200
|
+
body: { names: chunk },
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const body = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
204
|
+
throw new Error(
|
|
205
|
+
`Failed to batch-load secrets: ${body.error ?? res.statusText}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const data = (await res.json()) as {
|
|
209
|
+
secrets: Array<{ name: string; value?: string }>;
|
|
210
|
+
errors: Array<{ name: string; code: string; message?: string }>;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
for (const s of data.secrets ?? []) {
|
|
214
|
+
if (s.value == null) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Secret '${s.name}' has no value (reveal may not be permitted).`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
writeCache(companyUid, s.name, s.value);
|
|
220
|
+
resolved.set(s.name, s.value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const errorsByName = new Map<string, { code: string; message?: string }>();
|
|
224
|
+
for (const e of data.errors ?? []) {
|
|
225
|
+
errorsByName.set(e.name, { code: e.code, message: e.message });
|
|
226
|
+
}
|
|
227
|
+
// Any requested key in this chunk the server did not return is a per-key
|
|
228
|
+
// failure — surface it with the same prefix the single-GET path used so
|
|
229
|
+
// callers (and scripts grepping stderr) see no behavior change.
|
|
230
|
+
for (const key of chunk) {
|
|
231
|
+
if (resolved.has(key)) continue;
|
|
232
|
+
const err = errorsByName.get(key);
|
|
233
|
+
const reason =
|
|
234
|
+
err?.code === "not_found"
|
|
235
|
+
? "Secret not found"
|
|
236
|
+
: err?.code === "forbidden"
|
|
237
|
+
? err.message ?? "No read permission"
|
|
238
|
+
: err?.message ?? err?.code ?? "not returned by vault";
|
|
239
|
+
throw new Error(`Failed to fetch secret '${key}': ${reason}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return resolved;
|
|
244
|
+
}
|
|
245
|
+
|
|
155
246
|
export function registerSecretsCommand(program: Command): void {
|
|
156
247
|
const secrets = program
|
|
157
248
|
.command("secrets")
|
|
@@ -527,36 +618,16 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
527
618
|
scopeOpts(secrets.opts()),
|
|
528
619
|
);
|
|
529
620
|
|
|
530
|
-
const revealed = await
|
|
531
|
-
keys.map(async (key) => {
|
|
532
|
-
const cached = readCache(companyUid, key);
|
|
533
|
-
if (cached !== null) {
|
|
534
|
-
return { key, value: cached };
|
|
535
|
-
}
|
|
536
|
-
const res = await vaultApiFetch({
|
|
537
|
-
token,
|
|
538
|
-
path: buildSecretNamePath(companyUid, key),
|
|
539
|
-
query: { reveal: "true" },
|
|
540
|
-
});
|
|
541
|
-
if (!res.ok) {
|
|
542
|
-
const body = await res.json().catch(() => ({}));
|
|
543
|
-
throw new Error(
|
|
544
|
-
`Failed to fetch secret '${key}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
const data = (await res.json()) as {
|
|
548
|
-
secret: { name: string; value?: string };
|
|
549
|
-
};
|
|
550
|
-
if (data.secret.value == null) {
|
|
551
|
-
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
552
|
-
}
|
|
553
|
-
writeCache(companyUid, key, data.secret.value);
|
|
554
|
-
return { key, value: data.secret.value };
|
|
555
|
-
}),
|
|
556
|
-
);
|
|
621
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys);
|
|
557
622
|
|
|
558
623
|
const secretEnv: Record<string, string> = {};
|
|
559
|
-
for (const
|
|
624
|
+
for (const key of keys) {
|
|
625
|
+
const value = revealed.get(key);
|
|
626
|
+
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
627
|
+
// unreachable — guard rather than inject an `undefined` into the env.
|
|
628
|
+
if (value === undefined) {
|
|
629
|
+
throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
|
|
630
|
+
}
|
|
560
631
|
secretEnv[key] = value;
|
|
561
632
|
}
|
|
562
633
|
|
|
@@ -620,35 +691,15 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
620
691
|
scopeOpts(secrets.opts()),
|
|
621
692
|
);
|
|
622
693
|
|
|
623
|
-
const revealed = await
|
|
624
|
-
keys.map(async (key) => {
|
|
625
|
-
const cached = readCache(companyUid, key);
|
|
626
|
-
if (cached !== null) {
|
|
627
|
-
return { key, value: cached };
|
|
628
|
-
}
|
|
629
|
-
const res = await vaultApiFetch({
|
|
630
|
-
token,
|
|
631
|
-
path: buildSecretNamePath(companyUid, key),
|
|
632
|
-
query: { reveal: "true" },
|
|
633
|
-
});
|
|
634
|
-
if (!res.ok) {
|
|
635
|
-
const body = await res.json().catch(() => ({}));
|
|
636
|
-
throw new Error(
|
|
637
|
-
`Failed to fetch secret '${key}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
const data = (await res.json()) as {
|
|
641
|
-
secret: { name: string; value?: string };
|
|
642
|
-
};
|
|
643
|
-
if (data.secret.value == null) {
|
|
644
|
-
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
645
|
-
}
|
|
646
|
-
writeCache(companyUid, key, data.secret.value);
|
|
647
|
-
return { key, value: data.secret.value };
|
|
648
|
-
}),
|
|
649
|
-
);
|
|
694
|
+
const revealed = await loadRevealedSecrets(token, companyUid, keys);
|
|
650
695
|
|
|
651
|
-
for (const
|
|
696
|
+
for (const key of keys) {
|
|
697
|
+
const value = revealed.get(key);
|
|
698
|
+
// loadRevealedSecrets throws on any unresolved key, so a miss here is
|
|
699
|
+
// unreachable — guard rather than print an `undefined` export line.
|
|
700
|
+
if (value === undefined) {
|
|
701
|
+
throw new Error(`Failed to fetch secret '${key}': not returned by vault`);
|
|
702
|
+
}
|
|
652
703
|
const out = redact ? "[REDACTED]" : value;
|
|
653
704
|
process.stdout.write(`export ${key}=${shellSingleQuote(out)}\n`);
|
|
654
705
|
}
|