@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.
- package/CHANGELOG.md +31 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/meetings.js +16 -9
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +42 -2
- package/dist/commands/pkg-install.js +39 -2
- package/dist/commands/run.js +6 -2
- package/dist/commands/secrets.js +120 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2256
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs-update-api-key.test.ts +0 -105
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -103
- package/src/commands/pkg-install.ts +0 -221
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -190
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2050
- package/src/commands/secrets.ts +0 -2249
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- 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
|
-
});
|