@indigoai-us/hq-cli 5.50.1 → 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/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 +12 -0
- package/dist/commands/pack-install.js +74 -3
- package/dist/commands/packs.js +17 -3
- package/dist/types.d.ts +18 -0
- package/dist/utils/pack-contributions.d.ts +7 -0
- package/dist/utils/pack-contributions.js +12 -2
- package/dist/utils/version-gate.d.ts +40 -1
- package/dist/utils/version-gate.js +91 -20
- package/package.json +1 -1
- 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 +144 -0
- package/src/commands/pack-install.ts +86 -1
- package/src/commands/packs.ts +19 -0
- package/src/types.ts +19 -1
- package/src/utils/pack-contributions.test.ts +53 -0
- package/src/utils/pack-contributions.ts +17 -0
- package/src/utils/version-gate.test.ts +122 -0
- package/src/utils/version-gate.ts +109 -13
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
runScanPackages,
|
|
27
27
|
stampInstallSource,
|
|
28
28
|
validateManifest,
|
|
29
|
+
assertPackDependencies,
|
|
29
30
|
getStartedLine,
|
|
30
31
|
toMarketplaceListing,
|
|
31
32
|
fetchMarketplace,
|
|
@@ -1319,3 +1320,146 @@ describe('pack-install: fetchMarketplace honors the envelope-mapped contentHash'
|
|
|
1319
1320
|
}
|
|
1320
1321
|
});
|
|
1321
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
|
+
});
|
|
@@ -53,7 +53,7 @@ import semverValid from 'semver/functions/valid.js';
|
|
|
53
53
|
import semverValidRange from 'semver/ranges/valid.js';
|
|
54
54
|
import semverGt from 'semver/functions/gt.js';
|
|
55
55
|
import { findHqRoot } from '../utils/manifest.js';
|
|
56
|
-
import { readHqVersion, routeContribution } from '../utils/pack-contributions.js';
|
|
56
|
+
import { readHqVersion, routeContribution, listInstalledPacks } from '../utils/pack-contributions.js';
|
|
57
57
|
import { CONTRIBUTION_TABLE, payloadFor } from '../utils/contribution-table.js';
|
|
58
58
|
import { safeExtractTarball } from './safe-extract.js';
|
|
59
59
|
import { vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
@@ -1201,6 +1201,40 @@ export function validateManifest(
|
|
|
1201
1201
|
`Host hqCore ${hqVersion} does not satisfy pack requirement ${range}`
|
|
1202
1202
|
);
|
|
1203
1203
|
}
|
|
1204
|
+
// 6b. requires.packs (M0) — OPTIONAL pack-to-pack dependencies. Absent → legacy
|
|
1205
|
+
// behavior (hqCore is the only prerequisite). Present → a list of
|
|
1206
|
+
// { name, version? } where `name` is a valid hq-pack name and `version` (if
|
|
1207
|
+
// given) is a valid semver RANGE. SHAPE validation only (no filesystem) so a
|
|
1208
|
+
// malformed dependency can't masquerade as valid; whether the named packs are
|
|
1209
|
+
// actually INSTALLED is enforced at install time by assertPackDependencies
|
|
1210
|
+
// (which needs hqRoot — a pure manifest validator doesn't have it).
|
|
1211
|
+
const reqPacks = m.requires?.packs;
|
|
1212
|
+
if (reqPacks !== undefined) {
|
|
1213
|
+
if (!Array.isArray(reqPacks)) {
|
|
1214
|
+
throw new Error('requires.packs must be a list of { name, version? } entries');
|
|
1215
|
+
}
|
|
1216
|
+
for (const dep of reqPacks) {
|
|
1217
|
+
if (!dep || typeof dep !== 'object' || Array.isArray(dep)) {
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
'requires.packs entries must be mappings with a name (and optional version)'
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
const d = dep as unknown as Record<string, unknown>;
|
|
1223
|
+
if (typeof d.name !== 'string' || !/^hq-pack-[a-z0-9][a-z0-9-]*$/.test(d.name)) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
`requires.packs[].name "${d.name}" must match ^hq-pack-[a-z0-9][a-z0-9-]*$`
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (
|
|
1229
|
+
d.version !== undefined &&
|
|
1230
|
+
(typeof d.version !== 'string' || !semverValidRange(d.version))
|
|
1231
|
+
) {
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`requires.packs entry for "${d.name}" has an invalid version range "${d.version}"`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1204
1238
|
// 7. contributes has at least one non-empty subfield
|
|
1205
1239
|
const contributes = (m.contributes ?? {}) as PackManifest['contributes'];
|
|
1206
1240
|
const nonEmpty = Object.values(contributes).some(
|
|
@@ -1312,6 +1346,51 @@ export function validateManifest(
|
|
|
1312
1346
|
return m as PackManifest;
|
|
1313
1347
|
}
|
|
1314
1348
|
|
|
1349
|
+
// ---------------------------------------------------------------------------
|
|
1350
|
+
// Pack-to-pack dependency pre-flight (M0)
|
|
1351
|
+
// ---------------------------------------------------------------------------
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Enforce a pack's `requires.packs`: every named dependency MUST already be
|
|
1355
|
+
* installed (and satisfy its optional semver RANGE) before we write anything.
|
|
1356
|
+
*
|
|
1357
|
+
* Installed packs are discovered by FILESYSTEM PRESENCE via `listInstalledPacks`
|
|
1358
|
+
* — deliberately NOT `modules.yaml`, which `installPack` no longer writes under
|
|
1359
|
+
* the v12+ layout (a modules.yaml-based check would silently ignore every modern
|
|
1360
|
+
* pack). Throws on the first unmet dependency so the install aborts with NO
|
|
1361
|
+
* partial state (it is called before installToPackages). No-op when
|
|
1362
|
+
* `requires.packs` is absent/empty, keeping legacy packs unaffected.
|
|
1363
|
+
*/
|
|
1364
|
+
export function assertPackDependencies(hqRoot: string, pkg: PackManifest): void {
|
|
1365
|
+
const deps = pkg.requires?.packs ?? [];
|
|
1366
|
+
if (deps.length === 0) return;
|
|
1367
|
+
const installed = new Map<string, string | undefined>();
|
|
1368
|
+
for (const p of listInstalledPacks(hqRoot)) {
|
|
1369
|
+
// Key on the manifest name when readable, else the directory name.
|
|
1370
|
+
installed.set(p.manifest?.name ?? p.name, p.manifest?.version);
|
|
1371
|
+
}
|
|
1372
|
+
for (const dep of deps) {
|
|
1373
|
+
if (dep.name === pkg.name) {
|
|
1374
|
+
throw new Error(`Pack ${pkg.name} cannot list itself in requires.packs.`);
|
|
1375
|
+
}
|
|
1376
|
+
if (!installed.has(dep.name)) {
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
`Pack ${pkg.name} requires ${dep.name}, which is not installed. ` +
|
|
1379
|
+
`Install it first, e.g.: hq install marketplace:${dep.name}`
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
if (dep.version) {
|
|
1383
|
+
const have = installed.get(dep.name);
|
|
1384
|
+
if (!have || !semverSatisfies(have, dep.version, { includePrerelease: true })) {
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
`Pack ${pkg.name} requires ${dep.name} ${dep.version}, but ` +
|
|
1387
|
+
`${dep.name}${have ? ` ${have}` : ' (version unknown)'} is installed.`
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1315
1394
|
// ---------------------------------------------------------------------------
|
|
1316
1395
|
// Post-install get-started line (US-005)
|
|
1317
1396
|
// ---------------------------------------------------------------------------
|
|
@@ -1919,6 +1998,12 @@ export async function installPack(
|
|
|
1919
1998
|
|
|
1920
1999
|
const pkg = validateManifest(fetched.payloadDir, hqVersion);
|
|
1921
2000
|
|
|
2001
|
+
// M0 — pack-to-pack dependency pre-flight. Runs BEFORE any prompts or writes
|
|
2002
|
+
// so a missing/unsatisfied `requires.packs` aborts with no partial state, and
|
|
2003
|
+
// before we bother the operator with hook/MCP trust prompts for a pack that
|
|
2004
|
+
// can't install anyway.
|
|
2005
|
+
assertPackDependencies(hqRoot, pkg);
|
|
2006
|
+
|
|
1922
2007
|
if (pkg.conditional) {
|
|
1923
2008
|
const allowed = await confirmConditional(pkg, opts.allowHooks ?? false);
|
|
1924
2009
|
if (!allowed) {
|
package/src/commands/packs.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
contributionLinks,
|
|
39
39
|
linkStatus,
|
|
40
40
|
listInstalledPacks,
|
|
41
|
+
findDependentPacks,
|
|
41
42
|
readPackManifest,
|
|
42
43
|
unwirePack,
|
|
43
44
|
unwirePackMcp,
|
|
@@ -394,6 +395,7 @@ interface UninstallResult {
|
|
|
394
395
|
interface UninstallOpts extends CommonOpts {
|
|
395
396
|
yes?: boolean;
|
|
396
397
|
archive?: boolean; // commander sets false for --no-archive
|
|
398
|
+
force?: boolean; // bypass the dependents guard (M0)
|
|
397
399
|
}
|
|
398
400
|
|
|
399
401
|
function archiveTimestamp(): string {
|
|
@@ -413,6 +415,22 @@ async function runUninstall(name: string, opts: UninstallOpts): Promise<Uninstal
|
|
|
413
415
|
warnings.push('package.yaml unreadable -- host symlinks could not be computed precisely; ran a re-scan to reconcile.');
|
|
414
416
|
}
|
|
415
417
|
|
|
418
|
+
// 0. Dependents guard (M0): refuse to remove a pack that another installed pack
|
|
419
|
+
// lists in its `requires.packs`, unless --force. Filesystem presence is the
|
|
420
|
+
// source of truth (listInstalledPacks), consistent with the install-time
|
|
421
|
+
// assertPackDependencies check. Runs BEFORE any un-wiring so a blocked uninstall
|
|
422
|
+
// leaves the pack fully intact.
|
|
423
|
+
if (!opts.force) {
|
|
424
|
+
const dependents = findDependentPacks(listInstalledPacks(hqRoot), name);
|
|
425
|
+
if (dependents.length > 0) {
|
|
426
|
+
const who = dependents.map((p) => p.manifest?.name ?? p.name).join(', ');
|
|
427
|
+
throw new Error(
|
|
428
|
+
`Cannot uninstall "${name}": required by ${who}. ` +
|
|
429
|
+
`Uninstall the dependent pack(s) first, or pass --force to override.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
416
434
|
// 1. Un-wire only our symlinks.
|
|
417
435
|
const { unlinked, skipped } = unwirePack(hqRoot, packDir, contributes);
|
|
418
436
|
|
|
@@ -550,6 +568,7 @@ export function registerPacksCommand(parent: Command): void {
|
|
|
550
568
|
.option('--hq-root <path>', 'HQ root (default: auto-detect)')
|
|
551
569
|
.option('-y, --yes', 'Skip confirmation')
|
|
552
570
|
.option('--no-archive', 'Delete instead of archiving')
|
|
571
|
+
.option('--force', 'Uninstall even if other installed packs require this one')
|
|
553
572
|
.action(async (name: string, opts: UninstallOpts) => {
|
|
554
573
|
try {
|
|
555
574
|
if (!opts.yes) {
|
package/src/types.ts
CHANGED
|
@@ -95,12 +95,30 @@ export interface PackAuthor {
|
|
|
95
95
|
displayName: string;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A pack-to-pack dependency (M0). OPTIONAL and backwards-compatible — packs
|
|
100
|
+
* published before this field omit `requires.packs` and install unchanged. When
|
|
101
|
+
* present, each entry names another content pack that MUST already be installed
|
|
102
|
+
* before this one (enforced at install time by `assertPackDependencies`, which
|
|
103
|
+
* tracks installed packs by FILESYSTEM PRESENCE — not `modules.yaml`). `version`
|
|
104
|
+
* is an optional semver RANGE the installed dependency must satisfy.
|
|
105
|
+
*/
|
|
106
|
+
export interface PackDependency {
|
|
107
|
+
name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
|
|
108
|
+
version?: string; // optional semver range
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
export interface PackManifest {
|
|
99
112
|
name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
|
|
100
113
|
version: string; // semver
|
|
101
114
|
publisher: string; // @scope
|
|
102
115
|
access: 'public' | 'private';
|
|
103
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Host + pack prerequisites. `hqCore` is a required semver RANGE the host HQ
|
|
118
|
+
* must satisfy. `packs` (M0) is an OPTIONAL list of other content packs that
|
|
119
|
+
* must be installed first — see PackDependency.
|
|
120
|
+
*/
|
|
121
|
+
requires: { hqCore: string; packs?: PackDependency[] };
|
|
104
122
|
contributes: Partial<Record<PackContributeKey, string[]>>;
|
|
105
123
|
description?: string;
|
|
106
124
|
license?: string;
|
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
contributionLinks,
|
|
17
17
|
linkStatus,
|
|
18
18
|
listInstalledPacks,
|
|
19
|
+
findDependentPacks,
|
|
19
20
|
unwirePack,
|
|
20
21
|
unwirePackMcp,
|
|
21
22
|
packagesDir,
|
|
22
23
|
readHqVersion,
|
|
23
24
|
type WiredLink,
|
|
25
|
+
type InstalledPack,
|
|
24
26
|
} from './pack-contributions.js';
|
|
25
27
|
import { parse as parseToml } from 'smol-toml';
|
|
26
28
|
import {
|
|
@@ -489,3 +491,54 @@ describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)
|
|
|
489
491
|
expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
|
|
490
492
|
});
|
|
491
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
|
+
});
|
|
@@ -264,6 +264,23 @@ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
|
|
|
264
264
|
return out;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Installed packs that declare `name` in their `requires.packs` (M0). Pure over
|
|
269
|
+
* the supplied list — the uninstall dependents guard calls this with
|
|
270
|
+
* `listInstalledPacks(hqRoot)`. Excludes the pack named `name` itself, so a
|
|
271
|
+
* self-reference never counts as its own dependent.
|
|
272
|
+
*/
|
|
273
|
+
export function findDependentPacks(
|
|
274
|
+
installed: InstalledPack[],
|
|
275
|
+
name: string,
|
|
276
|
+
): InstalledPack[] {
|
|
277
|
+
return installed.filter(
|
|
278
|
+
(p) =>
|
|
279
|
+
p.name !== name &&
|
|
280
|
+
(p.manifest?.requires?.packs ?? []).some((d) => d.name === name),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
267
284
|
// ---------------------------------------------------------------------------
|
|
268
285
|
// Un-wiring (the uninstall guarantee)
|
|
269
286
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +49,51 @@ describe("shouldSkipGate", () => {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
describe("prefix install helpers", () => {
|
|
53
|
+
it("derives the npm prefix from unix global package layouts", async () => {
|
|
54
|
+
const { __test__ } = await loadModule();
|
|
55
|
+
expect(
|
|
56
|
+
__test__.npmPrefixFromPackageDir(
|
|
57
|
+
"/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global/lib/node_modules/@indigoai-us/hq-cli",
|
|
58
|
+
),
|
|
59
|
+
).toBe("/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global");
|
|
60
|
+
expect(
|
|
61
|
+
__test__.npmPrefixFromPackageDir(
|
|
62
|
+
"/usr/local/lib/node_modules/@indigoai-us/hq-cli",
|
|
63
|
+
),
|
|
64
|
+
).toBe("/usr/local");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("derives the npm prefix from a windows-style package layout", async () => {
|
|
68
|
+
const { __test__ } = await loadModule();
|
|
69
|
+
expect(
|
|
70
|
+
__test__.npmPrefixFromPackageDir(
|
|
71
|
+
"C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@indigoai-us\\hq-cli",
|
|
72
|
+
),
|
|
73
|
+
).toBe("C:/Users/x/AppData/Roaming/npm");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns null when the package path is not under node_modules", async () => {
|
|
77
|
+
const { __test__ } = await loadModule();
|
|
78
|
+
expect(
|
|
79
|
+
__test__.npmPrefixFromPackageDir(
|
|
80
|
+
"/Users/x/dev/hq/packages/hq-cli",
|
|
81
|
+
),
|
|
82
|
+
).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("builds the prefixed npm install argv", async () => {
|
|
86
|
+
const { __test__ } = await loadModule();
|
|
87
|
+
expect(__test__.buildPrefixedInstallArgv("/tmp/npm-global")).toEqual([
|
|
88
|
+
"install",
|
|
89
|
+
"-g",
|
|
90
|
+
"--prefix",
|
|
91
|
+
"/tmp/npm-global",
|
|
92
|
+
"@indigoai-us/hq-cli@latest",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
52
97
|
describe("enforceVersionGate — opt-out + soft paths (no process.exit)", () => {
|
|
53
98
|
it("is silent + no fetch when HQ_NO_UPDATE_CHECK=1", async () => {
|
|
54
99
|
vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
|
|
@@ -242,4 +287,81 @@ describe("enforceVersionGate — hard-update path", () => {
|
|
|
242
287
|
expect(body.currentVersion).toBe("5.10.0");
|
|
243
288
|
expect(typeof body.platform).toBe("string");
|
|
244
289
|
});
|
|
290
|
+
|
|
291
|
+
it("installs with the resolved running prefix instead of the bare server command", async () => {
|
|
292
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
293
|
+
const exitSpy = vi
|
|
294
|
+
.spyOn(process, "exit")
|
|
295
|
+
.mockImplementation(((code?: number) => {
|
|
296
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
297
|
+
}) as never);
|
|
298
|
+
const runner = vi.fn().mockReturnValue({ ok: true });
|
|
299
|
+
const { __test__ } = await loadModule();
|
|
300
|
+
const prefix = "/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global";
|
|
301
|
+
|
|
302
|
+
expect(() =>
|
|
303
|
+
__test__.enforceUpdateRequired(
|
|
304
|
+
{
|
|
305
|
+
clientId: "hq-cli",
|
|
306
|
+
currentVersion: "5.10.0",
|
|
307
|
+
minVersion: "5.20.0",
|
|
308
|
+
latestVersion: "5.24.0",
|
|
309
|
+
updateRequired: true,
|
|
310
|
+
updateRecommended: false,
|
|
311
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
resolvePrefix: () => prefix,
|
|
315
|
+
runner,
|
|
316
|
+
},
|
|
317
|
+
),
|
|
318
|
+
).toThrow(/__process_exit__:0/);
|
|
319
|
+
|
|
320
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
321
|
+
expect(runner).toHaveBeenCalledWith("npm", [
|
|
322
|
+
"install",
|
|
323
|
+
"-g",
|
|
324
|
+
"--prefix",
|
|
325
|
+
prefix,
|
|
326
|
+
"@indigoai-us/hq-cli@latest",
|
|
327
|
+
]);
|
|
328
|
+
expect(runner).not.toHaveBeenCalledWith("npm", [
|
|
329
|
+
"install",
|
|
330
|
+
"-g",
|
|
331
|
+
"@indigoai-us/hq-cli@latest",
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("falls back to the server updateCommand when no running prefix resolves", async () => {
|
|
336
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
337
|
+
const exitSpy = vi
|
|
338
|
+
.spyOn(process, "exit")
|
|
339
|
+
.mockImplementation(((code?: number) => {
|
|
340
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
341
|
+
}) as never);
|
|
342
|
+
const performUpdateString = vi.fn().mockReturnValue({ ok: true });
|
|
343
|
+
const { __test__ } = await loadModule();
|
|
344
|
+
const updateCommand = "npm install -g @indigoai-us/hq-cli@latest";
|
|
345
|
+
|
|
346
|
+
expect(() =>
|
|
347
|
+
__test__.enforceUpdateRequired(
|
|
348
|
+
{
|
|
349
|
+
clientId: "hq-cli",
|
|
350
|
+
currentVersion: "5.10.0",
|
|
351
|
+
minVersion: "5.20.0",
|
|
352
|
+
latestVersion: "5.24.0",
|
|
353
|
+
updateRequired: true,
|
|
354
|
+
updateRecommended: false,
|
|
355
|
+
updateCommand,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
performUpdateString,
|
|
359
|
+
resolvePrefix: () => null,
|
|
360
|
+
},
|
|
361
|
+
),
|
|
362
|
+
).toThrow(/__process_exit__:0/);
|
|
363
|
+
|
|
364
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
365
|
+
expect(performUpdateString).toHaveBeenCalledWith(updateCommand);
|
|
366
|
+
});
|
|
245
367
|
});
|