@indigoai-us/hq-cli 5.77.14 → 5.79.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 (309) hide show
  1. package/CHANGELOG.md +31 -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/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. 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
  }
@@ -95,9 +95,12 @@ async function resolveShortId(token, prefix, query) {
95
95
  console.error(chalk.red(`No meeting matches ID "${prefix}". It may be older than the meetings shown by \`hq meetings list\`, still processing, or attributed to a different company. Pass the full meeting id, add --company <slug>, or widen the list with \`hq meetings list --limit <n>\`.`));
96
96
  process.exit(1);
97
97
  }
98
- async function handleApiError(res) {
98
+ async function handleApiError(res, json = false) {
99
99
  const body = (await res.json().catch(() => ({})));
100
- if (res.status === 401) {
100
+ if (json) {
101
+ console.log(JSON.stringify(body, null, 2));
102
+ }
103
+ else if (res.status === 401) {
101
104
  console.error(chalk.red("Not authenticated — run `hq login` first"));
102
105
  }
103
106
  else if (res.status === 403) {
@@ -106,6 +109,10 @@ async function handleApiError(res) {
106
109
  else if (res.status === 404) {
107
110
  console.error(chalk.red(body.error ?? "Not found"));
108
111
  }
112
+ else if (res.status === 402) {
113
+ console.error(chalk.red("Meeting notetaker bots require the $500/mo HQ Team plan."));
114
+ console.error(chalk.dim(body.message ?? body.error ?? "Upgrade your company to HQ Team to record meetings."));
115
+ }
109
116
  else {
110
117
  console.error(chalk.red(`API error (${res.status}): ${body.error ?? res.statusText}`));
111
118
  }
@@ -190,7 +197,7 @@ export function registerMeetingsCommand(program) {
190
197
  query.companyId = await getCompanyUid(token, companySlug);
191
198
  const res = await vaultApiFetch({ token, path: "/v1/meetings", query });
192
199
  if (!res.ok)
193
- await handleApiError(res);
200
+ await handleApiError(res, meetings.opts().json);
194
201
  const data = (await res.json());
195
202
  if (meetings.opts().json) {
196
203
  console.log(JSON.stringify(data, null, 2));
@@ -227,7 +234,7 @@ export function registerMeetingsCommand(program) {
227
234
  body: { meetingUrl },
228
235
  });
229
236
  if (!res.ok)
230
- await handleApiError(res);
237
+ await handleApiError(res, meetings.opts().json);
231
238
  const data = (await res.json());
232
239
  if (meetings.opts().json) {
233
240
  console.log(JSON.stringify(data, null, 2));
@@ -263,7 +270,7 @@ export function registerMeetingsCommand(program) {
263
270
  query,
264
271
  });
265
272
  if (!res.ok)
266
- await handleApiError(res);
273
+ await handleApiError(res, meetings.opts().json);
267
274
  const detail = (await res.json());
268
275
  if (meetings.opts().json) {
269
276
  console.log(JSON.stringify(detail, null, 2));
@@ -348,7 +355,7 @@ export function registerMeetingsCommand(program) {
348
355
  body: { companyId, applyToSeries },
349
356
  });
350
357
  if (!res.ok)
351
- await handleApiError(res);
358
+ await handleApiError(res, meetings.opts().json);
352
359
  const data = (await res.json());
353
360
  if (meetings.opts().json) {
354
361
  console.log(JSON.stringify(data, null, 2));
@@ -389,7 +396,7 @@ export function registerMeetingsCommand(program) {
389
396
  query: params,
390
397
  });
391
398
  if (!res.ok)
392
- await handleApiError(res);
399
+ await handleApiError(res, meetings.opts().json);
393
400
  const data = (await res.json());
394
401
  if (meetings.opts().json) {
395
402
  console.log(JSON.stringify(data, null, 2));
@@ -422,7 +429,7 @@ export function registerMeetingsCommand(program) {
422
429
  query,
423
430
  });
424
431
  if (!res.ok)
425
- await handleApiError(res);
432
+ await handleApiError(res, meetings.opts().json);
426
433
  const detail = (await res.json());
427
434
  if (isMarkdownShape(detail)) {
428
435
  const documentUrl = detail.source.presigned_url;
@@ -492,7 +499,7 @@ export function registerMeetingsCommand(program) {
492
499
  query,
493
500
  });
494
501
  if (!res.ok)
495
- await handleApiError(res);
502
+ await handleApiError(res, meetings.opts().json);
496
503
  const detail = (await res.json());
497
504
  if (isMarkdownShape(detail)) {
498
505
  if (hasSignals(detail.signals)) {
@@ -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,