@indigoai-us/hq-cli 5.77.14 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -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
- });