@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 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
@@ -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]="b75c8495-7e0e-53f8-a0d2-c92a993e6492")}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]="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=b75c8495-7e0e-53f8-a0d2-c92a993e6492
757
+ //# debugId=7e988c72-7dae-5ad2-aaa3-2c5b430fa5ae
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.38.2",
3
+ "version": "5.39.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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();
@@ -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)")