@indigoai-us/hq-cli 5.77.13 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -1,2247 +0,0 @@
1
- /**
2
- * hq install <source> — content-pack installer (US-003, hq-core v12.0.0+)
3
- *
4
- * Source patterns and transports:
5
- * @scope/name[@version] → npm (default pin = latest, frozen on install)
6
- * https://... | git@... | *.git → git (SHA-pinned by default; --branch to follow)
7
- * github:owner/repo[#...] → git (sugar — expands to https://github.com/owner/repo.git)
8
- * ./path | ../path | /path | file:... → local (path recorded as-is)
9
- *
10
- * Git fragment grammar (after '#'):
11
- * '#<ref>' → whole repo at ref (tag / branch / SHA)
12
- * '#<subpath>' → subdirectory of repo at default branch
13
- * '#<subpath>@<ref>' → subdirectory at ref
14
- * Fragment disambiguation: if the token after '#' contains '/' it's a subpath;
15
- * otherwise it's a ref. Subpath enables monorepo-hosted packs (e.g.
16
- * 'github:indigoai-us/hq#packages/hq-pack-gstack@abc1234') without a separate
17
- * registry or release tarball.
18
- *
19
- * Distinct from pkg-install.ts (entitlement-gated registry flow against the
20
- * proprietary HQ registry — that still runs for plain slugs). Spec:
21
- * knowledge/public/hq-core/package-yaml-spec.md.
22
- *
23
- * Flow:
24
- * 1. Classify source → transport
25
- * 2. Fetch payload to tmpdir (npm pack | git clone | rsync)
26
- * 3. Parse + validate package.yaml (10 checks from spec)
27
- * 4. Evaluate `conditional` predicate — skip if exits non-zero
28
- * 5. Confirm hooks if `contributes.hooks` non-empty (unless --allow-hooks)
29
- * 6. Move into core/packages/{name}/
30
- * 7. Run core/scripts/scan-packages.sh to wire contributions into host paths
31
- *
32
- * Installed packs are tracked by filesystem presence — there's no separate
33
- * registry file under the v12 layout. (`hq update <pack>` re-resolves source
34
- * from each pack's package.yaml; rationale lives in the layout-fix PR.)
35
- */
36
-
37
- import * as fs from 'fs';
38
- import * as os from 'os';
39
- import * as path from 'path';
40
- import * as readline from 'readline';
41
- import { parseUserYaml } from '../utils/user-yaml-error.js';
42
- import {
43
- createHash,
44
- createPublicKey,
45
- verify as cryptoVerify,
46
- type KeyObject,
47
- } from 'node:crypto';
48
- import { execFileSync, spawnSync } from 'child_process';
49
- import { Command } from 'commander';
50
- import chalk from 'chalk';
51
- import semverSatisfies from 'semver/functions/satisfies.js';
52
- import semverValid from 'semver/functions/valid.js';
53
- import semverValidRange from 'semver/ranges/valid.js';
54
- import semverGt from 'semver/functions/gt.js';
55
- import { findHqRoot } from '../utils/manifest.js';
56
- import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
57
- import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
58
- import { safeExtractTarball } from './safe-extract.js';
59
- import { getCompanyUid, vaultApiFetchPublic } from '../utils/vault-api.js';
60
- import { ensureCognitoToken } from '../utils/cognito-session.js';
61
- import {
62
- redactSecrets,
63
- SECRET_REDACTION,
64
- registerMcpServers,
65
- McpManifestError,
66
- type McpManifest,
67
- type SecretResolver,
68
- } from './mcp-registration.js';
69
- import { listSecretCacheScopes } from '../utils/secrets-cache.js';
70
- import { loadRevealedSecrets } from './secrets.js';
71
- import type { PackManifest, PackContributeKey } from '../types.js';
72
-
73
- const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
74
- const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
75
-
76
- interface PackUpdateCacheEntry {
77
- latest: string;
78
- fetchedAt: number;
79
- }
80
-
81
- interface PackUpdateCacheFile {
82
- entries: Record<string, PackUpdateCacheEntry>;
83
- }
84
-
85
- export interface ResolveLatestOptions {
86
- forceRefresh?: boolean;
87
- now?: number;
88
- cacheTtlMs?: number;
89
- fetchImpl?: typeof fetch;
90
- }
91
-
92
- const gitLsRemoteMemo = new Map<string, string>();
93
-
94
- function packUpdateCachePath(): string {
95
- return path.join(os.homedir(), '.hq', 'pack-update-cache.json');
96
- }
97
-
98
- function readPackUpdateCache(): PackUpdateCacheFile {
99
- try {
100
- const parsed = JSON.parse(fs.readFileSync(packUpdateCachePath(), 'utf-8')) as Partial<PackUpdateCacheFile>;
101
- if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object') {
102
- return { entries: {} };
103
- }
104
- return { entries: parsed.entries as Record<string, PackUpdateCacheEntry> };
105
- } catch {
106
- return { entries: {} };
107
- }
108
- }
109
-
110
- function writePackUpdateCache(cache: PackUpdateCacheFile): void {
111
- try {
112
- const file = packUpdateCachePath();
113
- fs.mkdirSync(path.dirname(file), { recursive: true });
114
- fs.writeFileSync(file, JSON.stringify(cache));
115
- } catch {
116
- // best-effort; update checks must never fail because the cache is unwritable
117
- }
118
- }
119
-
120
- function cachedLatest(cacheKey: string, opts: ResolveLatestOptions): string | undefined {
121
- if (opts.forceRefresh) return undefined;
122
- const entry = readPackUpdateCache().entries[cacheKey];
123
- if (!entry || typeof entry.latest !== 'string' || typeof entry.fetchedAt !== 'number') return undefined;
124
- const now = opts.now ?? Date.now();
125
- const ttl = opts.cacheTtlMs ?? PACK_UPDATE_CACHE_TTL_MS;
126
- return now - entry.fetchedAt <= ttl ? entry.latest : undefined;
127
- }
128
-
129
- function storeCachedLatest(cacheKey: string, latest: string, opts: ResolveLatestOptions): void {
130
- const cache = readPackUpdateCache();
131
- cache.entries[cacheKey] = { latest, fetchedAt: opts.now ?? Date.now() };
132
- writePackUpdateCache(cache);
133
- }
134
-
135
- async function latestWithDiskCache(
136
- cacheKey: string,
137
- opts: ResolveLatestOptions,
138
- refresh: () => Promise<string | undefined> | string | undefined,
139
- ): Promise<string | undefined> {
140
- const cached = cachedLatest(cacheKey, opts);
141
- if (cached) return cached;
142
- const latest = await refresh();
143
- if (latest) storeCachedLatest(cacheKey, latest, opts);
144
- return latest;
145
- }
146
-
147
- // ---------------------------------------------------------------------------
148
- // Source classification
149
- // ---------------------------------------------------------------------------
150
-
151
- export type Transport = 'npm' | 'git' | 'local' | 'marketplace';
152
-
153
- /** Prefix that routes a source through the HQ marketplace transport (US-006). */
154
- export const MARKETPLACE_PREFIX = 'marketplace:';
155
-
156
- export function classify(source: string): Transport {
157
- // US-006: marketplace:<slug>[@version] is recognized BEFORE the legacy
158
- // registry fallback and ahead of every other transport. The slug is resolved
159
- // against the public listings API, the presigned tarball is downloaded,
160
- // verified (US-021), and safe-extracted (US-020) into the existing local
161
- // install + symlink-wiring path.
162
- if (source.startsWith(MARKETPLACE_PREFIX)) return 'marketplace';
163
- if (source.startsWith('@')) return 'npm';
164
- if (
165
- source.startsWith('http://') ||
166
- source.startsWith('https://') ||
167
- source.startsWith('git@') ||
168
- source.startsWith('git://') ||
169
- source.startsWith('github:') ||
170
- source.endsWith('.git') ||
171
- /\.git#/.test(source)
172
- ) {
173
- return 'git';
174
- }
175
- // A '#' fragment is only meaningful to git (ref / subpath). If we see one
176
- // on a file: or path source, the caller means "clone this local git repo"
177
- // — route to git. Local transport does not consume fragments.
178
- if (source.includes('#')) {
179
- return 'git';
180
- }
181
- if (
182
- source.startsWith('./') ||
183
- source.startsWith('../') ||
184
- source.startsWith('/') ||
185
- source.startsWith('file:')
186
- ) {
187
- return 'local';
188
- }
189
- // Fallback: bare slug — caller (dispatcher) routes those to legacy flow.
190
- throw new Error(
191
- `Cannot classify source "${source}" as pack (npm @scope/name, git URL, or path). ` +
192
- `Bare slugs go through the registry (Cognito) flow.`
193
- );
194
- }
195
-
196
- /**
197
- * Expand 'github:owner/repo' shorthand to a cloneable URL. Passes anything
198
- * else through unchanged. Separate from classify() so the transport-dispatch
199
- * stays readable.
200
- */
201
- function expandGithubShorthand(url: string): string {
202
- if (!url.startsWith('github:')) return url;
203
- const slug = url.slice('github:'.length);
204
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(slug)) {
205
- throw new Error(
206
- `github: shorthand must be 'github:owner/repo' (got 'github:${slug}')`
207
- );
208
- }
209
- return `https://github.com/${slug}.git`;
210
- }
211
-
212
- /**
213
- * Parse a git source's '#<...>' fragment into { url, subpath, ref }.
214
- * Disambiguation: fragment containing '/' is a subpath (optionally with
215
- * '@<ref>' suffix); fragment without '/' is a ref.
216
- */
217
- export function parseGitFragment(source: string): {
218
- url: string;
219
- subpath?: string;
220
- ref?: string;
221
- } {
222
- const hashAt = source.indexOf('#');
223
- if (hashAt < 0) return { url: source };
224
- const url = source.slice(0, hashAt);
225
- const frag = source.slice(hashAt + 1);
226
- if (frag.includes('/')) {
227
- // subpath[@ref]
228
- const atAt = frag.lastIndexOf('@');
229
- if (atAt > 0) {
230
- return { url, subpath: frag.slice(0, atAt), ref: frag.slice(atAt + 1) };
231
- }
232
- return { url, subpath: frag };
233
- }
234
- return { url, ref: frag || undefined };
235
- }
236
-
237
- /**
238
- * sourceMatchesPackPattern — exported for the dispatcher in pkg-install.ts
239
- * so it can decide whether to route to the new content-pack handler or fall
240
- * back to the legacy registry flow.
241
- */
242
- export function sourceMatchesPackPattern(source: string): boolean {
243
- try {
244
- classify(source);
245
- return true;
246
- } catch {
247
- return false;
248
- }
249
- }
250
-
251
- // ---------------------------------------------------------------------------
252
- // Transports
253
- // ---------------------------------------------------------------------------
254
-
255
- interface FetchResult {
256
- payloadDir: string; // where the extracted pack lives (caller mvs it)
257
- resolvedSource: string; // e.g. '@scope/name@1.0.0' (version frozen) or URL#sha
258
- resolvedSha?: string; // git transport only
259
- }
260
-
261
- /**
262
- * Optional marketplace integrity metadata for a tarball-based install (US-021).
263
- * When present, `verifyArtifact` runs over the downloaded tarball bytes BEFORE
264
- * `safeExtractTarball` — a hash/signature mismatch aborts the install before
265
- * any byte is unpacked. US-006 populates this from the listing detail; the npm
266
- * fetch path also honors it so a registry-fetched tarball can be verified.
267
- */
268
- export interface ArtifactIntegrity {
269
- expectedHash: string;
270
- signature?: string;
271
- publicKey?: string;
272
- requireSignature?: boolean;
273
- }
274
-
275
- function fetchNpm(
276
- source: string,
277
- tmpDir: string,
278
- integrity?: ArtifactIntegrity,
279
- ): FetchResult {
280
- // Use `npm pack` to grab the tarball without actually installing anything.
281
- // Capture output to get the produced filename. Arguments are passed as an
282
- // argv array — never interpolated into a shell string — so `source` cannot
283
- // break out even if it contains metacharacters.
284
- const out = execFileSync(
285
- 'npm',
286
- ['pack', '--silent', '--pack-destination', tmpDir, source],
287
- { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'inherit'] },
288
- );
289
- const tarball = out.trim().split('\n').filter(Boolean).pop();
290
- if (!tarball) {
291
- throw new Error(`npm pack produced no tarball for "${source}"`);
292
- }
293
- const tarballPath = path.join(tmpDir, tarball);
294
- const extractDir = path.join(tmpDir, 'extracted');
295
- // SECURITY (US-021): verify integrity + authenticity over the EXACT
296
- // downloaded bytes BEFORE we extract. A hash/signature mismatch throws
297
- // `ArtifactVerificationError` here, so a tampered artifact is never unpacked
298
- // or wired. This MUST precede `safeExtractTarball` (US-020). Only runs when
299
- // the caller supplied a pinned hash (i.e. a marketplace listing did); the
300
- // plain `npm pack` path with no listing metadata is unaffected.
301
- if (integrity) {
302
- const tarballBytes = fs.readFileSync(tarballPath);
303
- verifyArtifact({
304
- tarballBytes,
305
- expectedHash: integrity.expectedHash,
306
- signature: integrity.signature,
307
- publicKey: integrity.publicKey,
308
- requireSignature: integrity.requireSignature,
309
- });
310
- }
311
- // SECURITY (US-020): the tarball is untrusted. Extract through the hardened
312
- // path — zip-slip / link-escape containment + decompression-bomb caps +
313
- // staged-then-atomic commit with full rollback — instead of a blind
314
- // `tar -xzf`. safeExtractTarball requires the destination not to pre-exist,
315
- // so we do NOT mkdir extractDir first; it is created atomically on success.
316
- safeExtractTarball(tarballPath, extractDir);
317
- // npm tarballs unpack into ./package/
318
- const payloadDir = path.join(extractDir, 'package');
319
- if (!fs.existsSync(payloadDir)) {
320
- throw new Error(`npm tarball layout unexpected — no "package/" in ${tarballPath}`);
321
- }
322
- // Freeze version from the resolved tarball filename when caller passed e.g.
323
- // '@scope/name' (no @version) — fall back to the source string.
324
- // npm pack writes the filename as scope-name-version.tgz (no leading @).
325
- const m = /([0-9]+\.[0-9]+\.[0-9]+[^.]*)\.tgz$/.exec(tarball);
326
- const resolvedSource = m ? `${stripVersion(source)}@${m[1]}` : source;
327
- return { payloadDir, resolvedSource };
328
- }
329
-
330
- function stripVersion(src: string): string {
331
- // '@scope/name@1.0.0' → '@scope/name'
332
- const at = src.lastIndexOf('@');
333
- if (at > 0) return src.slice(0, at);
334
- return src;
335
- }
336
-
337
- // ---------------------------------------------------------------------------
338
- // Marketplace transport (US-006)
339
- //
340
- // `marketplace:<slug>[@version]` resolves a slug against the PUBLIC listings
341
- // API (US-005, NONE-auth), downloads the presigned tarball, verifies it
342
- // (US-021) BEFORE extraction, and feeds it into the existing safe-extract +
343
- // validate + symlink-wire path. The legacy registry/npm/git/local transports
344
- // are untouched.
345
- // ---------------------------------------------------------------------------
346
-
347
- /** Parsed `marketplace:<slug>[@version]` source. */
348
- export function parseMarketplaceSource(source: string): {
349
- slug: string;
350
- version?: string;
351
- } {
352
- if (!source.startsWith(MARKETPLACE_PREFIX)) {
353
- throw new Error(`Not a marketplace source: "${source}"`);
354
- }
355
- const rest = source.slice(MARKETPLACE_PREFIX.length).trim();
356
- if (!rest) {
357
- throw new Error('marketplace: source requires a slug (marketplace:<slug>[@version]).');
358
- }
359
- // Split on the LAST '@' so a slug never legitimately contains one, but be
360
- // defensive: only treat the suffix as a version if there's a non-empty slug.
361
- const at = rest.lastIndexOf('@');
362
- if (at > 0) {
363
- return { slug: rest.slice(0, at), version: rest.slice(at + 1) || undefined };
364
- }
365
- return { slug: rest };
366
- }
367
-
368
- /**
369
- * A resolved marketplace listing detail — the public `GET /v1/listings/{id}`
370
- * shape (US-005) reduced to the fields the installer needs. The presigned
371
- * `downloadUrl` is short-lived; `listingId` lets us re-resolve a fresh one if
372
- * it expires mid-download.
373
- */
374
- export interface MarketplaceListing {
375
- listingId: string;
376
- slug: string;
377
- version?: string;
378
- /** Short-lived presigned S3 URL for the tarball. */
379
- downloadUrl: string;
380
- /** Lowercase-hex sha256 the listing pinned + approval is bound to (US-021). */
381
- contentHash: string;
382
- /** Hash algorithm the server reported (e.g. `sha256`), when present. */
383
- contentHashAlg?: string;
384
- /** Base64 Ed25519 signature over the contentHash (optional during key-defer). */
385
- signature?: string;
386
- /** Identifier of the signing key the server used, when present. */
387
- signingKeyId?: string;
388
- /** Platform public key (PEM/SPKI) to verify the signature against. */
389
- publicKey?: string;
390
- }
391
-
392
- /**
393
- * Network seams for the marketplace transport, dependency-injected so unit
394
- * tests can mock resolve + download without real HTTP/S3. Production wiring
395
- * lives in `defaultMarketplaceDeps`.
396
- */
397
- export interface MarketplaceDeps {
398
- /**
399
- * Resolve `slug[@version]` to a listing detail (id + presigned URL + the
400
- * approved hash/signature/publicKey). Throws if no approved listing matches.
401
- */
402
- resolveListing: (slug: string, version?: string) => Promise<MarketplaceListing>;
403
- /** Re-fetch the listing detail by id to mint a FRESH presigned URL. */
404
- refreshListing: (listingId: string) => Promise<MarketplaceListing>;
405
- /** Download the tarball bytes from a presigned URL. Returns null on 403/expired. */
406
- download: (url: string) => Promise<Uint8Array | { expired: true }>;
407
- }
408
-
409
- /** True when a download result signals an expired/forbidden presigned URL. */
410
- function isExpired(r: Uint8Array | { expired: true }): r is { expired: true } {
411
- return !(r instanceof Uint8Array) && (r as { expired?: true }).expired === true;
412
- }
413
-
414
- interface RawListingSummary {
415
- listingId?: string;
416
- id?: string;
417
- slug?: string;
418
- version?: string;
419
- latestVersion?: string;
420
- status?: string;
421
- }
422
-
423
- interface RawListingDetailFields extends RawListingSummary {
424
- downloadUrl?: string;
425
- url?: string;
426
- contentHash?: string;
427
- contentHashAlg?: string;
428
- sha256?: string;
429
- signature?: string;
430
- signingKeyId?: string;
431
- publicKey?: string;
432
- }
433
-
434
- /**
435
- * `GET /v1/listings/{id}` returns the listing WRAPPED in an envelope
436
- * (`{ listing: { id, downloadUrl, contentHash, ... } }`). Older/back-compat
437
- * responses returned the listing fields at the top level, so we accept both.
438
- */
439
- interface RawListingDetail extends RawListingDetailFields {
440
- listing?: RawListingDetailFields;
441
- }
442
-
443
- /** Map a raw `GET /v1/listings/{id}` body into a MarketplaceListing. */
444
- export function toMarketplaceListing(raw: RawListingDetail): MarketplaceListing {
445
- // Unwrap the `{ listing: {...} }` envelope when present; fall back to the
446
- // top level for the legacy flat shape (backwards-compat).
447
- const detail: RawListingDetailFields =
448
- raw.listing && typeof raw.listing === 'object' ? raw.listing : raw;
449
- // The detail uses `id`; the legacy flat shape may use `listingId`.
450
- const listingId = detail.id ?? detail.listingId;
451
- const downloadUrl = detail.downloadUrl ?? detail.url;
452
- const contentHash = detail.contentHash ?? detail.sha256;
453
- if (!listingId) throw new Error('Listing detail missing an id.');
454
- if (!downloadUrl) {
455
- throw new Error(`Listing ${listingId} has no download URL — it may not be approved yet.`);
456
- }
457
- if (!contentHash) {
458
- throw new Error(
459
- `Listing ${listingId} has no content hash — refusing to install an unverifiable artifact.`,
460
- );
461
- }
462
- return {
463
- listingId,
464
- slug: detail.slug ?? '',
465
- version: detail.version ?? detail.latestVersion,
466
- downloadUrl,
467
- contentHash,
468
- contentHashAlg: detail.contentHashAlg,
469
- signature: detail.signature,
470
- signingKeyId: detail.signingKeyId,
471
- publicKey: detail.publicKey,
472
- };
473
- }
474
-
475
- /** Default production marketplace deps — public listings API + presigned S3. */
476
- export function defaultMarketplaceDeps(): MarketplaceDeps {
477
- const fetchDetail = async (listingId: string): Promise<MarketplaceListing> => {
478
- const res = await vaultApiFetchPublic({
479
- path: `/v1/listings/${encodeURIComponent(listingId)}`,
480
- });
481
- if (!res.ok) {
482
- throw new Error(
483
- `Failed to resolve listing ${listingId} (HTTP ${res.status}). The pack may be unavailable or unapproved.`,
484
- );
485
- }
486
- const body = (await res.json().catch(() => ({}))) as RawListingDetail;
487
- return toMarketplaceListing(body);
488
- };
489
- return {
490
- resolveListing: async (slug, version) => {
491
- // Search by slug (public). The API returns approved listings only.
492
- //
493
- // BUGFIX: the browse handler historically IGNORED `?slug=` and only
494
- // honored `?q=` (fuzzy), so `?slug=tdd` returned ALL approved listings
495
- // newest-first — and blindly taking `listings[0]` then installed the
496
- // newest pack, not the requested slug (e.g. `marketplace:tdd` installed
497
- // hq-pack-review). We now (a) query with BOTH `slug` (exact, server-side
498
- // filter being added in parallel) AND `q` (fuzzy fallback) so the right
499
- // results come back whichever param the server honors, and (b) NEVER take
500
- // `listings[0]` — we exact-match `l.slug === slug` among the results and
501
- // throw a clear error if none matches, rather than installing the wrong
502
- // pack.
503
- const query: Record<string, string> = { slug, q: slug };
504
- if (version) query.version = version;
505
- const res = await vaultApiFetchPublic({
506
- path: '/v1/listings',
507
- query,
508
- });
509
- if (!res.ok) {
510
- throw new Error(
511
- `Failed to search marketplace for "${slug}" (HTTP ${res.status}).`,
512
- );
513
- }
514
- const body = (await res.json().catch(() => ({}))) as {
515
- listings?: RawListingSummary[];
516
- };
517
- const listings = body.listings ?? [];
518
- // Only consider listings whose slug EXACTLY matches the request — the
519
- // fuzzy `q` search (and a slug-ignoring browse handler) can return
520
- // unrelated packs, so an exact-slug filter is the safety floor.
521
- const exact = listings.filter((l) => l.slug === slug);
522
- let match: RawListingSummary | undefined;
523
- if (version) {
524
- // Pinned `marketplace:<slug>@<version>` → require an exact version too.
525
- match = exact.find((l) => (l.version ?? l.latestVersion) === version);
526
- } else {
527
- // No pin → newest exact-slug match. The feed is newest-first, so the
528
- // first exact-slug entry is the newest version of that slug.
529
- match = exact[0];
530
- }
531
- if (!match) {
532
- throw new Error(
533
- `No marketplace listing found for slug "${slug}"${version ? `@${version}` : ''}.`,
534
- );
535
- }
536
- const id = match.listingId ?? match.id;
537
- if (!id) throw new Error(`Listing for "${slug}" has no id.`);
538
- // The summary may omit the presigned URL; always fetch detail to mint it.
539
- return fetchDetail(id);
540
- },
541
- refreshListing: fetchDetail,
542
- download: async (url) => {
543
- const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
544
- if (response.status === 403) return { expired: true };
545
- if (!response.ok) {
546
- throw new Error(`Tarball download failed (HTTP ${response.status}).`);
547
- }
548
- return new Uint8Array(await response.arrayBuffer());
549
- },
550
- };
551
- }
552
-
553
- /**
554
- * Fetch + verify + safe-extract a marketplace pack. Mirrors `fetchNpm`'s
555
- * contract (returns a payloadDir the caller mvs into core/packages/), but
556
- * resolves the tarball from the marketplace and ALWAYS verifies it against the
557
- * listing's approved hash/signature BEFORE extraction (US-021 → US-020).
558
- *
559
- * @param requireSignature production posture once signing keys exist; during
560
- * the key-deferral window callers pass false (hash still always checked).
561
- */
562
- export async function fetchMarketplace(
563
- source: string,
564
- tmpDir: string,
565
- deps: MarketplaceDeps,
566
- opts: { requireSignature?: boolean } = {},
567
- ): Promise<FetchResult> {
568
- const { slug, version } = parseMarketplaceSource(source);
569
- let listing = await deps.resolveListing(slug, version);
570
-
571
- // Download with a single expired-URL retry: a presigned URL can expire
572
- // between resolve and download, surfacing as a raw S3 403. We re-resolve the
573
- // detail by id to mint a fresh URL and retry ONCE rather than leaking the 403.
574
- let bytes = await deps.download(listing.downloadUrl);
575
- if (isExpired(bytes)) {
576
- listing = await deps.refreshListing(listing.listingId);
577
- bytes = await deps.download(listing.downloadUrl);
578
- if (isExpired(bytes)) {
579
- throw new Error(
580
- `Marketplace download URL for "${slug}" expired and re-resolving did not help. ` +
581
- `Try again in a moment.`,
582
- );
583
- }
584
- }
585
- const tarballBytes = bytes as Uint8Array;
586
-
587
- // SECURITY (US-021): verify integrity (+ authenticity) over the EXACT
588
- // downloaded bytes BEFORE extraction. A hash/signature mismatch throws
589
- // `ArtifactVerificationError` and nothing is unpacked or wired. The hash is
590
- // ALWAYS verified when present; signature is enforced when requireSignature.
591
- verifyArtifact({
592
- tarballBytes,
593
- expectedHash: listing.contentHash,
594
- signature: listing.signature,
595
- publicKey: listing.publicKey,
596
- requireSignature: opts.requireSignature ?? false,
597
- });
598
-
599
- // Write to a tmp tarball and run it through the SAME hardened extractor
600
- // (US-020) as every other tarball transport.
601
- const tarballPath = path.join(tmpDir, 'marketplace.tar.gz');
602
- fs.writeFileSync(tarballPath, tarballBytes);
603
- const extractDir = path.join(tmpDir, 'extracted');
604
- safeExtractTarball(tarballPath, extractDir);
605
-
606
- // Marketplace tarballs are packed with `tar -C payloadDir .` (publish.ts), so
607
- // the manifest + contributes dirs are at the archive root — no `package/`
608
- // wrapper to descend into.
609
- const payloadDir = extractDir;
610
- if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
611
- throw new Error(
612
- `Marketplace tarball for "${slug}" has no package.yaml at its root.`,
613
- );
614
- }
615
-
616
- // Record the marketplace source for re-install + `hq packs update`. We stamp
617
- // the resolved version so a later `update` can detect a newer listing.
618
- const resolvedSource = listing.version
619
- ? `${MARKETPLACE_PREFIX}${slug}@${listing.version}`
620
- : `${MARKETPLACE_PREFIX}${slug}`;
621
- return { payloadDir, resolvedSource };
622
- }
623
-
624
- function fetchGit(
625
- source: string,
626
- tmpDir: string,
627
- followBranch: boolean
628
- ): FetchResult {
629
- const parsed = parseGitFragment(source);
630
- const url = expandGithubShorthand(parsed.url);
631
- const { subpath, ref } = parsed;
632
-
633
- // Defensive: subpath must not escape the clone root.
634
- if (subpath) {
635
- if (
636
- subpath.startsWith('/') ||
637
- subpath.split('/').some((seg) => seg === '..' || seg === '')
638
- ) {
639
- throw new Error(
640
- `Invalid subpath "${subpath}" — must be a relative path within the repo.`
641
- );
642
- }
643
- }
644
-
645
- // Defensive: reject refs that would be ambiguous or shell-dangerous.
646
- // `git` itself would tolerate most of these, but refusing them early avoids
647
- // having to reason about quoting later.
648
- if (ref && /[\s;&|`$<>(){}\\]/.test(ref)) {
649
- throw new Error(`Invalid ref "${ref}" — contains disallowed characters.`);
650
- }
651
-
652
- const cloneDir = path.join(tmpDir, 'clone');
653
- // Disambiguate ref=branch-or-tag vs. ref=SHA authoritatively via `git
654
- // ls-remote`. Hex-shaped branch names (e.g. `deadbeef`) previously got
655
- // silently downgraded to the "no --branch" path by a heuristic regex;
656
- // that is now fixed. Arguments are passed as argv; no shell.
657
- const refIsNamedRef = !!ref && isNamedRef(url, ref);
658
- const cloneArgs = ['clone', '--depth', '1'];
659
- if (refIsNamedRef) cloneArgs.push('--branch', ref);
660
- cloneArgs.push(url, cloneDir);
661
- execFileSync('git', cloneArgs, { stdio: 'inherit' });
662
- if (ref && !refIsNamedRef) {
663
- // SHA (or something ls-remote didn't recognize as a ref). Try a direct
664
- // checkout first — works if the SHA happens to be HEAD of the default
665
- // branch — otherwise unshallow and retry.
666
- try {
667
- execFileSync('git', ['-C', cloneDir, 'checkout', ref], {
668
- stdio: 'inherit',
669
- });
670
- } catch {
671
- execFileSync('git', ['-C', cloneDir, 'fetch', '--unshallow', 'origin'], {
672
- stdio: 'inherit',
673
- });
674
- execFileSync('git', ['-C', cloneDir, 'checkout', ref], {
675
- stdio: 'inherit',
676
- });
677
- }
678
- }
679
- const resolvedSha = execFileSync(
680
- 'git',
681
- ['-C', cloneDir, 'rev-parse', 'HEAD'],
682
- { encoding: 'utf-8' },
683
- ).trim();
684
-
685
- // If a subpath is specified, the payload is only that subdirectory.
686
- let payloadDir = cloneDir;
687
- if (subpath) {
688
- const subAbs = path.join(cloneDir, subpath);
689
- if (!fs.existsSync(subAbs) || !fs.statSync(subAbs).isDirectory()) {
690
- throw new Error(
691
- `subpath "${subpath}" not found in ${url}@${resolvedSha.slice(0, 7)}`
692
- );
693
- }
694
- payloadDir = path.join(tmpDir, 'payload');
695
- fs.mkdirSync(payloadDir, { recursive: true });
696
- rsyncDir(subAbs, payloadDir);
697
- } else {
698
- // Drop .git — the pack should be file content, not a nested repo
699
- fs.rmSync(path.join(cloneDir, '.git'), { recursive: true, force: true });
700
- }
701
-
702
- // Recorded source shape:
703
- // with subpath: url#subpath@<sha|ref>
704
- // without subpath: url#<sha|ref>
705
- // --branch opt-in follows the ref by name; default SHA-pins.
706
- const recordedRef = followBranch && ref ? ref : resolvedSha;
707
- const resolvedSource = subpath
708
- ? `${parsed.url}#${subpath}@${recordedRef}`
709
- : `${parsed.url}#${recordedRef}`;
710
- return { payloadDir, resolvedSource, resolvedSha };
711
- }
712
-
713
- function fetchLocal(source: string, tmpDir: string): FetchResult {
714
- const clean = source.startsWith('file:') ? source.slice('file:'.length) : source;
715
- const abs = path.resolve(process.cwd(), clean);
716
- if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
717
- throw new Error(`Local path not a directory: ${abs}`);
718
- }
719
- const payloadDir = path.join(tmpDir, 'local');
720
- fs.mkdirSync(payloadDir, { recursive: true });
721
- rsyncDir(abs, payloadDir);
722
- return { payloadDir, resolvedSource: clean };
723
- }
724
-
725
- /**
726
- * Invoke rsync with an argv array (no shell). Trailing slashes on the source
727
- * path are preserved by passing the exact strings through execFileSync.
728
- */
729
- function rsyncDir(src: string, dest: string): void {
730
- const srcSlashed = src.endsWith('/') ? src : `${src}/`;
731
- const destSlashed = dest.endsWith('/') ? dest : `${dest}/`;
732
- execFileSync(
733
- 'rsync',
734
- [
735
- '-a',
736
- '--exclude=.git',
737
- '--exclude=node_modules',
738
- '--exclude=.DS_Store',
739
- srcSlashed,
740
- destSlashed,
741
- ],
742
- { stdio: 'inherit' },
743
- );
744
- }
745
-
746
- /**
747
- * Ask git whether <ref> resolves as a named branch or tag on <url>. Returns
748
- * true only when ls-remote prints a matching refs/heads/<ref> or
749
- * refs/tags/<ref>. Anything else (including hex-shaped branch names that
750
- * happen to be missing, network failures, or explicit SHAs) falls through to
751
- * the SHA-checkout path. Uses argv — no shell.
752
- */
753
- function isNamedRef(url: string, ref: string): boolean {
754
- try {
755
- const out = gitLsRemote(['--heads', '--tags', url, ref]);
756
- return out.trim().length > 0;
757
- } catch {
758
- return false;
759
- }
760
- }
761
-
762
- function gitLsRemote(args: string[]): string {
763
- const key = args.join('\0');
764
- const cached = gitLsRemoteMemo.get(key);
765
- if (cached !== undefined) return cached;
766
- const out = execFileSync(
767
- 'git',
768
- ['ls-remote', ...args],
769
- { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
770
- );
771
- gitLsRemoteMemo.set(key, out);
772
- return out;
773
- }
774
-
775
- async function fetchLatestNpmVersion(pkg: string, opts: ResolveLatestOptions): Promise<string | undefined> {
776
- const fetchImpl = opts.fetchImpl ?? fetch;
777
- const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
778
- headers: { Accept: 'application/json' },
779
- signal: AbortSignal.timeout(PACK_UPDATE_FETCH_TIMEOUT_MS),
780
- });
781
- if (!res.ok) throw new Error(`registry returned ${res.status}`);
782
- const body = (await res.json()) as { version?: unknown };
783
- return typeof body.version === 'string' ? body.version : undefined;
784
- }
785
-
786
- // ---------------------------------------------------------------------------
787
- // Update-availability probe (no install) — used by `hq packs update --check-only`
788
- // ---------------------------------------------------------------------------
789
-
790
- export interface LatestResult {
791
- transport: Transport;
792
- /** Identifier of the currently-installed pack (sha for git, version for npm). */
793
- current?: string;
794
- /** Latest available identifier from the remote. */
795
- latest?: string;
796
- /** true/false when a comparison was possible; null when undeterminable (local). */
797
- updateAvailable: boolean | null;
798
- error?: string;
799
- }
800
-
801
- /** Extract the ref (sha or named ref) recorded in a stamped git source. */
802
- function gitRefFromSource(source: string): string | undefined {
803
- const { subpath, ref } = parseGitFragment(source);
804
- // For 'url#subpath@ref' parseGitFragment returns ref; for 'url#ref' likewise.
805
- // A bare 'url#subpath' (no @ref) has no ref.
806
- void subpath;
807
- return ref;
808
- }
809
-
810
- function isFullGitSha(ref: string): boolean {
811
- return /^[0-9a-f]{40}$/i.test(ref);
812
- }
813
-
814
- /**
815
- * Probe whether a newer version of an already-installed pack is available,
816
- * WITHOUT fetching or installing. Reuses the same git/npm primitives as the
817
- * install path. Never throws — network/parse failures return
818
- * `{ updateAvailable: null, error }` so callers (the menubar) stay resilient.
819
- *
820
- * @param source the stamped `source:` from the installed package.yaml
821
- * @param installedVersion the installed pack's manifest `version` (npm compare)
822
- */
823
- export async function resolveLatest(
824
- source: string,
825
- installedVersion?: string,
826
- opts: ResolveLatestOptions = {},
827
- ): Promise<LatestResult> {
828
- let transport: Transport;
829
- try {
830
- transport = classify(source);
831
- } catch (e) {
832
- return { transport: 'local', updateAvailable: null, error: (e as Error).message };
833
- }
834
-
835
- if (transport === 'local') {
836
- return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
837
- }
838
-
839
- if (transport === 'marketplace') {
840
- // The marketplace probe needs an async network call (listings API), which
841
- // this synchronous helper cannot make. Callers that want the marketplace
842
- // update check use the async `resolveLatestMarketplace`; here we report the
843
- // installed version but leave availability undeterminable (null) so the
844
- // sync menubar `--check-updates` path is not regressed.
845
- const { version } = parseMarketplaceSource(source);
846
- return {
847
- transport,
848
- current: installedVersion ?? version,
849
- updateAvailable: null,
850
- error: 'marketplace source — use async update check',
851
- };
852
- }
853
-
854
- if (transport === 'npm') {
855
- const pkg = stripVersion(source);
856
- const current =
857
- installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
858
- try {
859
- const latest = await latestWithDiskCache(`npm:${pkg}`, opts, () =>
860
- fetchLatestNpmVersion(pkg, opts),
861
- );
862
- const updateAvailable =
863
- current && latest ? semverGt(latest, current) : null;
864
- return { transport, current, latest, updateAvailable };
865
- } catch (e) {
866
- return { transport, current, updateAvailable: null, error: `npm registry check failed: ${(e as Error).message}` };
867
- }
868
- }
869
-
870
- // git
871
- const parsed = parseGitFragment(source);
872
- let url: string;
873
- try {
874
- url = expandGithubShorthand(parsed.url);
875
- } catch (e) {
876
- return { transport, updateAvailable: null, error: (e as Error).message };
877
- }
878
- const current = gitRefFromSource(source);
879
- // If install followed a named ref (branch/tag), compare that ref's tip;
880
- // otherwise (default SHA-pin) compare the default branch HEAD.
881
- const refArg = current && !isFullGitSha(current) && isNamedRef(url, current) ? current : 'HEAD';
882
- try {
883
- const latest = await latestWithDiskCache(`git:${url}#${refArg}`, opts, () => {
884
- const out = gitLsRemote([url, refArg]).trim();
885
- return out.split(/\s+/)[0] || undefined;
886
- });
887
- const updateAvailable =
888
- current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
889
- return { transport, current, latest, updateAvailable };
890
- } catch (e) {
891
- return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${(e as Error).message}` };
892
- }
893
- }
894
-
895
- /**
896
- * Async marketplace update probe (US-006): resolve the slug's latest approved
897
- * listing version and compare it to the installed version. Reuses the same
898
- * `MarketplaceDeps.resolveListing` seam as the install path (resolving with NO
899
- * version pin returns the latest listing). Never throws — network/parse
900
- * failures return `{ updateAvailable: null, error }` so `hq packs update`
901
- * stays resilient.
902
- *
903
- * @param source the stamped `marketplace:<slug>[@version]` source
904
- * @param installedVersion the installed pack's manifest version (semver compare)
905
- * @param deps injected for tests; defaults to the public API
906
- */
907
- export async function resolveLatestMarketplace(
908
- source: string,
909
- installedVersion?: string,
910
- deps?: MarketplaceDeps,
911
- ): Promise<LatestResult> {
912
- let slug: string;
913
- let pinned: string | undefined;
914
- try {
915
- ({ slug, version: pinned } = parseMarketplaceSource(source));
916
- } catch (e) {
917
- return { transport: 'marketplace', updateAvailable: null, error: (e as Error).message };
918
- }
919
- const current = installedVersion ?? pinned;
920
- try {
921
- const d = deps ?? defaultMarketplaceDeps();
922
- // No version pin → latest approved listing.
923
- const listing = await d.resolveListing(slug);
924
- const latest = listing.version;
925
- let updateAvailable: boolean | null = null;
926
- if (current && latest) {
927
- // Prefer semver compare; fall back to inequality if either side isn't
928
- // valid semver (so a non-semver listing version still flags a change).
929
- updateAvailable =
930
- semverValid(latest) && semverValid(current)
931
- ? semverGt(latest, current)
932
- : latest !== current;
933
- }
934
- return { transport: 'marketplace', current, latest, updateAvailable };
935
- } catch (e) {
936
- return {
937
- transport: 'marketplace',
938
- current,
939
- updateAvailable: null,
940
- error: `marketplace check failed: ${(e as Error).message}`,
941
- };
942
- }
943
- }
944
-
945
- // ---------------------------------------------------------------------------
946
- // Marketplace artifact verification (US-021, INSTALL side)
947
- //
948
- // Guarantees the bytes we install are EXACTLY the bytes a moderator approved:
949
- // 1. recompute sha256 over the downloaded tarball bytes and compare it to the
950
- // hash the listing pinned at publish (and approval bound to);
951
- // 2. verify the platform Ed25519 signature over that hash against a
952
- // distributable PUBLIC key.
953
- //
954
- // SHARED CONTRACT (must match hq-pro `src/listings/signing.ts`):
955
- // • CANONICAL BYTES = the gzipped tarball object EXACTLY as uploaded to S3
956
- // (i.e. exactly the bytes we downloaded — no transform).
957
- // • contentHash = sha256(canonical bytes), lowercase hex.
958
- // • signature = Ed25519 over the UTF-8 bytes of the lowercase-hex
959
- // contentHash, base64-encoded.
960
- //
961
- // This runs BEFORE safe-extract (US-020): a mismatch must abort the install
962
- // before any bytes are unpacked or wired. US-006 wires the marketplace
963
- // transport that surfaces the listing's {hash, signature, publicKey}; this
964
- // function is the reusable verifier it calls.
965
- // ---------------------------------------------------------------------------
966
-
967
- /** Thrown when a downloaded artifact fails hash or signature verification. */
968
- export class ArtifactVerificationError extends Error {
969
- constructor(message: string) {
970
- super(message);
971
- this.name = 'ArtifactVerificationError';
972
- }
973
- }
974
-
975
- /** Hash algorithm for the content hash — pinned to sha256 (matches publish). */
976
- export const ARTIFACT_HASH_ALG = 'sha256' as const;
977
-
978
- export interface VerifyArtifactInput {
979
- /** The downloaded gzipped tarball bytes (canonical bytes). */
980
- tarballBytes: Uint8Array;
981
- /** Lowercase-hex sha256 the listing pinned at publish (approval bound to it). */
982
- expectedHash: string;
983
- /**
984
- * Base64 Ed25519 signature over the lowercase-hex `expectedHash`. Optional:
985
- * when the listing carries no signature (e.g. published before signing-key
986
- * provisioning), hash verification still runs but signature verification is
987
- * skipped UNLESS `requireSignature` is set.
988
- */
989
- signature?: string;
990
- /**
991
- * The platform PUBLIC key (Ed25519) as a PEM/SPKI string or a pre-parsed
992
- * KeyObject. Required to verify a signature. The public key is distributable
993
- * (embedded in / fetched by the installer).
994
- */
995
- publicKey?: string | KeyObject;
996
- /**
997
- * When true, a missing signature OR missing public key is a hard failure
998
- * (production posture once signing keys are provisioned). Default false so an
999
- * unsigned listing still hash-verifies during the deferred-key window.
1000
- */
1001
- requireSignature?: boolean;
1002
- }
1003
-
1004
- /** Lowercase-hex sha256 of the given bytes — the install-side content hash. */
1005
- export function computeArtifactHash(tarballBytes: Uint8Array): string {
1006
- return createHash(ARTIFACT_HASH_ALG).update(tarballBytes).digest('hex');
1007
- }
1008
-
1009
- /**
1010
- * Verify a downloaded marketplace artifact's integrity (hash) and authenticity
1011
- * (signature). Throws `ArtifactVerificationError` on ANY mismatch — the caller
1012
- * MUST let it propagate so the install aborts before extraction.
1013
- *
1014
- * Constant-time-ish hash comparison: hashes are fixed-length lowercase hex, and
1015
- * the early-exit risk is negligible (the hash is public), but we still compare
1016
- * full strings rather than prefixes.
1017
- */
1018
- export function verifyArtifact(input: VerifyArtifactInput): void {
1019
- const {
1020
- tarballBytes,
1021
- expectedHash,
1022
- signature,
1023
- publicKey,
1024
- requireSignature = false,
1025
- } = input;
1026
-
1027
- if (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {
1028
- throw new ArtifactVerificationError(
1029
- `Refusing to install: listing did not provide a valid ${ARTIFACT_HASH_ALG} content hash.`,
1030
- );
1031
- }
1032
-
1033
- // 1. Integrity — recompute the hash over the EXACT downloaded bytes.
1034
- const actualHash = computeArtifactHash(tarballBytes);
1035
- if (actualHash !== expectedHash) {
1036
- throw new ArtifactVerificationError(
1037
- `Artifact hash mismatch — refusing to install. ` +
1038
- `expected ${ARTIFACT_HASH_ALG}=${expectedHash}, got ${actualHash}. ` +
1039
- `The downloaded bytes are NOT the bytes that were approved.`,
1040
- );
1041
- }
1042
-
1043
- // 2. Authenticity — verify the Ed25519 signature over the hash.
1044
- if (!signature || !publicKey) {
1045
- if (requireSignature) {
1046
- throw new ArtifactVerificationError(
1047
- signature
1048
- ? 'Artifact signature present but no public key available to verify it — refusing to install.'
1049
- : 'Artifact is unsigned and a signature is required — refusing to install.',
1050
- );
1051
- }
1052
- // Deferred-key window: hash verified, no signature to check. Caller opted to
1053
- // allow unsigned (requireSignature=false).
1054
- return;
1055
- }
1056
-
1057
- let key: KeyObject;
1058
- try {
1059
- key = typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey;
1060
- } catch (e) {
1061
- throw new ArtifactVerificationError(
1062
- `Invalid artifact public key — refusing to install: ${(e as Error).message}`,
1063
- );
1064
- }
1065
-
1066
- let ok = false;
1067
- try {
1068
- // Ed25519: digest algorithm is null (the scheme hashes internally). The
1069
- // signed payload is the UTF-8 bytes of the lowercase-hex hash — identical to
1070
- // what hq-pro signed at publish.
1071
- ok = cryptoVerify(
1072
- null,
1073
- Buffer.from(expectedHash, 'utf-8'),
1074
- key,
1075
- Buffer.from(signature, 'base64'),
1076
- );
1077
- } catch (e) {
1078
- throw new ArtifactVerificationError(
1079
- `Artifact signature verification errored — refusing to install: ${(e as Error).message}`,
1080
- );
1081
- }
1082
- if (!ok) {
1083
- throw new ArtifactVerificationError(
1084
- 'Artifact signature is invalid — refusing to install. ' +
1085
- 'The artifact was not signed by the platform key (possible tampering).',
1086
- );
1087
- }
1088
- }
1089
-
1090
- // ---------------------------------------------------------------------------
1091
- // MCP per-server manifest validation (US-005, the TS counterpart of the bash
1092
- // `validate_mcp_manifest` arm in core/scripts/scan-packages.sh).
1093
- //
1094
- // The `mcp` contributes key is `wire: 'merge'` — its per-server manifests are
1095
- // NEVER symlinked; they are validated here and (US-006) merged into the agent
1096
- // configs via registerMcpServers. This function MIRRORS the bash transport
1097
- // rules hand-coded against the draft-07 schema
1098
- // (repos/public/knowledge-hq-core/mcp-manifest.schema.json). There is no `ajv`
1099
- // dependency in hq-cli; the schema file is single-source documentation, so the
1100
- // rules are replicated here (dependency-free, robust if the schema is absent).
1101
- // ---------------------------------------------------------------------------
1102
-
1103
- /** Allowed top-level keys (schema `additionalProperties: false`). */
1104
- const MCP_ALLOWED_KEYS = ['type', 'url', 'headers', 'command', 'args', 'env', 'tools'];
1105
-
1106
- /** Schema `$id` for an at-a-glance error reference (kept inline; no file read). */
1107
- const MCP_SCHEMA_ID = 'mcp-manifest.schema.json';
1108
-
1109
- /** True iff a header/env value carries a `${secret:NAME}` reference. */
1110
- function hasSecretRef(value: string): boolean {
1111
- return /\$\{secret:/.test(value);
1112
- }
1113
-
1114
- /**
1115
- * Validate that a record (`headers` or `env`) is an object of STRING values,
1116
- * pushing one error per offending entry into `errs`. Mirrors the schema's
1117
- * `additionalProperties: { type: 'string' }`.
1118
- */
1119
- function checkStringMap(
1120
- errs: string[],
1121
- label: 'headers' | 'env',
1122
- value: unknown,
1123
- ): void {
1124
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1125
- errs.push(`${label} must be an object`);
1126
- return;
1127
- }
1128
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1129
- if (typeof v !== 'string') {
1130
- errs.push(`${label} "${k}" value must be a string`);
1131
- }
1132
- }
1133
- }
1134
-
1135
- /**
1136
- * Parse + shape-validate one pack's per-server MCP manifest (`mcp/{item}.json`),
1137
- * mirroring the bash `validate_mcp_manifest` arm. EXPORTED so the acceptance
1138
- * test (and US-006's registration engine) can call it directly.
1139
- *
1140
- * Throws an `Error` (whose message names the pack-relative payload path, e.g.
1141
- * `mcp/foo.json`) when the file does not parse as JSON or violates a transport
1142
- * rule:
1143
- * - `type` required, one of `http|stdio|sse`;
1144
- * - no unknown top-level keys (allowed: {@link MCP_ALLOWED_KEYS});
1145
- * - http/sse: `url` required + `^https?://`; `command`/`args`/`env` forbidden;
1146
- * - stdio: non-empty `command` required; `url`/`headers` forbidden;
1147
- * - `headers`/`env` (if present) are objects of string values;
1148
- * - NO inline literal `Bearer ` secret — such a value MUST carry a
1149
- * `${secret:NAME}` reference, never a literal token.
1150
- *
1151
- * @param payloadDir the pack payload root (holds `mcp/{item}.json`)
1152
- * @param item the bare server name declared under `contributes.mcp`
1153
- */
1154
- export function validateMcpManifest(payloadDir: string, item: string): void {
1155
- const rel = payloadFor('mcp', item); // mcp/{item}.json
1156
- const abs = path.join(payloadDir, rel);
1157
-
1158
- // EXISTS — the existsSync gate in validateManifest already covers this, but
1159
- // re-check so the helper is safe to call standalone (tests, US-006).
1160
- if (!fs.existsSync(abs)) {
1161
- throw new Error(`MCP manifest missing: ${rel} (declared under contributes.mcp but no such file)`);
1162
- }
1163
-
1164
- // PARSE — a non-JSON file is a hard error that names the file.
1165
- let doc: unknown;
1166
- try {
1167
- doc = JSON.parse(fs.readFileSync(abs, 'utf-8'));
1168
- } catch (e) {
1169
- throw new Error(`MCP manifest malformed: ${rel} is not valid JSON (failed to parse: ${(e as Error).message})`);
1170
- }
1171
- if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
1172
- throw new Error(`MCP manifest malformed: ${rel} must be a JSON object`);
1173
- }
1174
- const m = doc as Record<string, unknown>;
1175
- const errs: string[] = [];
1176
-
1177
- // type: required + enum.
1178
- const type = m.type;
1179
- if (!('type' in m)) {
1180
- errs.push('missing required field: type');
1181
- } else if (type !== 'http' && type !== 'stdio' && type !== 'sse') {
1182
- errs.push(`type must be one of http|stdio|sse (got: ${JSON.stringify(type)})`);
1183
- }
1184
-
1185
- // No unknown top-level keys (schema additionalProperties: false).
1186
- for (const k of Object.keys(m)) {
1187
- if (!MCP_ALLOWED_KEYS.includes(k)) {
1188
- errs.push(`unknown top-level key: ${k}`);
1189
- }
1190
- }
1191
-
1192
- // http / sse transport: url required (^https?://); command/args/env forbidden.
1193
- if (type === 'http' || type === 'sse') {
1194
- if (!('url' in m)) {
1195
- errs.push(`${type} transport requires a url`);
1196
- } else if (typeof m.url !== 'string' || !/^https?:\/\//.test(m.url)) {
1197
- errs.push('url must be an absolute http(s) URL');
1198
- }
1199
- for (const forbidden of ['command', 'args', 'env'] as const) {
1200
- if (forbidden in m) errs.push(`${type} transport forbids: ${forbidden}`);
1201
- }
1202
- }
1203
-
1204
- // stdio transport: non-empty command required; url/headers forbidden.
1205
- if (type === 'stdio') {
1206
- if (typeof m.command !== 'string' || m.command.length === 0) {
1207
- errs.push('stdio transport requires a non-empty command');
1208
- }
1209
- for (const forbidden of ['url', 'headers'] as const) {
1210
- if (forbidden in m) errs.push(`stdio transport forbids: ${forbidden}`);
1211
- }
1212
- }
1213
-
1214
- // headers / env values must be strings.
1215
- if ('headers' in m) checkStringMap(errs, 'headers', m.headers);
1216
- if ('env' in m) checkStringMap(errs, 'env', m.env);
1217
-
1218
- if (errs.length > 0) {
1219
- throw new Error(
1220
- `MCP manifest fails shape validation: ${rel} (schema: ${MCP_SCHEMA_ID})\n` +
1221
- errs.map((e) => ` - ${e}`).join('\n'),
1222
- );
1223
- }
1224
-
1225
- // Reject INLINE LITERAL Bearer secrets. A header/env value containing
1226
- // 'Bearer ' MUST use a ${secret:NAME} reference, never a literal token baked
1227
- // into a synced/committed artifact.
1228
- const bad: string[] = [];
1229
- for (const [label, mapVal] of [
1230
- ['header', m.headers],
1231
- ['env', m.env],
1232
- ] as const) {
1233
- if (mapVal === null || typeof mapVal !== 'object' || Array.isArray(mapVal)) continue;
1234
- for (const [k, v] of Object.entries(mapVal as Record<string, unknown>)) {
1235
- if (typeof v === 'string' && v.includes('Bearer ') && !hasSecretRef(v)) {
1236
- bad.push(`${label} "${k}"`);
1237
- }
1238
- }
1239
- }
1240
- if (bad.length > 0) {
1241
- throw new Error(
1242
- `MCP manifest contains an inline literal Bearer secret: ${rel} (${bad.join(', ')}) — ` +
1243
- 'a \'Bearer \' header/env value MUST use a ${secret:NAME} reference, never a literal token ' +
1244
- '(a resolved secret is never written to a synced/committed artifact)',
1245
- );
1246
- }
1247
- }
1248
-
1249
- // ---------------------------------------------------------------------------
1250
- // Manifest validation (spec §Validation, 10 checks)
1251
- // ---------------------------------------------------------------------------
1252
-
1253
- export function validateManifest(
1254
- payloadDir: string,
1255
- hqVersion: string | null
1256
- ): PackManifest {
1257
- // 1. parse
1258
- const manifestPath = path.join(payloadDir, 'package.yaml');
1259
- if (!fs.existsSync(manifestPath)) {
1260
- throw new Error('package.yaml missing from pack payload');
1261
- }
1262
- let parsed: unknown;
1263
- try {
1264
- parsed = parseUserYaml(fs.readFileSync(manifestPath, 'utf-8'), manifestPath);
1265
- } catch (e) {
1266
- if (e instanceof Error && (e as { expected?: unknown }).expected === true) {
1267
- throw e;
1268
- }
1269
- throw new Error(`package.yaml invalid YAML: ${(e as Error).message}`);
1270
- }
1271
- const m = parsed as Partial<PackManifest> & Record<string, unknown>;
1272
- if (!m || typeof m !== 'object') {
1273
- throw new Error('package.yaml must be a YAML mapping');
1274
- }
1275
- // 2. name
1276
- if (!m.name || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(m.name)) {
1277
- throw new Error(`Invalid pack name "${m.name}" — must match ^hq-pack-[a-z0-9][a-z0-9-]*$`);
1278
- }
1279
- // 3. version
1280
- if (!m.version || !semverValid(m.version)) {
1281
- throw new Error(`Invalid version "${m.version}" — must be valid semver`);
1282
- }
1283
- // 4. publisher
1284
- if (!m.publisher || !/^@[a-z0-9][a-z0-9-]*$/i.test(m.publisher)) {
1285
- throw new Error(`Invalid publisher "${m.publisher}" — must be a valid npm scope starting with @`);
1286
- }
1287
- // 5. access
1288
- if (m.access !== 'public' && m.access !== 'private') {
1289
- throw new Error(`access must be "public" or "private" (got "${m.access}")`);
1290
- }
1291
- // 6. requires.hqCore
1292
- const range = m.requires?.hqCore;
1293
- if (!range || !semverValidRange(range)) {
1294
- throw new Error(`requires.hqCore must be a valid semver range (got "${range}")`);
1295
- }
1296
- // `includePrerelease` so a prerelease host version (e.g. `15.0.9-beta.1`)
1297
- // satisfies a plain range like `>=14.2.0`. By default node-semver excludes
1298
- // prereleases from range matching, which would wrongly reject every dogfooding
1299
- // beta/rc HQ build from installing packs. A genuinely-too-old host (e.g.
1300
- // `13.0.0`) still fails the range.
1301
- if (hqVersion && !semverSatisfies(hqVersion, range, { includePrerelease: true })) {
1302
- throw new Error(
1303
- `Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`
1304
- );
1305
- }
1306
- // 6b. requires.packs (M0) — OPTIONAL pack-to-pack dependencies. Absent → legacy
1307
- // behavior (hqCore is the only prerequisite). Present → a list of
1308
- // { name, version? } where `name` is a valid hq-pack name and `version` (if
1309
- // given) is a valid semver RANGE. SHAPE validation only (no filesystem) so a
1310
- // malformed dependency can't masquerade as valid; whether the named packs are
1311
- // actually INSTALLED is enforced at install time by assertPackDependencies
1312
- // (which needs hqRoot — a pure manifest validator doesn't have it).
1313
- const reqPacks = m.requires?.packs;
1314
- if (reqPacks !== undefined) {
1315
- if (!Array.isArray(reqPacks)) {
1316
- throw new Error('requires.packs must be a list of { name, version? } entries');
1317
- }
1318
- for (const dep of reqPacks) {
1319
- if (!dep || typeof dep !== 'object' || Array.isArray(dep)) {
1320
- throw new Error(
1321
- 'requires.packs entries must be mappings with a name (and optional version)'
1322
- );
1323
- }
1324
- const d = dep as unknown as Record<string, unknown>;
1325
- if (typeof d.name !== 'string' || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(d.name)) {
1326
- throw new Error(
1327
- `requires.packs[].name "${d.name}" must match ^hq-pack-[a-z0-9][a-z0-9-]*$`
1328
- );
1329
- }
1330
- if (
1331
- d.version !== undefined &&
1332
- (typeof d.version !== 'string' || !semverValidRange(d.version))
1333
- ) {
1334
- throw new Error(
1335
- `requires.packs entry for "${d.name}" has an invalid version range "${d.version}"`
1336
- );
1337
- }
1338
- }
1339
- }
1340
- // 7. contributes has at least one non-empty subfield
1341
- const contributes = (m.contributes ?? {}) as PackManifest['contributes'];
1342
- const nonEmpty = Object.values(contributes).some(
1343
- (v) => Array.isArray(v) && v.length > 0
1344
- );
1345
- if (!nonEmpty) {
1346
- throw new Error('contributes must have at least one non-empty subfield');
1347
- }
1348
- // 10. payload files exist (hooks check happens separately in step 8).
1349
- // Payload paths are READ from the single-source CONTRIBUTION_TABLE (US-003)
1350
- // via payloadFor -- no restated `subpaths` record. An unknown key (not in the
1351
- // table) is rejected here so a typo can't silently install unwired.
1352
- for (const [key, items] of Object.entries(contributes) as [string, unknown][]) {
1353
- if (!(key in CONTRIBUTION_TABLE)) {
1354
- throw new Error(`contributes.${key} is not a known contribution type`);
1355
- }
1356
- if (!Array.isArray(items)) continue;
1357
- for (const item of items) {
1358
- const rel = payloadFor(key as PackContributeKey, item as string);
1359
- const abs = path.join(payloadDir, rel);
1360
- if (!fs.existsSync(abs)) {
1361
- throw new Error(
1362
- `contributes.${key} declares "${item}" but payload file missing: ${rel}`
1363
- );
1364
- }
1365
- // The `mcp` key (wire: 'merge') is NOT existsSync-only: after the file is
1366
- // confirmed present it must PARSE + SHAPE-validate against the per-server
1367
- // MCP schema (US-005), mirroring the bash validate_mcp_manifest arm. Other
1368
- // (symlink) keys keep their existsSync-only behavior. We dispatch on the
1369
- // ROUTE from the single-source table (not a hardcoded key) so the merge
1370
- // path is table-driven; the merge itself (registering into the agent
1371
- // configs via registerMcpServers, US-006) is NEVER a symlink.
1372
- if (routeContribution(key as PackContributeKey) === 'merge') {
1373
- validateMcpManifest(payloadDir, item as string);
1374
- }
1375
- }
1376
- }
1377
- // author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
1378
- // Absent → fine (legacy packs). Present → must be well-shaped so a malformed
1379
- // attribution can't masquerade as a valid one.
1380
- if (m.author !== undefined) {
1381
- const a = m.author as unknown as Record<string, unknown>;
1382
- if (!a || typeof a !== 'object' || Array.isArray(a)) {
1383
- throw new Error('author must be a mapping with uid, handle, displayName');
1384
- }
1385
- for (const field of ['uid', 'handle', 'displayName'] as const) {
1386
- if (typeof a[field] !== 'string' || (a[field] as string).trim() === '') {
1387
- throw new Error(`author.${field} must be a non-empty string`);
1388
- }
1389
- }
1390
- }
1391
- if (m.capabilities !== undefined) {
1392
- if (
1393
- !Array.isArray(m.capabilities) ||
1394
- !m.capabilities.every((c) => typeof c === 'string')
1395
- ) {
1396
- throw new Error('capabilities must be a list of strings');
1397
- }
1398
- }
1399
- // initialization (US-004) — OPTIONAL and backwards-compatible. Absent → fine
1400
- // (legacy packs). Present → the `entrypoint` is REQUIRED and MUST resolve to a
1401
- // declared `contributes.skills` or `contributes.commands` entry (this is the
1402
- // content-pack system, so entries are named under `contributes.*` — NOT the
1403
- // registry `exposes.*` system). The post-install initialization prompt is
1404
- // rendered/moderated in a later story; here we only validate shape so a
1405
- // malformed block can't slip through to install.
1406
- if (m.initialization !== undefined) {
1407
- const init = m.initialization as unknown;
1408
- if (!init || typeof init !== 'object' || Array.isArray(init)) {
1409
- throw new Error('initialization must be a mapping with an entrypoint');
1410
- }
1411
- const initObj = init as Record<string, unknown>;
1412
- const entrypoint = initObj.entrypoint;
1413
- if (typeof entrypoint !== 'string' || entrypoint.trim() === '') {
1414
- throw new Error(
1415
- 'initialization.entrypoint is required and must be a non-empty string',
1416
- );
1417
- }
1418
- // Resolve the entrypoint against declared skills/commands. Normalize a
1419
- // leading slash on BOTH sides so `/email-assistant` matches a contributes
1420
- // entry named `email-assistant` and vice-versa.
1421
- const stripSlash = (s: string): string => (s.startsWith('/') ? s.slice(1) : s);
1422
- const target = stripSlash(entrypoint.trim());
1423
- const declared = [
1424
- ...(contributes.skills ?? []),
1425
- ...(contributes.commands ?? []),
1426
- ];
1427
- const resolves = declared.some((d) => stripSlash(d) === target);
1428
- if (!resolves) {
1429
- throw new Error(
1430
- `initialization.entrypoint "${entrypoint}" does not resolve to a declared ` +
1431
- `contributes.skills or contributes.commands entry. ` +
1432
- `Valid entries: ${declared.length ? declared.join(', ') : '(none declared)'}`,
1433
- );
1434
- }
1435
- // initialization.prompt — OPTIONAL. When present it must be a string ≤ 2000
1436
- // chars. (Rendering/moderation is a later story; we only validate type/length.)
1437
- if (initObj.prompt !== undefined) {
1438
- if (typeof initObj.prompt !== 'string') {
1439
- throw new Error('initialization.prompt must be a string');
1440
- }
1441
- if (initObj.prompt.length > 2000) {
1442
- throw new Error(
1443
- `initialization.prompt must be ≤ 2000 characters (got ${initObj.prompt.length})`,
1444
- );
1445
- }
1446
- }
1447
- }
1448
- return m as PackManifest;
1449
- }
1450
-
1451
- // ---------------------------------------------------------------------------
1452
- // Pack-to-pack dependency pre-flight (M0)
1453
- // ---------------------------------------------------------------------------
1454
-
1455
- /**
1456
- * Enforce a pack's `requires.packs`: every named dependency MUST already be
1457
- * installed (and satisfy its optional semver RANGE) before we write anything.
1458
- *
1459
- * Installed packs are discovered by FILESYSTEM PRESENCE via `listInstalledPacks`
1460
- * — deliberately NOT `modules.yaml`, which `installPack` no longer writes under
1461
- * the v12+ layout (a modules.yaml-based check would silently ignore every modern
1462
- * pack). Throws on the first unmet dependency so the install aborts with NO
1463
- * partial state (it is called before installToPackages). No-op when
1464
- * `requires.packs` is absent/empty, keeping legacy packs unaffected.
1465
- */
1466
- export function assertPackDependencies(hqRoot: string, pkg: PackManifest): void {
1467
- const deps = pkg.requires?.packs ?? [];
1468
- if (deps.length === 0) return;
1469
- const installed = new Map<string, string | undefined>();
1470
- for (const p of listInstalledPacks(hqRoot)) {
1471
- // Key on the manifest name when readable, else the directory name.
1472
- installed.set(p.manifest?.name ?? p.name, p.manifest?.version);
1473
- }
1474
- for (const dep of deps) {
1475
- if (dep.name === pkg.name) {
1476
- throw new Error(`Pack ${pkg.name} cannot list itself in requires.packs.`);
1477
- }
1478
- if (!installed.has(dep.name)) {
1479
- throw new Error(
1480
- `Pack ${pkg.name} requires ${dep.name}, which is not installed. ` +
1481
- `Install it first, e.g.: hq install marketplace:${dep.name}`
1482
- );
1483
- }
1484
- if (dep.version) {
1485
- const have = installed.get(dep.name);
1486
- if (!have || !semverSatisfies(have, dep.version, { includePrerelease: true })) {
1487
- throw new Error(
1488
- `Pack ${pkg.name} requires ${dep.name} ${dep.version}, but ` +
1489
- `${dep.name}${have ? ` ${have}` : ' (version unknown)'} is installed.`
1490
- );
1491
- }
1492
- }
1493
- }
1494
- }
1495
-
1496
- // ---------------------------------------------------------------------------
1497
- // Post-install get-started line (US-005)
1498
- // ---------------------------------------------------------------------------
1499
-
1500
- /**
1501
- * Derive the safe, auto-generated "get started" line for a freshly installed
1502
- * pack from its `initialization.entrypoint` ONLY. PHASE 1 deliberately ignores
1503
- * the free-text `initialization.prompt` prose (rendering/moderation is a later
1504
- * story) so untrusted prose can't reach the operator's terminal.
1505
- *
1506
- * The command is slash-normalized to exactly one leading slash regardless of
1507
- * whether `entrypoint` was stored with or without one, matching the HQ Sync
1508
- * desktop render: ``Run `/email-assistant` to get started``.
1509
- *
1510
- * Returns `null` when there is no initialization block (backwards-compatible —
1511
- * the caller prints nothing extra).
1512
- */
1513
- export function getStartedLine(
1514
- initialization?: PackManifest['initialization'],
1515
- ): string | null {
1516
- const entrypoint = initialization?.entrypoint;
1517
- if (typeof entrypoint !== 'string' || entrypoint.trim() === '') return null;
1518
- const command = '/' + entrypoint.trim().replace(/^\/+/, '');
1519
- return `Run \`${command}\` to get started`;
1520
- }
1521
-
1522
- // ---------------------------------------------------------------------------
1523
- // Hooks confirmation
1524
- // ---------------------------------------------------------------------------
1525
-
1526
- async function confirmHooks(
1527
- pkg: PackManifest,
1528
- allowHooks: boolean
1529
- ): Promise<boolean> {
1530
- const hooks = pkg.contributes.hooks ?? [];
1531
- if (hooks.length === 0) return true;
1532
- if (allowHooks) {
1533
- console.log(
1534
- chalk.yellow(
1535
- `--allow-hooks set; installing ${hooks.length} hook(s) without prompting.`
1536
- )
1537
- );
1538
- return true;
1539
- }
1540
- console.log('');
1541
- console.log(
1542
- chalk.yellow(`Pack ${pkg.publisher}/${pkg.name} declares ${hooks.length} hook(s):`)
1543
- );
1544
- for (const h of hooks) console.log(chalk.yellow(` - ${h}.sh`));
1545
- console.log(
1546
- chalk.yellow(
1547
- 'These run automatically on tool events with your shell permissions.'
1548
- )
1549
- );
1550
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1551
- const answer: string = await new Promise((resolve) => {
1552
- rl.question('Install anyway? [y/N] ', (a) => {
1553
- rl.close();
1554
- resolve(a);
1555
- });
1556
- });
1557
- return /^(y|yes)$/i.test(answer.trim());
1558
- }
1559
-
1560
- // ---------------------------------------------------------------------------
1561
- // MCP confirmation (US-010)
1562
- //
1563
- // The MCP equivalent of confirmHooks. MCP servers are a high-trust surface: an
1564
- // http/sse server points your agent at a REMOTE endpoint (typosquat / phishing
1565
- // risk — the FULL url is disclosed verbatim so the operator can eyeball it), and
1566
- // a stdio server runs a LOCAL binary with your shell permissions. We GATE on a
1567
- // non-empty `contributes.mcp` (NEVER the advisory `capabilities` field, which is
1568
- // reserved/unenforced — gating on it would let a pack omit a capability to dodge
1569
- // the prompt) and render per-server transport + url-or-command + a count of ALL
1570
- // servers being added.
1571
- //
1572
- // REDACTION: header/env VALUES are never printed. The manifests use
1573
- // `${secret:NAME}` references (never literal Bearers), but we STILL run the
1574
- // rendered prompt through `redactSecrets` against the raw header/env values so
1575
- // that even a LITERAL secret could not leak. We show header KEY names with a
1576
- // redacted value marker.
1577
- //
1578
- // FAIL-SAFE: a manifest that fails to load/parse here is NOT a bypass — we
1579
- // surface the server name with a `[manifest unreadable]` note and STILL require
1580
- // confirmation (a malformed manifest should make the operator MORE cautious).
1581
- // ---------------------------------------------------------------------------
1582
-
1583
- /**
1584
- * Render one declared MCP server's prompt line, redacting every header/env value.
1585
- * EXPORTED so the acceptance/redaction self-test can assert no secret/Bearer
1586
- * substring ever appears in the rendered output.
1587
- */
1588
- export function renderMcpServerLine(payloadDir: string, item: string): string {
1589
- let manifest: McpManifest;
1590
- try {
1591
- const abs = path.join(payloadDir, payloadFor('mcp', item));
1592
- manifest = JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
1593
- if (manifest === null || typeof manifest !== 'object') throw new Error('not an object');
1594
- } catch {
1595
- // Fail-safe: do not crash the prompt; flag the server and keep requiring
1596
- // confirmation.
1597
- return ` - ${item} [manifest unreadable] — could not load mcp/${item}.json; treat with caution`;
1598
- }
1599
-
1600
- const transport = manifest.type;
1601
- // Accumulate every raw header/env value so a final redactSecrets pass scrubs
1602
- // even a literal token that slipped past `${secret:}` (defense in depth — we
1603
- // already replace the values with the redaction marker below).
1604
- const rawValues = new Set<string>();
1605
- for (const v of Object.values(manifest.headers ?? {})) rawValues.add(v);
1606
- for (const v of Object.values(manifest.env ?? {})) rawValues.add(v);
1607
-
1608
- // Header KEYS shown, VALUES always redacted (never the raw value).
1609
- const headerKeys = Object.keys(manifest.headers ?? {});
1610
- const headerStr =
1611
- headerKeys.length > 0
1612
- ? ` headers: { ${headerKeys.map((k) => `${k}: ${SECRET_REDACTION}`).join(', ')} }`
1613
- : '';
1614
-
1615
- let line: string;
1616
- if (transport === 'http' || transport === 'sse') {
1617
- // FULL url verbatim — typosquat disclosure is the whole point.
1618
- line =
1619
- ` - ${item} [${transport}] url: ${manifest.url ?? '(missing)'}` +
1620
- headerStr +
1621
- ` (contacts a remote endpoint)`;
1622
- } else {
1623
- // stdio — local binary with shell permissions.
1624
- const args = manifest.args ?? [];
1625
- const argsStr = args.length > 0 ? ` args: [${args.join(', ')}]` : '';
1626
- line =
1627
- ` - ${item} [stdio] command: ${manifest.command ?? '(missing)'}` +
1628
- argsStr +
1629
- headerStr +
1630
- ` (runs a local binary with your shell permissions)`;
1631
- }
1632
-
1633
- // Final defense-in-depth scrub: even though values are already replaced with
1634
- // the marker above, run the whole line through redactSecrets so a stray
1635
- // literal token (e.g. inside command/args) can never reach the terminal.
1636
- return redactSecrets(line, rawValues);
1637
- }
1638
-
1639
- /**
1640
- * Install-time MCP trust prompt. EXPORTED so the acceptance test can drive the
1641
- * gate / bypass / non-TTY branches directly (capture stdout, assert no secret
1642
- * substring leaks, assert deny returns false). See {@link confirmHooks} for the
1643
- * voice/shape this mirrors.
1644
- */
1645
- export async function confirmMcp(
1646
- pkg: PackManifest,
1647
- payloadDir: string,
1648
- allowMcp: boolean,
1649
- ): Promise<boolean> {
1650
- // GATE on non-empty `contributes.mcp` — mirrors confirmHooks. Never on the
1651
- // advisory `capabilities` field.
1652
- const servers = pkg.contributes.mcp ?? [];
1653
- if (servers.length === 0) return true;
1654
-
1655
- if (allowMcp) {
1656
- console.log(
1657
- chalk.yellow(
1658
- `--allow-mcp set; registering ${servers.length} MCP server(s) without prompting.`,
1659
- ),
1660
- );
1661
- return true;
1662
- }
1663
-
1664
- console.log('');
1665
- console.log(
1666
- chalk.yellow(
1667
- `Pack ${pkg.publisher}/${pkg.name} declares ${servers.length} MCP server(s):`,
1668
- ),
1669
- );
1670
- for (const item of servers) {
1671
- console.log(chalk.yellow(renderMcpServerLine(payloadDir, item)));
1672
- }
1673
- console.log(
1674
- chalk.yellow(
1675
- 'These servers will be registered into your Claude and Codex agent configs.',
1676
- ),
1677
- );
1678
-
1679
- // Non-TTY guard (mirrors the secrets.ts pattern): do NOT call rl.question in a
1680
- // non-interactive shell — it would hang. Instruct the operator to re-run with
1681
- // --allow-mcp and DENY (return false -> abort, no partial state).
1682
- if (!process.stdin.isTTY) {
1683
- console.log(
1684
- chalk.red(
1685
- `Refusing to register ${servers.length} MCP server(s) without confirmation ` +
1686
- 'in a non-interactive shell. Re-run with --allow-mcp to approve.',
1687
- ),
1688
- );
1689
- return false;
1690
- }
1691
-
1692
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1693
- const answer: string = await new Promise((resolve) => {
1694
- rl.question('Install anyway? [y/N] ', (a) => {
1695
- rl.close();
1696
- resolve(a);
1697
- });
1698
- });
1699
- return /^(y|yes)$/i.test(answer.trim());
1700
- }
1701
-
1702
- // ---------------------------------------------------------------------------
1703
- // Conditional predicate
1704
- //
1705
- // `package.yaml:conditional` is arbitrary bash sourced from a remote pack.
1706
- // Evaluating it is effectively code execution — same trust surface as the
1707
- // post-install hooks. We therefore gate it behind the same confirmation flow:
1708
- // a pack with a `conditional` must be approved (interactively) or run under
1709
- // `--allow-hooks` (ambient trust, e.g. `create-hq --full`).
1710
- // ---------------------------------------------------------------------------
1711
-
1712
- async function confirmConditional(
1713
- pkg: PackManifest,
1714
- allowHooks: boolean,
1715
- ): Promise<boolean> {
1716
- if (!pkg.conditional) return true;
1717
- if (allowHooks) return true;
1718
- console.log('');
1719
- console.log(
1720
- chalk.yellow(
1721
- `Pack ${pkg.publisher}/${pkg.name} declares a conditional predicate:`,
1722
- ),
1723
- );
1724
- console.log(chalk.yellow(` $ ${pkg.conditional}`));
1725
- console.log(
1726
- chalk.yellow(
1727
- 'This runs as bash with your shell permissions before install proceeds.',
1728
- ),
1729
- );
1730
- const rl = readline.createInterface({
1731
- input: process.stdin,
1732
- output: process.stdout,
1733
- });
1734
- const answer: string = await new Promise((resolve) => {
1735
- rl.question('Evaluate predicate? [y/N] ', (a) => {
1736
- rl.close();
1737
- resolve(a);
1738
- });
1739
- });
1740
- return /^(y|yes)$/i.test(answer.trim());
1741
- }
1742
-
1743
- function evalConditional(expr: string): boolean {
1744
- const r = spawnSync('bash', ['-c', expr], { stdio: 'ignore' });
1745
- return r.status === 0;
1746
- }
1747
-
1748
- // ---------------------------------------------------------------------------
1749
- // MCP registration wiring (US-007) — call registerMcpServers from installPack.
1750
- //
1751
- // The registration CORE + Claude/Codex emitters live in mcp-registration.ts and
1752
- // are fully unit-tested; this is the install-time SEAM that feeds them a real
1753
- // manifest loader and a production, vault-backed secret resolver, with per-server
1754
- // secret-deferral so a fresh install (before `/connect-shopify` has minted the
1755
- // key) still SUCCEEDS, skipping only the server whose secret is not yet present.
1756
- // ---------------------------------------------------------------------------
1757
-
1758
- /**
1759
- * Build an install-time {@link SecretResolver} from a fresh server authorization
1760
- * decision. The encrypted cache may identify one unambiguous scope, but its
1761
- * plaintext is never trusted or read to register an MCP server.
1762
- *
1763
- * With `--company`, the caller-selected company is resolved normally. Without
1764
- * it, exactly one cache scope may identify the intended company/person context;
1765
- * multiple scopes are ambiguous and fail closed. The requested names are then
1766
- * loaded through the same server-authorized endpoint as exec/env/run. Offline,
1767
- * expired-session, forbidden, not-found, script-lock, and malformed-response
1768
- * outcomes all produce an empty resolver, so MCP registration is deferred.
1769
- *
1770
- * The returned synchronous resolver contains only values from that fresh
1771
- * authorization response. No cached value is a fallback.
1772
- */
1773
- export async function makeInstallSecretResolver(
1774
- secretNames: string[],
1775
- company?: string,
1776
- ): Promise<SecretResolver> {
1777
- const names = [...new Set(secretNames)];
1778
- if (names.length === 0) return () => null;
1779
-
1780
- try {
1781
- const token = await ensureCognitoToken({ interactive: false });
1782
- let scopeUid: string;
1783
- if (company) {
1784
- scopeUid = await getCompanyUid(token, company);
1785
- } else {
1786
- const scopes = listSecretCacheScopes();
1787
- if (scopes.length !== 1) return () => null;
1788
- [scopeUid] = scopes;
1789
- }
1790
- const authorized = await loadRevealedSecrets(token, scopeUid, names);
1791
- return (name: string): string | null => authorized.get(name) ?? null;
1792
- } catch {
1793
- return () => null;
1794
- }
1795
- }
1796
-
1797
- const INSTALL_SECRET_REF_RE =
1798
- /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
1799
-
1800
- function collectManifestSecretNames(manifest: McpManifest): string[] {
1801
- const names = new Set<string>();
1802
- for (const value of [
1803
- ...Object.values(manifest.headers ?? {}),
1804
- ...Object.values(manifest.env ?? {}),
1805
- ]) {
1806
- INSTALL_SECRET_REF_RE.lastIndex = 0;
1807
- for (let match = INSTALL_SECRET_REF_RE.exec(value); match; match = INSTALL_SECRET_REF_RE.exec(value)) {
1808
- names.add(match[1]);
1809
- }
1810
- }
1811
- return [...names];
1812
- }
1813
-
1814
- /**
1815
- * Load + shape-validate + parse a pack's per-server MCP manifest from disk.
1816
- * `validateMcpManifest(destDir, name)` is the SAME shape gate `validateManifest`
1817
- * runs at install; it returns void and THROWS on bad shape, so we call it first
1818
- * (re-using one source of truth), then parse `mcp/<name>.json` and return it
1819
- * typed as {@link McpManifest}. `destDir` is the installed pack root
1820
- * (`core/packages/<pkg>/`), the realpath `registerMcpServers` emits from.
1821
- */
1822
- function loadMcpManifestFrom(destDir: string, name: string): McpManifest {
1823
- validateMcpManifest(destDir, name); // throws on bad shape (void on success)
1824
- const abs = path.join(destDir, 'mcp', `${name}.json`);
1825
- return JSON.parse(fs.readFileSync(abs, 'utf-8')) as McpManifest;
1826
- }
1827
-
1828
- /**
1829
- * Derive the per-server "needs secret" remedy command from the pack's
1830
- * `initialization.entrypoint` (e.g. the vyg pack's `connect-shopify` →
1831
- * `/connect-shopify`). Falls back to a generic "provision the secret then re-run
1832
- * `hq install`" when the pack declares no entrypoint.
1833
- */
1834
- function mcpSecretRemedy(initialization: PackManifest['initialization']): string {
1835
- const entrypoint = initialization?.entrypoint;
1836
- if (typeof entrypoint === 'string' && entrypoint.trim() !== '') {
1837
- const command = '/' + entrypoint.trim().replace(/^\/+/, '');
1838
- return `run \`${command}\` to provision it, then re-run \`hq install\``;
1839
- }
1840
- return 'provision the secret then re-run `hq install`';
1841
- }
1842
-
1843
- /** Pull the `${secret:NAME}` token out of a `cannot resolve ${secret:NAME}` error message. */
1844
- function extractDeferredSecretName(message: string): string {
1845
- const m = /\$\{secret:([^}]+)\}/.exec(message);
1846
- return m ? m[1] : 'a required secret';
1847
- }
1848
-
1849
- /**
1850
- * Register one pack's `contributes.mcp` servers into the shared Claude/Codex
1851
- * agent configs, PER SERVER, so a single unresolvable `${secret:NAME}` defers
1852
- * ONLY that server instead of aborting the whole install. Returns the registered
1853
- * and skipped (deferred) server-name lists for the one-line summary.
1854
- *
1855
- * Per-server policy:
1856
- * - SUCCESS → push to `registered`.
1857
- * - McpManifestError matching
1858
- * `/cannot resolve \$\{secret:/` → SKIP (push to `skipped`), warn on stderr,
1859
- * install still succeeds (the key gets minted later, then a re-install wires it).
1860
- * - ANY OTHER error → RE-THROW (abort install). Only the
1861
- * unresolvable-secret case is swallowed.
1862
- */
1863
- async function wireMcpServers(
1864
- pkg: PackManifest,
1865
- destDir: string,
1866
- company?: string,
1867
- ): Promise<{ registered: string[]; skipped: string[] }> {
1868
- const loadManifest = (name: string): McpManifest => loadMcpManifestFrom(destDir, name);
1869
- const registered: string[] = [];
1870
- const skipped: string[] = [];
1871
-
1872
- for (const name of pkg.contributes.mcp ?? []) {
1873
- try {
1874
- const resolveSecret = await makeInstallSecretResolver(
1875
- collectManifestSecretNames(loadManifest(name)),
1876
- company,
1877
- );
1878
- // Per-server call: registerMcpServers throws on the FIRST unresolvable
1879
- // secret, so calling it one name at a time lets us catch + continue.
1880
- registerMcpServers(pkg.name, [name], { loadManifest, resolveSecret });
1881
- registered.push(name);
1882
- } catch (e) {
1883
- const isUnresolvableSecret =
1884
- e instanceof McpManifestError && /cannot resolve \$\{secret:/.test(e.message);
1885
- if (!isUnresolvableSecret) {
1886
- // ConfigParseError / ConfigPermissionError / McpNameCollisionError / a
1887
- // malformed-manifest McpManifestError / etc. — propagate, abort install.
1888
- throw e;
1889
- }
1890
- skipped.push(name);
1891
- const secret = extractDeferredSecretName(e.message);
1892
- // Always stderr (never suppressed by --quiet): the user must see WHY a
1893
- // server was deferred and exactly how to finish wiring it.
1894
- process.stderr.write(
1895
- `MCP server '${name}' needs secret ${secret} — ${mcpSecretRemedy(pkg.initialization)}.\n`,
1896
- );
1897
- }
1898
- }
1899
- return { registered, skipped };
1900
- }
1901
-
1902
- // ---------------------------------------------------------------------------
1903
- // Move into core/packages/ + run core/scripts/scan-packages.sh
1904
- // ---------------------------------------------------------------------------
1905
-
1906
- /**
1907
- * Install the fetched payload to `<hqRoot>/core/packages/<pkg.name>/` (HQ
1908
- * v12+ layout). The HQ template (`hq-core` / `hq-core-staging`) ships
1909
- * `core/packages/` as the canonical pack root; writing to top-level
1910
- * `packages/` would leave an orphan tree alongside the real one.
1911
- *
1912
- * Re-installs replace the existing destination so stale contributions don't
1913
- * linger (the post-install `scan-packages.sh` would otherwise wire them
1914
- * back into host paths).
1915
- *
1916
- * Exported for tests in pack-install.test.ts — see that file for the
1917
- * contract this function pins.
1918
- */
1919
- export function installToPackages(
1920
- payloadDir: string,
1921
- pkg: PackManifest,
1922
- hqRoot: string
1923
- ): string {
1924
- const packagesDir = path.join(hqRoot, 'core', 'packages');
1925
- fs.mkdirSync(packagesDir, { recursive: true });
1926
- const destDir = path.join(packagesDir, pkg.name);
1927
-
1928
- // SECURITY/ATOMICITY (US-020): wire atomically. rsync into a sibling staging
1929
- // dir on the SAME filesystem first; only swap it into the final destination
1930
- // once the copy fully succeeds. A failed/partial rsync therefore never
1931
- // leaves a half-wired pack dir in place (which scan-packages.sh would then
1932
- // wire into host paths). On any error the staging dir is rolled back.
1933
- const stagingDir = fs.mkdtempSync(path.join(packagesDir, `.${pkg.name}.staging-`));
1934
- try {
1935
- // rsync preserves modes/symlinks; argv form — no shell.
1936
- const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
1937
- const stagingSlashed = stagingDir.endsWith('/') ? stagingDir : `${stagingDir}/`;
1938
- execFileSync('rsync', ['-a', srcSlashed, stagingSlashed], { stdio: 'inherit' });
1939
-
1940
- // Swap into place: remove any prior install, then atomic rename. The brief
1941
- // window between rm and rename is unavoidable with a same-name dir, but the
1942
- // staged copy guarantees the NEW contents are fully present before we touch
1943
- // the live location.
1944
- if (fs.existsSync(destDir)) {
1945
- fs.rmSync(destDir, { recursive: true, force: true });
1946
- }
1947
- fs.renameSync(stagingDir, destDir);
1948
- } finally {
1949
- if (fs.existsSync(stagingDir)) {
1950
- fs.rmSync(stagingDir, { recursive: true, force: true });
1951
- }
1952
- }
1953
- return destDir;
1954
- }
1955
-
1956
- /**
1957
- * Stamp the install-time `source:` into the destination package.yaml. This is
1958
- * the only on-disk record of which `recommended_packages[].source` produced
1959
- * each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
1960
- * `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
1961
- * against `core/core.yaml:recommended_packages` — without this stamp, the
1962
- * dedup set is empty and every recommended pack is re-prompted on every
1963
- * setup.sh run.
1964
- *
1965
- * Idempotent: re-installs (and `hq update`) replace any pre-existing
1966
- * top-level `source:` line rather than duplicating it. Nested `source:`
1967
- * fields under `contributes:` (or any other block) are left untouched.
1968
- *
1969
- * The value is YAML double-quoted so `github:` sources containing `#`
1970
- * survive round-trip (an unquoted `#` would be parsed as a comment).
1971
- *
1972
- * If the manifest opens with a `---` YAML document marker, the source line is
1973
- * inserted *after* the marker — otherwise the prepend would create a
1974
- * two-document stream that single-doc `yaml.load` callers in this codebase
1975
- * cannot read.
1976
- *
1977
- * Exported for tests.
1978
- */
1979
- export function stampInstallSource(destDir: string, source: string): void {
1980
- const manifestPath = path.join(destDir, 'package.yaml');
1981
- if (!fs.existsSync(manifestPath)) {
1982
- // installToPackages already validated the payload contained package.yaml.
1983
- // If it's gone by the time we stamp, something deleted it between calls —
1984
- // fail loud so the regression surfaces in tests.
1985
- throw new Error(`stampInstallSource: ${manifestPath} not found`);
1986
- }
1987
- const original = fs.readFileSync(manifestPath, 'utf-8');
1988
- // Strip any existing top-level `source:` line. Match only lines starting at
1989
- // column 0 so nested `source:` fields (none in spec today, but defensive)
1990
- // are not touched.
1991
- const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
1992
- const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
1993
- const sourceLine = `source: ${quoted}`;
1994
-
1995
- // Detect a leading YAML document marker (`---`, optionally followed by
1996
- // trailing whitespace or a comment). If present, the source line goes
1997
- // *after* it so the manifest stays a single document.
1998
- let insertAt = 0;
1999
- while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
2000
- const opensWithDocMarker =
2001
- insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
2002
- if (opensWithDocMarker) {
2003
- lines.splice(insertAt + 1, 0, sourceLine);
2004
- } else {
2005
- // Drop any leading blank lines we walked past so the file doesn't grow a
2006
- // blank-line gutter on every re-stamp.
2007
- lines.splice(0, insertAt, sourceLine);
2008
- }
2009
- fs.writeFileSync(manifestPath, lines.join('\n'));
2010
- }
2011
-
2012
- /**
2013
- * Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
2014
- * pack's contributions into the host paths (skills, hooks, policies, etc.).
2015
- * Skipping with a dim warning if the script is missing keeps fresh HQs (or
2016
- * older templates) usable — the next session start picks them up via its
2017
- * own scan.
2018
- *
2019
- * Exported for tests.
2020
- *
2021
- * `quiet` keeps the script's stdout off our stdout (it routes only stderr
2022
- * through, and sets HQ_SCAN_QUIET=1) so callers emitting machine-readable
2023
- * JSON — e.g. `hq packs uninstall --json` — produce clean output.
2024
- */
2025
- export function runScanPackages(hqRoot: string, opts: { quiet?: boolean } = {}): void {
2026
- const script = path.join(hqRoot, 'core', 'scripts', 'scan-packages.sh');
2027
- if (!fs.existsSync(script)) {
2028
- if (!opts.quiet) {
2029
- console.log(
2030
- chalk.dim(
2031
- ` (core/scripts/scan-packages.sh not present — skipping auto-wire; ` +
2032
- `will run on next session start)`
2033
- )
2034
- );
2035
- }
2036
- return;
2037
- }
2038
- const r = spawnSync('bash', [script], {
2039
- cwd: hqRoot,
2040
- env: { ...process.env, HQ_ROOT: hqRoot, ...(opts.quiet ? { HQ_SCAN_QUIET: '1' } : {}) },
2041
- stdio: opts.quiet ? ['ignore', 'ignore', 'inherit'] : 'inherit',
2042
- });
2043
- if (r.status !== 0 && !opts.quiet) {
2044
- console.log(chalk.yellow(' scan-packages.sh exited non-zero; see output above.'));
2045
- }
2046
- }
2047
-
2048
- // ---------------------------------------------------------------------------
2049
- // Public entry
2050
- // ---------------------------------------------------------------------------
2051
-
2052
- export interface InstallPackOptions {
2053
- allowHooks?: boolean;
2054
- /** Company context for online authorization of install-time MCP secret refs. */
2055
- company?: string;
2056
- /**
2057
- * US-010 install-time MCP trust prompt bypass (CI/ambient-trust). Mirrors
2058
- * `allowHooks`: when set, `confirmMcp` skips the prompt and prints a yellow
2059
- * notice. Absent/false → the operator is prompted (or, in a non-TTY shell,
2060
- * the install is refused with a "re-run with --allow-mcp" hint).
2061
- */
2062
- allowMcp?: boolean;
2063
- followBranch?: boolean;
2064
- /**
2065
- * Route this function's human output to stderr (and silence scan-packages
2066
- * stdout) so a caller emitting machine-readable JSON keeps stdout clean.
2067
- * The fetch tools are already quiet/stderr-only (`npm pack --silent`,
2068
- * `rsync -a`, `git clone` progress -> stderr), so this is sufficient.
2069
- */
2070
- quiet?: boolean;
2071
- /**
2072
- * US-021 marketplace artifact integrity. When provided (a marketplace listing
2073
- * carries a pinned content hash + signature + public key), the downloaded
2074
- * tarball is verified BEFORE extraction; a mismatch aborts the install. US-006
2075
- * populates this from the listing detail response.
2076
- */
2077
- integrity?: ArtifactIntegrity;
2078
- /**
2079
- * US-006 marketplace transport seams (resolve/refresh/download), injected for
2080
- * tests. When omitted, `defaultMarketplaceDeps()` (public listings API +
2081
- * presigned S3) is used. Only consulted when `source` is `marketplace:...`.
2082
- */
2083
- marketplaceDeps?: MarketplaceDeps;
2084
- /**
2085
- * US-006: require a valid signature for marketplace artifacts. Default false
2086
- * during the signing-key-deferral window (the content hash is still ALWAYS
2087
- * verified); set true once platform signing keys are provisioned.
2088
- */
2089
- requireSignature?: boolean;
2090
- }
2091
-
2092
- export async function installPack(
2093
- source: string,
2094
- opts: InstallPackOptions = {}
2095
- ): Promise<void> {
2096
- const transport = classify(source);
2097
- const hqRoot = findHqRoot();
2098
- // `readHqVersion` (shared with `hq packs`) reads the CANONICAL
2099
- // `core/core.yaml` (hq-core ≥v14), falling back to the legacy
2100
- // `<root>/core.yaml` only when the nested file is absent. The previous local
2101
- // reader looked at `<root>/core.yaml` ONLY, so on a post-v12→v15 tree it
2102
- // picked up a STALE leftover root copy (e.g. hqVersion 12.1.0) and wrongly
2103
- // rejected packs requiring a newer hqCore (feedback_57d7edcf, symptom b).
2104
- const hqVersion = readHqVersion(hqRoot);
2105
- const say: (...a: unknown[]) => void = opts.quiet
2106
- ? (...a) => console.error(...a)
2107
- : (...a) => console.log(...a);
2108
-
2109
- say(chalk.dim(`-> transport: ${transport}; source: ${source}`));
2110
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-pack-'));
2111
- try {
2112
- let fetched: FetchResult;
2113
- switch (transport) {
2114
- case 'npm':
2115
- fetched = fetchNpm(source, tmpDir, opts.integrity);
2116
- break;
2117
- case 'git':
2118
- fetched = fetchGit(source, tmpDir, opts.followBranch ?? false);
2119
- break;
2120
- case 'local':
2121
- fetched = fetchLocal(source, tmpDir);
2122
- break;
2123
- case 'marketplace':
2124
- fetched = await fetchMarketplace(
2125
- source,
2126
- tmpDir,
2127
- opts.marketplaceDeps ?? defaultMarketplaceDeps(),
2128
- { requireSignature: opts.requireSignature },
2129
- );
2130
- break;
2131
- }
2132
-
2133
- const pkg = validateManifest(fetched.payloadDir, hqVersion);
2134
-
2135
- // M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
2136
- // so a missing/unsatisfied `requires.packs` aborts with no partial state, and
2137
- // before we bother the operator with hook/MCP trust prompts for a pack that
2138
- // can't install anyway.
2139
- assertPackDependencies(hqRoot, pkg);
2140
-
2141
- if (pkg.conditional) {
2142
- const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
2143
- if (!allowed) {
2144
- say(
2145
- chalk.red(
2146
- 'Install aborted (conditional predicate not approved).',
2147
- ),
2148
- );
2149
- return;
2150
- }
2151
- const ok = evalConditional(pkg.conditional);
2152
- if (!ok) {
2153
- say(
2154
- chalk.yellow(
2155
- `Skipping ${pkg.name}: conditional "${pkg.conditional}" returned non-zero.`
2156
- )
2157
- );
2158
- return;
2159
- }
2160
- }
2161
-
2162
- const confirmed = await confirmHooks(pkg, opts.allowHooks ?? false);
2163
- if (!confirmed) {
2164
- say(chalk.red('Install aborted (hooks denied).'));
2165
- return;
2166
- }
2167
-
2168
- // US-010 — install-time MCP trust prompt. Positioned AFTER confirmHooks and
2169
- // BEFORE installToPackages so a deny aborts with NO partial state (no config
2170
- // written, no pack moved into core/packages, no registerMcpServers call). The
2171
- // actual registration (US-007) now fires AFTER installToPackages +
2172
- // runScanPackages below (pack on disk + symlinks wired), once this gate has
2173
- // confirmed the servers — so a deny here means registration never runs.
2174
- const mcpConfirmed = await confirmMcp(
2175
- pkg,
2176
- fetched.payloadDir,
2177
- opts.allowMcp ?? false,
2178
- );
2179
- if (!mcpConfirmed) {
2180
- say(chalk.red('Install aborted (MCP servers denied).'));
2181
- return;
2182
- }
2183
-
2184
- const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
2185
- // Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
2186
- // are tracked by filesystem presence — no `modules.yaml` write (that
2187
- // removed the orphan top-level `modules/` tree alongside the canonical
2188
- // `core/`). The one piece of install-time provenance we DO record is the
2189
- // input `source` string, stamped into the destination `package.yaml` so
2190
- // setup.sh can dedup against `core/core.yaml:recommended_packages` on
2191
- // re-runs. Stamping the literal input (not the resolved SHA/version)
2192
- // matches the verbatim equality check in setup.sh.
2193
- //
2194
- // US-006 EXCEPTION: for the marketplace transport we stamp the RESOLVED
2195
- // source (`marketplace:<slug>@<version>`) rather than the literal input.
2196
- // The version is what lets `hq packs update` detect a newer listing — the
2197
- // bare `marketplace:<slug>` input carries no version to compare against.
2198
- const stampedSource =
2199
- transport === 'marketplace' ? fetched.resolvedSource : source;
2200
- stampInstallSource(destDir, stampedSource);
2201
- runScanPackages(hqRoot, { quiet: opts.quiet });
2202
-
2203
- // US-007 — register `contributes.mcp` servers into the shared Claude/Codex
2204
- // agent configs. Fires HERE (after installToPackages + runScanPackages, i.e.
2205
- // pack on disk + symlinks wired) and only once the confirmMcp gate above
2206
- // approved them. The `mcp` key is wire:'merge' — NEVER symlinked; this merge
2207
- // is its only wiring path. registerMcpServers honors the
2208
- // HQ_DISABLE_MCP_REGISTRATION kill-switch internally, so we never re-check it.
2209
- // Per-server secret-deferral keeps a fresh install (key not yet minted) at
2210
- // exit 0, deferring only the unresolvable server (see wireMcpServers).
2211
- if (Array.isArray(pkg.contributes.mcp) && pkg.contributes.mcp.length > 0) {
2212
- const { registered, skipped } = await wireMcpServers(
2213
- pkg,
2214
- destDir,
2215
- opts.company,
2216
- );
2217
- // One-line summary (server NAMES only — never resolved secret VALUES).
2218
- say(
2219
- chalk.dim(
2220
- ` MCP servers: registered [${registered.join(', ')}]; ` +
2221
- `skipped [${skipped.join(', ')}].`
2222
- )
2223
- );
2224
- }
2225
-
2226
- say(
2227
- chalk.green(
2228
- `\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`
2229
- )
2230
- );
2231
- say(
2232
- chalk.dim(
2233
- ` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
2234
- `contribution(s) into host-side paths.`
2235
- )
2236
- );
2237
- // US-005 — when the pack declares an `initialization` block, print a safe,
2238
- // auto-generated "get started" line right after the success output. PHASE 1
2239
- // derives the line from `initialization.entrypoint` ONLY (never the
2240
- // free-text `initialization.prompt` prose), and matches the HQ Sync desktop
2241
- // wording for consistency. Absent block → nothing extra (backwards-compat).
2242
- const getStarted = getStartedLine(pkg.initialization);
2243
- if (getStarted) say(chalk.cyan(getStarted));
2244
- } finally {
2245
- fs.rmSync(tmpDir, { recursive: true, force: true });
2246
- }
2247
- }