@indigoai-us/hq-cli 5.50.0 → 5.50.2
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/dist/commands/mcp-registration.d.ts +905 -0
- package/dist/commands/mcp-registration.js +2001 -0
- package/dist/commands/mcp-status.d.ts +130 -0
- package/dist/commands/mcp-status.js +406 -0
- package/dist/commands/onboard-warning.d.ts +7 -0
- package/dist/commands/onboard-warning.js +14 -0
- package/dist/commands/onboard.js +5 -5
- package/dist/commands/pack-install.d.ts +74 -0
- package/dist/commands/pack-install.js +493 -14
- package/dist/commands/packs.js +42 -4
- package/dist/commands/pkg-install.js +5 -2
- package/dist/index.js +7 -2
- package/dist/types.d.ts +26 -1
- package/dist/utils/contribution-table.d.ts +103 -0
- package/dist/utils/contribution-table.js +65 -0
- package/dist/utils/pack-contributions.d.ts +93 -10
- package/dist/utils/pack-contributions.js +140 -48
- package/dist/utils/secrets-cache.d.ts +9 -0
- package/dist/utils/secrets-cache.js +24 -2
- package/dist/utils/version-gate.d.ts +40 -1
- package/dist/utils/version-gate.js +91 -20
- package/package.json +3 -2
- package/scripts/generate-scan-packages-table.mjs +113 -0
- package/src/commands/mcp-registration.test.ts +2787 -0
- package/src/commands/mcp-registration.ts +2612 -0
- package/src/commands/mcp-status.test.ts +483 -0
- package/src/commands/mcp-status.ts +575 -0
- package/src/commands/mcp-status.us011.test.ts +243 -0
- package/src/commands/onboard-warning.test.ts +26 -0
- package/src/commands/onboard-warning.ts +12 -0
- package/src/commands/onboard.ts +4 -7
- package/src/commands/pack-install.test.ts +733 -0
- package/src/commands/pack-install.ts +582 -13
- package/src/commands/packs.ts +45 -1
- package/src/commands/pkg-install.ts +4 -1
- package/src/index.ts +6 -0
- package/src/types.ts +28 -9
- package/src/utils/contribution-table.ts +83 -0
- package/src/utils/pack-contributions.test.ts +310 -25
- package/src/utils/pack-contributions.ts +194 -47
- package/src/utils/secrets-cache.ts +22 -0
- package/src/utils/version-gate.test.ts +122 -0
- package/src/utils/version-gate.ts +109 -13
- package/test/e2e/smoke-install-mcp.sh +113 -0
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
|
@@ -26,14 +26,24 @@ import {
|
|
|
26
26
|
runScanPackages,
|
|
27
27
|
stampInstallSource,
|
|
28
28
|
validateManifest,
|
|
29
|
+
assertPackDependencies,
|
|
29
30
|
getStartedLine,
|
|
30
31
|
toMarketplaceListing,
|
|
31
32
|
fetchMarketplace,
|
|
32
33
|
computeArtifactHash,
|
|
33
34
|
ArtifactVerificationError,
|
|
35
|
+
validateMcpManifest,
|
|
36
|
+
renderMcpServerLine,
|
|
37
|
+
confirmMcp,
|
|
34
38
|
} from './pack-install.js';
|
|
35
39
|
import type { MarketplaceDeps } from './pack-install.js';
|
|
36
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';
|
|
37
47
|
|
|
38
48
|
// ---------------------------------------------------------------------------
|
|
39
49
|
// Fixtures
|
|
@@ -357,6 +367,71 @@ describe('pack-install: install path layout', () => {
|
|
|
357
367
|
});
|
|
358
368
|
});
|
|
359
369
|
|
|
370
|
+
// ---- 7b. MCP-bearing pack version FLOOR (US-012, M1 platform-GA) ----------
|
|
371
|
+
// The `contributes.mcp` capability is carried by hqCore >=15.1.0. An MCP-bearing
|
|
372
|
+
// pack therefore declares `requires.hqCore '>=15.1.0'` (or '>15.0.19'). On an
|
|
373
|
+
// OLDER host (15.0.19) the installer must REFUSE the pack with a clear version-
|
|
374
|
+
// floor message naming the host version + the requirement — the MCP wiring is
|
|
375
|
+
// never silently dropped. On the floor host (15.1.0) the same pack PASSES.
|
|
376
|
+
// Enforcement lives in validateManifest at the `does not satisfy` throw site.
|
|
377
|
+
describe('US-012: MCP-bearing pack version floor (>=15.1.0)', () => {
|
|
378
|
+
// A real-shaped MCP pack: declares `contributes.mcp` (one server, manifest
|
|
379
|
+
// present) and a `requires.hqCore` range. The only variable is the range, so
|
|
380
|
+
// the floor check is what's under test.
|
|
381
|
+
function writeMcpPackRequiring(range: string): string {
|
|
382
|
+
const dir = mkFakePackPayload({
|
|
383
|
+
'mcp/vyg.json': JSON.stringify({
|
|
384
|
+
type: 'http',
|
|
385
|
+
url: 'https://mcp.vyg.app',
|
|
386
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
387
|
+
}),
|
|
388
|
+
});
|
|
389
|
+
fs.writeFileSync(
|
|
390
|
+
path.join(dir, 'package.yaml'),
|
|
391
|
+
[
|
|
392
|
+
'name: hq-pack-vyg-shopify',
|
|
393
|
+
'version: 1.0.0',
|
|
394
|
+
"publisher: '@indigoai-us'",
|
|
395
|
+
'access: public',
|
|
396
|
+
'requires:',
|
|
397
|
+
` hqCore: '${range}'`,
|
|
398
|
+
'contributes:',
|
|
399
|
+
' mcp:',
|
|
400
|
+
' - vyg',
|
|
401
|
+
'',
|
|
402
|
+
].join('\n'),
|
|
403
|
+
);
|
|
404
|
+
return dir;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
it('REFUSES an MCP pack (>=15.1.0) on an older host (15.0.19) with a clear floor message', () => {
|
|
408
|
+
const dir = writeMcpPackRequiring('>=15.1.0');
|
|
409
|
+
// Refused, and the message NAMES both the host version and the requirement.
|
|
410
|
+
expect(() => validateManifest(dir, '15.0.19')).toThrow(
|
|
411
|
+
/does not satisfy pack requirement/,
|
|
412
|
+
);
|
|
413
|
+
expect(() => validateManifest(dir, '15.0.19')).toThrow(/15\.0\.19/);
|
|
414
|
+
expect(() => validateManifest(dir, '15.0.19')).toThrow(/>=15\.1\.0/);
|
|
415
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('REFUSES the same pack expressed as `>15.0.19` on a 15.0.19 host', () => {
|
|
419
|
+
const dir = writeMcpPackRequiring('>15.0.19');
|
|
420
|
+
expect(() => validateManifest(dir, '15.0.19')).toThrow(
|
|
421
|
+
/does not satisfy pack requirement/,
|
|
422
|
+
);
|
|
423
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('PASSES the same MCP pack on the floor host (15.1.0)', () => {
|
|
427
|
+
const dir = writeMcpPackRequiring('>=15.1.0');
|
|
428
|
+
const m = validateManifest(dir, '15.1.0');
|
|
429
|
+
expect(m.name).toBe('hq-pack-vyg-shopify');
|
|
430
|
+
expect(m.contributes?.mcp).toEqual(['vyg']);
|
|
431
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
360
435
|
// ---- 8. initialization.entrypoint validation (US-004) -------------------
|
|
361
436
|
// A pack may declare an `initialization` block whose `entrypoint` MUST resolve
|
|
362
437
|
// to a declared contributes skill/command. The block is OPTIONAL and
|
|
@@ -608,6 +683,521 @@ describe('pack-install: install path layout', () => {
|
|
|
608
683
|
});
|
|
609
684
|
});
|
|
610
685
|
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// contributes.mcp — shape-validate manifest + route wire:merge (US-005)
|
|
688
|
+
//
|
|
689
|
+
// Acceptance: "Given a pack with contributes.mcp, when validateManifest runs,
|
|
690
|
+
// then the manifest is shape-validated and routed as wire:merge (not
|
|
691
|
+
// symlinked)." Mirrors the bash validate_mcp_manifest arm in scan-packages.sh.
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
describe('pack-install: contributes.mcp shape-validation + routing (US-005)', () => {
|
|
695
|
+
// Build a pack whose package.yaml declares one mcp server (`vyg`) and whose
|
|
696
|
+
// mcp/vyg.json holds `manifest`. The only variable across tests is the
|
|
697
|
+
// manifest JSON body, so the shape/secret rules are what's under test.
|
|
698
|
+
function writeMcpPack(manifest: unknown): string {
|
|
699
|
+
const dir = mkFakePackPayload({
|
|
700
|
+
'mcp/vyg.json':
|
|
701
|
+
typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
|
|
702
|
+
});
|
|
703
|
+
fs.writeFileSync(
|
|
704
|
+
path.join(dir, 'package.yaml'),
|
|
705
|
+
[
|
|
706
|
+
'name: hq-pack-test',
|
|
707
|
+
'version: 1.0.0',
|
|
708
|
+
"publisher: '@indigoai-us'",
|
|
709
|
+
'access: public',
|
|
710
|
+
'requires:',
|
|
711
|
+
" hqCore: '>=12.0.0'",
|
|
712
|
+
'contributes:',
|
|
713
|
+
' mcp:',
|
|
714
|
+
' - vyg',
|
|
715
|
+
'',
|
|
716
|
+
].join('\n'),
|
|
717
|
+
);
|
|
718
|
+
return dir;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
it('a valid http mcp manifest (secret-ref Bearer) passes validateManifest', () => {
|
|
722
|
+
const dir = writeMcpPack({
|
|
723
|
+
type: 'http',
|
|
724
|
+
url: 'https://mcp.vyg.app',
|
|
725
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
726
|
+
});
|
|
727
|
+
const m = validateManifest(dir, '12.0.0');
|
|
728
|
+
expect(m.name).toBe('hq-pack-test');
|
|
729
|
+
expect(m.contributes.mcp).toEqual(['vyg']);
|
|
730
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('rejects an inline literal Bearer secret (no ${secret:} reference)', () => {
|
|
734
|
+
const dir = writeMcpPack({
|
|
735
|
+
type: 'http',
|
|
736
|
+
url: 'https://mcp.vyg.app',
|
|
737
|
+
headers: { Authorization: 'Bearer abc123' },
|
|
738
|
+
});
|
|
739
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/Bearer/);
|
|
740
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/secret/);
|
|
741
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('rejects a malformed (non-JSON) mcp manifest, naming the file', () => {
|
|
745
|
+
const dir = writeMcpPack('{ this is not json ]');
|
|
746
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/mcp\/vyg\.json/);
|
|
747
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/not valid JSON/);
|
|
748
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('rejects an http manifest missing url', () => {
|
|
752
|
+
const dir = writeMcpPack({ type: 'http' });
|
|
753
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/requires a url/);
|
|
754
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('rejects an http manifest carrying a forbidden command', () => {
|
|
758
|
+
const dir = writeMcpPack({
|
|
759
|
+
type: 'http',
|
|
760
|
+
url: 'https://mcp.vyg.app',
|
|
761
|
+
command: 'run-me',
|
|
762
|
+
});
|
|
763
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/forbids: command/);
|
|
764
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('rejects an unknown type and unknown top-level keys', () => {
|
|
768
|
+
expect(() => validateManifest(writeMcpPack({ type: 'grpc' }), '12.0.0')).toThrow(
|
|
769
|
+
/type must be one of http\|stdio\|sse/,
|
|
770
|
+
);
|
|
771
|
+
expect(() =>
|
|
772
|
+
validateManifest(
|
|
773
|
+
writeMcpPack({ type: 'http', url: 'https://x.app', bogus: 1 }),
|
|
774
|
+
'12.0.0',
|
|
775
|
+
),
|
|
776
|
+
).toThrow(/unknown top-level key: bogus/);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('validates stdio transport rules (command required; url/headers forbidden)', () => {
|
|
780
|
+
// stdio is validated now even though EMIT is Phase-2 deferred.
|
|
781
|
+
const ok = writeMcpPack({ type: 'stdio', command: 'my-server' });
|
|
782
|
+
expect(validateManifest(ok, '12.0.0').name).toBe('hq-pack-test');
|
|
783
|
+
fs.rmSync(ok, { recursive: true, force: true });
|
|
784
|
+
|
|
785
|
+
expect(() =>
|
|
786
|
+
validateManifest(writeMcpPack({ type: 'stdio' }), '12.0.0'),
|
|
787
|
+
).toThrow(/requires a non-empty command/);
|
|
788
|
+
expect(() =>
|
|
789
|
+
validateManifest(
|
|
790
|
+
writeMcpPack({ type: 'stdio', command: 'x', url: 'https://x.app' }),
|
|
791
|
+
'12.0.0',
|
|
792
|
+
),
|
|
793
|
+
).toThrow(/stdio transport forbids: url/);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('rejects non-string header values', () => {
|
|
797
|
+
const dir = writeMcpPack({
|
|
798
|
+
type: 'http',
|
|
799
|
+
url: 'https://mcp.vyg.app',
|
|
800
|
+
headers: { Authorization: 42 },
|
|
801
|
+
});
|
|
802
|
+
expect(() => validateManifest(dir, '12.0.0')).toThrow(/headers "Authorization" value must be a string/);
|
|
803
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('validateMcpManifest can be called directly (the US-006 registration seam reuses it)', () => {
|
|
807
|
+
const dir = writeMcpPack({ type: 'http', url: 'https://mcp.vyg.app' });
|
|
808
|
+
expect(() => validateMcpManifest(dir, 'vyg')).not.toThrow();
|
|
809
|
+
// The pack-relative payload path it reads is mcp/{item}.json.
|
|
810
|
+
expect(fs.existsSync(path.join(dir, 'mcp', 'vyg.json'))).toBe(true);
|
|
811
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// -- Routing: wire:merge keys go to registerMcpServers, NEVER the symlink path
|
|
815
|
+
it('the mcp key routes to wire:merge in the single-source table', () => {
|
|
816
|
+
expect(routeContribution('mcp')).toBe('merge');
|
|
817
|
+
expect(mergeKeys({ mcp: ['vyg'] })).toEqual(['mcp']);
|
|
818
|
+
// A symlink key is NOT a merge key.
|
|
819
|
+
expect(routeContribution('commands')).toBe('symlink');
|
|
820
|
+
expect(mergeKeys({ commands: ['triage'] })).toEqual([]);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it('an mcp-only contributes block produces NO symlink (provably excluded)', () => {
|
|
824
|
+
const dir = writeMcpPack({ type: 'http', url: 'https://mcp.vyg.app' });
|
|
825
|
+
// contributionLinks is the symlink enumerator; merge keys must be skipped.
|
|
826
|
+
expect(contributionLinks('/fake/hq', dir, { mcp: ['vyg'] })).toEqual([]);
|
|
827
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('registerMcpServers requires a manifest loader (US-007 wired the Claude emit — no silent no-op)', () => {
|
|
831
|
+
// US-007 replaced the US-006 NotImplemented seam with a real Claude/JSON
|
|
832
|
+
// emitter. Called without a manifest loader (no payload context), it fails
|
|
833
|
+
// loudly with a named McpManifestError rather than throwing NotImplemented or
|
|
834
|
+
// silently doing nothing — and it does NOT touch any real config (no IO before
|
|
835
|
+
// the loader check).
|
|
836
|
+
expect(() => registerMcpServers('hq-pack-test', ['vyg'])).toThrow(McpManifestError);
|
|
837
|
+
try {
|
|
838
|
+
registerMcpServers('hq-pack-test', ['vyg']);
|
|
839
|
+
throw new Error('should have thrown');
|
|
840
|
+
} catch (e) {
|
|
841
|
+
expect(e).toBeInstanceOf(McpManifestError);
|
|
842
|
+
expect((e as McpManifestError).code).toBe('McpManifestError');
|
|
843
|
+
expect((e as Error).message).toMatch(/loadManifest/);
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// Install-time MCP trust prompt (US-010) — confirmMcp / renderMcpServerLine.
|
|
850
|
+
// Sanity coverage for the production helpers (the full acceptance matrix lands
|
|
851
|
+
// in a later phase). All fixtures use isolated tmpdirs; confirmMcp itself writes
|
|
852
|
+
// NO config — it only renders + (deny/allow) returns a boolean.
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
|
|
855
|
+
describe('pack-install: confirmMcp / renderMcpServerLine (US-010)', () => {
|
|
856
|
+
it('renderMcpServerLine discloses the FULL url and redacts header values (http)', () => {
|
|
857
|
+
const dir = mkFakePackPayload({
|
|
858
|
+
'mcp/vyg.json': JSON.stringify({
|
|
859
|
+
type: 'http',
|
|
860
|
+
url: 'https://mcp.vyg.app/sse',
|
|
861
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
862
|
+
}),
|
|
863
|
+
});
|
|
864
|
+
const line = renderMcpServerLine(dir, 'vyg');
|
|
865
|
+
// FULL url verbatim (typosquat disclosure).
|
|
866
|
+
expect(line).toContain('https://mcp.vyg.app/sse');
|
|
867
|
+
expect(line).toContain('[http]');
|
|
868
|
+
// Header KEY shown, but NEVER the raw Bearer/secret-ref value.
|
|
869
|
+
expect(line).toContain('Authorization');
|
|
870
|
+
expect(line).not.toContain('Bearer');
|
|
871
|
+
expect(line).not.toContain('secret:');
|
|
872
|
+
expect(line).not.toContain('VYG_API_KEY');
|
|
873
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('renderMcpServerLine shows command+args and stdio shell-permission warning', () => {
|
|
877
|
+
const dir = mkFakePackPayload({
|
|
878
|
+
'mcp/local.json': JSON.stringify({
|
|
879
|
+
type: 'stdio',
|
|
880
|
+
command: '/usr/local/bin/foo',
|
|
881
|
+
args: ['--serve'],
|
|
882
|
+
}),
|
|
883
|
+
});
|
|
884
|
+
const line = renderMcpServerLine(dir, 'local');
|
|
885
|
+
expect(line).toContain('[stdio]');
|
|
886
|
+
expect(line).toContain('/usr/local/bin/foo');
|
|
887
|
+
expect(line).toContain('--serve');
|
|
888
|
+
expect(line).toMatch(/runs a local binary with your shell permissions/);
|
|
889
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('renderMcpServerLine fails SAFE on an unreadable manifest (no crash, flagged)', () => {
|
|
893
|
+
const dir = mkFakePackPayload({ 'mcp/broken.json': '{ not json ]' });
|
|
894
|
+
const line = renderMcpServerLine(dir, 'broken');
|
|
895
|
+
expect(line).toContain('broken');
|
|
896
|
+
expect(line).toContain('[manifest unreadable]');
|
|
897
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('confirmMcp returns true with NO prompt when contributes.mcp is empty', async () => {
|
|
901
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
902
|
+
const ok = await confirmMcp(fakeManifest({ contributes: {} }), '/nope', false);
|
|
903
|
+
expect(ok).toBe(true);
|
|
904
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
905
|
+
logSpy.mockRestore();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it('confirmMcp does NOT gate on the advisory capabilities field (only contributes.mcp)', async () => {
|
|
909
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
910
|
+
// capabilities lists "mcp" but contributes.mcp is empty -> no prompt, true.
|
|
911
|
+
const ok = await confirmMcp(
|
|
912
|
+
fakeManifest({ contributes: {}, capabilities: ['mcp', 'network'] }),
|
|
913
|
+
'/nope',
|
|
914
|
+
false,
|
|
915
|
+
);
|
|
916
|
+
expect(ok).toBe(true);
|
|
917
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
918
|
+
logSpy.mockRestore();
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it('confirmMcp with --allow-mcp bypasses the prompt and returns true (CI path)', async () => {
|
|
922
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
923
|
+
const dir = mkFakePackPayload({
|
|
924
|
+
'mcp/vyg.json': JSON.stringify({
|
|
925
|
+
type: 'http',
|
|
926
|
+
url: 'https://mcp.vyg.app',
|
|
927
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
928
|
+
}),
|
|
929
|
+
});
|
|
930
|
+
const ok = await confirmMcp(
|
|
931
|
+
fakeManifest({ publisher: '@vyg', contributes: { mcp: ['vyg'] } }),
|
|
932
|
+
dir,
|
|
933
|
+
true,
|
|
934
|
+
);
|
|
935
|
+
expect(ok).toBe(true);
|
|
936
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
937
|
+
expect(out).toMatch(/--allow-mcp set; registering 1 MCP server\(s\) without prompting/);
|
|
938
|
+
// Bypass notice must never leak a secret either.
|
|
939
|
+
expect(out).not.toContain('VYG_API_KEY');
|
|
940
|
+
logSpy.mockRestore();
|
|
941
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('confirmMcp denies in a non-TTY shell and prints the --allow-mcp hint (no hang)', async () => {
|
|
945
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
946
|
+
const prevTty = process.stdin.isTTY;
|
|
947
|
+
// Force non-interactive — confirmMcp must NOT call rl.question (would hang).
|
|
948
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
949
|
+
const dir = mkFakePackPayload({
|
|
950
|
+
'mcp/vyg.json': JSON.stringify({
|
|
951
|
+
type: 'http',
|
|
952
|
+
url: 'https://mcp.vyg.app',
|
|
953
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
954
|
+
}),
|
|
955
|
+
});
|
|
956
|
+
try {
|
|
957
|
+
const ok = await confirmMcp(
|
|
958
|
+
fakeManifest({ publisher: '@vyg', contributes: { mcp: ['vyg'] } }),
|
|
959
|
+
dir,
|
|
960
|
+
false,
|
|
961
|
+
);
|
|
962
|
+
expect(ok).toBe(false);
|
|
963
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
964
|
+
expect(out).toMatch(/Re-run with --allow-mcp/);
|
|
965
|
+
// Rendered prompt must never echo a secret/Bearer substring.
|
|
966
|
+
expect(out).not.toContain('Bearer');
|
|
967
|
+
expect(out).not.toContain('VYG_API_KEY');
|
|
968
|
+
expect(out).not.toContain('secret:');
|
|
969
|
+
// FULL url still disclosed for the operator.
|
|
970
|
+
expect(out).toContain('https://mcp.vyg.app');
|
|
971
|
+
} finally {
|
|
972
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: prevTty, configurable: true });
|
|
973
|
+
logSpy.mockRestore();
|
|
974
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// ---------------------------------------------------------------------------
|
|
980
|
+
// US-010: install-time MCP trust prompt — acceptance tests
|
|
981
|
+
//
|
|
982
|
+
// Three story-level acceptance criteria:
|
|
983
|
+
// 1. URL disclosed verbatim; LITERAL sentinel secret never leaks (redaction).
|
|
984
|
+
// 2. Gate is on contributes.mcp, NOT the advisory capabilities field.
|
|
985
|
+
// 3. Non-TTY without --allow-mcp: prints --allow-mcp hint, resolves false,
|
|
986
|
+
// does NOT hang (rl.question is never called).
|
|
987
|
+
// ---------------------------------------------------------------------------
|
|
988
|
+
|
|
989
|
+
describe('US-010: install-time MCP trust prompt', () => {
|
|
990
|
+
let payloadDir: string;
|
|
991
|
+
let origIsTTY: boolean | undefined;
|
|
992
|
+
|
|
993
|
+
beforeEach(() => {
|
|
994
|
+
// Isolated tmpdir — never touches ~/.claude.json, ~/.mcp.json, or any real
|
|
995
|
+
// config. confirmMcp/renderMcpServerLine write NO config so this is the
|
|
996
|
+
// natural safety boundary; we just make sure the dir lives in os.tmpdir().
|
|
997
|
+
payloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'us010-'));
|
|
998
|
+
origIsTTY = process.stdin.isTTY;
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
afterEach(() => {
|
|
1002
|
+
// Restore isTTY regardless of how the test exited.
|
|
1003
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
1004
|
+
value: origIsTTY,
|
|
1005
|
+
configurable: true,
|
|
1006
|
+
});
|
|
1007
|
+
fs.rmSync(payloadDir, { recursive: true, force: true });
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// ---- US-010 AC-1: URL disclosed, LITERAL secret never leaks ----------------
|
|
1011
|
+
//
|
|
1012
|
+
// The security requirement is that a LITERAL secret value in a header (e.g.
|
|
1013
|
+
// a manifest that slipped past the shape-validator, or any future transport
|
|
1014
|
+
// that carries the secret inline) is STILL redacted by renderMcpServerLine.
|
|
1015
|
+
// We prove this by putting an unambiguous sentinel (`SENTINEL_SECRET_zzz123`)
|
|
1016
|
+
// directly in the header value and asserting it is absent from the output,
|
|
1017
|
+
// while the FULL url IS present.
|
|
1018
|
+
//
|
|
1019
|
+
// Two sub-assertions:
|
|
1020
|
+
// (a) renderMcpServerLine — pure render assertion without a prompt.
|
|
1021
|
+
// (b) confirmMcp (stdout capture) — full-prompt assertion; confirms the
|
|
1022
|
+
// "declares N MCP server(s)" header + url appear, sentinel does not.
|
|
1023
|
+
it(
|
|
1024
|
+
'AC-1: http manifest — FULL url appears, LITERAL sentinel secret is redacted',
|
|
1025
|
+
async () => {
|
|
1026
|
+
// Write the http manifest with a LITERAL sentinel (not a ${secret:} ref)
|
|
1027
|
+
// so the redaction-self-test is airtight: if redactSecrets had a bug the
|
|
1028
|
+
// sentinel would appear verbatim in the output.
|
|
1029
|
+
fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
|
|
1030
|
+
fs.writeFileSync(
|
|
1031
|
+
path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
|
|
1032
|
+
JSON.stringify({
|
|
1033
|
+
type: 'http',
|
|
1034
|
+
url: 'https://mcp.vyg.app',
|
|
1035
|
+
headers: { Authorization: 'Bearer SENTINEL_SECRET_zzz123' },
|
|
1036
|
+
}),
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
// (a) Pure render assertion — renderMcpServerLine directly.
|
|
1040
|
+
const line = renderMcpServerLine(payloadDir, 'vyg-shopify');
|
|
1041
|
+
|
|
1042
|
+
// Full url MUST appear (typosquat disclosure is the whole point).
|
|
1043
|
+
expect(line).toContain('https://mcp.vyg.app');
|
|
1044
|
+
expect(line).toContain('[http]');
|
|
1045
|
+
// The header KEY must appear so the operator knows what was registered.
|
|
1046
|
+
expect(line).toContain('Authorization');
|
|
1047
|
+
// LITERAL sentinel value MUST NOT appear — this is the redaction self-test.
|
|
1048
|
+
expect(line).not.toContain('SENTINEL_SECRET_zzz123');
|
|
1049
|
+
// The Bearer prefix itself should not appear either.
|
|
1050
|
+
expect(line).not.toContain('Bearer SENTINEL');
|
|
1051
|
+
// The known redaction marker should replace it.
|
|
1052
|
+
expect(line).toContain('«redacted»');
|
|
1053
|
+
|
|
1054
|
+
// (b) Full-prompt assertion — confirmMcp with isTTY=false (no-hang path).
|
|
1055
|
+
// We use non-TTY so we get stdout without needing a readline mock.
|
|
1056
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
1057
|
+
value: false,
|
|
1058
|
+
configurable: true,
|
|
1059
|
+
});
|
|
1060
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1061
|
+
try {
|
|
1062
|
+
const pkg = fakeManifest({
|
|
1063
|
+
publisher: '@vyg',
|
|
1064
|
+
contributes: { mcp: ['vyg-shopify'] },
|
|
1065
|
+
});
|
|
1066
|
+
const ok = await confirmMcp(pkg, payloadDir, false);
|
|
1067
|
+
// Non-TTY deny.
|
|
1068
|
+
expect(ok).toBe(false);
|
|
1069
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
1070
|
+
// URL is disclosed in the rendered server line.
|
|
1071
|
+
expect(out).toContain('https://mcp.vyg.app');
|
|
1072
|
+
// "declares N MCP server(s)" header must appear.
|
|
1073
|
+
expect(out).toMatch(/declares 1 MCP server\(s\)/);
|
|
1074
|
+
// Sentinel MUST NOT appear anywhere in output.
|
|
1075
|
+
expect(out).not.toContain('SENTINEL_SECRET_zzz123');
|
|
1076
|
+
expect(out).not.toContain('Bearer SENTINEL');
|
|
1077
|
+
expect(out).not.toContain('Bearer ');
|
|
1078
|
+
} finally {
|
|
1079
|
+
logSpy.mockRestore();
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
);
|
|
1083
|
+
|
|
1084
|
+
// ---- US-010 AC-2: gate is on contributes.mcp, NOT capabilities ------------
|
|
1085
|
+
//
|
|
1086
|
+
// Positive: capabilities omits 'mcp' (or is empty) but contributes.mcp is
|
|
1087
|
+
// non-empty → prompt FIRES (server line / "declares N" header in output).
|
|
1088
|
+
// Negative: capabilities includes 'mcp' but contributes.mcp is EMPTY → prompt
|
|
1089
|
+
// does NOT fire (confirmMcp returns true, console.log never called).
|
|
1090
|
+
it(
|
|
1091
|
+
'AC-2a (positive): prompt fires when contributes.mcp is set, regardless of capabilities',
|
|
1092
|
+
async () => {
|
|
1093
|
+
fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
|
|
1094
|
+
fs.writeFileSync(
|
|
1095
|
+
path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
|
|
1096
|
+
JSON.stringify({
|
|
1097
|
+
type: 'http',
|
|
1098
|
+
url: 'https://mcp.vyg.app',
|
|
1099
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
1100
|
+
}),
|
|
1101
|
+
);
|
|
1102
|
+
// capabilities is empty / does not include 'mcp' — gating MUST NOT rely on it.
|
|
1103
|
+
const pkg = fakeManifest({
|
|
1104
|
+
publisher: '@vyg',
|
|
1105
|
+
capabilities: [],
|
|
1106
|
+
contributes: { mcp: ['vyg-shopify'] },
|
|
1107
|
+
});
|
|
1108
|
+
// Non-TTY so we get the prompt output synchronously without readline.
|
|
1109
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
1110
|
+
value: false,
|
|
1111
|
+
configurable: true,
|
|
1112
|
+
});
|
|
1113
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1114
|
+
try {
|
|
1115
|
+
const ok = await confirmMcp(pkg, payloadDir, false);
|
|
1116
|
+
// Non-TTY deny (prompt fired but was denied).
|
|
1117
|
+
expect(ok).toBe(false);
|
|
1118
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
1119
|
+
// The prompt DID fire: the "declares N MCP server(s)" header appears.
|
|
1120
|
+
expect(out).toMatch(/declares 1 MCP server\(s\)/);
|
|
1121
|
+
// The server line appeared (url disclosed).
|
|
1122
|
+
expect(out).toContain('https://mcp.vyg.app');
|
|
1123
|
+
} finally {
|
|
1124
|
+
logSpy.mockRestore();
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
it(
|
|
1130
|
+
'AC-2b (negative): prompt does NOT fire when contributes.mcp is empty, even if capabilities includes "mcp"',
|
|
1131
|
+
async () => {
|
|
1132
|
+
// capabilities lists 'mcp' — but gating must be on contributes.mcp only.
|
|
1133
|
+
const pkg = fakeManifest({
|
|
1134
|
+
publisher: '@vyg',
|
|
1135
|
+
capabilities: ['mcp', 'network'],
|
|
1136
|
+
contributes: {},
|
|
1137
|
+
});
|
|
1138
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1139
|
+
try {
|
|
1140
|
+
const ok = await confirmMcp(pkg, payloadDir, false);
|
|
1141
|
+
// Empty contributes.mcp → pass-through, no prompt, returns true.
|
|
1142
|
+
expect(ok).toBe(true);
|
|
1143
|
+
// console.log must NEVER have been called — no prompt output.
|
|
1144
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
1145
|
+
} finally {
|
|
1146
|
+
logSpy.mockRestore();
|
|
1147
|
+
}
|
|
1148
|
+
},
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
// ---- US-010 AC-3: non-TTY shell — hint printed, returns false, no hang ----
|
|
1152
|
+
//
|
|
1153
|
+
// If confirmMcp called rl.question in a non-TTY context it would hang forever
|
|
1154
|
+
// (the test would never resolve and vitest's per-test timeout would fire as a
|
|
1155
|
+
// failure). The no-hang proof IS that the await resolves within the timeout.
|
|
1156
|
+
// We then assert: returns false (deny), and stdout contains the --allow-mcp
|
|
1157
|
+
// hint.
|
|
1158
|
+
it(
|
|
1159
|
+
'AC-3: non-TTY without --allow-mcp — resolves false, prints --allow-mcp hint, does NOT hang',
|
|
1160
|
+
async () => {
|
|
1161
|
+
fs.mkdirSync(path.join(payloadDir, 'mcp'), { recursive: true });
|
|
1162
|
+
fs.writeFileSync(
|
|
1163
|
+
path.join(payloadDir, 'mcp', 'vyg-shopify.json'),
|
|
1164
|
+
JSON.stringify({
|
|
1165
|
+
type: 'http',
|
|
1166
|
+
url: 'https://mcp.vyg.app',
|
|
1167
|
+
headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
|
|
1168
|
+
}),
|
|
1169
|
+
);
|
|
1170
|
+
const pkg = fakeManifest({
|
|
1171
|
+
publisher: '@vyg',
|
|
1172
|
+
contributes: { mcp: ['vyg-shopify'] },
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
1176
|
+
value: false,
|
|
1177
|
+
configurable: true,
|
|
1178
|
+
});
|
|
1179
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
1180
|
+
try {
|
|
1181
|
+
// This MUST resolve (not hang). The implementation guards on
|
|
1182
|
+
// `!process.stdin.isTTY` and returns false before ever calling
|
|
1183
|
+
// rl.question. If that guard were missing, the question callback would
|
|
1184
|
+
// never fire and this await would stall until vitest times out the test
|
|
1185
|
+
// — which itself counts as a failure, proving the no-hang property.
|
|
1186
|
+
const ok = await confirmMcp(pkg, payloadDir, false);
|
|
1187
|
+
|
|
1188
|
+
// Returns false in non-TTY (deny without partial state).
|
|
1189
|
+
expect(ok).toBe(false);
|
|
1190
|
+
|
|
1191
|
+
// The --allow-mcp hint must appear in stdout.
|
|
1192
|
+
const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
1193
|
+
expect(out).toMatch(/--allow-mcp/);
|
|
1194
|
+
} finally {
|
|
1195
|
+
logSpy.mockRestore();
|
|
1196
|
+
}
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
611
1201
|
// ---------------------------------------------------------------------------
|
|
612
1202
|
// GET /v1/listings/{id} detail-envelope mapping (Bug 1: contract/envelope)
|
|
613
1203
|
// ---------------------------------------------------------------------------
|
|
@@ -730,3 +1320,146 @@ describe('pack-install: fetchMarketplace honors the envelope-mapped contentHash'
|
|
|
730
1320
|
}
|
|
731
1321
|
});
|
|
732
1322
|
});
|
|
1323
|
+
|
|
1324
|
+
// ---------------------------------------------------------------------------
|
|
1325
|
+
// requires.packs — pack-to-pack dependencies (M0)
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
|
|
1328
|
+
describe('requires.packs validation (M0)', () => {
|
|
1329
|
+
// Mirrors the author/capabilities helper: a minimal otherwise-valid payload
|
|
1330
|
+
// whose only variable is the `requires.packs` block under test.
|
|
1331
|
+
function writePackWithRequires(extraYaml: string): string {
|
|
1332
|
+
const dir = mkFakePackPayload({ 'knowledge/demo/README.md': '# demo' });
|
|
1333
|
+
fs.writeFileSync(
|
|
1334
|
+
path.join(dir, 'package.yaml'),
|
|
1335
|
+
[
|
|
1336
|
+
'name: hq-pack-accounting',
|
|
1337
|
+
'version: 1.0.0',
|
|
1338
|
+
"publisher: '@indigoai-us'",
|
|
1339
|
+
'access: public',
|
|
1340
|
+
'requires:',
|
|
1341
|
+
" hqCore: '>=15.1.0'",
|
|
1342
|
+
extraYaml,
|
|
1343
|
+
'contributes:',
|
|
1344
|
+
' knowledge:',
|
|
1345
|
+
' - demo',
|
|
1346
|
+
'',
|
|
1347
|
+
].join('\n'),
|
|
1348
|
+
);
|
|
1349
|
+
return dir;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
it('a manifest WITHOUT requires.packs validates (backwards-compatible)', () => {
|
|
1353
|
+
const dir = writePackWithRequires('');
|
|
1354
|
+
const m = validateManifest(dir, '15.1.0');
|
|
1355
|
+
expect(m.requires.packs).toBeUndefined();
|
|
1356
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('a well-formed requires.packs (with and without version) is accepted', () => {
|
|
1360
|
+
const dir = writePackWithRequires(
|
|
1361
|
+
[
|
|
1362
|
+
' packs:',
|
|
1363
|
+
' - name: hq-pack-crm',
|
|
1364
|
+
" version: '>=1.0.0'",
|
|
1365
|
+
' - name: hq-pack-other',
|
|
1366
|
+
].join('\n'),
|
|
1367
|
+
);
|
|
1368
|
+
const m = validateManifest(dir, '15.1.0');
|
|
1369
|
+
expect(m.requires.packs).toEqual([
|
|
1370
|
+
{ name: 'hq-pack-crm', version: '>=1.0.0' },
|
|
1371
|
+
{ name: 'hq-pack-other' },
|
|
1372
|
+
]);
|
|
1373
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('rejects requires.packs that is not a list', () => {
|
|
1377
|
+
const dir = writePackWithRequires(' packs: hq-pack-crm');
|
|
1378
|
+
expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs must be a list/);
|
|
1379
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('rejects an entry with an invalid pack name', () => {
|
|
1383
|
+
const dir = writePackWithRequires([' packs:', ' - name: not-an-hq-pack'].join('\n'));
|
|
1384
|
+
expect(() => validateManifest(dir, '15.1.0')).toThrow(/requires\.packs\[\]\.name/);
|
|
1385
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it('rejects an entry with an invalid version range', () => {
|
|
1389
|
+
const dir = writePackWithRequires(
|
|
1390
|
+
[' packs:', ' - name: hq-pack-crm', ' version: not-a-range'].join('\n'),
|
|
1391
|
+
);
|
|
1392
|
+
expect(() => validateManifest(dir, '15.1.0')).toThrow(/invalid version range/);
|
|
1393
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
describe('assertPackDependencies (M0)', () => {
|
|
1398
|
+
let hqRoot: string;
|
|
1399
|
+
beforeEach(() => {
|
|
1400
|
+
hqRoot = mkFakeHq();
|
|
1401
|
+
});
|
|
1402
|
+
afterEach(() => {
|
|
1403
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// Seed an installed pack by writing core/packages/<name>/package.yaml — the
|
|
1407
|
+
// filesystem-presence source of truth listInstalledPacks reads (NOT modules.yaml).
|
|
1408
|
+
function seedInstalledPack(name: string, version?: string): void {
|
|
1409
|
+
const dir = path.join(hqRoot, 'core', 'packages', name);
|
|
1410
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1411
|
+
const lines = [`name: ${name}`];
|
|
1412
|
+
if (version) lines.push(`version: ${version}`);
|
|
1413
|
+
fs.writeFileSync(path.join(dir, 'package.yaml'), lines.join('\n') + '\n');
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
it('is a no-op when the pack declares no requires.packs', () => {
|
|
1417
|
+
const pkg = fakeManifest({ requires: { hqCore: '>=15.1.0' } });
|
|
1418
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('passes when every required pack is installed (no version constraint)', () => {
|
|
1422
|
+
seedInstalledPack('hq-pack-crm', '1.2.0');
|
|
1423
|
+
const pkg = fakeManifest({
|
|
1424
|
+
name: 'hq-pack-accounting',
|
|
1425
|
+
requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
|
|
1426
|
+
});
|
|
1427
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('throws a clear, actionable error when a required pack is missing', () => {
|
|
1431
|
+
const pkg = fakeManifest({
|
|
1432
|
+
name: 'hq-pack-accounting',
|
|
1433
|
+
requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm' }] },
|
|
1434
|
+
});
|
|
1435
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(
|
|
1436
|
+
/requires hq-pack-crm, which is not installed/,
|
|
1437
|
+
);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it('passes when the installed version satisfies the required range', () => {
|
|
1441
|
+
seedInstalledPack('hq-pack-crm', '1.2.0');
|
|
1442
|
+
const pkg = fakeManifest({
|
|
1443
|
+
name: 'hq-pack-accounting',
|
|
1444
|
+
requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
|
|
1445
|
+
});
|
|
1446
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).not.toThrow();
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it('throws when the installed version does NOT satisfy the required range', () => {
|
|
1450
|
+
seedInstalledPack('hq-pack-crm', '0.9.0');
|
|
1451
|
+
const pkg = fakeManifest({
|
|
1452
|
+
name: 'hq-pack-accounting',
|
|
1453
|
+
requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-crm', version: '>=1.0.0' }] },
|
|
1454
|
+
});
|
|
1455
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/requires hq-pack-crm >=1\.0\.0/);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it('rejects a pack that lists itself as a dependency', () => {
|
|
1459
|
+
const pkg = fakeManifest({
|
|
1460
|
+
name: 'hq-pack-accounting',
|
|
1461
|
+
requires: { hqCore: '>=15.1.0', packs: [{ name: 'hq-pack-accounting' }] },
|
|
1462
|
+
});
|
|
1463
|
+
expect(() => assertPackDependencies(hqRoot, pkg)).toThrow(/cannot list itself/);
|
|
1464
|
+
});
|
|
1465
|
+
});
|