@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
|
@@ -16,24 +16,42 @@ import {
|
|
|
16
16
|
contributionLinks,
|
|
17
17
|
linkStatus,
|
|
18
18
|
listInstalledPacks,
|
|
19
|
+
findDependentPacks,
|
|
19
20
|
unwirePack,
|
|
21
|
+
unwirePackMcp,
|
|
20
22
|
packagesDir,
|
|
21
23
|
readHqVersion,
|
|
22
24
|
type WiredLink,
|
|
25
|
+
type InstalledPack,
|
|
23
26
|
} from './pack-contributions.js';
|
|
27
|
+
import { parse as parseToml } from 'smol-toml';
|
|
28
|
+
import {
|
|
29
|
+
registerClaudeServer,
|
|
30
|
+
registerServer,
|
|
31
|
+
claudeConfigPath,
|
|
32
|
+
codexConfigPath,
|
|
33
|
+
codexHome,
|
|
34
|
+
} from '../commands/mcp-registration.js';
|
|
35
|
+
import {
|
|
36
|
+
CONTRIBUTION_TABLE,
|
|
37
|
+
CONTRIBUTION_KEYS,
|
|
38
|
+
SYMLINK_KEYS,
|
|
39
|
+
payloadFor,
|
|
40
|
+
type ContributionKey,
|
|
41
|
+
} from './contribution-table.js';
|
|
24
42
|
import type { PackContributeKey } from '../types.js';
|
|
25
43
|
|
|
26
|
-
// The canonical mapping
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
44
|
+
// The canonical mapping is DERIVED from the single-source CONTRIBUTION_TABLE
|
|
45
|
+
// (US-003) -- the oracle is no longer a hand-restated copy. For each SYMLINK
|
|
46
|
+
// key, src = the payload expanded for item "X"; dst = <host-dir>/<basename>.
|
|
47
|
+
const EXPECTED: Record<ContributionKey, { src: string; dst: string }> =
|
|
48
|
+
Object.fromEntries(
|
|
49
|
+
SYMLINK_KEYS.map((k) => {
|
|
50
|
+
const src = payloadFor(k, 'X');
|
|
51
|
+
const dst = `${CONTRIBUTION_TABLE[k].host}/${src.split('/').pop()}`;
|
|
52
|
+
return [k, { src, dst }];
|
|
53
|
+
}),
|
|
54
|
+
) as Record<ContributionKey, { src: string; dst: string }>;
|
|
37
55
|
|
|
38
56
|
function mkTmp(prefix: string): string {
|
|
39
57
|
// realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
|
|
@@ -41,10 +59,10 @@ function mkTmp(prefix: string): string {
|
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
describe('contributionLinks: mapping', () => {
|
|
44
|
-
it('maps every contributes key to the canonical src/dst', () => {
|
|
62
|
+
it('maps every SYMLINK contributes key to the canonical src/dst', () => {
|
|
45
63
|
const hqRoot = '/hq';
|
|
46
64
|
const packDir = '/hq/core/packages/hq-pack-x';
|
|
47
|
-
for (const key of
|
|
65
|
+
for (const key of SYMLINK_KEYS) {
|
|
48
66
|
const [link] = contributionLinks(hqRoot, packDir, { [key]: ['X'] });
|
|
49
67
|
expect(link.key).toBe(key);
|
|
50
68
|
expect(link.src).toBe(path.join(packDir, EXPECTED[key].src));
|
|
@@ -52,6 +70,19 @@ describe('contributionLinks: mapping', () => {
|
|
|
52
70
|
}
|
|
53
71
|
});
|
|
54
72
|
|
|
73
|
+
it('produces NO symlink for wire:merge keys (mcp)', () => {
|
|
74
|
+
// The mcp row is wire:merge — declared as data (US-003) but never wired as a
|
|
75
|
+
// symlink here; its host effect is a config merge (US-004/US-005).
|
|
76
|
+
const mergeKeys = CONTRIBUTION_KEYS.filter(
|
|
77
|
+
(k) => CONTRIBUTION_TABLE[k].wire === 'merge',
|
|
78
|
+
);
|
|
79
|
+
expect(mergeKeys).toContain('mcp'); // guards the row exists
|
|
80
|
+
for (const key of mergeKeys) {
|
|
81
|
+
const links = contributionLinks('/hq', '/p', { [key]: ['X'] });
|
|
82
|
+
expect(links).toEqual([]);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
55
86
|
it('ignores empty / non-array contributes subfields', () => {
|
|
56
87
|
const links = contributionLinks('/hq', '/p', {
|
|
57
88
|
skills: [],
|
|
@@ -63,21 +94,118 @@ describe('contributionLinks: mapping', () => {
|
|
|
63
94
|
});
|
|
64
95
|
});
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// US-003 single-source parity: FULL key-set + payload-suffix + wire-mode
|
|
99
|
+
// equivalence across every surface (table, derived union, validateManifest's
|
|
100
|
+
// payload reader, and the generated scan-packages.sh block). Replaces the old
|
|
101
|
+
// substring-only `toContain` check, which passed even if a key was MISSING, had
|
|
102
|
+
// the WRONG payload suffix, or the WRONG wire mode.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
describe('US-003 parity: contribution table is the single source', () => {
|
|
105
|
+
// Resolve the HQ root so we can read the GENERATED scan-packages.sh block.
|
|
106
|
+
const scanCandidates = [
|
|
70
107
|
path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
|
|
71
108
|
path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
|
|
72
109
|
];
|
|
73
|
-
const scanPath =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
110
|
+
const scanPath = scanCandidates.find((p) => fs.existsSync(p));
|
|
111
|
+
|
|
112
|
+
// Parse the generated `contrib_row` case arms back into {key:{payload,host,wire}}.
|
|
113
|
+
function parseBashTable(bash: string): Record<
|
|
114
|
+
string,
|
|
115
|
+
{ payload: string; host: string; wire: string }
|
|
116
|
+
> {
|
|
117
|
+
const out: Record<string, { payload: string; host: string; wire: string }> = {};
|
|
118
|
+
const re =
|
|
119
|
+
/^\s*([a-z]+)\)\s*printf\s+'%s'\s+'([^|]+)\|([^|]+)\|([^']+)'\s*;;/gm;
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = re.exec(bash)) !== null) {
|
|
122
|
+
out[m[1]] = { payload: m[2], host: m[3], wire: m[4] };
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it('the derived PackContributeKey union == the table keys (8 keys incl. mcp)', () => {
|
|
128
|
+
// CONTRIBUTION_KEYS IS PackContributeKey's source (types.ts derives the
|
|
129
|
+
// union from it). Assert the full expected set is present, no more, no less.
|
|
130
|
+
expect([...CONTRIBUTION_KEYS].sort()).toEqual(
|
|
131
|
+
[
|
|
132
|
+
'commands',
|
|
133
|
+
'hooks',
|
|
134
|
+
'knowledge',
|
|
135
|
+
'mcp',
|
|
136
|
+
'policies',
|
|
137
|
+
'scripts',
|
|
138
|
+
'skills',
|
|
139
|
+
'workers',
|
|
140
|
+
].sort(),
|
|
141
|
+
);
|
|
142
|
+
// Type-level: every key is assignable to PackContributeKey.
|
|
143
|
+
const asUnion: PackContributeKey[] = [...CONTRIBUTION_KEYS];
|
|
144
|
+
expect(asUnion.length).toBe(8);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('every key has a wire mode of exactly symlink|merge, mcp is the merge row', () => {
|
|
148
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
149
|
+
expect(['symlink', 'merge']).toContain(CONTRIBUTION_TABLE[k].wire);
|
|
150
|
+
}
|
|
151
|
+
expect(CONTRIBUTION_TABLE.mcp.wire).toBe('merge');
|
|
152
|
+
expect(CONTRIBUTION_TABLE.mcp.payload).toBe('mcp/{item}.json');
|
|
153
|
+
expect(CONTRIBUTION_TABLE.mcp.host).toBe('merge:claude+codex');
|
|
154
|
+
// All non-mcp rows are symlink in this milestone.
|
|
155
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
156
|
+
if (k !== 'mcp') expect(CONTRIBUTION_TABLE[k].wire).toBe('symlink');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('payload suffixes are pinned per key (suffix is load-bearing)', () => {
|
|
161
|
+
const suffix = (p: string) => {
|
|
162
|
+
const base = p.split('/').pop() as string;
|
|
163
|
+
const dot = base.indexOf('.', base.indexOf('}'));
|
|
164
|
+
return dot >= 0 ? base.slice(dot) : '';
|
|
165
|
+
};
|
|
166
|
+
expect(suffix(CONTRIBUTION_TABLE.commands.payload)).toBe('.md');
|
|
167
|
+
expect(suffix(CONTRIBUTION_TABLE.policies.payload)).toBe('.md');
|
|
168
|
+
expect(suffix(CONTRIBUTION_TABLE.hooks.payload)).toBe('.sh');
|
|
169
|
+
expect(suffix(CONTRIBUTION_TABLE.mcp.payload)).toBe('.json');
|
|
170
|
+
expect(suffix(CONTRIBUTION_TABLE.workers.payload)).toBe('');
|
|
171
|
+
expect(suffix(CONTRIBUTION_TABLE.knowledge.payload)).toBe('');
|
|
172
|
+
expect(suffix(CONTRIBUTION_TABLE.skills.payload)).toBe('');
|
|
173
|
+
expect(suffix(CONTRIBUTION_TABLE.scripts.payload)).toBe('');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
(scanPath ? it : it.skip)(
|
|
177
|
+
'scan-packages.sh generated block matches the table on key-set + payload + host + wire',
|
|
178
|
+
() => {
|
|
179
|
+
const bash = fs.readFileSync(scanPath as string, 'utf-8');
|
|
180
|
+
const bashTable = parseBashTable(bash);
|
|
181
|
+
|
|
182
|
+
// FULL key-set equivalence (fails if a surface is MISSING a key or has an
|
|
183
|
+
// EXTRA one — not just a substring presence check).
|
|
184
|
+
expect(Object.keys(bashTable).sort()).toEqual([...CONTRIBUTION_KEYS].sort());
|
|
185
|
+
|
|
186
|
+
// Per-key payload + host + wire-mode equivalence.
|
|
187
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
188
|
+
expect(bashTable[k], `scan-packages.sh missing row for "${k}"`).toBeDefined();
|
|
189
|
+
expect(bashTable[k].payload).toBe(CONTRIBUTION_TABLE[k].payload);
|
|
190
|
+
expect(bashTable[k].host).toBe(CONTRIBUTION_TABLE[k].host);
|
|
191
|
+
expect(bashTable[k].wire).toBe(CONTRIBUTION_TABLE[k].wire);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The CONTRIB_KEYS array in the script also lists every key.
|
|
195
|
+
const keysLine = bash.match(/CONTRIB_KEYS=\(([^)]*)\)/);
|
|
196
|
+
expect(keysLine).not.toBeNull();
|
|
197
|
+
const bashKeys = (keysLine as RegExpMatchArray)[1].trim().split(/\s+/).sort();
|
|
198
|
+
expect(bashKeys).toEqual([...CONTRIBUTION_KEYS].sort());
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
it('validateManifest payload reader (payloadFor) agrees with the table for every key', () => {
|
|
203
|
+
for (const k of CONTRIBUTION_KEYS) {
|
|
204
|
+
// payloadFor is exactly what validateManifest now uses for the
|
|
205
|
+
// payload-existence check, so this pins that surface to the table.
|
|
206
|
+
expect(payloadFor(k, 'demo')).toBe(
|
|
207
|
+
CONTRIBUTION_TABLE[k].payload.replace('{item}', 'demo'),
|
|
208
|
+
);
|
|
81
209
|
}
|
|
82
210
|
});
|
|
83
211
|
});
|
|
@@ -257,3 +385,160 @@ describe('readHqVersion: v15 layout awareness (feedback_57d7edcf, symptom b)', (
|
|
|
257
385
|
expect(readHqVersion(hqRoot)).toBeNull();
|
|
258
386
|
});
|
|
259
387
|
});
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// US-009: unwirePackMcp — the wire:merge (MCP) un-registration parallel to the
|
|
391
|
+
// symlink unwirePack. Runs against an ISOLATED tmpdir home (env.home) so the
|
|
392
|
+
// developer's real ~/.claude.json / ~/.codex are NEVER touched.
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)', () => {
|
|
396
|
+
let mcpHome: string;
|
|
397
|
+
|
|
398
|
+
beforeEach(() => {
|
|
399
|
+
mcpHome = mkTmp('hq-unwire-mcp-');
|
|
400
|
+
});
|
|
401
|
+
afterEach(() => {
|
|
402
|
+
fs.rmSync(mcpHome, { recursive: true, force: true });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('un-registers ONLY this pack\'s provenance-stamped mcp servers from ~/.claude.json', () => {
|
|
406
|
+
const env = { home: mcpHome };
|
|
407
|
+
// The user owns `figma`; our pack installs `vyg`.
|
|
408
|
+
fs.writeFileSync(
|
|
409
|
+
claudeConfigPath(env),
|
|
410
|
+
JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
|
|
411
|
+
);
|
|
412
|
+
registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
|
|
413
|
+
|
|
414
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
415
|
+
expect(result.servers).toHaveLength(1);
|
|
416
|
+
expect(result.servers[0]!.claude.outcome).toBe('removed');
|
|
417
|
+
|
|
418
|
+
const after = JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8'));
|
|
419
|
+
expect(after.mcpServers.vyg).toBeUndefined(); // ours removed
|
|
420
|
+
expect(after.mcpServers.figma.url).toBe('https://figma'); // user sibling preserved
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('no-ops cleanly when the pack declares no mcp (merge) keys', () => {
|
|
424
|
+
const result = unwirePackMcp('hq-pack-x', { commands: ['foo'], skills: ['bar'] }, { env: { home: mcpHome } });
|
|
425
|
+
expect(result.servers).toEqual([]);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('skip-and-warns (does not delete) a foreign/unstamped same-named entry', () => {
|
|
429
|
+
const env = { home: mcpHome };
|
|
430
|
+
// The user has their OWN `vyg` (no _hqPack stamp).
|
|
431
|
+
fs.writeFileSync(
|
|
432
|
+
claudeConfigPath(env),
|
|
433
|
+
JSON.stringify({ mcpServers: { vyg: { type: 'http', url: 'https://user-vyg' } } }, null, 2) + '\n',
|
|
434
|
+
);
|
|
435
|
+
const before = fs.readFileSync(claudeConfigPath(env), 'utf-8');
|
|
436
|
+
|
|
437
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
438
|
+
expect(result.servers[0]!.claude.outcome).toBe('skipped-foreign');
|
|
439
|
+
// The user's server is left byte-for-byte intact.
|
|
440
|
+
expect(fs.readFileSync(claudeConfigPath(env), 'utf-8')).toBe(before);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('is a clean no-op on a Codex-less host with no ~/.claude.json (never fabricates)', () => {
|
|
444
|
+
const env = { home: mcpHome };
|
|
445
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
446
|
+
expect(result.servers[0]!.claude.outcome).toBe('absent');
|
|
447
|
+
expect('skipped' in result.servers[0]!.codex && (result.servers[0]!.codex as any).skipped).toBe(true);
|
|
448
|
+
expect(fs.existsSync(path.join(mcpHome, '.claude.json'))).toBe(false);
|
|
449
|
+
expect(fs.existsSync(path.join(mcpHome, '.codex'))).toBe(false);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('delegates to unregisterMcpServers across BOTH surfaces when ~/.codex exists', () => {
|
|
453
|
+
// The unwire-hook must un-register from Claude AND Codex (not just Claude) — i.e.
|
|
454
|
+
// it really delegates to unregisterMcpServers, which fans out over both surfaces.
|
|
455
|
+
const env = { home: mcpHome };
|
|
456
|
+
fs.mkdirSync(codexHome(env), { recursive: true }); // make Codex a real runtime
|
|
457
|
+
// Install our vyg into BOTH surfaces, alongside a user-owned figma on Claude.
|
|
458
|
+
fs.writeFileSync(
|
|
459
|
+
claudeConfigPath(env),
|
|
460
|
+
JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'https://figma' } } }, null, 2) + '\n',
|
|
461
|
+
);
|
|
462
|
+
registerServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
|
|
463
|
+
// Both surfaces carry vyg before uninstall.
|
|
464
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeDefined();
|
|
465
|
+
expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeDefined();
|
|
466
|
+
|
|
467
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
468
|
+
expect(result.servers).toHaveLength(1);
|
|
469
|
+
expect(result.servers[0]!.claude.outcome).toBe('removed');
|
|
470
|
+
expect('skipped' in result.servers[0]!.codex).toBe(false);
|
|
471
|
+
expect((result.servers[0]!.codex as any).outcome).toBe('removed');
|
|
472
|
+
|
|
473
|
+
// Both surfaces no longer carry vyg; the user's Claude figma sibling survives.
|
|
474
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.vyg).toBeUndefined();
|
|
475
|
+
expect(JSON.parse(fs.readFileSync(claudeConfigPath(env), 'utf-8')).mcpServers.figma).toBeDefined();
|
|
476
|
+
expect((parseToml(fs.readFileSync(codexConfigPath(env), 'utf-8')) as any).mcp_servers.vyg).toBeUndefined();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('skip-and-warns a foreign Codex table too (does not delete another pack\'s server)', () => {
|
|
480
|
+
// The Codex parallel of the foreign skip: a vyg table stamped by ANOTHER pack must
|
|
481
|
+
// be left in place when uninstalling OUR pack — provenance scope, not name match.
|
|
482
|
+
const env = { home: mcpHome };
|
|
483
|
+
fs.mkdirSync(codexHome(env), { recursive: true });
|
|
484
|
+
const original =
|
|
485
|
+
'# keep me\n[mcp_servers.vyg]\ntype = "http"\nurl = "https://vyg"\n_hqPack = "hq-pack-OTHER"\n';
|
|
486
|
+
fs.writeFileSync(codexConfigPath(env), original);
|
|
487
|
+
|
|
488
|
+
const result = unwirePackMcp('hq-pack-vyg', { mcp: ['vyg'] }, { env });
|
|
489
|
+
expect((result.servers[0]!.codex as any).outcome).toBe('skipped-foreign');
|
|
490
|
+
// Byte-for-byte preserved (comment intact — no write on a foreign skip).
|
|
491
|
+
expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// findDependentPacks — uninstall dependents guard (M0)
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
describe('findDependentPacks (M0)', () => {
|
|
500
|
+
function ip(
|
|
501
|
+
name: string,
|
|
502
|
+
requiresPacks?: Array<{ name: string; version?: string }>,
|
|
503
|
+
): InstalledPack {
|
|
504
|
+
return {
|
|
505
|
+
name,
|
|
506
|
+
dir: `/tmp/nope/${name}`,
|
|
507
|
+
manifest: {
|
|
508
|
+
name,
|
|
509
|
+
version: '1.0.0',
|
|
510
|
+
publisher: '@indigoai-us',
|
|
511
|
+
access: 'public',
|
|
512
|
+
requires: { hqCore: '>=15.0.0', packs: requiresPacks },
|
|
513
|
+
contributes: { skills: ['x'] },
|
|
514
|
+
},
|
|
515
|
+
} as InstalledPack;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
it('returns the packs that require the named pack', () => {
|
|
519
|
+
const installed = [
|
|
520
|
+
ip('hq-pack-crm'),
|
|
521
|
+
ip('hq-pack-accounting', [{ name: 'hq-pack-crm', version: '>=1.0.0' }]),
|
|
522
|
+
ip('hq-pack-unrelated'),
|
|
523
|
+
];
|
|
524
|
+
const deps = findDependentPacks(installed, 'hq-pack-crm');
|
|
525
|
+
expect(deps.map((p) => p.name)).toEqual(['hq-pack-accounting']);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('returns empty when no installed pack requires it', () => {
|
|
529
|
+
const installed = [ip('hq-pack-crm'), ip('hq-pack-unrelated')];
|
|
530
|
+
expect(findDependentPacks(installed, 'hq-pack-crm')).toEqual([]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('never counts a pack that lists itself as its own dependent', () => {
|
|
534
|
+
// Defensive: a self-referencing manifest (rejected at install) must not make
|
|
535
|
+
// a pack un-removable.
|
|
536
|
+
const installed = [ip('hq-pack-weird', [{ name: 'hq-pack-weird' }])];
|
|
537
|
+
expect(findDependentPacks(installed, 'hq-pack-weird')).toEqual([]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('tolerates an unreadable manifest (manifest === null)', () => {
|
|
541
|
+
const broken: InstalledPack = { name: 'hq-pack-broken', dir: '/tmp/nope', manifest: null };
|
|
542
|
+
expect(findDependentPacks([broken], 'hq-pack-crm')).toEqual([]);
|
|
543
|
+
});
|
|
544
|
+
});
|