@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
|
@@ -1,26 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Content-pack contribution helpers -- the
|
|
3
|
-
* the `contributes.* -> host
|
|
2
|
+
* Content-pack contribution helpers -- the SINGLE SOURCE OF TRUTH (US-003) for
|
|
3
|
+
* the `contributes.* -> host` mapping that `hq install` wires via
|
|
4
4
|
* `core/scripts/scan-packages.sh`.
|
|
5
5
|
*
|
|
6
6
|
* `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
|
|
7
7
|
* tracked by filesystem presence -- there is no registry file). The list /
|
|
8
8
|
* update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
|
|
9
9
|
* SAME mapping so it can report link health and cleanly un-wire a pack without
|
|
10
|
-
* leaving dangling symlinks.
|
|
10
|
+
* leaving dangling symlinks.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Historically that mapping was RESTATED in five places (the scan-packages.sh
|
|
13
|
+
* bash `case`, pack-install.ts:validateManifest's `subpaths` record,
|
|
14
|
+
* `linkFor`'s switch, `contributionLinks`, and the `PackContributeKey` union),
|
|
15
|
+
* drift-guarded only by a substring check. US-003 collapses it to ONE
|
|
16
|
+
* declarative table -- `CONTRIBUTION_TABLE` below -- with a row per key
|
|
17
|
+
* `{ payload, host, wire }`:
|
|
14
18
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* - `PackContributeKey` is DERIVED from the table keys (see types.ts).
|
|
20
|
+
* - `linkFor` / `contributionLinks` / the `subpaths` validator READ the table.
|
|
21
|
+
* - `core/scripts/scan-packages.sh` reads a generated data block emitted from
|
|
22
|
+
* this same table (regenerate via `scripts/generate-scan-packages-table.mjs`).
|
|
23
|
+
*
|
|
24
|
+
* A parity test (`pack-contributions.test.ts`) asserts FULL key-set +
|
|
25
|
+
* payload-suffix + wire-mode equivalence across every surface, so the copies
|
|
26
|
+
* cannot drift.
|
|
27
|
+
*
|
|
28
|
+
* `wire` discriminates HOW a contribution reaches the host:
|
|
29
|
+
* - `symlink`: a single `ln -s` into a well-known host directory (every
|
|
30
|
+
* contribution type that shipped before MCP).
|
|
31
|
+
* - `merge`: the contribution is MERGED into a shared host config rather than
|
|
32
|
+
* symlinked (the `mcp` row -- registered into the Claude + Codex agent
|
|
33
|
+
* configs). The merge WIRING is delivered in US-004/US-005; US-003 only
|
|
34
|
+
* DECLARES the row as data so the table is the single source and the parity
|
|
35
|
+
* test guards the real invariant. `symlink`-only readers (linkFor,
|
|
36
|
+
* contributionLinks, the symlink validator, scan-packages) must SKIP
|
|
37
|
+
* `merge` rows -- they never produce a symlink.
|
|
18
38
|
*/
|
|
19
39
|
|
|
20
40
|
import * as fs from 'fs';
|
|
21
41
|
import * as path from 'path';
|
|
22
42
|
import * as yaml from 'js-yaml';
|
|
23
43
|
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
44
|
+
import { CONTRIBUTION_TABLE, payloadFor, type WireMode } from './contribution-table.js';
|
|
45
|
+
import {
|
|
46
|
+
unregisterMcpServers,
|
|
47
|
+
type UnregisterServerResult,
|
|
48
|
+
type SafeWriteEnv,
|
|
49
|
+
type AcquireLockOptions,
|
|
50
|
+
} from '../commands/mcp-registration.js';
|
|
51
|
+
|
|
52
|
+
// NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
|
|
53
|
+
// to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
|
|
54
|
+
// ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
|
|
55
|
+
// is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
|
|
56
|
+
|
|
57
|
+
export { CONTRIBUTION_TABLE };
|
|
58
|
+
export type { WireMode };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where a contributes key is ROUTED (US-005). Reads the single-source table's
|
|
62
|
+
* `wire` field — `'symlink'` keys go through `linkFor`/`contributionLinks` (a
|
|
63
|
+
* host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
|
|
64
|
+
* seam in `commands/mcp-registration.ts` and are NEVER symlinked.
|
|
65
|
+
*
|
|
66
|
+
* This is the explicit, code-enforced discriminator behind the invariant that
|
|
67
|
+
* merge keys never reach the symlink path: callers fan out on the return value
|
|
68
|
+
* rather than relying on `linkFor` happening to return `null`.
|
|
69
|
+
*/
|
|
70
|
+
export function routeContribution(key: PackContributeKey): WireMode {
|
|
71
|
+
return CONTRIBUTION_TABLE[key].wire;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
|
|
76
|
+
* (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
|
|
77
|
+
* and MUST NOT be symlinked. Unknown keys and non-array/empty values are
|
|
78
|
+
* ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
|
|
79
|
+
* order, de-duplicated.
|
|
80
|
+
*/
|
|
81
|
+
export function mergeKeys(
|
|
82
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
83
|
+
): PackContributeKey[] {
|
|
84
|
+
const out: PackContributeKey[] = [];
|
|
85
|
+
for (const [key, items] of Object.entries(contributes) as [
|
|
86
|
+
PackContributeKey,
|
|
87
|
+
unknown,
|
|
88
|
+
][]) {
|
|
89
|
+
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
90
|
+
if (!Array.isArray(items) || items.length === 0) continue;
|
|
91
|
+
if (routeContribution(key) === 'merge' && !out.includes(key)) out.push(key);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
24
95
|
|
|
25
96
|
/** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
|
|
26
97
|
export interface WiredLink {
|
|
@@ -37,58 +108,40 @@ export type LinkStatus =
|
|
|
37
108
|
| 'foreign'; // dst exists but points elsewhere or is a real file (collision)
|
|
38
109
|
|
|
39
110
|
/**
|
|
40
|
-
* Map one contributes entry to its source/host paths
|
|
41
|
-
*
|
|
111
|
+
* Map one SYMLINK contributes entry to its source/host paths, READING the
|
|
112
|
+
* single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
|
|
113
|
+
* payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
|
|
114
|
+
* (`.md`, `.sh`, none) carry through.
|
|
115
|
+
*
|
|
116
|
+
* Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
|
|
117
|
+
* is not a symlink and has no `WiredLink`. Callers that only handle symlinks
|
|
118
|
+
* (contributionLinks/unwirePack) skip those keys.
|
|
42
119
|
*/
|
|
43
120
|
function linkFor(
|
|
44
121
|
hqRoot: string,
|
|
45
122
|
packDir: string,
|
|
46
123
|
key: PackContributeKey,
|
|
47
124
|
item: string,
|
|
48
|
-
): WiredLink {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
break;
|
|
56
|
-
case 'knowledge':
|
|
57
|
-
srcRel = path.join('knowledge', item);
|
|
58
|
-
dstRel = path.join('core', 'knowledge', 'public', item);
|
|
59
|
-
break;
|
|
60
|
-
case 'skills':
|
|
61
|
-
srcRel = path.join('skills', item);
|
|
62
|
-
dstRel = path.join('.claude', 'skills', item);
|
|
63
|
-
break;
|
|
64
|
-
case 'commands':
|
|
65
|
-
srcRel = path.join('commands', `${item}.md`);
|
|
66
|
-
dstRel = path.join('.claude', 'commands', `${item}.md`);
|
|
67
|
-
break;
|
|
68
|
-
case 'hooks':
|
|
69
|
-
srcRel = path.join('hooks', `${item}.sh`);
|
|
70
|
-
dstRel = path.join('.claude', 'hooks', `${item}.sh`);
|
|
71
|
-
break;
|
|
72
|
-
case 'policies':
|
|
73
|
-
srcRel = path.join('policies', `${item}.md`);
|
|
74
|
-
dstRel = path.join('core', 'policies', `${item}.md`);
|
|
75
|
-
break;
|
|
76
|
-
case 'scripts':
|
|
77
|
-
srcRel = path.join('scripts', item);
|
|
78
|
-
dstRel = path.join('core', 'scripts', item);
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
125
|
+
): WiredLink | null {
|
|
126
|
+
const row = CONTRIBUTION_TABLE[key];
|
|
127
|
+
if (row.wire !== 'symlink') return null;
|
|
128
|
+
const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
|
|
129
|
+
// dst keeps the payload's basename (so the suffix carries through) under the
|
|
130
|
+
// host directory declared in the table.
|
|
131
|
+
const dstRel = path.join(row.host, path.basename(payloadRel));
|
|
81
132
|
return {
|
|
82
133
|
key,
|
|
83
134
|
item,
|
|
84
|
-
src: path.join(packDir,
|
|
135
|
+
src: path.join(packDir, payloadRel),
|
|
85
136
|
dst: path.join(hqRoot, dstRel),
|
|
86
137
|
};
|
|
87
138
|
}
|
|
88
139
|
|
|
89
140
|
/**
|
|
90
|
-
* Every
|
|
91
|
-
* non-array values are ignored, mirroring scan-packages.sh.
|
|
141
|
+
* Every SYMLINK a pack's `contributes` block declares. Empty subfields and
|
|
142
|
+
* non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
|
|
143
|
+
* keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
|
|
144
|
+
* effect (a config merge) is handled separately (US-004/US-005).
|
|
92
145
|
*/
|
|
93
146
|
export function contributionLinks(
|
|
94
147
|
hqRoot: string,
|
|
@@ -100,10 +153,17 @@ export function contributionLinks(
|
|
|
100
153
|
PackContributeKey,
|
|
101
154
|
unknown,
|
|
102
155
|
][]) {
|
|
156
|
+
if (!(key in CONTRIBUTION_TABLE)) continue; // unknown key -> ignore
|
|
103
157
|
if (!Array.isArray(items)) continue;
|
|
158
|
+
// INVARIANT (US-005): merge keys are NEVER symlinked. They route to
|
|
159
|
+
// registerMcpServers (commands/mcp-registration.ts) instead. Skip them
|
|
160
|
+
// explicitly here -- not just via linkFor returning null -- so the symlink
|
|
161
|
+
// path provably excludes them.
|
|
162
|
+
if (routeContribution(key) === 'merge') continue;
|
|
104
163
|
for (const item of items) {
|
|
105
164
|
if (typeof item !== 'string' || item.length === 0) continue;
|
|
106
|
-
|
|
165
|
+
const link = linkFor(hqRoot, packDir, key, item);
|
|
166
|
+
if (link) links.push(link); // null for merge keys -- skip
|
|
107
167
|
}
|
|
108
168
|
}
|
|
109
169
|
return links;
|
|
@@ -204,6 +264,23 @@ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
|
|
|
204
264
|
return out;
|
|
205
265
|
}
|
|
206
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
|
+
|
|
207
284
|
// ---------------------------------------------------------------------------
|
|
208
285
|
// Un-wiring (the uninstall guarantee)
|
|
209
286
|
// ---------------------------------------------------------------------------
|
|
@@ -256,6 +333,76 @@ export function unwirePack(
|
|
|
256
333
|
return result;
|
|
257
334
|
}
|
|
258
335
|
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// MCP un-wiring (US-009) — the `wire: 'merge'` parallel to symlink un-wiring.
|
|
338
|
+
//
|
|
339
|
+
// `unwirePack` above only removes SYMLINKS: it iterates `contributionLinks`, which
|
|
340
|
+
// SKIPS every `wire: 'merge'` key (e.g. `mcp`). So a pack's MCP servers — registered
|
|
341
|
+
// into ~/.claude.json + ~/.codex/config.toml, NOT symlinked — are INVISIBLE to the
|
|
342
|
+
// symlink unwire and would leak after uninstall. `unwirePackMcp` is the parallel
|
|
343
|
+
// path: for each name in `contributes.mcp` it calls `unregisterMcpServers` (US-009),
|
|
344
|
+
// which removes ONLY entries whose `_hqPack` provenance matches THIS pack and
|
|
345
|
+
// skip-and-warns on any foreign/unstamped same-named entry.
|
|
346
|
+
//
|
|
347
|
+
// Kept as a SEPARATE, composable call from `unwirePack` (not folded into it) so the
|
|
348
|
+
// existing `unwirePack` signature + tests stay intact and each path is testable in
|
|
349
|
+
// isolation. The uninstall command composes both (symlink unwire + MCP unwire).
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
/** The result of {@link unwirePackMcp}: the per-server un-registration outcomes (empty when no `mcp` keys). */
|
|
353
|
+
export interface UnwireMcpResult {
|
|
354
|
+
/** One {@link UnregisterServerResult} per declared `contributes.mcp` server, in declaration order. */
|
|
355
|
+
servers: UnregisterServerResult[];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Tuning passthrough for {@link unwirePackMcp} (tests inject a tmpdir `env.home` + tiny lock timeouts). */
|
|
359
|
+
export interface UnwirePackMcpOptions {
|
|
360
|
+
/** Injectable env — tests MUST pass a tmpdir `home` so the real ~/.claude.json / ~/.codex are untouched. */
|
|
361
|
+
env?: Partial<SafeWriteEnv>;
|
|
362
|
+
/** Lock tuning (tests use tiny timeouts). */
|
|
363
|
+
lock?: AcquireLockOptions;
|
|
364
|
+
/** Backup timestamp override (deterministic tests). */
|
|
365
|
+
stamp?: string;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
|
|
370
|
+
* {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
|
|
371
|
+
* `contributes.<mergeKey>` (today only `mcp`), this delegates to
|
|
372
|
+
* {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
|
|
373
|
+
* `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
|
|
374
|
+
*
|
|
375
|
+
* No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
|
|
376
|
+
* when the runtime config is absent (Codex-less host → first-class skip; missing
|
|
377
|
+
* ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
|
|
378
|
+
* NOT throw on a foreign entry — uninstall of one pack must not abort on a server
|
|
379
|
+
* another pack (or the user) owns.
|
|
380
|
+
*
|
|
381
|
+
* @param packName the pack being uninstalled — the provenance to match
|
|
382
|
+
* @param contributes the pack's `contributes` block (its `mcp` list drives removal)
|
|
383
|
+
*/
|
|
384
|
+
export function unwirePackMcp(
|
|
385
|
+
packName: string,
|
|
386
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
387
|
+
options?: UnwirePackMcpOptions,
|
|
388
|
+
): UnwireMcpResult {
|
|
389
|
+
const servers: UnregisterServerResult[] = [];
|
|
390
|
+
for (const key of mergeKeys(contributes)) {
|
|
391
|
+
const names = (contributes[key] ?? []).filter(
|
|
392
|
+
(n): n is string => typeof n === 'string' && n.length > 0,
|
|
393
|
+
);
|
|
394
|
+
if (names.length === 0) continue;
|
|
395
|
+
servers.push(
|
|
396
|
+
...unregisterMcpServers(packName, names, {
|
|
397
|
+
env: options?.env,
|
|
398
|
+
lock: options?.lock,
|
|
399
|
+
stamp: options?.stamp,
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return { servers };
|
|
404
|
+
}
|
|
405
|
+
|
|
259
406
|
// ---------------------------------------------------------------------------
|
|
260
407
|
// Host introspection: hqVersion + recommended_packages catalog
|
|
261
408
|
// ---------------------------------------------------------------------------
|
|
@@ -142,6 +142,28 @@ export function writeCache(
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
|
|
147
|
+
* secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
|
|
148
|
+
* registration) that have no `--company` flag and no network token and so cannot
|
|
149
|
+
* resolve a single active company UID up front: they instead probe every cached
|
|
150
|
+
* scope for a given secret name. Returns `[]` when the cache root is absent or
|
|
151
|
+
* unreadable (the desired graceful-deferral behavior — no scopes, no hits).
|
|
152
|
+
*/
|
|
153
|
+
export function listSecretCacheScopes(): string[] {
|
|
154
|
+
try {
|
|
155
|
+
return fs
|
|
156
|
+
.readdirSync(CACHE_DIR, { withFileTypes: true })
|
|
157
|
+
.filter((e) => e.isDirectory())
|
|
158
|
+
.map((e) => e.name)
|
|
159
|
+
// Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
|
|
160
|
+
// rejects anything with `/` or `..`, so this is belt-and-suspenders.
|
|
161
|
+
.filter((name) => !name.startsWith("."));
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
145
167
|
export function removeCacheEntry(companyUid: string, name: string): void {
|
|
146
168
|
if (!validateInputs(companyUid, name)) return;
|
|
147
169
|
const filePath = path.join(CACHE_DIR, companyUid, name);
|
|
@@ -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
|
});
|
|
@@ -29,13 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
32
35
|
import chalk from "chalk";
|
|
33
|
-
import { CLI_VERSION } from "../cli-version.js";
|
|
36
|
+
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
34
37
|
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
35
38
|
|
|
36
39
|
const CLIENT_ID = "hq-cli";
|
|
37
40
|
const ENDPOINT_PATH = "/v1/client-version/check";
|
|
38
41
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
42
|
+
const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
|
|
39
43
|
|
|
40
44
|
interface VersionCheckResponse {
|
|
41
45
|
clientId: string;
|
|
@@ -53,6 +57,53 @@ function isOptedOut(): boolean {
|
|
|
53
57
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
function findRunningPackageRoot(): string | null {
|
|
61
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
while (true) {
|
|
63
|
+
try {
|
|
64
|
+
const pkg = JSON.parse(
|
|
65
|
+
readFileSync(path.join(dir, "package.json"), "utf-8"),
|
|
66
|
+
) as { name?: unknown };
|
|
67
|
+
if (pkg.name === CLI_NAME) return dir;
|
|
68
|
+
} catch {
|
|
69
|
+
// Keep walking; compiled installs usually start under dist/.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parent = path.dirname(dir);
|
|
73
|
+
if (parent === dir) return null;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function npmPrefixFromPackageDir(pkgDir: string): string | null {
|
|
79
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
80
|
+
const segments = normalized.split("/");
|
|
81
|
+
const nodeModulesIndex = segments.lastIndexOf("node_modules");
|
|
82
|
+
if (nodeModulesIndex === -1) return null;
|
|
83
|
+
|
|
84
|
+
const prefixEnd =
|
|
85
|
+
segments[nodeModulesIndex - 1] === "lib"
|
|
86
|
+
? nodeModulesIndex - 1
|
|
87
|
+
: nodeModulesIndex;
|
|
88
|
+
const prefix = segments.slice(0, prefixEnd).join("/");
|
|
89
|
+
if (prefix === "" && normalized.startsWith("/")) return "/";
|
|
90
|
+
return prefix || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveRunningPrefix(): string | null {
|
|
94
|
+
try {
|
|
95
|
+
const pkgRoot = findRunningPackageRoot();
|
|
96
|
+
if (!pkgRoot) return null;
|
|
97
|
+
return npmPrefixFromPackageDir(pkgRoot);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildPrefixedInstallArgv(prefix: string): string[] {
|
|
104
|
+
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
/**
|
|
57
108
|
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
58
109
|
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
@@ -95,13 +146,10 @@ async function fetchVersionDecision(): Promise<VersionCheckResponse | null> {
|
|
|
95
146
|
* forcing a re-invocation would run twice on the same process and feel
|
|
96
147
|
* janky; instead we print a clear "rerun your command" message and exit.
|
|
97
148
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (parts.length === 0) return { ok: false, detail: "empty command" };
|
|
103
|
-
const cmd = parts[0]!;
|
|
104
|
-
const args = parts.slice(1);
|
|
149
|
+
type UpdateResult = { ok: boolean; detail?: string };
|
|
150
|
+
type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
|
|
151
|
+
|
|
152
|
+
function runUpdateCommand(cmd: string, args: string[]): UpdateResult {
|
|
105
153
|
try {
|
|
106
154
|
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
107
155
|
if (result.status !== 0) {
|
|
@@ -116,6 +164,25 @@ function performUpdate(
|
|
|
116
164
|
}
|
|
117
165
|
}
|
|
118
166
|
|
|
167
|
+
function performUpdateCommand(
|
|
168
|
+
cmd: string,
|
|
169
|
+
args: string[],
|
|
170
|
+
runner: UpdateRunner = runUpdateCommand,
|
|
171
|
+
): UpdateResult {
|
|
172
|
+
return runner(cmd, args);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function performUpdate(
|
|
176
|
+
command: string,
|
|
177
|
+
runner: UpdateRunner = runUpdateCommand,
|
|
178
|
+
): UpdateResult {
|
|
179
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
180
|
+
if (parts.length === 0) return { ok: false, detail: "empty command" };
|
|
181
|
+
const cmd = parts[0]!;
|
|
182
|
+
const args = parts.slice(1);
|
|
183
|
+
return performUpdateCommand(cmd, args, runner);
|
|
184
|
+
}
|
|
185
|
+
|
|
119
186
|
/**
|
|
120
187
|
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
121
188
|
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
@@ -140,7 +207,14 @@ function nudgeUpdateRecommended(decision: VersionCheckResponse): void {
|
|
|
140
207
|
* 0 — update succeeded; user must rerun their command
|
|
141
208
|
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
142
209
|
*/
|
|
143
|
-
function enforceUpdateRequired(
|
|
210
|
+
function enforceUpdateRequired(
|
|
211
|
+
decision: VersionCheckResponse,
|
|
212
|
+
deps: {
|
|
213
|
+
performUpdateString?: (command: string) => UpdateResult;
|
|
214
|
+
resolvePrefix?: () => string | null;
|
|
215
|
+
runner?: UpdateRunner;
|
|
216
|
+
} = {},
|
|
217
|
+
): never {
|
|
144
218
|
const banner = chalk.red.bold(
|
|
145
219
|
`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`,
|
|
146
220
|
);
|
|
@@ -148,7 +222,8 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
|
|
|
148
222
|
if (decision.message) console.error(chalk.dim(` ${decision.message}`));
|
|
149
223
|
|
|
150
224
|
const command = decision.updateCommand;
|
|
151
|
-
|
|
225
|
+
const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
|
|
226
|
+
if (!command && !prefix) {
|
|
152
227
|
console.error(
|
|
153
228
|
chalk.red(
|
|
154
229
|
" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps.",
|
|
@@ -160,13 +235,28 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
|
|
|
160
235
|
process.exit(75);
|
|
161
236
|
}
|
|
162
237
|
|
|
163
|
-
|
|
164
|
-
const result =
|
|
238
|
+
const runner = deps.runner ?? runUpdateCommand;
|
|
239
|
+
const result = prefix
|
|
240
|
+
? (() => {
|
|
241
|
+
const args = buildPrefixedInstallArgv(prefix);
|
|
242
|
+
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
243
|
+
console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
|
|
244
|
+
return performUpdateCommand("npm", args, runner);
|
|
245
|
+
})()
|
|
246
|
+
: (() => {
|
|
247
|
+
console.error(chalk.dim(` Running: ${command}`));
|
|
248
|
+
return deps.performUpdateString
|
|
249
|
+
? deps.performUpdateString(command!)
|
|
250
|
+
: performUpdate(command!, runner);
|
|
251
|
+
})();
|
|
165
252
|
if (!result.ok) {
|
|
166
253
|
console.error(
|
|
167
254
|
chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`),
|
|
168
255
|
);
|
|
169
|
-
|
|
256
|
+
const manual = prefix
|
|
257
|
+
? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
|
|
258
|
+
: command!;
|
|
259
|
+
console.error(chalk.dim(` Try manually: ${manual}`));
|
|
170
260
|
process.exit(75);
|
|
171
261
|
}
|
|
172
262
|
|
|
@@ -215,5 +305,11 @@ export const __test__ = {
|
|
|
215
305
|
CLIENT_ID,
|
|
216
306
|
ENDPOINT_PATH,
|
|
217
307
|
FETCH_TIMEOUT_MS,
|
|
308
|
+
buildPrefixedInstallArgv,
|
|
309
|
+
enforceUpdateRequired,
|
|
310
|
+
npmPrefixFromPackageDir,
|
|
218
311
|
performUpdate,
|
|
312
|
+
performUpdateCommand,
|
|
313
|
+
runUpdateCommand,
|
|
314
|
+
resolveRunningPrefix,
|
|
219
315
|
};
|