@indigoai-us/hq-cli 5.77.11 → 5.77.12

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.12]
6
+
7
+ ### Added
8
+
9
+ - `HQ_API_KEY` fail-closed consume path: `hqk_…` keys route `hq secrets get`
10
+ / `exec` / `env` through vault key fetch; Cognito-only commands hard-error.
11
+ - `hq api-keys create --deploy-app` (repeatable) and deploy-app column in list
12
+ output for identity-bound deploy keys. (#270)
13
+
5
14
  ## [5.77.11]
6
15
 
7
16
  ### Fixed
@@ -1,6 +1,11 @@
1
1
  import chalk from "chalk";
2
2
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
+ import { assertCognitoOnlyCommand } from "../utils/resolve-vault-credential.js";
3
4
  import { getCompanyUid, vaultApiFetch } from "./secrets.js";
5
+ async function requireCognitoForApiKeys(label) {
6
+ assertCognitoOnlyCommand(label);
7
+ return ensureCognitoToken();
8
+ }
4
9
  function collectRepeatedOption(value, previous) {
5
10
  return [...previous, value];
6
11
  }
@@ -10,6 +15,11 @@ function formatMaybe(value) {
10
15
  function formatPrefixes(prefixes) {
11
16
  return prefixes.length > 0 ? prefixes.join(", ") : "-";
12
17
  }
18
+ function formatDeployApps(deploy) {
19
+ if (!deploy?.apps?.length)
20
+ return "-";
21
+ return deploy.apps.join(", ");
22
+ }
13
23
  function parsePermission(value) {
14
24
  if (value === "read" || value === "write" || value === "admin") {
15
25
  return value;
@@ -51,6 +61,7 @@ function renderApiKeysTable(apiKeys) {
51
61
  name: apiKey.name,
52
62
  permission: apiKey.scope.permission,
53
63
  prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
64
+ deployApps: formatDeployApps(apiKey.scope.deploy),
54
65
  status: apiKey.status,
55
66
  lastUsedAt: formatMaybe(apiKey.lastUsedAt),
56
67
  expiresAt: formatMaybe(apiKey.expiresAt),
@@ -59,6 +70,7 @@ function renderApiKeysTable(apiKeys) {
59
70
  const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
60
71
  const permissionWidth = Math.max(10, ...rows.map((row) => row.permission.length));
61
72
  const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
73
+ const deployWidth = Math.max(6, ...rows.map((row) => row.deployApps.length));
62
74
  const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
63
75
  const lastUsedWidth = Math.max(11, ...rows.map((row) => row.lastUsedAt.length));
64
76
  const expiresWidth = Math.max(10, ...rows.map((row) => row.expiresAt.length));
@@ -67,6 +79,7 @@ function renderApiKeysTable(apiKeys) {
67
79
  "NAME".padEnd(nameWidth),
68
80
  "PERMISSION".padEnd(permissionWidth),
69
81
  "PREFIXES".padEnd(prefixesWidth),
82
+ "DEPLOY".padEnd(deployWidth),
70
83
  "STATUS".padEnd(statusWidth),
71
84
  "LAST USED".padEnd(lastUsedWidth),
72
85
  "EXPIRES".padEnd(expiresWidth),
@@ -78,6 +91,7 @@ function renderApiKeysTable(apiKeys) {
78
91
  row.name.padEnd(nameWidth),
79
92
  row.permission.padEnd(permissionWidth),
80
93
  row.prefixes.padEnd(prefixesWidth),
94
+ row.deployApps.padEnd(deployWidth),
81
95
  row.status.padEnd(statusWidth),
82
96
  row.lastUsedAt.padEnd(lastUsedWidth),
83
97
  row.expiresAt.padEnd(expiresWidth),
@@ -91,20 +105,21 @@ export function registerApiKeysCommand(program) {
91
105
  .option("--company <slug>", "Company slug (resolves to companyUid)");
92
106
  apiKeys
93
107
  .command("create")
94
- .description("Create a new API key")
108
+ .description("Create a new API key (vault secrets and/or scoped deploy via --deploy-app)")
95
109
  .requiredOption("--name <label>", "Human-readable label for the API key")
96
- .option("--scope <prefix>", "Allowed prefix (repeatable)", collectRepeatedOption, [])
97
- .option("--permission <level>", "Permission level: read | write | admin", "read")
110
+ .option("--scope <prefix>", "Allowed secret prefix (repeatable)", collectRepeatedOption, [])
111
+ .option("--deploy-app <id>", "Deploy app id/slug allowed for publish (repeatable)", collectRepeatedOption, [])
112
+ .option("--permission <level>", "Secret permission level: read | write | admin (required when --scope is set)", "read")
98
113
  .option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
99
114
  .action(async (opts) => {
100
115
  try {
101
- if (opts.scope.length === 0) {
102
- console.error(chalk.red("Error: at least one --scope <prefix> is required."));
116
+ if (opts.scope.length === 0 && opts.deployApp.length === 0) {
117
+ console.error(chalk.red("Error: provide at least one --scope <prefix> and/or --deploy-app <id>."));
103
118
  process.exit(1);
104
119
  }
105
120
  const permission = parsePermission(opts.permission);
106
121
  const expiresAt = parseExpires(opts.expires);
107
- const token = await ensureCognitoToken();
122
+ const token = await requireCognitoForApiKeys("api-keys create");
108
123
  const companyUid = await getCompanyUid(token, apiKeys.opts().company);
109
124
  const res = await vaultApiFetch({
110
125
  token,
@@ -113,8 +128,20 @@ export function registerApiKeysCommand(program) {
113
128
  body: {
114
129
  companyUid,
115
130
  name: opts.name,
116
- allowedPrefixes: opts.scope,
117
- permission,
131
+ ...(opts.scope.length > 0
132
+ ? { allowedPrefixes: opts.scope, permission }
133
+ : {}),
134
+ ...(opts.deployApp.length > 0
135
+ ? {
136
+ deploy: {
137
+ apps: opts.deployApp,
138
+ capabilities: ["deploy:write"],
139
+ },
140
+ }
141
+ : {}),
142
+ ...(opts.scope.length === 0 && opts.deployApp.length > 0
143
+ ? { permission }
144
+ : {}),
118
145
  ...(expiresAt ? { expiresAt } : {}),
119
146
  },
120
147
  });
@@ -131,10 +158,26 @@ export function registerApiKeysCommand(program) {
131
158
  console.log(` Company: ${data.apiKey.companyUid}`);
132
159
  console.log(` Permission: ${data.apiKey.scope.permission}`);
133
160
  console.log(` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`);
161
+ console.log(` Deploy apps: ${formatDeployApps(data.apiKey.scope.deploy)}`);
134
162
  console.log(` Status: ${data.apiKey.status}`);
135
163
  console.log(` Created: ${data.apiKey.createdAt}`);
136
164
  console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
137
165
  console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
166
+ console.log("");
167
+ console.log(chalk.bold("Usage"));
168
+ console.log(" This key acts as you (Cognito identity), limited to the scopes above.");
169
+ console.log(" Export it for automation (never falls back to a session):");
170
+ console.log(`\n export HQ_API_KEY='${data.key.value}'\n`);
171
+ console.log(" Vault secrets:");
172
+ console.log(" hq secrets get <NAME> --reveal");
173
+ console.log(" hq secrets exec --only <NAME> -- <command>");
174
+ console.log(" Or HTTP: POST /v1/keys/secrets/fetch with Authorization: Bearer <key>");
175
+ if (data.apiKey.scope.deploy?.apps?.length) {
176
+ console.log(" Deploy (scoped apps only):");
177
+ console.log(" Authorization: Bearer <key> against the hq-deploy API");
178
+ console.log(chalk.dim(" Cannot change access-mode, password, or mint hqd_ keys."));
179
+ }
180
+ console.log(chalk.dim(" Unsupported under HQ_API_KEY: secrets list/set/share/acl (use a Cognito session)."));
138
181
  }
139
182
  catch (err) {
140
183
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -146,7 +189,7 @@ export function registerApiKeysCommand(program) {
146
189
  .description("List API keys for a company")
147
190
  .action(async () => {
148
191
  try {
149
- const token = await ensureCognitoToken();
192
+ const token = await requireCognitoForApiKeys("api-keys list");
150
193
  const companyUid = await getCompanyUid(token, apiKeys.opts().company);
151
194
  const res = await vaultApiFetch({
152
195
  token,
@@ -173,7 +216,7 @@ export function registerApiKeysCommand(program) {
173
216
  .description("Revoke an API key")
174
217
  .action(async (keyId) => {
175
218
  try {
176
- const token = await ensureCognitoToken();
219
+ const token = await requireCognitoForApiKeys("api-keys revoke");
177
220
  const res = await vaultApiFetch({
178
221
  token,
179
222
  path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
@@ -8,8 +8,14 @@ import { computeSha256 } from "../utils/integrity.js";
8
8
  import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
9
9
  import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
10
10
  import { vaultApiFetch, getCompanyUid, getEntityUid, } from "../utils/vault-api.js";
11
+ import { HQ_API_KEY_PREFIX, assertCognitoOnlyCommand, resolveVaultCredential, } from "../utils/resolve-vault-credential.js";
11
12
  import { SandboxRunnerClient, } from "../utils/sandbox-runner-client.js";
12
13
  export { vaultApiFetch, getCompanyUid, getEntityUid };
14
+ /** Cognito session for secrets commands that do not support HQ_API_KEY. */
15
+ async function requireCognitoTokenForSecrets(commandLabel) {
16
+ assertCognitoOnlyCommand(commandLabel);
17
+ return ensureCognitoToken();
18
+ }
13
19
  function scopeOpts(opts) {
14
20
  if (opts.personal && opts.company) {
15
21
  console.error(chalk.red("Error: --personal cannot be combined with --company."));
@@ -424,7 +430,54 @@ function renderPolicyScripts(scripts) {
424
430
  // Requests are chunked at MAX_BATCH_NAMES and throw on the FIRST unresolved key
425
431
  // with the same `Failed to fetch secret '<k>': <reason>` shape the per-key GET
426
432
  // path used — never swallows a failure.
433
+ async function loadRevealedSecretsViaApiKey(token, keys) {
434
+ const resolved = new Map();
435
+ const requested = [...new Set(keys)];
436
+ const cacheScope = "__api_key__";
437
+ for (const name of requested) {
438
+ const res = await vaultApiFetch({
439
+ token,
440
+ path: "/v1/keys/secrets/fetch",
441
+ method: "POST",
442
+ body: { name },
443
+ signal: AbortSignal.timeout(SECRET_LOAD_TIMEOUT_MS),
444
+ });
445
+ const body = (await res.json().catch(() => ({})));
446
+ if (!res.ok) {
447
+ if (res.status === 404) {
448
+ throw new Error(`Failed to fetch secret '${name}': Secret not found`);
449
+ }
450
+ if (res.status === 403) {
451
+ const message = typeof body.error === "string"
452
+ ? body.error
453
+ : typeof body.message === "string"
454
+ ? body.message
455
+ : "No read permission";
456
+ if (body.highSecurity === true) {
457
+ throw new Error(highSecuritySandboxOnlyMessage(name));
458
+ }
459
+ throw new Error(`Failed to fetch secret '${name}': ${message}`);
460
+ }
461
+ if (res.status === 401) {
462
+ throw new Error(`Failed to fetch secret '${name}': Invalid or missing API key`);
463
+ }
464
+ throw new Error(`Failed to fetch secret '${name}': ${extractApiMessage(body, res.statusText)}`);
465
+ }
466
+ const secret = typeof body.secret === "object" && body.secret !== null
467
+ ? body.secret
468
+ : null;
469
+ if (typeof secret?.value !== "string") {
470
+ throw new Error(`Failed to fetch secret '${name}': malformed fetch response`);
471
+ }
472
+ removeCacheEntry(cacheScope, name);
473
+ resolved.set(name, secret.value);
474
+ }
475
+ return resolved;
476
+ }
427
477
  export async function loadRevealedSecrets(token, companyUid, keys, usage) {
478
+ if (token.startsWith(HQ_API_KEY_PREFIX)) {
479
+ return loadRevealedSecretsViaApiKey(token, keys);
480
+ }
428
481
  const resolved = new Map();
429
482
  const requested = [...new Set(keys)];
430
483
  try {
@@ -632,7 +685,7 @@ export function registerSecretsCommand(program) {
632
685
  console.error(chalk.red(`Secret value exceeds 4096-byte SSM limit (got ${Buffer.byteLength(value, "utf8")} bytes).`));
633
686
  process.exit(1);
634
687
  }
635
- const token = await ensureCognitoToken();
688
+ const token = await requireCognitoTokenForSecrets("secrets set");
636
689
  const scope = scopeOpts(secrets.opts());
637
690
  const companyUid = await getEntityUid(token, scope);
638
691
  const scopeLabel = describeSecretsScope({
@@ -677,7 +730,52 @@ export function registerSecretsCommand(program) {
677
730
  .option("--reveal", "Include the decrypted secret value")
678
731
  .action(async (name, opts) => {
679
732
  try {
680
- const token = await ensureCognitoToken();
733
+ const cred = await resolveVaultCredential();
734
+ if (cred.kind === "api-key") {
735
+ const res = await vaultApiFetch({
736
+ token: cred.token,
737
+ path: "/v1/keys/secrets/fetch",
738
+ method: "POST",
739
+ body: { name },
740
+ });
741
+ const body = (await res.json().catch(() => ({})));
742
+ if (!res.ok) {
743
+ if (res.status === 403 && body.highSecurity === true) {
744
+ console.error(chalk.red(highSecuritySandboxOnlyMessage(name)));
745
+ process.exit(1);
746
+ }
747
+ console.error(chalk.red(`Failed to get secret: ${extractApiMessage(body, res.statusText)}`));
748
+ process.exit(1);
749
+ }
750
+ const secret = typeof body.secret === "object" && body.secret !== null
751
+ ? body.secret
752
+ : null;
753
+ if (!secret || typeof secret.name !== "string") {
754
+ console.error(chalk.red("Failed to get secret: malformed response"));
755
+ process.exit(1);
756
+ }
757
+ console.log(chalk.bold(`Secret: ${secret.name}`));
758
+ if (secret.lastModifiedDate) {
759
+ console.log(` Last Modified: ${secret.lastModifiedDate}`);
760
+ }
761
+ if (secret.version != null) {
762
+ console.log(` Version: ${secret.version}`);
763
+ }
764
+ console.log(` Tier: ${normalizeSecretTier(secret.tier)}`);
765
+ console.log(` Script Lock: ${normalizeScriptLockMode(secret.scriptLock?.mode)}`);
766
+ if (opts.reveal) {
767
+ if (typeof secret.value !== "string") {
768
+ console.error(chalk.red("Failed to get secret: reveal requested but response omitted secret.value"));
769
+ process.exit(1);
770
+ }
771
+ console.log(` Value: ${secret.value}`);
772
+ }
773
+ else {
774
+ console.log(` Value: ${chalk.dim("[REDACTED]")}`);
775
+ }
776
+ return;
777
+ }
778
+ const token = cred.token;
681
779
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
682
780
  const res = await vaultApiFetch({
683
781
  token,
@@ -748,7 +846,7 @@ export function registerSecretsCommand(program) {
748
846
  .option("--quiet", "Suppress the present/absent line (use the exit code only)")
749
847
  .action(async (name, opts) => {
750
848
  try {
751
- const token = await ensureCognitoToken();
849
+ const token = await requireCognitoTokenForSecrets("secrets exists");
752
850
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
753
851
  const res = await vaultApiFetch({
754
852
  token,
@@ -791,7 +889,7 @@ export function registerSecretsCommand(program) {
791
889
  }
792
890
  normalizedPrefix = normalized;
793
891
  }
794
- const token = await ensureCognitoToken();
892
+ const token = await requireCognitoTokenForSecrets("secrets list");
795
893
  const scope = scopeOpts(secrets.opts());
796
894
  const companyUid = await getEntityUid(token, scope);
797
895
  const scopeLabel = describeSecretsScope({
@@ -865,7 +963,7 @@ export function registerSecretsCommand(program) {
865
963
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
866
964
  process.exit(1);
867
965
  }
868
- const token = await ensureCognitoToken();
966
+ const token = await requireCognitoTokenForSecrets("secrets");
869
967
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
870
968
  const res = await vaultApiFetch({
871
969
  token,
@@ -926,7 +1024,7 @@ export function registerSecretsCommand(program) {
926
1024
  console.error(chalk.red("Error: provide at least one of --tier or --lock-script."));
927
1025
  process.exit(1);
928
1026
  }
929
- const token = await ensureCognitoToken();
1027
+ const token = await requireCognitoTokenForSecrets("secrets");
930
1028
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
931
1029
  const res = await vaultApiFetch({
932
1030
  token,
@@ -971,7 +1069,7 @@ export function registerSecretsCommand(program) {
971
1069
  process.exit(1);
972
1070
  }
973
1071
  const usage = await buildSecretUsage("exec", opts.script, opts.id, opts.attestation);
974
- const token = await ensureCognitoToken();
1072
+ const token = await requireCognitoTokenForSecrets("secrets");
975
1073
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
976
1074
  const res = await vaultApiFetch({
977
1075
  token,
@@ -1009,7 +1107,7 @@ export function registerSecretsCommand(program) {
1009
1107
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1010
1108
  process.exit(1);
1011
1109
  }
1012
- const token = await ensureCognitoToken();
1110
+ const token = await requireCognitoTokenForSecrets("secrets");
1013
1111
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1014
1112
  const res = await vaultApiFetch({
1015
1113
  token,
@@ -1040,7 +1138,7 @@ export function registerSecretsCommand(program) {
1040
1138
  console.error(chalk.red(`Invalid secret path '${secretPath}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1041
1139
  process.exit(1);
1042
1140
  }
1043
- const token = await ensureCognitoToken();
1141
+ const token = await requireCognitoTokenForSecrets("secrets");
1044
1142
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1045
1143
  const res = await vaultApiFetch({
1046
1144
  token,
@@ -1087,7 +1185,7 @@ export function registerSecretsCommand(program) {
1087
1185
  return;
1088
1186
  }
1089
1187
  }
1090
- const token = await ensureCognitoToken();
1188
+ const token = await requireCognitoTokenForSecrets("secrets");
1091
1189
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1092
1190
  const res = await vaultApiFetch({
1093
1191
  token,
@@ -1131,7 +1229,7 @@ export function registerSecretsCommand(program) {
1131
1229
  process.exit(1);
1132
1230
  }
1133
1231
  const keys = parseSecretNameList(opts.only);
1134
- const token = await ensureCognitoToken();
1232
+ const token = await requireCognitoTokenForSecrets("secrets");
1135
1233
  const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
1136
1234
  const companyUid = await getEntityUid(token, scope);
1137
1235
  const client = new SandboxRunnerClient();
@@ -1184,9 +1282,13 @@ export function registerSecretsCommand(program) {
1184
1282
  process.exit(1);
1185
1283
  }
1186
1284
  const keys = parseSecretNameList(_opts.only);
1187
- const token = await ensureCognitoToken();
1188
- const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1189
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("exec", _opts.script, _opts.scriptId));
1285
+ const cred = await resolveVaultCredential();
1286
+ const companyUid = cred.kind === "api-key"
1287
+ ? "__api_key__"
1288
+ : await getEntityUid(cred.token, scopeOpts(secrets.opts()));
1289
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1290
+ ? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
1291
+ : undefined);
1190
1292
  const secretEnv = {};
1191
1293
  for (const key of keys) {
1192
1294
  const value = revealed.get(key);
@@ -1231,9 +1333,13 @@ export function registerSecretsCommand(program) {
1231
1333
  console.error(chalk.yellow("stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)"));
1232
1334
  }
1233
1335
  const keys = parseSecretNameList(opts.only);
1234
- const token = await ensureCognitoToken();
1235
- const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1236
- const revealed = await loadRevealedSecrets(token, companyUid, keys, await buildSecretUsage("env", opts.script, opts.scriptId));
1336
+ const cred = await resolveVaultCredential();
1337
+ const companyUid = cred.kind === "api-key"
1338
+ ? "__api_key__"
1339
+ : await getEntityUid(cred.token, scopeOpts(secrets.opts()));
1340
+ const revealed = await loadRevealedSecrets(cred.token, companyUid, keys, cred.kind === "cognito"
1341
+ ? await buildSecretUsage("env", opts.script, opts.scriptId)
1342
+ : undefined);
1237
1343
  for (const key of keys) {
1238
1344
  const value = revealed.get(key);
1239
1345
  // loadRevealedSecrets throws on any unresolved key, so a miss here is
@@ -1270,7 +1376,7 @@ export function registerSecretsCommand(program) {
1270
1376
  console.error(chalk.red("Maximum expiry is 7 days (7d)."));
1271
1377
  process.exit(1);
1272
1378
  }
1273
- const token = await ensureCognitoToken();
1379
+ const token = await requireCognitoTokenForSecrets("secrets");
1274
1380
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1275
1381
  const res = await vaultApiFetch({
1276
1382
  token,
@@ -1316,7 +1422,7 @@ export function registerSecretsCommand(program) {
1316
1422
  if (!principal) {
1317
1423
  process.exit(1);
1318
1424
  }
1319
- const token = await ensureCognitoToken();
1425
+ const token = await requireCognitoTokenForSecrets("secrets");
1320
1426
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1321
1427
  const res = await vaultApiFetch({
1322
1428
  token,
@@ -1373,7 +1479,7 @@ export function registerSecretsCommand(program) {
1373
1479
  if (!principal) {
1374
1480
  process.exit(1);
1375
1481
  }
1376
- const token = await ensureCognitoToken();
1482
+ const token = await requireCognitoTokenForSecrets("secrets");
1377
1483
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1378
1484
  const res = await vaultApiFetch({
1379
1485
  token,
@@ -1422,7 +1528,7 @@ export function registerSecretsCommand(program) {
1422
1528
  console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
1423
1529
  process.exit(1);
1424
1530
  }
1425
- const token = await ensureCognitoToken();
1531
+ const token = await requireCognitoTokenForSecrets("secrets");
1426
1532
  const companyUid = await getEntityUid(token, scopeOpts(secrets.opts()));
1427
1533
  const secretPath = path;
1428
1534
  const res = await vaultApiFetch({
@@ -0,0 +1,30 @@
1
+ /** Vault API keys issued by `hq api-keys create` (hq-pro). */
2
+ export declare const HQ_API_KEY_PREFIX = "hqk_";
3
+ export type VaultCredential = {
4
+ kind: "api-key";
5
+ token: string;
6
+ } | {
7
+ kind: "cognito";
8
+ token: string;
9
+ };
10
+ /**
11
+ * Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
12
+ * Does not validate prefix — use {@link resolveVaultCredential} for that.
13
+ */
14
+ export declare function peekHqApiKey(): string | undefined;
15
+ /**
16
+ * Resolve vault auth for CLI commands.
17
+ *
18
+ * When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
19
+ * and Cognito is never used as a fallback (fail-closed). When unset, uses the
20
+ * cached Cognito session (interactive login if needed).
21
+ */
22
+ export declare function resolveVaultCredential(options?: {
23
+ interactive?: boolean;
24
+ }): Promise<VaultCredential>;
25
+ /**
26
+ * Throw when HQ_API_KEY is set but the command only supports Cognito sessions
27
+ * (list, set, ACL, api-keys admin, etc.).
28
+ */
29
+ export declare function assertCognitoOnlyCommand(commandLabel: string): void;
30
+ //# sourceMappingURL=resolve-vault-credential.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { ensureCognitoToken } from "./cognito-session.js";
2
+ /** Vault API keys issued by `hq api-keys create` (hq-pro). */
3
+ export const HQ_API_KEY_PREFIX = "hqk_";
4
+ /**
5
+ * Raw HQ_API_KEY from the environment, trimmed. Undefined when unset/empty.
6
+ * Does not validate prefix — use {@link resolveVaultCredential} for that.
7
+ */
8
+ export function peekHqApiKey() {
9
+ const raw = process.env.HQ_API_KEY;
10
+ if (raw === undefined)
11
+ return undefined;
12
+ const trimmed = raw.trim();
13
+ return trimmed.length > 0 ? trimmed : undefined;
14
+ }
15
+ /**
16
+ * Resolve vault auth for CLI commands.
17
+ *
18
+ * When `HQ_API_KEY` is set it is authoritative: must be a vault key (`hqk_…`)
19
+ * and Cognito is never used as a fallback (fail-closed). When unset, uses the
20
+ * cached Cognito session (interactive login if needed).
21
+ */
22
+ export async function resolveVaultCredential(options) {
23
+ const apiKey = peekHqApiKey();
24
+ if (apiKey !== undefined) {
25
+ if (!apiKey.startsWith(HQ_API_KEY_PREFIX)) {
26
+ throw new Error(`HQ_API_KEY must start with '${HQ_API_KEY_PREFIX}' (vault API key). ` +
27
+ `Got a value that is not a vault key — refusing to fall back to Cognito. ` +
28
+ `Unset HQ_API_KEY to use your session, or create a key with \`hq api-keys create\`.`);
29
+ }
30
+ return { kind: "api-key", token: apiKey };
31
+ }
32
+ const token = await ensureCognitoToken({
33
+ interactive: options?.interactive,
34
+ });
35
+ return { kind: "cognito", token };
36
+ }
37
+ /**
38
+ * Throw when HQ_API_KEY is set but the command only supports Cognito sessions
39
+ * (list, set, ACL, api-keys admin, etc.).
40
+ */
41
+ export function assertCognitoOnlyCommand(commandLabel) {
42
+ if (peekHqApiKey() === undefined)
43
+ return;
44
+ throw new Error(`HQ_API_KEY is set; '${commandLabel}' is not supported for API keys. ` +
45
+ `API keys support scoped secret reads via \`hq secrets get\`, \`hq secrets exec\`, ` +
46
+ `and \`hq secrets env\`. Unset HQ_API_KEY to use your Cognito session.`);
47
+ }
48
+ //# sourceMappingURL=resolve-vault-credential.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.77.11",
3
+ "version": "5.77.12",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -126,10 +126,78 @@ describe("hq api-keys create", () => {
126
126
  .map((call) => call.map((value) => String(value)).join(" "))
127
127
  .join("\n");
128
128
  expect(printed).toContain("Store this key now");
129
- expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(1);
129
+ expect(printed).toContain("export HQ_API_KEY=");
130
+ expect(printed).toContain("hq secrets get");
131
+ // Key value appears in Store line + export line only (not in list metadata).
132
+ expect(printed.match(/hqk_test_secret_value/g)?.length ?? 0).toBe(2);
130
133
  expect(errSpy).not.toHaveBeenCalled();
131
134
  expect(exitSpy).not.toHaveBeenCalled();
132
135
  });
136
+
137
+ it("includes deploy scope when --deploy-app is set", async () => {
138
+ vi.mocked(vaultApiFetch).mockResolvedValueOnce(
139
+ jsonResponse({
140
+ apiKey: {
141
+ keyId: "key_deploy",
142
+ companyUid: "cmp_acme",
143
+ name: "Deploy key",
144
+ scope: {
145
+ allowedPrefixes: ["CI"],
146
+ permission: "read",
147
+ deploy: {
148
+ apps: ["my-app", "other-app"],
149
+ capabilities: ["deploy:write"],
150
+ },
151
+ },
152
+ status: "active",
153
+ createdAt: "2026-06-19T12:00:00.000Z",
154
+ lastUsedAt: null,
155
+ expiresAt: null,
156
+ },
157
+ key: { value: "hqk_deploy_secret" },
158
+ }),
159
+ );
160
+
161
+ const program = buildProgram();
162
+ await program.parseAsync(
163
+ [
164
+ "api-keys",
165
+ "create",
166
+ "--name",
167
+ "Deploy key",
168
+ "--scope",
169
+ "CI",
170
+ "--deploy-app",
171
+ "my-app",
172
+ "--deploy-app",
173
+ "other-app",
174
+ ],
175
+ { from: "user" },
176
+ );
177
+
178
+ expect(vaultApiFetch).toHaveBeenCalledWith({
179
+ token: "test-token",
180
+ path: "/v1/api-keys",
181
+ method: "POST",
182
+ body: {
183
+ companyUid: "cmp_acme",
184
+ name: "Deploy key",
185
+ allowedPrefixes: ["CI"],
186
+ permission: "read",
187
+ deploy: {
188
+ apps: ["my-app", "other-app"],
189
+ capabilities: ["deploy:write"],
190
+ },
191
+ },
192
+ });
193
+
194
+ const printed = logSpy.mock.calls
195
+ .map((call) => call.map((value) => String(value)).join(" "))
196
+ .join("\n");
197
+ expect(printed).toContain("Deploy apps:");
198
+ expect(printed).toContain("my-app, other-app");
199
+ expect(printed).toContain("hq-deploy API");
200
+ });
133
201
  });
134
202
 
135
203
  describe("hq api-keys list", () => {
@@ -145,6 +213,10 @@ describe("hq api-keys list", () => {
145
213
  scope: {
146
214
  allowedPrefixes: ["HQ_PRO/HQ_PROD", "HQ_PRO/HQ_DEV"],
147
215
  permission: "read",
216
+ deploy: {
217
+ apps: ["preview-app"],
218
+ capabilities: ["deploy:write"],
219
+ },
148
220
  },
149
221
  status: "active",
150
222
  createdAt: "2026-06-19T12:00:00.000Z",
@@ -173,9 +245,11 @@ describe("hq api-keys list", () => {
173
245
  .map((call) => call.map((value) => String(value)).join(" "))
174
246
  .join("\n");
175
247
  expect(printed).toContain("KEY ID");
248
+ expect(printed).toContain("DEPLOY");
176
249
  expect(printed).toContain("key_123");
177
250
  expect(printed).toContain("CI key");
178
251
  expect(printed).toContain("HQ_PRO/HQ_PROD, HQ_PRO/HQ_DEV");
252
+ expect(printed).toContain("preview-app");
179
253
  expect(printed).not.toContain("hqk_should_not_print");
180
254
  expect(exitSpy).not.toHaveBeenCalled();
181
255
  });