@indigoai-us/hq-cli 5.47.2 → 5.47.4

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 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
@@ -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]="7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae")}catch(e){}}();
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 Promise.all(keys.map(async (key) => {
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 { key, value } of revealed) {
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 Promise.all(keys.map(async (key) => {
476
- const cached = readCache(companyUid, key);
477
- if (cached !== null) {
478
- return { key, value: cached };
479
- }
480
- const res = await vaultApiFetch({
481
- token,
482
- path: buildSecretNamePath(companyUid, key),
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=7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae
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.2",
3
+ "version": "5.47.4",
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.1",
18
+ "@indigoai-us/hq-cloud": "^6.11.2",
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
+ });
@@ -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 Promise.all(
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 { key, value } of revealed) {
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 Promise.all(
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 { key, value } of revealed) {
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
  }