@indigoai-us/hq-cli 5.77.14 → 5.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/meetings.js +16 -9
  31. package/dist/commands/members.js +16 -5
  32. package/dist/commands/pack-install.d.ts +14 -0
  33. package/dist/commands/pack-install.js +42 -2
  34. package/dist/commands/pkg-install.js +39 -2
  35. package/dist/commands/run.js +6 -2
  36. package/dist/commands/secrets.js +120 -65
  37. package/dist/main.js +6 -0
  38. package/dist/utils/api-key-command-gate.d.ts +45 -0
  39. package/dist/utils/api-key-command-gate.js +51 -0
  40. package/dist/utils/hq-roots.d.ts +91 -0
  41. package/dist/utils/hq-roots.js +212 -0
  42. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  43. package/dist/utils/resolve-vault-credential.js +152 -6
  44. package/dist/utils/run-bundled-script.d.ts +66 -0
  45. package/dist/utils/run-bundled-script.js +80 -0
  46. package/dist/utils/vault-api.d.ts +21 -0
  47. package/dist/utils/vault-api.js +74 -3
  48. package/package.json +7 -1
  49. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  50. package/.github/workflows/ci.yml +0 -46
  51. package/.github/workflows/publish.yml +0 -36
  52. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  53. package/e2e/cli.test.ts +0 -128
  54. package/eslint.config.js +0 -37
  55. package/pnpm-workspace.yaml +0 -4
  56. package/scripts/chmod-bins.mjs +0 -49
  57. package/scripts/generate-dsn.mjs +0 -26
  58. package/scripts/generate-scan-packages-table.mjs +0 -113
  59. package/scripts/smoke-sources-signals.sh +0 -103
  60. package/src/bin/hq-auth-refresh.ts +0 -43
  61. package/src/cli-version.ts +0 -13
  62. package/src/commands/__fixtures__/make-tar.ts +0 -126
  63. package/src/commands/_patterns.ts +0 -10
  64. package/src/commands/add.ts +0 -75
  65. package/src/commands/agents.test.ts +0 -527
  66. package/src/commands/agents.ts +0 -923
  67. package/src/commands/api-keys.test.ts +0 -291
  68. package/src/commands/api-keys.ts +0 -382
  69. package/src/commands/artifact-verify.test.ts +0 -177
  70. package/src/commands/auth.test.ts +0 -82
  71. package/src/commands/auth.ts +0 -213
  72. package/src/commands/billing.test.ts +0 -158
  73. package/src/commands/billing.ts +0 -146
  74. package/src/commands/channels.test.ts +0 -74
  75. package/src/commands/channels.ts +0 -91
  76. package/src/commands/cloud-demote.test.ts +0 -401
  77. package/src/commands/cloud-demote.ts +0 -278
  78. package/src/commands/cloud-provision.test.ts +0 -1117
  79. package/src/commands/cloud-provision.ts +0 -942
  80. package/src/commands/cloud.pull-all.test.ts +0 -711
  81. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  82. package/src/commands/cloud.push-all.test.ts +0 -485
  83. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  84. package/src/commands/cloud.selectors.test.ts +0 -208
  85. package/src/commands/cloud.status.test.ts +0 -160
  86. package/src/commands/cloud.test.ts +0 -41
  87. package/src/commands/cloud.ts +0 -2130
  88. package/src/commands/company.test.ts +0 -202
  89. package/src/commands/company.ts +0 -148
  90. package/src/commands/creators.test.ts +0 -60
  91. package/src/commands/creators.ts +0 -117
  92. package/src/commands/crm.test.ts +0 -179
  93. package/src/commands/crm.ts +0 -236
  94. package/src/commands/db-migrate.ts +0 -55
  95. package/src/commands/db-provision.ts +0 -114
  96. package/src/commands/db-sql.ts +0 -124
  97. package/src/commands/db-status.ts +0 -108
  98. package/src/commands/db.ts +0 -26
  99. package/src/commands/dm.test.ts +0 -748
  100. package/src/commands/dm.ts +0 -1226
  101. package/src/commands/feedback.test.ts +0 -413
  102. package/src/commands/feedback.ts +0 -169
  103. package/src/commands/files-browse.test.ts +0 -1249
  104. package/src/commands/files-browse.ts +0 -1506
  105. package/src/commands/files-delete.test.ts +0 -484
  106. package/src/commands/files-recovery.test.ts +0 -370
  107. package/src/commands/files.test.ts +0 -722
  108. package/src/commands/files.ts +0 -1446
  109. package/src/commands/group-grants.test.ts +0 -330
  110. package/src/commands/group-grants.ts +0 -455
  111. package/src/commands/groups.test.ts +0 -44
  112. package/src/commands/groups.ts +0 -368
  113. package/src/commands/integrations.test.ts +0 -633
  114. package/src/commands/integrations.ts +0 -507
  115. package/src/commands/list.ts +0 -85
  116. package/src/commands/login.ts +0 -52
  117. package/src/commands/logout.ts +0 -30
  118. package/src/commands/marketplace-install.test.ts +0 -543
  119. package/src/commands/marketplace-security.test.ts +0 -646
  120. package/src/commands/mcp-registration.test.ts +0 -2787
  121. package/src/commands/mcp-registration.ts +0 -2612
  122. package/src/commands/mcp-status.test.ts +0 -483
  123. package/src/commands/mcp-status.ts +0 -575
  124. package/src/commands/mcp-status.us011.test.ts +0 -243
  125. package/src/commands/meetings.test.ts +0 -522
  126. package/src/commands/meetings.ts +0 -789
  127. package/src/commands/members.test.ts +0 -1474
  128. package/src/commands/members.ts +0 -1181
  129. package/src/commands/onboard-identity-guard.test.ts +0 -99
  130. package/src/commands/onboard-identity-guard.ts +0 -103
  131. package/src/commands/onboard-join.test.ts +0 -96
  132. package/src/commands/onboard-join.ts +0 -101
  133. package/src/commands/onboard-warning.test.ts +0 -26
  134. package/src/commands/onboard-warning.ts +0 -12
  135. package/src/commands/onboard.ts +0 -267
  136. package/src/commands/outposts-heartbeat.test.ts +0 -299
  137. package/src/commands/outposts-heartbeat.ts +0 -310
  138. package/src/commands/outposts-replica-sync.test.ts +0 -272
  139. package/src/commands/outposts-self-deploy.test.ts +0 -243
  140. package/src/commands/outposts.test.ts +0 -1039
  141. package/src/commands/outposts.ts +0 -1935
  142. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  143. package/src/commands/pack-install.test.ts +0 -1469
  144. package/src/commands/pack-install.ts +0 -2256
  145. package/src/commands/pack-update-cache.test.ts +0 -149
  146. package/src/commands/packs-update-api-key.test.ts +0 -105
  147. package/src/commands/packs.test.ts +0 -88
  148. package/src/commands/packs.ts +0 -622
  149. package/src/commands/people.test.ts +0 -807
  150. package/src/commands/people.ts +0 -422
  151. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  152. package/src/commands/pkg-install.ts +0 -221
  153. package/src/commands/pkg-list.test.ts +0 -79
  154. package/src/commands/pkg-list.ts +0 -13
  155. package/src/commands/pkg-remove.ts +0 -71
  156. package/src/commands/pkg-update.ts +0 -189
  157. package/src/commands/publish.test.ts +0 -650
  158. package/src/commands/publish.ts +0 -580
  159. package/src/commands/reindex.test.ts +0 -259
  160. package/src/commands/reindex.ts +0 -274
  161. package/src/commands/rescue.test.ts +0 -39
  162. package/src/commands/rescue.ts +0 -210
  163. package/src/commands/run.env-local.test.ts +0 -88
  164. package/src/commands/run.test.ts +0 -45
  165. package/src/commands/run.ts +0 -190
  166. package/src/commands/safe-extract.test.ts +0 -459
  167. package/src/commands/safe-extract.ts +0 -444
  168. package/src/commands/secrets-scope.test.ts +0 -56
  169. package/src/commands/secrets-scope.ts +0 -32
  170. package/src/commands/secrets.parse-destination.test.ts +0 -38
  171. package/src/commands/secrets.test.ts +0 -2050
  172. package/src/commands/secrets.ts +0 -2249
  173. package/src/commands/signals.ts +0 -351
  174. package/src/commands/skill.test.ts +0 -770
  175. package/src/commands/skill.ts +0 -796
  176. package/src/commands/sources.ts +0 -362
  177. package/src/commands/sync-mode.test.ts +0 -366
  178. package/src/commands/sync-mode.ts +0 -387
  179. package/src/commands/sync-narrow.test.ts +0 -633
  180. package/src/commands/sync-narrow.ts +0 -555
  181. package/src/commands/sync.ts +0 -165
  182. package/src/commands/team-sync.ts +0 -628
  183. package/src/commands/update.ts +0 -71
  184. package/src/commands/whoami.ts +0 -83
  185. package/src/commands/workers.test.ts +0 -158
  186. package/src/commands/workers.ts +0 -301
  187. package/src/index.test.ts +0 -32
  188. package/src/index.ts +0 -20
  189. package/src/lib/db/control-plane.test.ts +0 -59
  190. package/src/lib/db/control-plane.ts +0 -113
  191. package/src/lib/db/local.test.ts +0 -81
  192. package/src/lib/db/local.ts +0 -148
  193. package/src/lib/db/migrate.test.ts +0 -133
  194. package/src/lib/db/migrate.ts +0 -137
  195. package/src/lib/db/paths.test.ts +0 -112
  196. package/src/lib/db/paths.ts +0 -128
  197. package/src/lib/db/remote-engine.test.ts +0 -44
  198. package/src/lib/db/remote-engine.ts +0 -148
  199. package/src/lib/db/remote-sql.test.ts +0 -32
  200. package/src/lib/db/remote-sql.ts +0 -62
  201. package/src/lib/db/sql.test.ts +0 -106
  202. package/src/lib/db/sql.ts +0 -192
  203. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  204. package/src/lib/local-tree-diff.test.ts +0 -301
  205. package/src/lib/local-tree-diff.ts +0 -362
  206. package/src/lib/narrow-hint-banner.test.ts +0 -235
  207. package/src/lib/narrow-hint-banner.ts +0 -212
  208. package/src/lib/plan-limit-nag.test.ts +0 -317
  209. package/src/lib/plan-limit-nag.ts +0 -264
  210. package/src/main.ts +0 -371
  211. package/src/node-preflight.test.ts +0 -60
  212. package/src/node-preflight.ts +0 -67
  213. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  214. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  215. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  216. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  217. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  218. package/src/outpost/session-heartbeat.test.ts +0 -459
  219. package/src/outpost/session-heartbeat.ts +0 -877
  220. package/src/packaging.test.ts +0 -109
  221. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  222. package/src/run/discover-schemas.test.ts +0 -153
  223. package/src/run/discover-schemas.ts +0 -79
  224. package/src/run/hq-plugin.test.ts +0 -371
  225. package/src/run/hq-plugin.ts +0 -277
  226. package/src/run/varlock-shape.test.ts +0 -57
  227. package/src/schemas/hq-package.schema.json +0 -155
  228. package/src/sentry-before-send.test.ts +0 -359
  229. package/src/sentry-before-send.ts +0 -221
  230. package/src/sentry-epipe.test.ts +0 -37
  231. package/src/sentry-release.test.ts +0 -54
  232. package/src/sentry.test.ts +0 -24
  233. package/src/sentry.ts +0 -54
  234. package/src/strategies/link.ts +0 -62
  235. package/src/strategies/merge.ts +0 -142
  236. package/src/types.ts +0 -146
  237. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  238. package/src/utils/auth-error.test.ts +0 -40
  239. package/src/utils/auth-error.ts +0 -42
  240. package/src/utils/billing-gate.test.ts +0 -95
  241. package/src/utils/billing-gate.ts +0 -228
  242. package/src/utils/breadcrumb-buffer.ts +0 -18
  243. package/src/utils/cli-telemetry.test.ts +0 -153
  244. package/src/utils/cli-telemetry.ts +0 -61
  245. package/src/utils/cognito-session.machine.test.ts +0 -109
  246. package/src/utils/cognito-session.refresh.test.ts +0 -84
  247. package/src/utils/cognito-session.test.ts +0 -328
  248. package/src/utils/cognito-session.ts +0 -448
  249. package/src/utils/company-selection-error.test.ts +0 -42
  250. package/src/utils/company-selection-error.ts +0 -45
  251. package/src/utils/contribution-table.ts +0 -83
  252. package/src/utils/environmental-error.test.ts +0 -45
  253. package/src/utils/environmental-error.ts +0 -39
  254. package/src/utils/epipe.test.ts +0 -28
  255. package/src/utils/epipe.ts +0 -29
  256. package/src/utils/expected-cli-error.test.ts +0 -28
  257. package/src/utils/expected-cli-error.ts +0 -39
  258. package/src/utils/feedback-diagnostics.test.ts +0 -183
  259. package/src/utils/feedback-diagnostics.ts +0 -123
  260. package/src/utils/feedback-screenshots.test.ts +0 -134
  261. package/src/utils/feedback-screenshots.ts +0 -124
  262. package/src/utils/feedback-versions.test.ts +0 -98
  263. package/src/utils/feedback-versions.ts +0 -68
  264. package/src/utils/git.ts +0 -74
  265. package/src/utils/integrity.ts +0 -59
  266. package/src/utils/intercepted-process-exit.test.ts +0 -37
  267. package/src/utils/intercepted-process-exit.ts +0 -36
  268. package/src/utils/login-provider.test.ts +0 -42
  269. package/src/utils/login-provider.ts +0 -30
  270. package/src/utils/manifest.test.ts +0 -167
  271. package/src/utils/manifest.ts +0 -131
  272. package/src/utils/pack-contributions.test.ts +0 -603
  273. package/src/utils/pack-contributions.ts +0 -450
  274. package/src/utils/people.ts +0 -215
  275. package/src/utils/registry-client.ts +0 -204
  276. package/src/utils/registry.ts +0 -90
  277. package/src/utils/resolve-vault-credential.test.ts +0 -69
  278. package/src/utils/resolve-vault-credential.ts +0 -60
  279. package/src/utils/sandbox-runner-client.test.ts +0 -279
  280. package/src/utils/sandbox-runner-client.ts +0 -260
  281. package/src/utils/secrets-cache.ts +0 -183
  282. package/src/utils/sentry-identity.ts +0 -45
  283. package/src/utils/settle-with-timeout.test.ts +0 -21
  284. package/src/utils/settle-with-timeout.ts +0 -22
  285. package/src/utils/user-yaml-error.test.ts +0 -24
  286. package/src/utils/user-yaml-error.ts +0 -30
  287. package/src/utils/vault-api.test.ts +0 -536
  288. package/src/utils/vault-api.ts +0 -382
  289. package/src/utils/version-check.test.ts +0 -176
  290. package/src/utils/version-check.ts +0 -155
  291. package/src/utils/version-gate.test.ts +0 -1024
  292. package/src/utils/version-gate.ts +0 -702
  293. package/test/commands/db-tenant-isolation.test.ts +0 -94
  294. package/test/commands/db.test.ts +0 -85
  295. package/test/commands/signals.test.ts +0 -207
  296. package/test/commands/sources.test.ts +0 -231
  297. package/test/e2e/smoke-install-mcp.sh +0 -113
  298. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  299. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  300. package/test/fixtures/signals/action_item/sample.md +0 -16
  301. package/test/fixtures/signals/summary/sample.md +0 -12
  302. package/test/fixtures/sources/meetings/sample.md +0 -25
  303. package/test/helpers/cli-runner.ts +0 -150
  304. package/test/helpers/s3-list-mock.ts +0 -79
  305. package/test/helpers/vault-service-mock.ts +0 -295
  306. package/test/sources-signals/smoke.test.ts +0 -228
  307. package/tsconfig.json +0 -20
  308. package/vitest.config.ts +0 -11
  309. package/vitest.e2e.config.ts +0 -7
@@ -1,1469 +0,0 @@
1
- /**
2
- * pack-install layout tests — guards the boundary between hq-cli and the
3
- * HQ template's on-disk layout.
4
- *
5
- * The HQ template (hq-core / hq-core-staging) ships:
6
- * - `core/packages/` as the install root for content packs
7
- * - `core/scripts/scan-packages.sh` as the post-install wirer
8
- * - NO top-level `modules/` directory under the new layout
9
- *
10
- * Earlier `pack-install` wrote to `packages/<name>/` (top-level), called
11
- * `scripts/scan-packages.sh` (top-level), and appended entries to
12
- * `modules/modules.yaml` — leaving three orphan trees alongside the real
13
- * `core/packages/`. This file pins the new contract end-to-end.
14
- *
15
- * Uses real `rsync` (POSIX-available on macOS / Linux runners) so the test
16
- * exercises the same shell-out the production path does — no hand-rolled
17
- * mocks of payload-copy behavior.
18
- */
19
-
20
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
21
- import * as fs from 'fs';
22
- import * as os from 'os';
23
- import * as path from 'path';
24
- import {
25
- installToPackages,
26
- runScanPackages,
27
- stampInstallSource,
28
- validateManifest,
29
- assertPackDependencies,
30
- getStartedLine,
31
- toMarketplaceListing,
32
- fetchMarketplace,
33
- computeArtifactHash,
34
- ArtifactVerificationError,
35
- validateMcpManifest,
36
- renderMcpServerLine,
37
- confirmMcp,
38
- } from './pack-install.js';
39
- import type { MarketplaceDeps } from './pack-install.js';
40
- import type { PackManifest } from '../types.js';
41
- import { registerMcpServers, McpManifestError } from './mcp-registration.js';
42
- import {
43
- routeContribution,
44
- mergeKeys,
45
- contributionLinks,
46
- } from '../utils/pack-contributions.js';
47
-
48
- // ---------------------------------------------------------------------------
49
- // Fixtures
50
- // ---------------------------------------------------------------------------
51
-
52
- function mkFakeHq(): string {
53
- return fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-test-hq-'));
54
- }
55
-
56
- function mkFakePackPayload(files: Record<string, string>): string {
57
- const payload = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-test-pack-'));
58
- for (const [rel, body] of Object.entries(files)) {
59
- const abs = path.join(payload, rel);
60
- fs.mkdirSync(path.dirname(abs), { recursive: true });
61
- fs.writeFileSync(abs, body);
62
- }
63
- return payload;
64
- }
65
-
66
- function fakeManifest(overrides?: Partial<PackManifest>): PackManifest {
67
- return {
68
- name: 'hq-pack-test',
69
- version: '1.0.0',
70
- contributes: {},
71
- ...overrides,
72
- } as PackManifest;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Tests
77
- // ---------------------------------------------------------------------------
78
-
79
- describe('pack-install: install path layout', () => {
80
- let hqRoot: string;
81
- let payload: string;
82
-
83
- beforeEach(() => {
84
- hqRoot = mkFakeHq();
85
- payload = mkFakePackPayload({
86
- 'README.md': '# pack readme',
87
- 'package.yaml': 'name: hq-pack-test\nversion: 1.0.0\n',
88
- 'hooks/pre.sh': '#!/bin/sh\necho hi\n',
89
- });
90
- });
91
-
92
- afterEach(() => {
93
- fs.rmSync(hqRoot, { recursive: true, force: true });
94
- fs.rmSync(payload, { recursive: true, force: true });
95
- });
96
-
97
- // ---- 1. install destination ---------------------------------------------
98
- it('installToPackages writes the payload under core/packages/<name>/, NOT top-level packages/', () => {
99
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
100
-
101
- // New layout: core/packages/<name>/
102
- expect(dest).toBe(path.join(hqRoot, 'core', 'packages', 'hq-pack-test'));
103
- expect(fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-pack-test', 'README.md'))).toBe(
104
- true,
105
- );
106
- expect(
107
- fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-pack-test', 'hooks', 'pre.sh')),
108
- ).toBe(true);
109
-
110
- // Top-level packages/ must NOT be created — that was the old buggy layout.
111
- expect(fs.existsSync(path.join(hqRoot, 'packages'))).toBe(false);
112
- });
113
-
114
- // ---- 2. re-install idempotency -----------------------------------------
115
- it('installToPackages replaces an existing core/packages/<name> destination cleanly', () => {
116
- // Seed a stale install with a file the new payload doesn't have. A clean
117
- // re-install must wipe it — otherwise stale contributions linger and
118
- // scan-packages.sh wires them back into host paths.
119
- const dest = path.join(hqRoot, 'core', 'packages', 'hq-pack-test');
120
- fs.mkdirSync(dest, { recursive: true });
121
- fs.writeFileSync(path.join(dest, 'STALE-FILE.md'), 'old content');
122
-
123
- installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
124
-
125
- expect(fs.existsSync(path.join(dest, 'README.md'))).toBe(true); // new content
126
- expect(fs.existsSync(path.join(dest, 'STALE-FILE.md'))).toBe(false); // wiped
127
- });
128
-
129
- // ---- 3. scan-packages.sh resolution -------------------------------------
130
- // Spawns a real `bash` subprocess (spawnSync), which under the saturated
131
- // full-suite parallel pool can take well past vitest's 5s default. The work
132
- // is a trivial `touch`; the extra budget is for scheduling contention, not
133
- // slow logic. Isolated, this runs in milliseconds.
134
- it('runScanPackages invokes core/scripts/scan-packages.sh when present', () => {
135
- const scriptDir = path.join(hqRoot, 'core', 'scripts');
136
- fs.mkdirSync(scriptDir, { recursive: true });
137
- const sentinel = path.join(hqRoot, '.scan-ran');
138
- // Self-attesting script: writes a sentinel file when invoked. Lets us
139
- // verify path resolution without spying on spawnSync.
140
- fs.writeFileSync(
141
- path.join(scriptDir, 'scan-packages.sh'),
142
- `#!/bin/sh\ntouch "${sentinel}"\n`,
143
- { mode: 0o755 },
144
- );
145
-
146
- runScanPackages(hqRoot);
147
-
148
- expect(fs.existsSync(sentinel)).toBe(true);
149
- }, 30_000);
150
-
151
- it('runScanPackages skips with a dim warning when core/scripts/scan-packages.sh is absent', () => {
152
- // No script anywhere — runScanPackages must not throw, must not invoke
153
- // the old top-level scripts/scan-packages.sh either.
154
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
155
-
156
- expect(() => runScanPackages(hqRoot)).not.toThrow();
157
-
158
- expect(logSpy).toHaveBeenCalled();
159
- // The "skipping auto-wire" message goes through chalk.dim, so just match
160
- // the human-readable substring rather than the ANSI sequence.
161
- const allLogs = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
162
- expect(allLogs).toMatch(/scan-packages\.sh not present/);
163
- logSpy.mockRestore();
164
- });
165
-
166
- // ---- 4. no modules/ directory -------------------------------------------
167
- it('regression: installing a pack does not create top-level modules/ or modules.yaml', () => {
168
- installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
169
-
170
- expect(fs.existsSync(path.join(hqRoot, 'modules'))).toBe(false);
171
- expect(fs.existsSync(path.join(hqRoot, 'modules.yaml'))).toBe(false);
172
- });
173
-
174
- // ---- 5. stampInstallSource — setup.sh dedup contract --------------------
175
- // setup.sh in hq-core ≥14.2.x reads `^source:` from every installed
176
- // core/packages/*/package.yaml and uses verbatim equality against
177
- // core.yaml:recommended_packages[].source to skip "already installed" packs.
178
- // Without the stamp the dedup set is empty and every recommended pack gets
179
- // re-prompted on every setup.sh run — the bug this stamp closes.
180
- it('stampInstallSource writes a top-level source: line into package.yaml', () => {
181
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
182
- const src = '@indigoai-us/hq-pack-test@1.2.3';
183
- stampInstallSource(dest, src);
184
-
185
- const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
186
- // First line so a `grep ^source:` style scan never misses it.
187
- expect(written.split('\n')[0]).toBe(`source: "${src}"`);
188
- // Original fields preserved verbatim — the stamp must not clobber name/version.
189
- expect(written).toContain('name: hq-pack-test');
190
- expect(written).toContain('version: 1.0.0');
191
- });
192
-
193
- it('stampInstallSource is idempotent — re-install replaces (not duplicates) the source line', () => {
194
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
195
- stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
196
- stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.2.3');
197
-
198
- const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
199
- // Exactly one top-level source: line, and it carries the latest value.
200
- const sourceLines = written.split('\n').filter((l) => /^source\s*:/.test(l));
201
- expect(sourceLines).toHaveLength(1);
202
- expect(sourceLines[0]).toBe('source: "@indigoai-us/hq-pack-test@1.2.3"');
203
- });
204
-
205
- it('stampInstallSource quotes github: sources so the # is not parsed as a YAML comment', () => {
206
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
207
- const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
208
- stampInstallSource(dest, src);
209
-
210
- const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
211
- // Double-quoted form survives the '#' that would otherwise truncate the
212
- // value at the first YAML comment marker.
213
- expect(written.split('\n')[0]).toBe(`source: "${src}"`);
214
- // Round-trip through js-yaml to confirm a real parser reads the full value.
215
- const yamlLib = require('js-yaml');
216
- const parsed = yamlLib.load(written) as { source: string };
217
- expect(parsed.source).toBe(src);
218
- });
219
-
220
- it('stampInstallSource only strips top-level source: lines, not nested ones', () => {
221
- // Defensive: today's spec has no nested `source:` keys, but if a pack
222
- // author adds e.g. `metadata: { source: ... }` we should leave it alone.
223
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
224
- fs.writeFileSync(
225
- path.join(dest, 'package.yaml'),
226
- [
227
- 'name: hq-pack-test',
228
- 'version: 1.0.0',
229
- 'metadata:',
230
- ' source: nested-value-keep-me',
231
- '',
232
- ].join('\n'),
233
- );
234
-
235
- stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
236
-
237
- const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
238
- expect(written.split('\n')[0]).toBe('source: "@indigoai-us/hq-pack-test@1.0.0"');
239
- // Nested source line survives.
240
- expect(written).toContain(' source: nested-value-keep-me');
241
- });
242
-
243
- it('stampInstallSource throws when package.yaml is missing — surfaces upstream regressions loudly', () => {
244
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
245
- fs.unlinkSync(path.join(dest, 'package.yaml'));
246
-
247
- expect(() => stampInstallSource(dest, '@scope/x@1.0.0')).toThrow(/package\.yaml.*not found/);
248
- });
249
-
250
- // ---- 6. author + capabilities attribution (US-001) ----------------------
251
- // Records pack authorship so installs can be attributed. Both fields are
252
- // OPTIONAL and backwards-compatible: legacy packs without an author still
253
- // validate; a pack that declares an author surfaces uid/handle/displayName.
254
- describe('author + capabilities (US-001)', () => {
255
- // A minimal, otherwise-valid pack payload that validateManifest accepts.
256
- // `contributes` declares one knowledge entry whose payload file exists, so
257
- // the only variable across these tests is the author/capabilities block.
258
- function writeValidPack(extraYaml: string): string {
259
- const dir = mkFakePackPayload({
260
- 'knowledge/demo/README.md': '# demo knowledge',
261
- });
262
- fs.writeFileSync(
263
- path.join(dir, 'package.yaml'),
264
- [
265
- 'name: hq-pack-test',
266
- 'version: 1.0.0',
267
- "publisher: '@indigoai-us'",
268
- 'access: public',
269
- 'requires:',
270
- " hqCore: '>=12.0.0'",
271
- 'contributes:',
272
- ' knowledge:',
273
- ' - demo',
274
- extraYaml,
275
- '',
276
- ].join('\n'),
277
- );
278
- return dir;
279
- }
280
-
281
- it('a manifest WITHOUT author validates (backwards-compatible)', () => {
282
- const dir = writeValidPack('');
283
- const m = validateManifest(dir, '12.0.0');
284
- expect(m.name).toBe('hq-pack-test');
285
- expect(m.author).toBeUndefined();
286
- expect(m.capabilities).toBeUndefined();
287
- fs.rmSync(dir, { recursive: true, force: true });
288
- });
289
-
290
- it('a manifest WITH an author block surfaces uid/handle/displayName', () => {
291
- const dir = writeValidPack(
292
- [
293
- 'author:',
294
- ' uid: prs_abc123',
295
- ' handle: corey',
296
- " displayName: 'Corey Epstein'",
297
- 'capabilities:',
298
- ' - hooks',
299
- ' - network',
300
- ].join('\n'),
301
- );
302
- const m = validateManifest(dir, '12.0.0');
303
- expect(m.author).toEqual({
304
- uid: 'prs_abc123',
305
- handle: 'corey',
306
- displayName: 'Corey Epstein',
307
- });
308
- expect(m.capabilities).toEqual(['hooks', 'network']);
309
- fs.rmSync(dir, { recursive: true, force: true });
310
- });
311
-
312
- it('a malformed author (missing displayName) is rejected — not silently accepted', () => {
313
- const dir = writeValidPack(['author:', ' uid: prs_abc123', ' handle: corey'].join('\n'));
314
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/author\.displayName/);
315
- fs.rmSync(dir, { recursive: true, force: true });
316
- });
317
-
318
- it('capabilities must be a list of strings', () => {
319
- const dir = writeValidPack(['capabilities:', ' hooks: true'].join('\n'));
320
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/capabilities must be a list/);
321
- fs.rmSync(dir, { recursive: true, force: true });
322
- });
323
- });
324
-
325
- // ---- 7. hqCore host-version check is prerelease-tolerant -------------------
326
- // BUGFIX: a prerelease host version (e.g. `15.0.9-beta.1`) was rejected by the
327
- // `requires.hqCore` check because node-semver excludes prereleases from range
328
- // matching by default. The fix passes `{ includePrerelease: true }` so a beta/rc
329
- // HQ build can still install packs — while a genuinely-too-old host still fails.
330
- describe('requires.hqCore prerelease tolerance', () => {
331
- function writePackRequiring(range: string): string {
332
- const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
333
- fs.writeFileSync(
334
- path.join(dir, 'package.yaml'),
335
- [
336
- 'name: hq-pack-test',
337
- 'version: 1.0.0',
338
- "publisher: '@indigoai-us'",
339
- 'access: public',
340
- 'requires:',
341
- ` hqCore: '${range}'`,
342
- 'contributes:',
343
- ' knowledge:',
344
- ' - demo',
345
- '',
346
- ].join('\n'),
347
- );
348
- return dir;
349
- }
350
-
351
- it('ACCEPTS a prerelease host version against a plain range (>=14.2.0)', () => {
352
- const dir = writePackRequiring('>=14.2.0');
353
- const m = validateManifest(dir, '15.0.9-beta.1');
354
- expect(m.name).toBe('hq-pack-test');
355
- fs.rmSync(dir, { recursive: true, force: true });
356
- });
357
-
358
- it('still REJECTS a genuinely-too-old host (13.0.0 against >=14.2.0)', () => {
359
- const dir = writePackRequiring('>=14.2.0');
360
- expect(() => validateManifest(dir, '13.0.0')).toThrow(
361
- /does not satisfy pack requirement/,
362
- );
363
- fs.rmSync(dir, { recursive: true, force: true });
364
- });
365
-
366
- it('still ACCEPTS a normal (non-prerelease) host version', () => {
367
- const dir = writePackRequiring('>=14.2.0');
368
- const m = validateManifest(dir, '15.0.0');
369
- expect(m.name).toBe('hq-pack-test');
370
- fs.rmSync(dir, { recursive: true, force: true });
371
- });
372
- });
373
-
374
- // ---- 7b. MCP-bearing pack version FLOOR (US-012, M1 platform-GA) ----------
375
- // The `contributes.mcp` capability is carried by hqCore >=15.1.0. An MCP-bearing
376
- // pack therefore declares `requires.hqCore '>=15.1.0'` (or '>15.0.19'). On an
377
- // OLDER host (15.0.19) the installer must REFUSE the pack with a clear version-
378
- // floor message naming the host version + the requirement — the MCP wiring is
379
- // never silently dropped. On the floor host (15.1.0) the same pack PASSES.
380
- // Enforcement lives in validateManifest at the `does not satisfy` throw site.
381
- describe('US-012: MCP-bearing pack version floor (>=15.1.0)', () => {
382
- // A real-shaped MCP pack: declares `contributes.mcp` (one server, manifest
383
- // present) and a `requires.hqCore` range. The only variable is the range, so
384
- // the floor check is what's under test.
385
- function writeMcpPackRequiring(range: string): string {
386
- const dir = mkFakePackPayload({
387
- 'mcp/vyg.json': JSON.stringify({
388
- type: 'http',
389
- url: 'https://mcp.vyg.app',
390
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
391
- }),
392
- });
393
- fs.writeFileSync(
394
- path.join(dir, 'package.yaml'),
395
- [
396
- 'name: hq-pack-vyg-shopify',
397
- 'version: 1.0.0',
398
- "publisher: '@indigoai-us'",
399
- 'access: public',
400
- 'requires:',
401
- ` hqCore: '${range}'`,
402
- 'contributes:',
403
- ' mcp:',
404
- ' - vyg',
405
- '',
406
- ].join('\n'),
407
- );
408
- return dir;
409
- }
410
-
411
- it('REFUSES an MCP pack (>=15.1.0) on an older host (15.0.19) with a clear floor message', () => {
412
- const dir = writeMcpPackRequiring('>=15.1.0');
413
- // Refused, and the message NAMES both the host version and the requirement.
414
- expect(() => validateManifest(dir, '15.0.19')).toThrow(
415
- /does not satisfy pack requirement/,
416
- );
417
- expect(() => validateManifest(dir, '15.0.19')).toThrow(/15\.0\.19/);
418
- expect(() => validateManifest(dir, '15.0.19')).toThrow(/>=15\.1\.0/);
419
- fs.rmSync(dir, { recursive: true, force: true });
420
- });
421
-
422
- it('REFUSES the same pack expressed as `>15.0.19` on a 15.0.19 host', () => {
423
- const dir = writeMcpPackRequiring('>15.0.19');
424
- expect(() => validateManifest(dir, '15.0.19')).toThrow(
425
- /does not satisfy pack requirement/,
426
- );
427
- fs.rmSync(dir, { recursive: true, force: true });
428
- });
429
-
430
- it('PASSES the same MCP pack on the floor host (15.1.0)', () => {
431
- const dir = writeMcpPackRequiring('>=15.1.0');
432
- const m = validateManifest(dir, '15.1.0');
433
- expect(m.name).toBe('hq-pack-vyg-shopify');
434
- expect(m.contributes?.mcp).toEqual(['vyg']);
435
- fs.rmSync(dir, { recursive: true, force: true });
436
- });
437
- });
438
-
439
- // ---- 8. initialization.entrypoint validation (US-004) -------------------
440
- // A pack may declare an `initialization` block whose `entrypoint` MUST resolve
441
- // to a declared contributes skill/command. The block is OPTIONAL and
442
- // backwards-compatible: legacy packs without it still validate. Slash
443
- // normalization lets `/foo` match a contributes entry named `foo` (and v.v.).
444
- describe('initialization.entrypoint (US-004)', () => {
445
- // A valid pack that contributes one skill (`email-assistant`) and one
446
- // command (`triage`), so an entrypoint can resolve to either. The only
447
- // variable across these tests is the `initialization` block (extraYaml).
448
- function writePackWithInit(extraYaml: string): string {
449
- const dir = mkFakePackPayload({
450
- 'skills/email-assistant/SKILL.md': '# email assistant skill',
451
- 'commands/triage.md': '# triage command',
452
- });
453
- fs.writeFileSync(
454
- path.join(dir, 'package.yaml'),
455
- [
456
- 'name: hq-pack-test',
457
- 'version: 1.0.0',
458
- "publisher: '@indigoai-us'",
459
- 'access: public',
460
- 'requires:',
461
- " hqCore: '>=12.0.0'",
462
- 'contributes:',
463
- ' skills:',
464
- ' - email-assistant',
465
- ' commands:',
466
- ' - triage',
467
- extraYaml,
468
- '',
469
- ].join('\n'),
470
- );
471
- return dir;
472
- }
473
-
474
- it('accepts an entrypoint matching a contributes.skills entry', () => {
475
- const dir = writePackWithInit(
476
- ['initialization:', ' entrypoint: email-assistant'].join('\n'),
477
- );
478
- const m = validateManifest(dir, '12.0.0');
479
- expect(m.name).toBe('hq-pack-test');
480
- fs.rmSync(dir, { recursive: true, force: true });
481
- });
482
-
483
- it('accepts a leading-slash entrypoint matching a no-slash contributes entry', () => {
484
- const dir = writePackWithInit(
485
- ['initialization:', ' entrypoint: /email-assistant'].join('\n'),
486
- );
487
- const m = validateManifest(dir, '12.0.0');
488
- expect(m.name).toBe('hq-pack-test');
489
- fs.rmSync(dir, { recursive: true, force: true });
490
- });
491
-
492
- it('accepts a no-slash entrypoint matching a slashed contributes entry (vice-versa)', () => {
493
- // Contributes declares `/onboard` (slashed); the entrypoint is `onboard`
494
- // (no slash). The payload-file check computes `commands/${i}.md` which
495
- // path.join normalizes (`commands//onboard.md` → `commands/onboard.md`),
496
- // so a single `commands/onboard.md` file satisfies it.
497
- const dir = mkFakePackPayload({
498
- 'commands/onboard.md': '# onboard command',
499
- });
500
- fs.writeFileSync(
501
- path.join(dir, 'package.yaml'),
502
- [
503
- 'name: hq-pack-test',
504
- 'version: 1.0.0',
505
- "publisher: '@indigoai-us'",
506
- 'access: public',
507
- 'requires:',
508
- " hqCore: '>=12.0.0'",
509
- 'contributes:',
510
- ' commands:',
511
- ' - /onboard',
512
- 'initialization:',
513
- ' entrypoint: onboard',
514
- '',
515
- ].join('\n'),
516
- );
517
- const m = validateManifest(dir, '12.0.0');
518
- expect(m.name).toBe('hq-pack-test');
519
- fs.rmSync(dir, { recursive: true, force: true });
520
- });
521
-
522
- it('accepts an entrypoint matching a contributes.commands entry', () => {
523
- const dir = writePackWithInit(
524
- ['initialization:', ' entrypoint: triage'].join('\n'),
525
- );
526
- const m = validateManifest(dir, '12.0.0');
527
- expect(m.name).toBe('hq-pack-test');
528
- fs.rmSync(dir, { recursive: true, force: true });
529
- });
530
-
531
- it('rejects an entrypoint that matches no contributes skill/command', () => {
532
- const dir = writePackWithInit(
533
- ['initialization:', ' entrypoint: does-not-exist'].join('\n'),
534
- );
535
- expect(() => validateManifest(dir, '12.0.0')).toThrow(
536
- /initialization\.entrypoint.*does not resolve/,
537
- );
538
- fs.rmSync(dir, { recursive: true, force: true });
539
- });
540
-
541
- it('rejects an initialization block with a missing entrypoint', () => {
542
- const dir = writePackWithInit(
543
- ['initialization:', ' prompt: hello'].join('\n'),
544
- );
545
- expect(() => validateManifest(dir, '12.0.0')).toThrow(
546
- /initialization\.entrypoint is required/,
547
- );
548
- fs.rmSync(dir, { recursive: true, force: true });
549
- });
550
-
551
- it('rejects an initialization block with an empty entrypoint', () => {
552
- const dir = writePackWithInit(
553
- ['initialization:', " entrypoint: ''"].join('\n'),
554
- );
555
- expect(() => validateManifest(dir, '12.0.0')).toThrow(
556
- /initialization\.entrypoint is required/,
557
- );
558
- fs.rmSync(dir, { recursive: true, force: true });
559
- });
560
-
561
- it('accepts an initialization.prompt at the 2000-char limit', () => {
562
- const dir = writePackWithInit(
563
- [
564
- 'initialization:',
565
- ' entrypoint: email-assistant',
566
- ` prompt: '${'a'.repeat(2000)}'`,
567
- ].join('\n'),
568
- );
569
- const m = validateManifest(dir, '12.0.0');
570
- expect(m.name).toBe('hq-pack-test');
571
- fs.rmSync(dir, { recursive: true, force: true });
572
- });
573
-
574
- it('rejects an initialization.prompt longer than 2000 chars', () => {
575
- const dir = writePackWithInit(
576
- [
577
- 'initialization:',
578
- ' entrypoint: email-assistant',
579
- ` prompt: '${'a'.repeat(2001)}'`,
580
- ].join('\n'),
581
- );
582
- expect(() => validateManifest(dir, '12.0.0')).toThrow(
583
- /initialization\.prompt must be ≤ 2000 characters/,
584
- );
585
- fs.rmSync(dir, { recursive: true, force: true });
586
- });
587
-
588
- it('rejects a non-string initialization.prompt', () => {
589
- const dir = writePackWithInit(
590
- ['initialization:', ' entrypoint: email-assistant', ' prompt: 42'].join('\n'),
591
- );
592
- expect(() => validateManifest(dir, '12.0.0')).toThrow(
593
- /initialization\.prompt must be a string/,
594
- );
595
- fs.rmSync(dir, { recursive: true, force: true });
596
- });
597
-
598
- it('a manifest WITHOUT an initialization block still validates (backwards-compatible)', () => {
599
- const dir = writePackWithInit('');
600
- const m = validateManifest(dir, '12.0.0');
601
- expect(m.name).toBe('hq-pack-test');
602
- expect((m as unknown as Record<string, unknown>).initialization).toBeUndefined();
603
- fs.rmSync(dir, { recursive: true, force: true });
604
- });
605
- });
606
-
607
- // ---- 8b. get-started line derivation (US-005) ---------------------------
608
- // After a successful install, `installPack` prints a safe, auto-generated
609
- // "get started" line derived from `initialization.entrypoint` ONLY (never the
610
- // free-text prompt). The line matches the HQ Sync desktop wording and the
611
- // command is normalized to exactly one leading slash. No initialization block
612
- // → no extra line (backwards-compatible).
613
- describe('getStartedLine (US-005)', () => {
614
- it('renders a get-started line referencing the entrypoint', () => {
615
- expect(getStartedLine({ entrypoint: 'email-assistant' })).toBe(
616
- 'Run `/email-assistant` to get started',
617
- );
618
- });
619
-
620
- it('slash-normalizes an entrypoint already stored with a leading slash', () => {
621
- expect(getStartedLine({ entrypoint: '/email-assistant' })).toBe(
622
- 'Run `/email-assistant` to get started',
623
- );
624
- });
625
-
626
- it('collapses multiple leading slashes to exactly one', () => {
627
- expect(getStartedLine({ entrypoint: '///triage' })).toBe(
628
- 'Run `/triage` to get started',
629
- );
630
- });
631
-
632
- it('ignores the free-text prompt prose (PHASE 1 — entrypoint only)', () => {
633
- const line = getStartedLine({
634
- entrypoint: 'email-assistant',
635
- prompt: 'do not surface this untrusted prose',
636
- });
637
- expect(line).toBe('Run `/email-assistant` to get started');
638
- expect(line).not.toContain('untrusted');
639
- });
640
-
641
- it('returns null when there is no initialization block (prints nothing extra)', () => {
642
- expect(getStartedLine(undefined)).toBeNull();
643
- });
644
-
645
- it('returns null for an empty/blank entrypoint', () => {
646
- expect(getStartedLine({ entrypoint: '' })).toBeNull();
647
- expect(getStartedLine({ entrypoint: ' ' })).toBeNull();
648
- });
649
- });
650
-
651
- it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
652
- // Regression: prepending `source:` before a `---` would split the file
653
- // into two YAML documents, and single-doc `yaml.load` callers downstream
654
- // (in this codebase + hq-core) silently misread or fail. Insert AFTER
655
- // the marker so the manifest stays a single document.
656
- const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
657
- fs.writeFileSync(
658
- path.join(dest, 'package.yaml'),
659
- ['---', 'name: hq-pack-test', 'version: 1.0.0', ''].join('\n'),
660
- );
661
- const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
662
-
663
- stampInstallSource(dest, src);
664
-
665
- const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
666
- // `---` stays on line 0, `source:` follows immediately so `grep ^source:`
667
- // dedup scans still match.
668
- const lines = written.split('\n');
669
- expect(lines[0]).toBe('---');
670
- expect(lines[1]).toBe(`source: "${src}"`);
671
-
672
- // Critical assertion: js-yaml's single-doc loader returns the merged
673
- // manifest, not just the first of two documents.
674
- const yamlLib = require('js-yaml');
675
- const parsed = yamlLib.load(written) as { source: string; name: string; version: string };
676
- expect(parsed.source).toBe(src);
677
- expect(parsed.name).toBe('hq-pack-test');
678
- expect(parsed.version).toBe('1.0.0');
679
-
680
- // Re-stamp is still idempotent in the doc-marker case.
681
- stampInstallSource(dest, '@scope/x@2.0.0');
682
- const rewritten = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
683
- const sourceLines = rewritten.split('\n').filter((l) => /^source\s*:/.test(l));
684
- expect(sourceLines).toHaveLength(1);
685
- expect(sourceLines[0]).toBe('source: "@scope/x@2.0.0"');
686
- expect(rewritten.split('\n')[0]).toBe('---');
687
- });
688
- });
689
-
690
- // ---------------------------------------------------------------------------
691
- // contributes.mcp — shape-validate manifest + route wire:merge (US-005)
692
- //
693
- // Acceptance: "Given a pack with contributes.mcp, when validateManifest runs,
694
- // then the manifest is shape-validated and routed as wire:merge (not
695
- // symlinked)." Mirrors the bash validate_mcp_manifest arm in scan-packages.sh.
696
- // ---------------------------------------------------------------------------
697
-
698
- describe('pack-install: contributes.mcp shape-validation + routing (US-005)', () => {
699
- // Build a pack whose package.yaml declares one mcp server (`vyg`) and whose
700
- // mcp/vyg.json holds `manifest`. The only variable across tests is the
701
- // manifest JSON body, so the shape/secret rules are what's under test.
702
- function writeMcpPack(manifest: unknown): string {
703
- const dir = mkFakePackPayload({
704
- 'mcp/vyg.json':
705
- typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
706
- });
707
- fs.writeFileSync(
708
- path.join(dir, 'package.yaml'),
709
- [
710
- 'name: hq-pack-test',
711
- 'version: 1.0.0',
712
- "publisher: '@indigoai-us'",
713
- 'access: public',
714
- 'requires:',
715
- " hqCore: '>=12.0.0'",
716
- 'contributes:',
717
- ' mcp:',
718
- ' - vyg',
719
- '',
720
- ].join('\n'),
721
- );
722
- return dir;
723
- }
724
-
725
- it('a valid http mcp manifest (secret-ref Bearer) passes validateManifest', () => {
726
- const dir = writeMcpPack({
727
- type: 'http',
728
- url: 'https://mcp.vyg.app',
729
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
730
- });
731
- const m = validateManifest(dir, '12.0.0');
732
- expect(m.name).toBe('hq-pack-test');
733
- expect(m.contributes.mcp).toEqual(['vyg']);
734
- fs.rmSync(dir, { recursive: true, force: true });
735
- });
736
-
737
- it('rejects an inline literal Bearer secret (no ${secret:} reference)', () => {
738
- const dir = writeMcpPack({
739
- type: 'http',
740
- url: 'https://mcp.vyg.app',
741
- headers: { Authorization: 'Bearer abc123' },
742
- });
743
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/Bearer/);
744
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/secret/);
745
- fs.rmSync(dir, { recursive: true, force: true });
746
- });
747
-
748
- it('rejects a malformed (non-JSON) mcp manifest, naming the file', () => {
749
- const dir = writeMcpPack('{ this is not json ]');
750
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/mcp\/vyg\.json/);
751
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/not valid JSON/);
752
- fs.rmSync(dir, { recursive: true, force: true });
753
- });
754
-
755
- it('rejects an http manifest missing url', () => {
756
- const dir = writeMcpPack({ type: 'http' });
757
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/requires a url/);
758
- fs.rmSync(dir, { recursive: true, force: true });
759
- });
760
-
761
- it('rejects an http manifest carrying a forbidden command', () => {
762
- const dir = writeMcpPack({
763
- type: 'http',
764
- url: 'https://mcp.vyg.app',
765
- command: 'run-me',
766
- });
767
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/forbids: command/);
768
- fs.rmSync(dir, { recursive: true, force: true });
769
- });
770
-
771
- it('rejects an unknown type and unknown top-level keys', () => {
772
- expect(() => validateManifest(writeMcpPack({ type: 'grpc' }), '12.0.0')).toThrow(
773
- /type must be one of http\|stdio\|sse/,
774
- );
775
- expect(() =>
776
- validateManifest(
777
- writeMcpPack({ type: 'http', url: 'https://x.app', bogus: 1 }),
778
- '12.0.0',
779
- ),
780
- ).toThrow(/unknown top-level key: bogus/);
781
- });
782
-
783
- it('validates stdio transport rules (command required; url/headers forbidden)', () => {
784
- // stdio is validated now even though EMIT is Phase-2 deferred.
785
- const ok = writeMcpPack({ type: 'stdio', command: 'my-server' });
786
- expect(validateManifest(ok, '12.0.0').name).toBe('hq-pack-test');
787
- fs.rmSync(ok, { recursive: true, force: true });
788
-
789
- expect(() =>
790
- validateManifest(writeMcpPack({ type: 'stdio' }), '12.0.0'),
791
- ).toThrow(/requires a non-empty command/);
792
- expect(() =>
793
- validateManifest(
794
- writeMcpPack({ type: 'stdio', command: 'x', url: 'https://x.app' }),
795
- '12.0.0',
796
- ),
797
- ).toThrow(/stdio transport forbids: url/);
798
- });
799
-
800
- it('rejects non-string header values', () => {
801
- const dir = writeMcpPack({
802
- type: 'http',
803
- url: 'https://mcp.vyg.app',
804
- headers: { Authorization: 42 },
805
- });
806
- expect(() => validateManifest(dir, '12.0.0')).toThrow(/headers "Authorization" value must be a string/);
807
- fs.rmSync(dir, { recursive: true, force: true });
808
- });
809
-
810
- it('validateMcpManifest can be called directly (the US-006 registration seam reuses it)', () => {
811
- const dir = writeMcpPack({ type: 'http', url: 'https://mcp.vyg.app' });
812
- expect(() => validateMcpManifest(dir, 'vyg')).not.toThrow();
813
- // The pack-relative payload path it reads is mcp/{item}.json.
814
- expect(fs.existsSync(path.join(dir, 'mcp', 'vyg.json'))).toBe(true);
815
- fs.rmSync(dir, { recursive: true, force: true });
816
- });
817
-
818
- // -- Routing: wire:merge keys go to registerMcpServers, NEVER the symlink path
819
- it('the mcp key routes to wire:merge in the single-source table', () => {
820
- expect(routeContribution('mcp')).toBe('merge');
821
- expect(mergeKeys({ mcp: ['vyg'] })).toEqual(['mcp']);
822
- // A symlink key is NOT a merge key.
823
- expect(routeContribution('commands')).toBe('symlink');
824
- expect(mergeKeys({ commands: ['triage'] })).toEqual([]);
825
- });
826
-
827
- it('an mcp-only contributes block produces NO symlink (provably excluded)', () => {
828
- const dir = writeMcpPack({ type: 'http', url: 'https://mcp.vyg.app' });
829
- // contributionLinks is the symlink enumerator; merge keys must be skipped.
830
- expect(contributionLinks('/fake/hq', dir, { mcp: ['vyg'] })).toEqual([]);
831
- fs.rmSync(dir, { recursive: true, force: true });
832
- });
833
-
834
- it('registerMcpServers requires a manifest loader (US-007 wired the Claude emit — no silent no-op)', () => {
835
- // US-007 replaced the US-006 NotImplemented seam with a real Claude/JSON
836
- // emitter. Called without a manifest loader (no payload context), it fails
837
- // loudly with a named McpManifestError rather than throwing NotImplemented or
838
- // silently doing nothing — and it does NOT touch any real config (no IO before
839
- // the loader check).
840
- expect(() => registerMcpServers('hq-pack-test', ['vyg'])).toThrow(McpManifestError);
841
- try {
842
- registerMcpServers('hq-pack-test', ['vyg']);
843
- throw new Error('should have thrown');
844
- } catch (e) {
845
- expect(e).toBeInstanceOf(McpManifestError);
846
- expect((e as McpManifestError).code).toBe('McpManifestError');
847
- expect((e as Error).message).toMatch(/loadManifest/);
848
- }
849
- });
850
- });
851
-
852
- // ---------------------------------------------------------------------------
853
- // Install-time MCP trust prompt (US-010) — confirmMcp / renderMcpServerLine.
854
- // Sanity coverage for the production helpers (the full acceptance matrix lands
855
- // in a later phase). All fixtures use isolated tmpdirs; confirmMcp itself writes
856
- // NO config — it only renders + (deny/allow) returns a boolean.
857
- // ---------------------------------------------------------------------------
858
-
859
- describe('pack-install: confirmMcp / renderMcpServerLine (US-010)', () => {
860
- it('renderMcpServerLine discloses the FULL url and redacts header values (http)', () => {
861
- const dir = mkFakePackPayload({
862
- 'mcp/vyg.json': JSON.stringify({
863
- type: 'http',
864
- url: 'https://mcp.vyg.app/sse',
865
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
866
- }),
867
- });
868
- const line = renderMcpServerLine(dir, 'vyg');
869
- // FULL url verbatim (typosquat disclosure).
870
- expect(line).toContain('https://mcp.vyg.app/sse');
871
- expect(line).toContain('[http]');
872
- // Header KEY shown, but NEVER the raw Bearer/secret-ref value.
873
- expect(line).toContain('Authorization');
874
- expect(line).not.toContain('Bearer');
875
- expect(line).not.toContain('secret:');
876
- expect(line).not.toContain('VYG_API_KEY');
877
- fs.rmSync(dir, { recursive: true, force: true });
878
- });
879
-
880
- it('renderMcpServerLine shows command+args and stdio shell-permission warning', () => {
881
- const dir = mkFakePackPayload({
882
- 'mcp/local.json': JSON.stringify({
883
- type: 'stdio',
884
- command: '/usr/local/bin/foo',
885
- args: ['--serve'],
886
- }),
887
- });
888
- const line = renderMcpServerLine(dir, 'local');
889
- expect(line).toContain('[stdio]');
890
- expect(line).toContain('/usr/local/bin/foo');
891
- expect(line).toContain('--serve');
892
- expect(line).toMatch(/runs a local binary with your shell permissions/);
893
- fs.rmSync(dir, { recursive: true, force: true });
894
- });
895
-
896
- it('renderMcpServerLine fails SAFE on an unreadable manifest (no crash, flagged)', () => {
897
- const dir = mkFakePackPayload({ 'mcp/broken.json': '{ not json ]' });
898
- const line = renderMcpServerLine(dir, 'broken');
899
- expect(line).toContain('broken');
900
- expect(line).toContain('[manifest unreadable]');
901
- fs.rmSync(dir, { recursive: true, force: true });
902
- });
903
-
904
- it('confirmMcp returns true with NO prompt when contributes.mcp is empty', async () => {
905
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
906
- const ok = await confirmMcp(fakeManifest({ contributes: {} }), '/nope', false);
907
- expect(ok).toBe(true);
908
- expect(logSpy).not.toHaveBeenCalled();
909
- logSpy.mockRestore();
910
- });
911
-
912
- it('confirmMcp does NOT gate on the advisory capabilities field (only contributes.mcp)', async () => {
913
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
914
- // capabilities lists "mcp" but contributes.mcp is empty -> no prompt, true.
915
- const ok = await confirmMcp(
916
- fakeManifest({ contributes: {}, capabilities: ['mcp', 'network'] }),
917
- '/nope',
918
- false,
919
- );
920
- expect(ok).toBe(true);
921
- expect(logSpy).not.toHaveBeenCalled();
922
- logSpy.mockRestore();
923
- });
924
-
925
- it('confirmMcp with --allow-mcp bypasses the prompt and returns true (CI path)', async () => {
926
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
927
- const dir = mkFakePackPayload({
928
- 'mcp/vyg.json': JSON.stringify({
929
- type: 'http',
930
- url: 'https://mcp.vyg.app',
931
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
932
- }),
933
- });
934
- const ok = await confirmMcp(
935
- fakeManifest({ publisher: '@vyg', contributes: { mcp: ['vyg'] } }),
936
- dir,
937
- true,
938
- );
939
- expect(ok).toBe(true);
940
- const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
941
- expect(out).toMatch(/--allow-mcp set; registering 1 MCP server\(s\) without prompting/);
942
- // Bypass notice must never leak a secret either.
943
- expect(out).not.toContain('VYG_API_KEY');
944
- logSpy.mockRestore();
945
- fs.rmSync(dir, { recursive: true, force: true });
946
- });
947
-
948
- it('confirmMcp denies in a non-TTY shell and prints the --allow-mcp hint (no hang)', async () => {
949
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
950
- const prevTty = process.stdin.isTTY;
951
- // Force non-interactive — confirmMcp must NOT call rl.question (would hang).
952
- Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
953
- const dir = mkFakePackPayload({
954
- 'mcp/vyg.json': JSON.stringify({
955
- type: 'http',
956
- url: 'https://mcp.vyg.app',
957
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
958
- }),
959
- });
960
- try {
961
- const ok = await confirmMcp(
962
- fakeManifest({ publisher: '@vyg', contributes: { mcp: ['vyg'] } }),
963
- dir,
964
- false,
965
- );
966
- expect(ok).toBe(false);
967
- const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
968
- expect(out).toMatch(/Re-run with --allow-mcp/);
969
- // Rendered prompt must never echo a secret/Bearer substring.
970
- expect(out).not.toContain('Bearer');
971
- expect(out).not.toContain('VYG_API_KEY');
972
- expect(out).not.toContain('secret:');
973
- // FULL url still disclosed for the operator.
974
- expect(out).toContain('https://mcp.vyg.app');
975
- } finally {
976
- Object.defineProperty(process.stdin, 'isTTY', { value: prevTty, configurable: true });
977
- logSpy.mockRestore();
978
- fs.rmSync(dir, { recursive: true, force: true });
979
- }
980
- });
981
- });
982
-
983
- // ---------------------------------------------------------------------------
984
- // US-010: install-time MCP trust prompt — acceptance tests
985
- //
986
- // Three story-level acceptance criteria:
987
- // 1. URL disclosed verbatim; LITERAL sentinel secret never leaks (redaction).
988
- // 2. Gate is on contributes.mcp, NOT the advisory capabilities field.
989
- // 3. Non-TTY without --allow-mcp: prints --allow-mcp hint, resolves false,
990
- // does NOT hang (rl.question is never called).
991
- // ---------------------------------------------------------------------------
992
-
993
- describe('US-010: install-time MCP trust prompt', () => {
994
- let payloadDir: string;
995
- let origIsTTY: boolean | undefined;
996
-
997
- beforeEach(() => {
998
- // Isolated tmpdir — never touches ~/.claude.json, ~/.mcp.json, or any real
999
- // config. confirmMcp/renderMcpServerLine write NO config so this is the
1000
- // natural safety boundary; we just make sure the dir lives in os.tmpdir().
1001
- payloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'us010-'));
1002
- origIsTTY = process.stdin.isTTY;
1003
- });
1004
-
1005
- afterEach(() => {
1006
- // Restore isTTY regardless of how the test exited.
1007
- Object.defineProperty(process.stdin, 'isTTY', {
1008
- value: origIsTTY,
1009
- configurable: true,
1010
- });
1011
- fs.rmSync(payloadDir, { recursive: true, force: true });
1012
- });
1013
-
1014
- // ---- US-010 AC-1: URL disclosed, LITERAL secret never leaks ----------------
1015
- //
1016
- // The security requirement is that a LITERAL secret value in a header (e.g.
1017
- // a manifest that slipped past the shape-validator, or any future transport
1018
- // that carries the secret inline) is STILL redacted by renderMcpServerLine.
1019
- // We prove this by putting an unambiguous sentinel (`SENTINEL_SECRET_zzz123`)
1020
- // directly in the header value and asserting it is absent from the output,
1021
- // while the FULL url IS present.
1022
- //
1023
- // Two sub-assertions:
1024
- // (a) renderMcpServerLine — pure render assertion without a prompt.
1025
- // (b) confirmMcp (stdout capture) — full-prompt assertion; confirms the
1026
- // "declares N MCP server(s)" header + url appear, sentinel does not.
1027
- it(
1028
- 'AC-1: http manifest — FULL url appears, LITERAL sentinel secret is redacted',
1029
- async () => {
1030
- // Write the http manifest with a LITERAL sentinel (not a ${secret:} ref)
1031
- // so the redaction-self-test is airtight: if redactSecrets had a bug the
1032
- // sentinel would appear verbatim in the output.
1033
- fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
1034
- fs.writeFileSync(
1035
- path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
1036
- JSON.stringify({
1037
- type: 'http',
1038
- url: 'https://mcp.vyg.app',
1039
- headers: { Authorization: 'Bearer SENTINEL_SECRET_zzz123' },
1040
- }),
1041
- );
1042
-
1043
- // (a) Pure render assertion — renderMcpServerLine directly.
1044
- const line = renderMcpServerLine(payloadDir, 'vyg-shopify');
1045
-
1046
- // Full url MUST appear (typosquat disclosure is the whole point).
1047
- expect(line).toContain('https://mcp.vyg.app');
1048
- expect(line).toContain('[http]');
1049
- // The header KEY must appear so the operator knows what was registered.
1050
- expect(line).toContain('Authorization');
1051
- // LITERAL sentinel value MUST NOT appear — this is the redaction self-test.
1052
- expect(line).not.toContain('SENTINEL_SECRET_zzz123');
1053
- // The Bearer prefix itself should not appear either.
1054
- expect(line).not.toContain('Bearer SENTINEL');
1055
- // The known redaction marker should replace it.
1056
- expect(line).toContain('«redacted»');
1057
-
1058
- // (b) Full-prompt assertion — confirmMcp with isTTY=false (no-hang path).
1059
- // We use non-TTY so we get stdout without needing a readline mock.
1060
- Object.defineProperty(process.stdin, 'isTTY', {
1061
- value: false,
1062
- configurable: true,
1063
- });
1064
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
1065
- try {
1066
- const pkg = fakeManifest({
1067
- publisher: '@vyg',
1068
- contributes: { mcp: ['vyg-shopify'] },
1069
- });
1070
- const ok = await confirmMcp(pkg, payloadDir, false);
1071
- // Non-TTY deny.
1072
- expect(ok).toBe(false);
1073
- const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
1074
- // URL is disclosed in the rendered server line.
1075
- expect(out).toContain('https://mcp.vyg.app');
1076
- // "declares N MCP server(s)" header must appear.
1077
- expect(out).toMatch(/declares 1 MCP server\(s\)/);
1078
- // Sentinel MUST NOT appear anywhere in output.
1079
- expect(out).not.toContain('SENTINEL_SECRET_zzz123');
1080
- expect(out).not.toContain('Bearer SENTINEL');
1081
- expect(out).not.toContain('Bearer ');
1082
- } finally {
1083
- logSpy.mockRestore();
1084
- }
1085
- },
1086
- );
1087
-
1088
- // ---- US-010 AC-2: gate is on contributes.mcp, NOT capabilities ------------
1089
- //
1090
- // Positive: capabilities omits 'mcp' (or is empty) but contributes.mcp is
1091
- // non-empty → prompt FIRES (server line / "declares N" header in output).
1092
- // Negative: capabilities includes 'mcp' but contributes.mcp is EMPTY → prompt
1093
- // does NOT fire (confirmMcp returns true, console.log never called).
1094
- it(
1095
- 'AC-2a (positive): prompt fires when contributes.mcp is set, regardless of capabilities',
1096
- async () => {
1097
- fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
1098
- fs.writeFileSync(
1099
- path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
1100
- JSON.stringify({
1101
- type: 'http',
1102
- url: 'https://mcp.vyg.app',
1103
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
1104
- }),
1105
- );
1106
- // capabilities is empty / does not include 'mcp' — gating MUST NOT rely on it.
1107
- const pkg = fakeManifest({
1108
- publisher: '@vyg',
1109
- capabilities: [],
1110
- contributes: { mcp: ['vyg-shopify'] },
1111
- });
1112
- // Non-TTY so we get the prompt output synchronously without readline.
1113
- Object.defineProperty(process.stdin, 'isTTY', {
1114
- value: false,
1115
- configurable: true,
1116
- });
1117
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
1118
- try {
1119
- const ok = await confirmMcp(pkg, payloadDir, false);
1120
- // Non-TTY deny (prompt fired but was denied).
1121
- expect(ok).toBe(false);
1122
- const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
1123
- // The prompt DID fire: the "declares N MCP server(s)" header appears.
1124
- expect(out).toMatch(/declares 1 MCP server\(s\)/);
1125
- // The server line appeared (url disclosed).
1126
- expect(out).toContain('https://mcp.vyg.app');
1127
- } finally {
1128
- logSpy.mockRestore();
1129
- }
1130
- },
1131
- );
1132
-
1133
- it(
1134
- 'AC-2b (negative): prompt does NOT fire when contributes.mcp is empty, even if capabilities includes "mcp"',
1135
- async () => {
1136
- // capabilities lists 'mcp' — but gating must be on contributes.mcp only.
1137
- const pkg = fakeManifest({
1138
- publisher: '@vyg',
1139
- capabilities: ['mcp', 'network'],
1140
- contributes: {},
1141
- });
1142
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
1143
- try {
1144
- const ok = await confirmMcp(pkg, payloadDir, false);
1145
- // Empty contributes.mcp → pass-through, no prompt, returns true.
1146
- expect(ok).toBe(true);
1147
- // console.log must NEVER have been called — no prompt output.
1148
- expect(logSpy).not.toHaveBeenCalled();
1149
- } finally {
1150
- logSpy.mockRestore();
1151
- }
1152
- },
1153
- );
1154
-
1155
- // ---- US-010 AC-3: non-TTY shell — hint printed, returns false, no hang ----
1156
- //
1157
- // If confirmMcp called rl.question in a non-TTY context it would hang forever
1158
- // (the test would never resolve and vitest's per-test timeout would fire as a
1159
- // failure). The no-hang proof IS that the await resolves within the timeout.
1160
- // We then assert: returns false (deny), and stdout contains the --allow-mcp
1161
- // hint.
1162
- it(
1163
- 'AC-3: non-TTY without --allow-mcp — resolves false, prints --allow-mcp hint, does NOT hang',
1164
- async () => {
1165
- fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
1166
- fs.writeFileSync(
1167
- path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
1168
- JSON.stringify({
1169
- type: 'http',
1170
- url: 'https://mcp.vyg.app',
1171
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
1172
- }),
1173
- );
1174
- const pkg = fakeManifest({
1175
- publisher: '@vyg',
1176
- contributes: { mcp: ['vyg-shopify'] },
1177
- });
1178
-
1179
- Object.defineProperty(process.stdin, 'isTTY', {
1180
- value: false,
1181
- configurable: true,
1182
- });
1183
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
1184
- try {
1185
- // This MUST resolve (not hang). The implementation guards on
1186
- // `!process.stdin.isTTY` and returns false before ever calling
1187
- // rl.question. If that guard were missing, the question callback would
1188
- // never fire and this await would stall until vitest times out the test
1189
- // — which itself counts as a failure, proving the no-hang property.
1190
- const ok = await confirmMcp(pkg, payloadDir, false);
1191
-
1192
- // Returns false in non-TTY (deny without partial state).
1193
- expect(ok).toBe(false);
1194
-
1195
- // The --allow-mcp hint must appear in stdout.
1196
- const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
1197
- expect(out).toMatch(/--allow-mcp/);
1198
- } finally {
1199
- logSpy.mockRestore();
1200
- }
1201
- },
1202
- );
1203
- });
1204
-
1205
- // ---------------------------------------------------------------------------
1206
- // GET /v1/listings/{id} detail-envelope mapping (Bug 1: contract/envelope)
1207
- // ---------------------------------------------------------------------------
1208
-
1209
- describe('pack-install: listing-detail envelope mapping (toMarketplaceListing)', () => {
1210
- it('maps from the `{ listing: {...} }` envelope (id + downloadUrl + contentHash)', () => {
1211
- // Prod shape: GET /v1/listings/{id} wraps the listing in an envelope and
1212
- // uses `id` (not `listingId`). Previously this threw "Listing detail
1213
- // missing an id." because the mapper read from the top level.
1214
- const listing = toMarketplaceListing({
1215
- listing: {
1216
- id: 'lst_abc123',
1217
- slug: 'hq-pack-test',
1218
- version: '1.2.3',
1219
- downloadUrl: 'https://s3.example/presigned',
1220
- contentHash: 'a'.repeat(64),
1221
- contentHashAlg: 'sha256',
1222
- signature: 'sig-base64',
1223
- signingKeyId: 'key-1',
1224
- publicKey: 'pub-pem',
1225
- },
1226
- });
1227
- expect(listing.listingId).toBe('lst_abc123');
1228
- expect(listing.downloadUrl).toBe('https://s3.example/presigned');
1229
- expect(listing.contentHash).toBe('a'.repeat(64));
1230
- expect(listing.contentHashAlg).toBe('sha256');
1231
- expect(listing.slug).toBe('hq-pack-test');
1232
- expect(listing.version).toBe('1.2.3');
1233
- expect(listing.signature).toBe('sig-base64');
1234
- expect(listing.signingKeyId).toBe('key-1');
1235
- expect(listing.publicKey).toBe('pub-pem');
1236
- });
1237
-
1238
- it('still maps the legacy FLAT shape at the top level (backwards-compat)', () => {
1239
- const listing = toMarketplaceListing({
1240
- listingId: 'lst_legacy',
1241
- slug: 'hq-pack-old',
1242
- version: '0.9.0',
1243
- downloadUrl: 'https://s3.example/legacy',
1244
- contentHash: 'b'.repeat(64),
1245
- });
1246
- expect(listing.listingId).toBe('lst_legacy');
1247
- expect(listing.downloadUrl).toBe('https://s3.example/legacy');
1248
- expect(listing.contentHash).toBe('b'.repeat(64));
1249
- });
1250
-
1251
- it('throws the missing-id error only when neither envelope nor flat carry an id', () => {
1252
- expect(() =>
1253
- toMarketplaceListing({ listing: { downloadUrl: 'x', contentHash: 'c'.repeat(64) } }),
1254
- ).toThrow(/missing an id/);
1255
- });
1256
-
1257
- it('refuses (no content hash) when the unwrapped detail lacks contentHash', () => {
1258
- expect(() =>
1259
- toMarketplaceListing({
1260
- listing: { id: 'lst_nohash', downloadUrl: 'https://s3.example/x' },
1261
- }),
1262
- ).toThrow(/no content hash/);
1263
- });
1264
- });
1265
-
1266
- // ---------------------------------------------------------------------------
1267
- // fetchMarketplace integrity: envelope-mapped hash verifies / refuses
1268
- // ---------------------------------------------------------------------------
1269
-
1270
- describe('pack-install: fetchMarketplace honors the envelope-mapped contentHash', () => {
1271
- function depsFor(
1272
- envelopeContentHash: string,
1273
- tarballBytes: Uint8Array,
1274
- ): MarketplaceDeps {
1275
- // resolveListing returns the envelope-mapped detail; download returns the
1276
- // bytes. fetchMarketplace verifies the bytes against the pinned hash.
1277
- const listing = toMarketplaceListing({
1278
- listing: {
1279
- id: 'lst_int',
1280
- slug: 'hq-pack-int',
1281
- version: '1.0.0',
1282
- downloadUrl: 'https://s3.example/int',
1283
- contentHash: envelopeContentHash,
1284
- contentHashAlg: 'sha256',
1285
- },
1286
- });
1287
- return {
1288
- resolveListing: async () => listing,
1289
- refreshListing: async () => listing,
1290
- download: async () => tarballBytes,
1291
- };
1292
- }
1293
-
1294
- it('a tampered/mismatched hash still refuses (ArtifactVerificationError)', async () => {
1295
- const bytes = new Uint8Array([1, 2, 3, 4]);
1296
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-int-'));
1297
- try {
1298
- await expect(
1299
- fetchMarketplace(
1300
- 'marketplace:hq-pack-int@1.0.0',
1301
- tmpDir,
1302
- depsFor('f'.repeat(64), bytes), // wrong hash
1303
- ),
1304
- ).rejects.toBeInstanceOf(ArtifactVerificationError);
1305
- } finally {
1306
- fs.rmSync(tmpDir, { recursive: true, force: true });
1307
- }
1308
- });
1309
-
1310
- it('the correct envelope-mapped hash passes verification (reaches extraction)', async () => {
1311
- const bytes = new Uint8Array([1, 2, 3, 4]);
1312
- const realHash = computeArtifactHash(bytes);
1313
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-cli-int-'));
1314
- try {
1315
- // The 4 junk bytes are not a real gzip tarball, so extraction fails AFTER
1316
- // verification — proving the hash check passed (it did NOT throw the
1317
- // verification error first). A verification failure would reject with
1318
- // ArtifactVerificationError instead.
1319
- await expect(
1320
- fetchMarketplace('marketplace:hq-pack-int@1.0.0', tmpDir, depsFor(realHash, bytes)),
1321
- ).rejects.not.toBeInstanceOf(ArtifactVerificationError);
1322
- } finally {
1323
- fs.rmSync(tmpDir, { recursive: true, force: true });
1324
- }
1325
- });
1326
- });
1327
-
1328
- // ---------------------------------------------------------------------------
1329
- // requires.packs — pack-to-pack dependencies (M0)
1330
- // ---------------------------------------------------------------------------
1331
-
1332
- describe('requires.packs validation (M0)', () => {
1333
- // Mirrors the author/capabilities helper: a minimal otherwise-valid payload
1334
- // whose only variable is the `requires.packs` block under test.
1335
- function writePackWithRequires(extraYaml: string): string {
1336
- const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
1337
- fs.writeFileSync(
1338
- path.join(dir, 'package.yaml'),
1339
- [
1340
- 'name: hq-pack-accounting',
1341
- 'version: 1.0.0',
1342
- "publisher: '@indigoai-us'",
1343
- 'access: public',
1344
- 'requires:',
1345
- " hqCore: '>=15.1.0'",
1346
- extraYaml,
1347
- 'contributes:',
1348
- ' knowledge:',
1349
- ' - demo',
1350
- '',
1351
- ].join('\n'),
1352
- );
1353
- return dir;
1354
- }
1355
-
1356
- it('a manifest WITHOUT requires.packs validates (backwards-compatible)', () => {
1357
- const dir = writePackWithRequires('');
1358
- const m = validateManifest(dir, '15.1.0');
1359
- expect(m.requires.packs).toBeUndefined();
1360
- fs.rmSync(dir, { recursive: true, force: true });
1361
- });
1362
-
1363
- it('a well-formed requires.packs (with and without version) is accepted', () => {
1364
- const dir = writePackWithRequires(
1365
- [
1366
- ' packs:',
1367
- ' - name: hq-pack-crm',
1368
- " version: '>=1.0.0'",
1369
- ' - name: hq-pack-other',
1370
- ].join('\n'),
1371
- );
1372
- const m = validateManifest(dir, '15.1.0');
1373
- expect(m.requires.packs).toEqual([
1374
- { name: 'hq-pack-crm', version: '>=1.0.0' },
1375
- { name: 'hq-pack-other' },
1376
- ]);
1377
- fs.rmSync(dir, { recursive: true, force: true });
1378
- });
1379
-
1380
- it('rejects requires.packs that is not a list', () => {
1381
- const dir = writePackWithRequires(' packs: hq-pack-crm');
1382
- expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs must be a list/);
1383
- fs.rmSync(dir, { recursive: true, force: true });
1384
- });
1385
-
1386
- it('rejects an entry with an invalid pack name', () => {
1387
- const dir = writePackWithRequires([' packs:', ' - name: not-an-hq-pack'].join('\n'));
1388
- expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs\[\]\.name/);
1389
- fs.rmSync(dir, { recursive: true, force: true });
1390
- });
1391
-
1392
- it('rejects an entry with an invalid version range', () => {
1393
- const dir = writePackWithRequires(
1394
- [' packs:', ' - name: hq-pack-crm', ' version: not-a-range'].join('\n'),
1395
- );
1396
- expect(() => validateManifest(dir, '15.1.0')).toThrow(/invalid version range/);
1397
- fs.rmSync(dir, { recursive: true, force: true });
1398
- });
1399
- });
1400
-
1401
- describe('assertPackDependencies (M0)', () => {
1402
- let hqRoot: string;
1403
- beforeEach(() => {
1404
- hqRoot = mkFakeHq();
1405
- });
1406
- afterEach(() => {
1407
- fs.rmSync(hqRoot, { recursive: true, force: true });
1408
- });
1409
-
1410
- // Seed an installed pack by writing core/packages/<name>/package.yaml — the
1411
- // filesystem-presence source of truth listInstalledPacks reads (NOT modules.yaml).
1412
- function seedInstalledPack(name: string, version?: string): void {
1413
- const dir = path.join(hqRoot, 'core', 'packages', name);
1414
- fs.mkdirSync(dir, { recursive: true });
1415
- const lines = [`name: ${name}`];
1416
- if (version) lines.push(`version: ${version}`);
1417
- fs.writeFileSync(path.join(dir, 'package.yaml'), lines.join('\n') + '\n');
1418
- }
1419
-
1420
- it('is a no-op when the pack declares no requires.packs', () => {
1421
- const pkg = fakeManifest({ requires: { hqCore: '>=15.1.0' } });
1422
- expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1423
- });
1424
-
1425
- it('passes when every required pack is installed (no version constraint)', () => {
1426
- seedInstalledPack('hq-pack-crm', '1.2.0');
1427
- const pkg = fakeManifest({
1428
- name: 'hq-pack-accounting',
1429
- requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
1430
- });
1431
- expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1432
- });
1433
-
1434
- it('throws a clear, actionable error when a required pack is missing', () => {
1435
- const pkg = fakeManifest({
1436
- name: 'hq-pack-accounting',
1437
- requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
1438
- });
1439
- expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(
1440
- /requires hq-pack-crm, which is not installed/,
1441
- );
1442
- });
1443
-
1444
- it('passes when the installed version satisfies the required range', () => {
1445
- seedInstalledPack('hq-pack-crm', '1.2.0');
1446
- const pkg = fakeManifest({
1447
- name: 'hq-pack-accounting',
1448
- requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
1449
- });
1450
- expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
1451
- });
1452
-
1453
- it('throws when the installed version does NOT satisfy the required range', () => {
1454
- seedInstalledPack('hq-pack-crm', '0.9.0');
1455
- const pkg = fakeManifest({
1456
- name: 'hq-pack-accounting',
1457
- requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
1458
- });
1459
- expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/requires hq-pack-crm >=1\.0\.0/);
1460
- });
1461
-
1462
- it('rejects a pack that lists itself as a dependency', () => {
1463
- const pkg = fakeManifest({
1464
- name: 'hq-pack-accounting',
1465
- requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-accounting' }] },
1466
- });
1467
- expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/cannot list itself/);
1468
- });
1469
- });