@indigoai-us/hq-cli 5.77.14 → 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 (308) hide show
  1. package/CHANGELOG.md +23 -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 +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -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 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. 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.
@@ -1649,7 +1686,10 @@ export async function installPack(source, opts = {}) {
1649
1686
  fetched = fetchLocal(source, tmpDir);
1650
1687
  break;
1651
1688
  case 'marketplace':
1652
- 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
+ });
1653
1693
  break;
1654
1694
  }
1655
1695
  const pkg = validateManifest(fetched.payloadDir, hqVersion);
@@ -23,8 +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 { assertCognitoOnlyCommand } from '../utils/resolve-vault-credential.js';
26
+ import { peekHqApiKey, requireApiKeyCapability, } from '../utils/resolve-vault-credential.js';
27
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
+ }
28
61
  export function registerPackageInstallCommand(parent) {
29
62
  parent
30
63
  .command('install <source>')
@@ -40,7 +73,11 @@ export function registerPackageInstallCommand(parent) {
40
73
  // itself is also called by `hq packs update` (packs.ts), which must
41
74
  // stay able to fail closed at its OWN call site (before it un-wires
42
75
  // an existing pack) rather than mid-flight inside installPack.
43
- assertCognitoOnlyCommand('hq install');
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);
44
81
  if (sourceMatchesPackPattern(source)) {
45
82
  await installPack(source, {
46
83
  company: opts.company,
@@ -3,7 +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 { peekHqApiKey } from '../utils/resolve-vault-credential.js';
6
+ import { apiKeyCapabilityGateMessage, peekHqApiKey, } from '../utils/resolve-vault-credential.js';
7
7
  import { computeSha256 } from '../utils/integrity.js';
8
8
  import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
9
9
  import { discoverSchemas } from '../run/discover-schemas.js';
@@ -39,8 +39,12 @@ export function registerRunCommand(program) {
39
39
  .allowUnknownOption(true)
40
40
  .action(async (opts) => {
41
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.
42
44
  if (peekHqApiKey() !== undefined) {
43
- throw new Error('HQ_API_KEY is set; `hq run` requires a Cognito session. Unset HQ_API_KEY or use `hq secrets exec`.');
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.`);
44
48
  }
45
49
  const dashIndex = process.argv.indexOf('--');
46
50
  const childArgs = dashIndex !== -1 ? process.argv.slice(dashIndex + 1) : [];