@indigoai-us/hq-cli 5.77.13 → 5.78.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.
Files changed (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -40,8 +40,9 @@ import { Readable } from "node:stream";
40
40
  import { pipeline } from "node:stream/promises";
41
41
  import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
42
42
  import { VaultClient, grantPathToPrefix, } from "@indigoai-us/hq-cloud";
43
- import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
44
- import { getCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
43
+ import { DEFAULT_HQ_ROOT, DEFAULT_COGNITO, buildVaultConfig, } from "../utils/cognito-session.js";
44
+ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
45
+ import { getCompanyUid, looksLikeCompanyUid, vaultApiFetch, } from "../utils/vault-api.js";
45
46
  import { resolveCanonicalPersonUid } from "./cloud.js";
46
47
  // ── Pure helpers ────────────────────────────────────────────────────────────
47
48
  /**
@@ -636,6 +637,119 @@ export function createCompanyPresignClient(input) {
636
637
  function makeCompanyPresignFactory(token) {
637
638
  return ({ companyUid }) => createCompanyPresignClient({ token, companyUid });
638
639
  }
640
+ // ── HQ_API_KEY vault client (keyed routes only) ──────────────────────────────
641
+ /** Fail-closed error the top-level handler prints without a Sentry report. */
642
+ function apiKeyUnsupported(message) {
643
+ return Object.assign(new Error(message), { expected: true });
644
+ }
645
+ /**
646
+ * Bucket name handed to the orchestrators on the keyed company path.
647
+ *
648
+ * `runBrowse`/`runCat`/`runGet` require a non-empty `bucketName` and pass it as
649
+ * `Bucket` on each S3-shaped command, but the keyed client sends
650
+ * `/v1/keys/files/list` + `/v1/keys/files/presign`, which resolve the real
651
+ * bucket server-side from the company uid — so this value is never read.
652
+ */
653
+ const API_KEY_BUCKET_RESOLVED_SERVER_SIDE = "resolved-server-side";
654
+ /**
655
+ * `FilesBrowseVaultClient` for a Bearer `hqk_` key.
656
+ *
657
+ * `VaultClient` talks to the Cognito-JWT routes directly — it never passes
658
+ * through `vaultApiFetch`, so it cannot be handed an `hqk_` token: the key
659
+ * would arrive at a JWT-only route and come back as a 401 that reads like the
660
+ * caller is logged out. Company browse/cat/get/search need only three things
661
+ * from the client, and each has a keyed answer:
662
+ *
663
+ * - the company uid — supplied explicitly (`--company cmp_…` or
664
+ * HQ_COMPANY_UID). A key is company-bound and entity slug resolution has no
665
+ * keyed route, so a slug is refused rather than guessed.
666
+ * - the bucket name — unused on this path (see the constant above).
667
+ * - the caller's explicit grants — `GET /v1/keys/files/grants` (files:read),
668
+ * so the `shared-with-you` vs `role-bypass` column stays accurate.
669
+ *
670
+ * The identity surfaces a key cannot reach (`sts.vend`/`vendSelf` for the
671
+ * personal vault, `entity.get`, `listMyMemberships` for the cross-company
672
+ * roll-up) throw a fail-closed error naming the Cognito requirement.
673
+ */
674
+ export function createApiKeyBrowseVaultClient(token) {
675
+ const needsCognito = (surface) => apiKeyUnsupported(`HQ_API_KEY is set; ${surface} is not supported for API keys — hq-pro ` +
676
+ `serves no vault API-key route for it. Unset HQ_API_KEY to use your ` +
677
+ `Cognito session.`);
678
+ return {
679
+ sts: {
680
+ vend: () => {
681
+ throw needsCognito("STS credential vending");
682
+ },
683
+ vendSelf: () => {
684
+ throw needsCognito("the personal vault (--personal)");
685
+ },
686
+ },
687
+ async listMyExplicitGrants(companyUid) {
688
+ const res = await vaultApiFetch({
689
+ token,
690
+ path: "/v1/files/grants",
691
+ query: { company: companyUid },
692
+ });
693
+ if (!res.ok) {
694
+ const body = (await res.json().catch(() => ({})));
695
+ throw new Error(body.message ?? body.error ?? `files grants failed (${res.status})`);
696
+ }
697
+ const body = (await res.json());
698
+ return body.grants ?? [];
699
+ },
700
+ listMyMemberships: () => {
701
+ throw needsCognito("the cross-company roll-up");
702
+ },
703
+ entity: {
704
+ get: () => {
705
+ throw needsCognito("entity lookup by uid");
706
+ },
707
+ findInMyNamespace: async (_type, ref) => {
708
+ // A key cannot resolve a slug (no keyed `/entity/check-slug/me`), so the
709
+ // company must already be a uid. The CLI layer resolves `--company` /
710
+ // HQ_COMPANY_UID through `getCompanyUid`, which rejects a slug under a
711
+ // key — this guard covers a path-derived slug reaching us anyway.
712
+ if (!looksLikeCompanyUid(ref)) {
713
+ throw apiKeyUnsupported(`HQ_API_KEY is set; the company must be a uid (cmp_…) — resolving ` +
714
+ `the slug '${ref}' needs a Cognito session. Pass --company cmp_… ` +
715
+ `(or set HQ_COMPANY_UID) and anchor the path at ` +
716
+ `'companies/cmp_…/'.`);
717
+ }
718
+ return {
719
+ uid: ref,
720
+ slug: ref,
721
+ bucketName: API_KEY_BUCKET_RESOLVED_SERVER_SIDE,
722
+ };
723
+ },
724
+ },
725
+ };
726
+ }
727
+ /** Fail-closed error for `--personal` (and the roll-up) under an API key. */
728
+ function personalVaultNeedsCognito() {
729
+ return apiKeyUnsupported(`HQ_API_KEY is set; --personal is not supported for API keys — a key is ` +
730
+ `company-bound and the personal vault is reached by vending STS ` +
731
+ `credentials, which no /v1/keys route serves. Target a company with ` +
732
+ `--company cmp_… instead, or unset HQ_API_KEY to use your Cognito session.`);
733
+ }
734
+ /**
735
+ * The caller's credential plus the vault client that can carry it.
736
+ *
737
+ * `cognito` is undefined under HQ_API_KEY: `VaultClient` speaks only the JWT
738
+ * routes, so the keyed adapter stands in for the company path — and the
739
+ * personal path, which needs `VaultClient.entity.listByType` plus an STS vend,
740
+ * has nothing to run on and must fail closed at its branch.
741
+ */
742
+ async function resolveBrowseSession() {
743
+ const cred = await resolveVaultCredential();
744
+ if (cred.kind === "api-key") {
745
+ return {
746
+ token: cred.token,
747
+ client: createApiKeyBrowseVaultClient(cred.token),
748
+ };
749
+ }
750
+ const cognito = new VaultClient(buildVaultConfig(cred.token));
751
+ return { token: cred.token, client: cognito, cognito };
752
+ }
639
753
  /**
640
754
  * Wire `hq files browse` + `hq files cat` onto an existing `files`
641
755
  * Commander group. `registerFilesCommand` in files.ts builds the group
@@ -659,23 +773,23 @@ export function registerFilesBrowseCommands(filesCmd) {
659
773
  if (options.personal && company) {
660
774
  throw new Error("--personal and --company are mutually exclusive. Pick one.");
661
775
  }
662
- const accessToken = await ensureCognitoToken();
663
- const vaultConfig = buildVaultConfig(accessToken);
664
- const client = new VaultClient(vaultConfig);
776
+ const { token: accessToken, client, cognito } = await resolveBrowseSession();
665
777
  if (options.personal) {
778
+ if (!cognito)
779
+ throw personalVaultNeedsCognito();
666
780
  // Personal-vault path. Resolve the caller's canonical person
667
781
  // entity once; the orchestrator does the bucket lookup + vend.
668
782
  // Empty [path] → list bucket root.
669
783
  const personalUid = await resolveCanonicalPersonUid({
670
- listMyMemberships: () => client.listMyMemberships(),
671
- listPersonEntities: () => client.entity.listByType("person"),
784
+ listMyMemberships: () => cognito.listMyMemberships(),
785
+ listPersonEntities: () => cognito.entity.listByType("person"),
672
786
  getEntity: async () => null,
673
787
  });
674
788
  const result = await runBrowse({
675
789
  pathPrefix: pathArg ?? "",
676
790
  personalMode: true,
677
791
  personalUid,
678
- vaultClient: client,
792
+ vaultClient: cognito,
679
793
  s3Factory: defaultS3Factory,
680
794
  region: DEFAULT_COGNITO.region,
681
795
  });
@@ -746,13 +860,13 @@ export function registerFilesBrowseCommands(filesCmd) {
746
860
  if (options.personal && company) {
747
861
  throw new Error("--personal and --company are mutually exclusive. Pick one.");
748
862
  }
749
- const accessToken = await ensureCognitoToken();
750
- const vaultConfig = buildVaultConfig(accessToken);
751
- const client = new VaultClient(vaultConfig);
863
+ const { token: accessToken, client, cognito } = await resolveBrowseSession();
752
864
  if (options.personal) {
865
+ if (!cognito)
866
+ throw personalVaultNeedsCognito();
753
867
  const personalUid = await resolveCanonicalPersonUid({
754
- listMyMemberships: () => client.listMyMemberships(),
755
- listPersonEntities: () => client.entity.listByType("person"),
868
+ listMyMemberships: () => cognito.listMyMemberships(),
869
+ listPersonEntities: () => cognito.entity.listByType("person"),
756
870
  getEntity: async () => null,
757
871
  });
758
872
  const result = await runCat({
@@ -761,7 +875,7 @@ export function registerFilesBrowseCommands(filesCmd) {
761
875
  hqRoot: options.hqRoot,
762
876
  personalMode: true,
763
877
  personalUid,
764
- vaultClient: client,
878
+ vaultClient: cognito,
765
879
  s3Factory: defaultS3Factory,
766
880
  region: DEFAULT_COGNITO.region,
767
881
  });
@@ -811,9 +925,16 @@ export function registerFilesBrowseCommands(filesCmd) {
811
925
  try {
812
926
  // `--company` is bound on the parent `files` group — read merged opts.
813
927
  const company = command.optsWithGlobals().company;
814
- const accessToken = await ensureCognitoToken();
815
- const vaultConfig = buildVaultConfig(accessToken);
816
- const client = new VaultClient(vaultConfig);
928
+ const { token: accessToken, client, cognito } = await resolveBrowseSession();
929
+ // The roll-up fans out over `listMyMemberships` + `entity.get`, neither
930
+ // of which a key can reach. Refuse up front so the caller is told to
931
+ // scope the query instead of watching it fail mid-fan-out.
932
+ if (!cognito && !company) {
933
+ throw apiKeyUnsupported(`HQ_API_KEY is set; the cross-company roll-up is not supported for ` +
934
+ `API keys — a key is bound to one company and the roll-up needs ` +
935
+ `your membership graph. Scope it with --company cmp_… (or set ` +
936
+ `HQ_COMPANY_UID), or unset HQ_API_KEY.`);
937
+ }
817
938
  let companyUid;
818
939
  if (company) {
819
940
  // Confirm membership + resolve UID, same early-failure pattern as
@@ -845,12 +966,13 @@ export function registerFilesBrowseCommands(filesCmd) {
845
966
  if (options.personal && company) {
846
967
  throw new Error("--personal and --company are mutually exclusive. Pick one.");
847
968
  }
848
- const accessToken = await ensureCognitoToken();
849
- const client = new VaultClient(buildVaultConfig(accessToken));
969
+ const { token: accessToken, client, cognito } = await resolveBrowseSession();
850
970
  if (options.personal) {
971
+ if (!cognito)
972
+ throw personalVaultNeedsCognito();
851
973
  const personalUid = await resolveCanonicalPersonUid({
852
- listMyMemberships: () => client.listMyMemberships(),
853
- listPersonEntities: () => client.entity.listByType("person"),
974
+ listMyMemberships: () => cognito.listMyMemberships(),
975
+ listPersonEntities: () => cognito.entity.listByType("person"),
854
976
  getEntity: async () => null,
855
977
  });
856
978
  const rows = await runSearch({
@@ -858,7 +980,7 @@ export function registerFilesBrowseCommands(filesCmd) {
858
980
  companySlug: "personal",
859
981
  personalMode: true,
860
982
  personalUid,
861
- vaultClient: client,
983
+ vaultClient: cognito,
862
984
  s3Factory: defaultS3Factory,
863
985
  region: DEFAULT_COGNITO.region,
864
986
  });
@@ -891,8 +1013,7 @@ export function registerFilesBrowseCommands(filesCmd) {
891
1013
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
892
1014
  .action(async (pathArg, options, command) => {
893
1015
  try {
894
- const accessToken = await ensureCognitoToken();
895
- const client = new VaultClient(buildVaultConfig(accessToken));
1016
+ const { token: accessToken, client } = await resolveBrowseSession();
896
1017
  // `--company` is bound on the parent `files` group — read merged opts.
897
1018
  const companyOpt = command.optsWithGlobals().company;
898
1019
  const slug = companyOpt ?? parseCompanySlugFromPath(pathArg);
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import open from "open";
3
3
  import * as readline from "node:readline";
4
- import { ensureCognitoToken } from "../utils/cognito-session.js";
4
+ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
5
+ import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
5
6
  import { vaultApiFetch, getCompanyUid } from "./secrets.js";
6
7
  import { GROUP_ID_PATTERN, EMAIL_PATTERN, normalizeFilePrefix } from "./_patterns.js";
7
8
  // ---------------------------------------------------------------------------
@@ -92,6 +93,31 @@ export function registerFilesCommand(program) {
92
93
  .command("files")
93
94
  .description("Manage file access controls in HQ vault")
94
95
  .option("--company <slug>", "Company slug (resolves to companyUid)");
96
+ // An exported HQ_API_KEY must never fall back to the caller's Cognito session
97
+ // (that would ignore the key's scope entirely), so every subcommand is gated.
98
+ //
99
+ // hq-pro serves keyed `/v1/keys/files/…` parallels for the LIST-shaped reads
100
+ // (`list`, `versions`, `versions-list`, `grants`, `tombstones`) and the
101
+ // presign/delete/restore writes — those subcommands work under a key. The ACL
102
+ // and share-session surfaces are still Cognito-only: they live on the
103
+ // unversioned `/files/{companyUid}/acl…` + `/share-session` routes, which have
104
+ // no `/v1/keys` parallel. Marking them `routeAvailable: false` fails them
105
+ // closed AT THE GATE with the capability named, instead of letting the key
106
+ // travel to a JWT-only route and come back as a login-looking 401.
107
+ gateApiKeyCapabilities(files, {
108
+ default: "files:read",
109
+ bySubcommand: {
110
+ share: { capability: "files:write", routeAvailable: false },
111
+ unshare: { capability: "files:write", routeAvailable: false },
112
+ acl: { capability: "files:read", routeAvailable: false },
113
+ delete: "files:write",
114
+ restore: "files:write",
115
+ // `hq files search` filters a `/v1/files/list` listing (see runSearch) —
116
+ // it does NOT call the content-search route, so `files:read` is the
117
+ // capability hq-pro will actually check on `/v1/keys/files/list`.
118
+ search: "files:read",
119
+ },
120
+ });
95
121
  files
96
122
  .command("share [paths...]")
97
123
  .description("Share file paths. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
@@ -184,7 +210,7 @@ export function registerFilesCommand(program) {
184
210
  ? principal.trim().toLowerCase()
185
211
  : principal;
186
212
  const principalLabel = isAll ? "everyone in the company" : granteeId;
187
- const token = await ensureCognitoToken();
213
+ const token = (await resolveVaultCredential()).token;
188
214
  const companySlug = files.opts().company;
189
215
  const companyUid = await getCompanyUid(token, companySlug);
190
216
  const res = await vaultApiFetch({
@@ -239,7 +265,7 @@ export function registerFilesCommand(program) {
239
265
  .action(async (prefix) => {
240
266
  try {
241
267
  const canonicalPrefix = normalizeFilePrefix(prefix);
242
- const token = await ensureCognitoToken();
268
+ const token = (await resolveVaultCredential()).token;
243
269
  const companySlug = files.opts().company;
244
270
  const companyUid = await getCompanyUid(token, companySlug);
245
271
  // `/acl/tree` carries the prefix's own row metadata (directRow) and the
@@ -477,7 +503,7 @@ async function runDirectGrant(params) {
477
503
  : principal;
478
504
  // Display label used in success messages — `@all` reads better than `""`.
479
505
  const principalLabel = isAll ? "everyone in the company" : granteeId;
480
- const token = await ensureCognitoToken();
506
+ const token = (await resolveVaultCredential()).token;
481
507
  const companyUid = await getCompanyUid(token, params.companySlug);
482
508
  let res = await vaultApiFetch({
483
509
  token,
@@ -555,7 +581,7 @@ async function runShareSession(params) {
555
581
  }
556
582
  expiresInMs = parsed;
557
583
  }
558
- const token = await ensureCognitoToken();
584
+ const token = (await resolveVaultCredential()).token;
559
585
  const companyUid = await getCompanyUid(token, params.companySlug);
560
586
  let session;
561
587
  try {
@@ -721,7 +747,7 @@ export async function runFilesDelete(params, deps = {}) {
721
747
  console.error(chalk.red("Refusing to delete the vault root. Pass a bounded prefix (e.g. 'projects/foo/' or 'projects/foo/*') or an exact key."));
722
748
  process.exit(1);
723
749
  }
724
- const token = await ensureCognitoToken();
750
+ const token = (await resolveVaultCredential()).token;
725
751
  // Personal scope resolves the vault server-side from the caller's identity —
726
752
  // no company to look up. Company scope resolves the companyUid as before.
727
753
  const companyUid = params.personal
@@ -905,7 +931,7 @@ function formatFilesRecoveryError(err, key) {
905
931
  }
906
932
  async function resolveRecoveryScope(params) {
907
933
  assertRecoveryScope(params.personal, params.companySlug);
908
- const token = await ensureCognitoToken();
934
+ const token = (await resolveVaultCredential()).token;
909
935
  if (params.personal) {
910
936
  return { token, personal: true };
911
937
  }
@@ -1,5 +1,6 @@
1
1
  import chalk from "chalk";
2
- import { ensureCognitoToken } from "../utils/cognito-session.js";
2
+ import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
3
+ import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
3
4
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
4
5
  const EMAIL_PATTERN = /^[^\s]+@[^\s]+$/;
5
6
  const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
@@ -369,6 +370,16 @@ export function registerMembersCommand(program) {
369
370
  .command("members")
370
371
  .description("Manage company memberships and invites")
371
372
  .option("--company <slug>", "Company slug (resolves to companyUid)");
373
+ // Membership routes are Cognito-JWT only today; an API key must fail closed
374
+ // rather than fall back to the caller's session.
375
+ gateApiKeyCapabilities(members, {
376
+ default: "members:read",
377
+ bySubcommand: {
378
+ promote: "members:write",
379
+ invite: "members:write",
380
+ revoke: "members:write",
381
+ },
382
+ });
372
383
  // Canonical role-change command. `promote` is the discoverable verb users
373
384
  // reach for; `set-role` is kept as an alias for the older name. The route is a
374
385
  // GENERAL role change, so the help text and success message are honest that it
@@ -386,7 +397,7 @@ export function registerMembersCommand(program) {
386
397
  console.error(chalk.red(`Invalid role '${newRole}': must be one of owner, admin, member, guest`));
387
398
  process.exit(1);
388
399
  }
389
- const token = await ensureCognitoToken();
400
+ const token = (await resolveVaultCredential()).token;
390
401
  const companySlug = members.opts().company;
391
402
  const companyUid = await getCompanyUid(token, companySlug);
392
403
  const membershipKey = await resolveRoleChangeTarget(token, companyUid, target);
@@ -425,7 +436,7 @@ export function registerMembersCommand(program) {
425
436
  let companyUid;
426
437
  let companySlug;
427
438
  try {
428
- token = await ensureCognitoToken();
439
+ token = (await resolveVaultCredential()).token;
429
440
  companySlug = members.opts().company;
430
441
  companyUid = await getCompanyUid(token, companySlug);
431
442
  const callerUid = await getCallerPersonUid(token);
@@ -608,7 +619,7 @@ export function registerMembersCommand(program) {
608
619
  .option("--pending", "List pending invites instead of active members")
609
620
  .action(async (opts) => {
610
621
  try {
611
- const token = await ensureCognitoToken();
622
+ const token = (await resolveVaultCredential()).token;
612
623
  const companySlug = members.opts().company;
613
624
  const companyUid = await getCompanyUid(token, companySlug);
614
625
  if (opts.pending) {
@@ -677,7 +688,7 @@ export function registerMembersCommand(program) {
677
688
  .description("Revoke a pending invite. Accepts an email, personUid, full membershipKey, or legacy inviteToken.")
678
689
  .action(async (target) => {
679
690
  try {
680
- const token = await ensureCognitoToken();
691
+ const token = (await resolveVaultCredential()).token;
681
692
  const companySlug = members.opts().company;
682
693
  const companyUid = await getCompanyUid(token, companySlug);
683
694
  const membershipKey = resolveRevokeTargetToMembershipKey(target, companyUid);
@@ -125,6 +125,19 @@ export interface MarketplaceDeps {
125
125
  download: (url: string) => Promise<Uint8Array | {
126
126
  expired: true;
127
127
  }>;
128
+ /**
129
+ * Record the install against the listing (`POST /v1/listings/{id}/installs`).
130
+ *
131
+ * Load-bearing under HQ_API_KEY: browse + download are PUBLIC, so this is the
132
+ * only call that ever presents the key — which makes it the only place hq-pro
133
+ * can enforce `packs:install` on the key's REAL grants. The CLI-side
134
+ * capability gate can check the catalog, never the presented key. Optional so
135
+ * test doubles written before the keyed wire keep compiling.
136
+ */
137
+ recordInstall?: (input: {
138
+ listingId: string;
139
+ companySlug?: string;
140
+ }) => Promise<void>;
128
141
  }
129
142
  interface RawListingSummary {
130
143
  listingId?: string;
@@ -167,6 +180,7 @@ export declare function defaultMarketplaceDeps(): MarketplaceDeps;
167
180
  */
168
181
  export declare function fetchMarketplace(source: string, tmpDir: string, deps: MarketplaceDeps, opts?: {
169
182
  requireSignature?: boolean;
183
+ company?: string;
170
184
  }): Promise<FetchResult>;
171
185
  export interface LatestResult {
172
186
  transport: Transport;
@@ -49,8 +49,9 @@ import { findHqRoot } from '../utils/manifest.js';
49
49
  import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
50
50
  import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
51
51
  import { safeExtractTarball } from './safe-extract.js';
52
- import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
52
+ import { getCompanyUid, vaultApiFetch, vaultApiFetchPublic, } from '../utils/vault-api.js';
53
53
  import { ensureCognitoToken } from '../utils/cognito-session.js';
54
+ import { formatApiKeyCapabilityDenial, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
54
55
  import { redactSecrets, SECRET_REDACTION, registerMcpServers, McpManifestError, } from './mcp-registration.js';
55
56
  import { listSecretCacheScopes } from '../utils/secrets-cache.js';
56
57
  import { loadRevealedSecrets } from './secrets.js';
@@ -375,6 +376,31 @@ export function defaultMarketplaceDeps() {
375
376
  }
376
377
  return new Uint8Array(await response.arrayBuffer());
377
378
  },
379
+ recordInstall: async ({ listingId, companySlug }) => {
380
+ // Only the API-key path calls this. A Cognito (or logged-out) install
381
+ // stays on the public browse+download wire — `hq install` must keep
382
+ // working without a login, and forcing one here would be a regression.
383
+ const apiKey = peekHqApiKey();
384
+ if (apiKey === undefined)
385
+ return;
386
+ const res = await vaultApiFetch({
387
+ token: apiKey,
388
+ // Rewritten to POST /v1/keys/listings/{id}/installs, which hq-pro
389
+ // serves behind `requireCapability: 'packs:install'`.
390
+ path: `/v1/listings/${encodeURIComponent(listingId)}/installs`,
391
+ method: 'POST',
392
+ body: companySlug
393
+ ? { scope: 'company', companySlug }
394
+ : { scope: 'personal' },
395
+ });
396
+ if (res.ok)
397
+ return;
398
+ const body = (await res.json().catch(() => ({})));
399
+ // 403 carries hq-pro's granted/missing capability report — render it so
400
+ // the operator sees what the presented key actually holds.
401
+ throw Object.assign(new Error(formatApiKeyCapabilityDenial(body, `HQ_API_KEY was refused for this install (HTTP ${res.status}). ` +
402
+ `The key needs the 'packs:install' capability.`)), { expected: true });
403
+ },
378
404
  };
379
405
  }
380
406
  /**
@@ -389,6 +415,17 @@ export function defaultMarketplaceDeps() {
389
415
  export async function fetchMarketplace(source, tmpDir, deps, opts = {}) {
390
416
  const { slug, version } = parseMarketplaceSource(source);
391
417
  let listing = await deps.resolveListing(slug, version);
418
+ // Authorize the install SERVER-side before pulling any bytes. Under
419
+ // HQ_API_KEY this is the only call that presents the key, so it is what
420
+ // actually enforces `packs:install` against the key's real grants — the
421
+ // listing browse + tarball download are both public and would happily serve
422
+ // a key that holds nothing. A no-op on the Cognito path.
423
+ if (deps.recordInstall) {
424
+ await deps.recordInstall({
425
+ listingId: listing.listingId,
426
+ ...(opts.company !== undefined ? { companySlug: opts.company } : {}),
427
+ });
428
+ }
392
429
  // Download with a single expired-URL retry: a presigned URL can expire
393
430
  // between resolve and download, surfacing as a raw S3 403. We re-resolve the
394
431
  // detail by id to mint a fresh URL and retry ONCE rather than leaking the 403.
@@ -1613,6 +1650,15 @@ export function runScanPackages(hqRoot, opts = {}) {
1613
1650
  }
1614
1651
  }
1615
1652
  export async function installPack(source, opts = {}) {
1653
+ // NOTE: the HQ_API_KEY fail-closed gate is NOT enforced here. `installPack`
1654
+ // is a shared primitive called both by the top-level `hq install` CLI route
1655
+ // (where Cognito-only is the right gate — checked there instead) AND by
1656
+ // `hq packs update` (packs.ts:runUpdate), which un-wires an existing pack's
1657
+ // contributions BEFORE re-installing. Throwing from inside `installPack`
1658
+ // would leave an update mid-flight (unwired, not reinstalled) whenever
1659
+ // HQ_API_KEY happened to be set. Restricting the assertion to the install
1660
+ // entrypoint keeps `hq packs update` fail-closed at its own call site
1661
+ // instead (checked before it un-wires anything).
1616
1662
  const transport = classify(source);
1617
1663
  const hqRoot = findHqRoot();
1618
1664
  // `readHqVersion` (shared with `hq packs`) reads the CANONICAL
@@ -1640,7 +1686,10 @@ export async function installPack(source, opts = {}) {
1640
1686
  fetched = fetchLocal(source, tmpDir);
1641
1687
  break;
1642
1688
  case 'marketplace':
1643
- fetched = await fetchMarketplace(source, tmpDir, opts.marketplaceDeps ?? defaultMarketplaceDeps(), { requireSignature: opts.requireSignature });
1689
+ fetched = await fetchMarketplace(source, tmpDir, opts.marketplaceDeps ?? defaultMarketplaceDeps(), {
1690
+ requireSignature: opts.requireSignature,
1691
+ ...(opts.company !== undefined ? { company: opts.company } : {}),
1692
+ });
1644
1693
  break;
1645
1694
  }
1646
1695
  const pkg = validateManifest(fetched.payloadDir, hqVersion);
@@ -23,7 +23,41 @@ import { resolveDefaultHqRoot } from '../utils/cognito-session.js';
23
23
  import { getRegistryUrl, RegistryClient, } from '../utils/registry-client.js';
24
24
  import { verifySha256, verifyRsaSignature } from '../utils/integrity.js';
25
25
  import { addToRegistry } from '../utils/registry.js';
26
+ import { peekHqApiKey, requireApiKeyCapability, } from '../utils/resolve-vault-credential.js';
26
27
  import { MARKETPLACE_PREFIX, installPack, sourceMatchesPackPattern, } from './pack-install.js';
28
+ /**
29
+ * True when `source` installs through the marketplace — an explicit
30
+ * `marketplace:` source, or a bare slug (which the dispatch below rewrites into
31
+ * one). Every other shape (npm scope, git URL, local path) is fetched from npm,
32
+ * GitHub, or the filesystem.
33
+ */
34
+ function isMarketplaceInstall(source) {
35
+ return (source.startsWith(MARKETPLACE_PREFIX) || !sourceMatchesPackPattern(source));
36
+ }
37
+ /**
38
+ * Refuse an `HQ_API_KEY` install whose transport never presents the key.
39
+ *
40
+ * `requireApiKeyCapability` can only confirm that hq-cli KNOWS `packs:install`;
41
+ * the key's real grants live on the server. The marketplace transport gets them
42
+ * checked, because it records the install at
43
+ * `POST /v1/keys/listings/{id}/installs` (see `MarketplaceDeps.recordInstall`).
44
+ * An npm/git/local source talks to npm, GitHub, or the filesystem and never
45
+ * reaches hq-pro, so a key holding no capabilities at all would install exactly
46
+ * as well as a fully scoped one — the gate would be theatre. Fail closed and
47
+ * name the two ways forward.
48
+ */
49
+ function assertApiKeyCanAuthorizeInstall(source) {
50
+ if (peekHqApiKey() === undefined)
51
+ return;
52
+ if (isMarketplaceInstall(source))
53
+ return;
54
+ throw Object.assign(new Error(`HQ_API_KEY is set; 'hq install ${source}' is not supported for API keys ` +
55
+ `— this source is fetched directly from npm/git/disk, so hq-pro never ` +
56
+ `sees the key and cannot enforce 'packs:install'.\n` +
57
+ ` Install from the marketplace instead (a bare slug or ` +
58
+ `'marketplace:<slug>'), where the key IS authorized server-side.\n` +
59
+ ` Unset HQ_API_KEY to use your Cognito session.`), { expected: true });
60
+ }
27
61
  export function registerPackageInstallCommand(parent) {
28
62
  parent
29
63
  .command('install <source>')
@@ -35,6 +69,15 @@ export function registerPackageInstallCommand(parent) {
35
69
  .option('--branch', 'Follow a ref instead of SHA-pinning (git content-pack flow)')
36
70
  .action(async (source, opts) => {
37
71
  try {
72
+ // Gate the top-level `hq install` entrypoint only — `installPack`
73
+ // itself is also called by `hq packs update` (packs.ts), which must
74
+ // stay able to fail closed at its OWN call site (before it un-wires
75
+ // an existing pack) rather than mid-flight inside installPack.
76
+ //
77
+ // Capability gate: under HQ_API_KEY the install must present a key
78
+ // with packs:install (hq-pro serves POST /v1/keys/listings/…/installs).
79
+ requireApiKeyCapability('packs:install', 'hq install');
80
+ assertApiKeyCanAuthorizeInstall(source);
38
81
  if (sourceMatchesPackPattern(source)) {
39
82
  await installPack(source, {
40
83
  company: opts.company,
@@ -3,6 +3,7 @@ import * as path from 'node:path';
3
3
  import * as fs from 'node:fs';
4
4
  import { internal } from 'varlock';
5
5
  import { ensureCognitoToken } from '../utils/cognito-session.js';
6
+ import { apiKeyCapabilityGateMessage, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
6
7
  import { computeSha256 } from '../utils/integrity.js';
7
8
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
8
9
  import { discoverSchemas } from '../run/discover-schemas.js';
@@ -38,6 +39,13 @@ export function registerRunCommand(program) {
38
39
  .allowUnknownOption(true)
39
40
  .action(async (opts) => {
40
41
  try {
42
+ // `hq run` batch-loads through `/secrets/{uid}/load`, which has no
43
+ // Bearer `hqk_` parallel route — `hq secrets exec` is the API-key path.
44
+ if (peekHqApiKey() !== undefined) {
45
+ throw new Error(`${apiKeyCapabilityGateMessage('secrets:read', 'hq run', {
46
+ routeAvailable: false,
47
+ })}\n Use \`hq secrets exec --only <NAME> -- <command>\` with this key instead.`);
48
+ }
41
49
  const dashIndex = process.argv.indexOf('--');
42
50
  const childArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : [];
43
51
  if (!opts.check && childArgs.length === 0) {