@indigoai-us/hq-cli 5.38.2 → 5.39.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/CHANGELOG.md +18 -0
- package/dist/commands/secrets.js +44 -2
- package/package.json +1 -1
- package/src/commands/secrets.test.ts +65 -0
- package/src/commands/secrets.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.39.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`hq secrets exists <name>` — HEAD existence probe (HQ-4H).** Checks whether a
|
|
10
|
+
secret exists without fetching or decrypting its value, so optional-credential
|
|
11
|
+
readers can HEAD-first instead of blind-`GET`ting:
|
|
12
|
+
`hq secrets exists FOO && hq secrets get FOO --reveal`. A missing secret no
|
|
13
|
+
longer fires a spurious server-side `Secret not found` Sentry warning (HQ-4H:
|
|
14
|
+
706 nameless events / 0 users) — a `HEAD` is by contract an expected-absence
|
|
15
|
+
probe, so a not-found is its normal answer and is not captured. Exit codes are
|
|
16
|
+
built for shell chaining: `0` present, `1` absent (the normal "no", not an
|
|
17
|
+
error), `2` real failure (auth/network/permission) so `&&` chains never
|
|
18
|
+
mistake an outage for "absent and proceed". `--quiet` suppresses the
|
|
19
|
+
present/absent line. Backed by the new hq-pro `HEAD
|
|
20
|
+
/secrets/{companyUid}/name/{proxy+}` route. Callers that genuinely *require* a
|
|
21
|
+
secret keep `GET`ting directly — their 404s still surface.
|
|
22
|
+
|
|
5
23
|
## [5.38.2]
|
|
6
24
|
|
|
7
25
|
### Fixed
|
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]="7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae")}catch(e){}}();
|
|
3
3
|
import chalk from "chalk";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -227,6 +227,48 @@ export function registerSecretsCommand(program) {
|
|
|
227
227
|
process.exit(1);
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
|
+
// HQ-4H: existence probe via the HEAD route. Optional-credential readers
|
|
231
|
+
// should HEAD-first so a missing secret no longer fires a spurious GET-404
|
|
232
|
+
// Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
|
|
233
|
+
// A HEAD never decrypts or reveals the value.
|
|
234
|
+
//
|
|
235
|
+
// Exit codes are designed for shell chaining:
|
|
236
|
+
// 0 → secret exists (200)
|
|
237
|
+
// 1 → secret is absent (404) — the normal "no" answer, NOT an error
|
|
238
|
+
// 2 → a real failure (auth/network/permission) — distinct from absence so
|
|
239
|
+
// `&&` chains don't mistake an outage for "absent and proceed"
|
|
240
|
+
secrets
|
|
241
|
+
.command("exists <name>")
|
|
242
|
+
.description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
|
|
243
|
+
.option("--quiet", "Suppress the present/absent line (use the exit code only)")
|
|
244
|
+
.action(async (name, opts) => {
|
|
245
|
+
try {
|
|
246
|
+
const token = await ensureCognitoToken();
|
|
247
|
+
const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
|
|
248
|
+
const res = await vaultApiFetch({
|
|
249
|
+
token,
|
|
250
|
+
method: "HEAD",
|
|
251
|
+
path: buildSecretNamePath(companyUid, name),
|
|
252
|
+
});
|
|
253
|
+
if (res.status === 200) {
|
|
254
|
+
if (!opts.quiet)
|
|
255
|
+
console.log(chalk.green(`exists: ${name}`));
|
|
256
|
+
process.exit(0);
|
|
257
|
+
}
|
|
258
|
+
if (res.status === 404) {
|
|
259
|
+
if (!opts.quiet)
|
|
260
|
+
console.log(chalk.dim(`absent: ${name}`));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
// Any other status (401/403/5xx) is a real failure, not an absence.
|
|
264
|
+
console.error(chalk.red(`Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`));
|
|
265
|
+
process.exit(2);
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
269
|
+
process.exit(2);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
230
272
|
secrets
|
|
231
273
|
.command("list")
|
|
232
274
|
.description("List all secrets for the company (including nested path-based names)")
|
|
@@ -712,4 +754,4 @@ export function registerSecretsCommand(program) {
|
|
|
712
754
|
});
|
|
713
755
|
}
|
|
714
756
|
//# sourceMappingURL=secrets.js.map
|
|
715
|
-
//# debugId=
|
|
757
|
+
//# debugId=7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae
|
package/package.json
CHANGED
|
@@ -62,6 +62,71 @@ function buildProgram(): Command {
|
|
|
62
62
|
return program;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// HQ-4H: `hq secrets exists` — HEAD existence probe with shell-chaining exit
|
|
66
|
+
// codes (0=present, 1=absent, 2=error). process.exit is spied so the command's
|
|
67
|
+
// terminal exit doesn't kill the runner; we assert the code it requested.
|
|
68
|
+
describe("secrets exists (HQ-4H HEAD probe)", () => {
|
|
69
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
// Throw so the command stops at its first process.exit (the real runtime
|
|
73
|
+
// terminates there). The action's own try/catch re-invokes exit(2) on the
|
|
74
|
+
// thrown sentinel, so we assert on the FIRST recorded exit code — the one
|
|
75
|
+
// the command actually intended — not the last.
|
|
76
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
77
|
+
throw new Error("__exit__");
|
|
78
|
+
}) as never);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function firstExitCode(name: string): Promise<number | undefined> {
|
|
82
|
+
const program = buildProgram();
|
|
83
|
+
try {
|
|
84
|
+
await program.parseAsync(["node", "hq", "secrets", "exists", name]);
|
|
85
|
+
} catch {
|
|
86
|
+
// sentinel(s) from the exit spy
|
|
87
|
+
}
|
|
88
|
+
return exitSpy.mock.calls[0]?.[0] as number | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
it("issues a HEAD and intends exit 0 when the secret exists (200)", async () => {
|
|
92
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
93
|
+
new Response(null, { status: 200 }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const code = await firstExitCode("MY_KEY");
|
|
97
|
+
|
|
98
|
+
expect(vaultApiFetch).toHaveBeenCalledWith({
|
|
99
|
+
token: "test-token",
|
|
100
|
+
method: "HEAD",
|
|
101
|
+
path: "/secrets/prs_alice/name/MY_KEY",
|
|
102
|
+
});
|
|
103
|
+
expect(code).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("intends exit 1 (absent, not an error) on 404", async () => {
|
|
107
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
108
|
+
new Response(
|
|
109
|
+
JSON.stringify({ error: "Secret not found", name: "MISSING" }),
|
|
110
|
+
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const code = await firstExitCode("MISSING");
|
|
115
|
+
|
|
116
|
+
expect(code).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("intends exit 2 (real failure, distinct from absence) on a 5xx", async () => {
|
|
120
|
+
vi.mocked(vaultApiFetch).mockResolvedValueOnce(
|
|
121
|
+
new Response("boom", { status: 503 }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const code = await firstExitCode("MY_KEY");
|
|
125
|
+
|
|
126
|
+
expect(code).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
65
130
|
describe("secrets generate-link", () => {
|
|
66
131
|
it("mints one-time submission links for personal secrets", async () => {
|
|
67
132
|
const program = buildProgram();
|
package/src/commands/secrets.ts
CHANGED
|
@@ -294,6 +294,58 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
294
294
|
}
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
// HQ-4H: existence probe via the HEAD route. Optional-credential readers
|
|
298
|
+
// should HEAD-first so a missing secret no longer fires a spurious GET-404
|
|
299
|
+
// Sentry warning server-side: `hq secrets exists FOO && hq secrets get FOO …`.
|
|
300
|
+
// A HEAD never decrypts or reveals the value.
|
|
301
|
+
//
|
|
302
|
+
// Exit codes are designed for shell chaining:
|
|
303
|
+
// 0 → secret exists (200)
|
|
304
|
+
// 1 → secret is absent (404) — the normal "no" answer, NOT an error
|
|
305
|
+
// 2 → a real failure (auth/network/permission) — distinct from absence so
|
|
306
|
+
// `&&` chains don't mistake an outage for "absent and proceed"
|
|
307
|
+
secrets
|
|
308
|
+
.command("exists <name>")
|
|
309
|
+
.description("Check whether a secret exists (HEAD; exit 0=present, 1=absent, 2=error)")
|
|
310
|
+
.option("--quiet", "Suppress the present/absent line (use the exit code only)")
|
|
311
|
+
.action(async (name: string, opts: { quiet?: boolean }) => {
|
|
312
|
+
try {
|
|
313
|
+
const token = await ensureCognitoToken();
|
|
314
|
+
const companyUid = await getEntityUid(
|
|
315
|
+
token,
|
|
316
|
+
scopeOpts(secrets.opts()),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const res = await vaultApiFetch({
|
|
320
|
+
token,
|
|
321
|
+
method: "HEAD",
|
|
322
|
+
path: buildSecretNamePath(companyUid, name),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (res.status === 200) {
|
|
326
|
+
if (!opts.quiet) console.log(chalk.green(`exists: ${name}`));
|
|
327
|
+
process.exit(0);
|
|
328
|
+
}
|
|
329
|
+
if (res.status === 404) {
|
|
330
|
+
if (!opts.quiet) console.log(chalk.dim(`absent: ${name}`));
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
// Any other status (401/403/5xx) is a real failure, not an absence.
|
|
334
|
+
console.error(
|
|
335
|
+
chalk.red(
|
|
336
|
+
`Failed to check secret '${name}': HTTP ${res.status} ${res.statusText}`,
|
|
337
|
+
),
|
|
338
|
+
);
|
|
339
|
+
process.exit(2);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error(
|
|
342
|
+
chalk.red("Error:"),
|
|
343
|
+
err instanceof Error ? err.message : String(err),
|
|
344
|
+
);
|
|
345
|
+
process.exit(2);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
297
349
|
secrets
|
|
298
350
|
.command("list")
|
|
299
351
|
.description("List all secrets for the company (including nested path-based names)")
|