@indigoai-us/hq-cli 5.50.1 → 5.51.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/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +5 -2
- package/dist/commands/members.d.ts +17 -0
- package/dist/commands/members.js +65 -28
- 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/commands/people.d.ts +26 -1
- package/dist/commands/people.js +70 -7
- package/dist/commands/secrets-scope.d.ts +20 -0
- package/dist/commands/secrets-scope.js +19 -0
- package/dist/commands/secrets.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +44 -14
- package/dist/node-preflight.d.ts +39 -0
- package/dist/node-preflight.js +55 -0
- package/dist/sentry.d.ts +12 -0
- package/dist/sentry.js +19 -3
- package/dist/types.d.ts +18 -0
- package/dist/utils/epipe.d.ts +8 -0
- package/dist/utils/epipe.js +30 -0
- package/dist/utils/intercepted-process-exit.d.ts +7 -0
- package/dist/utils/intercepted-process-exit.js +38 -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/e2e/cli.test.ts +35 -0
- package/package.json +1 -1
- package/src/bin/hq-auth-refresh.ts +3 -0
- package/src/commands/members.test.ts +176 -0
- package/src/commands/members.ts +113 -28
- 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/commands/people.test.ts +212 -5
- package/src/commands/people.ts +141 -5
- package/src/commands/secrets-scope.test.ts +56 -0
- package/src/commands/secrets-scope.ts +32 -0
- package/src/commands/secrets.ts +24 -10
- package/src/index.ts +40 -12
- package/src/node-preflight.test.ts +60 -0
- package/src/node-preflight.ts +67 -0
- package/src/sentry-epipe.test.ts +37 -0
- package/src/sentry-release.test.ts +54 -0
- package/src/sentry.ts +21 -1
- package/src/types.ts +19 -1
- package/src/utils/epipe.test.ts +28 -0
- package/src/utils/epipe.ts +29 -0
- package/src/utils/intercepted-process-exit.test.ts +37 -0
- package/src/utils/intercepted-process-exit.ts +36 -0
- 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) {
|
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
resolveNameToEmail,
|
|
20
20
|
type PersonRecord,
|
|
21
21
|
} from "../utils/people.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
registerPeopleCommand,
|
|
24
|
+
resolveCompanySlug,
|
|
25
|
+
type RefreshPeopleRoster,
|
|
26
|
+
} from "./people.js";
|
|
23
27
|
|
|
24
28
|
// ---------------------------------------------------------------------------
|
|
25
29
|
// Filesystem fixture helpers
|
|
@@ -335,15 +339,20 @@ describe("companyPeopleDir", () => {
|
|
|
335
339
|
// Wired CLI surface (list / search / resolve)
|
|
336
340
|
// ---------------------------------------------------------------------------
|
|
337
341
|
|
|
338
|
-
function buildProgram(): Command {
|
|
342
|
+
function buildProgram(deps?: { refreshRoster?: RefreshPeopleRoster }): Command {
|
|
339
343
|
const program = new Command();
|
|
340
344
|
program.exitOverride(); // throw instead of process.exit on commander errors
|
|
341
|
-
registerPeopleCommand(program);
|
|
345
|
+
registerPeopleCommand(program, deps);
|
|
342
346
|
return program;
|
|
343
347
|
}
|
|
344
348
|
|
|
345
|
-
async function run(
|
|
346
|
-
|
|
349
|
+
async function run(
|
|
350
|
+
args: string[],
|
|
351
|
+
deps: { refreshRoster?: RefreshPeopleRoster } = {
|
|
352
|
+
refreshRoster: async () => {},
|
|
353
|
+
},
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
await buildProgram(deps).parseAsync(["node", "hq", ...args]);
|
|
347
356
|
}
|
|
348
357
|
|
|
349
358
|
describe("hq people (wired)", () => {
|
|
@@ -396,6 +405,104 @@ describe("hq people (wired)", () => {
|
|
|
396
405
|
expect(out[0].email).toBe("jane@acme.com");
|
|
397
406
|
});
|
|
398
407
|
|
|
408
|
+
it("search local hit does not refresh the roster", async () => {
|
|
409
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>();
|
|
410
|
+
await run(
|
|
411
|
+
[
|
|
412
|
+
"people",
|
|
413
|
+
"search",
|
|
414
|
+
"jane",
|
|
415
|
+
"--company",
|
|
416
|
+
"acme",
|
|
417
|
+
"--hq-root",
|
|
418
|
+
tmpRoot,
|
|
419
|
+
"--json",
|
|
420
|
+
],
|
|
421
|
+
{ refreshRoster },
|
|
422
|
+
);
|
|
423
|
+
expect(refreshRoster).not.toHaveBeenCalled();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("search local miss refreshes once and retries from the refreshed roster", async () => {
|
|
427
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
|
|
428
|
+
makePerson("acme", "ada-lovelace", {
|
|
429
|
+
name: "Ada Lovelace",
|
|
430
|
+
email: "ada@acme.com",
|
|
431
|
+
type: "internal",
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
await run(
|
|
436
|
+
[
|
|
437
|
+
"people",
|
|
438
|
+
"search",
|
|
439
|
+
"ada",
|
|
440
|
+
"--company",
|
|
441
|
+
"acme",
|
|
442
|
+
"--hq-root",
|
|
443
|
+
tmpRoot,
|
|
444
|
+
"--json",
|
|
445
|
+
],
|
|
446
|
+
{ refreshRoster },
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(refreshRoster).toHaveBeenCalledTimes(1);
|
|
450
|
+
expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
|
|
451
|
+
const out = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
452
|
+
expect(out).toHaveLength(1);
|
|
453
|
+
expect(out[0]).toMatchObject({
|
|
454
|
+
name: "Ada Lovelace",
|
|
455
|
+
email: "ada@acme.com",
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("search local miss with refresh failure reports the original empty JSON and logs a note", async () => {
|
|
460
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
|
|
461
|
+
throw new Error("offline");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await run(
|
|
465
|
+
[
|
|
466
|
+
"people",
|
|
467
|
+
"search",
|
|
468
|
+
"ada",
|
|
469
|
+
"--company",
|
|
470
|
+
"acme",
|
|
471
|
+
"--hq-root",
|
|
472
|
+
tmpRoot,
|
|
473
|
+
"--json",
|
|
474
|
+
],
|
|
475
|
+
{ refreshRoster },
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
expect(refreshRoster).toHaveBeenCalledTimes(1);
|
|
479
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
|
|
480
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
481
|
+
expect.stringContaining("Could not refresh people roster"),
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("search --local-only skips refresh on a miss", async () => {
|
|
486
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>();
|
|
487
|
+
await run(
|
|
488
|
+
[
|
|
489
|
+
"people",
|
|
490
|
+
"search",
|
|
491
|
+
"ada",
|
|
492
|
+
"--company",
|
|
493
|
+
"acme",
|
|
494
|
+
"--hq-root",
|
|
495
|
+
tmpRoot,
|
|
496
|
+
"--json",
|
|
497
|
+
"--local-only",
|
|
498
|
+
],
|
|
499
|
+
{ refreshRoster },
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(refreshRoster).not.toHaveBeenCalled();
|
|
503
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual([]);
|
|
504
|
+
});
|
|
505
|
+
|
|
399
506
|
it("resolve prints the bare email on stdout", async () => {
|
|
400
507
|
await run([
|
|
401
508
|
"people",
|
|
@@ -409,6 +516,106 @@ describe("hq people (wired)", () => {
|
|
|
409
516
|
expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
|
|
410
517
|
});
|
|
411
518
|
|
|
519
|
+
it("resolve local hit does not refresh the roster", async () => {
|
|
520
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>();
|
|
521
|
+
await run(
|
|
522
|
+
[
|
|
523
|
+
"people",
|
|
524
|
+
"resolve",
|
|
525
|
+
"Jane Smith",
|
|
526
|
+
"--company",
|
|
527
|
+
"acme",
|
|
528
|
+
"--hq-root",
|
|
529
|
+
tmpRoot,
|
|
530
|
+
],
|
|
531
|
+
{ refreshRoster },
|
|
532
|
+
);
|
|
533
|
+
expect(refreshRoster).not.toHaveBeenCalled();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("resolve local miss refreshes once and retries from the refreshed roster", async () => {
|
|
537
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
|
|
538
|
+
makePerson("acme", "ada-lovelace", {
|
|
539
|
+
name: "Ada Lovelace",
|
|
540
|
+
email: "ada@acme.com",
|
|
541
|
+
type: "internal",
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
await run(
|
|
546
|
+
[
|
|
547
|
+
"people",
|
|
548
|
+
"resolve",
|
|
549
|
+
"Ada Lovelace",
|
|
550
|
+
"--company",
|
|
551
|
+
"acme",
|
|
552
|
+
"--hq-root",
|
|
553
|
+
tmpRoot,
|
|
554
|
+
],
|
|
555
|
+
{ refreshRoster },
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(refreshRoster).toHaveBeenCalledTimes(1);
|
|
559
|
+
expect(refreshRoster).toHaveBeenCalledWith(tmpRoot, "acme");
|
|
560
|
+
expect(logSpy).toHaveBeenCalledWith("ada@acme.com");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("resolve local miss with refresh failure reports unchanged not_found JSON and logs a note", async () => {
|
|
564
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>(async () => {
|
|
565
|
+
throw new Error("not signed in");
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await expect(
|
|
569
|
+
run(
|
|
570
|
+
[
|
|
571
|
+
"people",
|
|
572
|
+
"resolve",
|
|
573
|
+
"Ada Lovelace",
|
|
574
|
+
"--company",
|
|
575
|
+
"acme",
|
|
576
|
+
"--hq-root",
|
|
577
|
+
tmpRoot,
|
|
578
|
+
"--json",
|
|
579
|
+
],
|
|
580
|
+
{ refreshRoster },
|
|
581
|
+
),
|
|
582
|
+
).rejects.toThrow("__exit__");
|
|
583
|
+
|
|
584
|
+
expect(refreshRoster).toHaveBeenCalledTimes(1);
|
|
585
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
|
|
586
|
+
status: "not_found",
|
|
587
|
+
});
|
|
588
|
+
expect(errSpy).toHaveBeenCalledWith(
|
|
589
|
+
expect.stringContaining("Could not refresh people roster"),
|
|
590
|
+
);
|
|
591
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("resolve --local-only skips refresh on a miss", async () => {
|
|
595
|
+
const refreshRoster = vi.fn<RefreshPeopleRoster>();
|
|
596
|
+
await expect(
|
|
597
|
+
run(
|
|
598
|
+
[
|
|
599
|
+
"people",
|
|
600
|
+
"resolve",
|
|
601
|
+
"Ada Lovelace",
|
|
602
|
+
"--company",
|
|
603
|
+
"acme",
|
|
604
|
+
"--hq-root",
|
|
605
|
+
tmpRoot,
|
|
606
|
+
"--json",
|
|
607
|
+
"--local-only",
|
|
608
|
+
],
|
|
609
|
+
{ refreshRoster },
|
|
610
|
+
),
|
|
611
|
+
).rejects.toThrow("__exit__");
|
|
612
|
+
|
|
613
|
+
expect(refreshRoster).not.toHaveBeenCalled();
|
|
614
|
+
expect(JSON.parse(logSpy.mock.calls[0][0] as string)).toEqual({
|
|
615
|
+
status: "not_found",
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
412
619
|
it("resolve exits non-zero when not found", async () => {
|
|
413
620
|
await expect(
|
|
414
621
|
run([
|