@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.
Files changed (46) hide show
  1. package/dist/commands/mcp-registration.d.ts +905 -0
  2. package/dist/commands/mcp-registration.js +2001 -0
  3. package/dist/commands/mcp-status.d.ts +130 -0
  4. package/dist/commands/mcp-status.js +406 -0
  5. package/dist/commands/onboard-warning.d.ts +7 -0
  6. package/dist/commands/onboard-warning.js +14 -0
  7. package/dist/commands/onboard.js +5 -5
  8. package/dist/commands/pack-install.d.ts +74 -0
  9. package/dist/commands/pack-install.js +493 -14
  10. package/dist/commands/packs.js +42 -4
  11. package/dist/commands/pkg-install.js +5 -2
  12. package/dist/index.js +7 -2
  13. package/dist/types.d.ts +26 -1
  14. package/dist/utils/contribution-table.d.ts +103 -0
  15. package/dist/utils/contribution-table.js +65 -0
  16. package/dist/utils/pack-contributions.d.ts +93 -10
  17. package/dist/utils/pack-contributions.js +140 -48
  18. package/dist/utils/secrets-cache.d.ts +9 -0
  19. package/dist/utils/secrets-cache.js +24 -2
  20. package/dist/utils/version-gate.d.ts +40 -1
  21. package/dist/utils/version-gate.js +91 -20
  22. package/package.json +3 -2
  23. package/scripts/generate-scan-packages-table.mjs +113 -0
  24. package/src/commands/mcp-registration.test.ts +2787 -0
  25. package/src/commands/mcp-registration.ts +2612 -0
  26. package/src/commands/mcp-status.test.ts +483 -0
  27. package/src/commands/mcp-status.ts +575 -0
  28. package/src/commands/mcp-status.us011.test.ts +243 -0
  29. package/src/commands/onboard-warning.test.ts +26 -0
  30. package/src/commands/onboard-warning.ts +12 -0
  31. package/src/commands/onboard.ts +4 -7
  32. package/src/commands/pack-install.test.ts +733 -0
  33. package/src/commands/pack-install.ts +582 -13
  34. package/src/commands/packs.ts +45 -1
  35. package/src/commands/pkg-install.ts +4 -1
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +28 -9
  38. package/src/utils/contribution-table.ts +83 -0
  39. package/src/utils/pack-contributions.test.ts +310 -25
  40. package/src/utils/pack-contributions.ts +194 -47
  41. package/src/utils/secrets-cache.ts +22 -0
  42. package/src/utils/version-gate.test.ts +122 -0
  43. package/src/utils/version-gate.ts +109 -13
  44. package/test/e2e/smoke-install-mcp.sh +113 -0
  45. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
  46. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
@@ -1,83 +1,135 @@
1
1
  /**
2
- * Content-pack contribution helpers -- the single source of truth (in TS) for
3
- * the `contributes.* -> host-path` symlink mapping that `hq install` wires via
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. That mapping is duplicated today in two places:
10
+ * leaving dangling symlinks.
11
11
  *
12
- * - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
13
- * - pack-install.ts validateManifest's `subpaths` record (payload validation)
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
- * This module re-encodes it once for TS callers. A parity test
16
- * (`pack-contributions.test.ts`) asserts it matches scan-packages.sh's `case`
17
- * arms so the three copies cannot drift.
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
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f5492359-fdf3-5e85-9a81-699c4cd68f6a")}catch(e){}}();
40
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="63e778df-65dd-51a4-8a43-3a7d2aa9a451")}catch(e){}}();
21
41
  import * as fs from 'fs';
22
42
  import * as path from 'path';
23
43
  import * as yaml from 'js-yaml';
44
+ import { CONTRIBUTION_TABLE, payloadFor } from './contribution-table.js';
45
+ import { unregisterMcpServers, } from '../commands/mcp-registration.js';
46
+ // NOTE on dependency direction: this module imports from ../commands/mcp-registration.js
47
+ // to un-register `wire:merge` (mcp) servers on uninstall. mcp-registration.ts imports
48
+ // ONLY fs/os/path/smol-toml (it does NOT import from pack-contributions.ts), so there
49
+ // is NO import cycle. If that ever changes, invert via a callback injected by packs.ts.
50
+ export { CONTRIBUTION_TABLE };
24
51
  /**
25
- * Map one contributes entry to its source/host paths. MUST stay in lockstep
26
- * with scan-packages.sh:wire_one_package and pack-install.ts:validateManifest.
52
+ * Where a contributes key is ROUTED (US-005). Reads the single-source table's
53
+ * `wire` field `'symlink'` keys go through `linkFor`/`contributionLinks` (a
54
+ * host `ln -s`); `'merge'` keys (e.g. `mcp`) go through the `registerMcpServers`
55
+ * seam in `commands/mcp-registration.ts` and are NEVER symlinked.
56
+ *
57
+ * This is the explicit, code-enforced discriminator behind the invariant that
58
+ * merge keys never reach the symlink path: callers fan out on the return value
59
+ * rather than relying on `linkFor` happening to return `null`.
27
60
  */
28
- function linkFor(hqRoot, packDir, key, item) {
29
- let srcRel;
30
- let dstRel;
31
- switch (key) {
32
- case 'workers':
33
- srcRel = path.join('workers', item);
34
- dstRel = path.join('core', 'workers', 'public', item);
35
- break;
36
- case 'knowledge':
37
- srcRel = path.join('knowledge', item);
38
- dstRel = path.join('core', 'knowledge', 'public', item);
39
- break;
40
- case 'skills':
41
- srcRel = path.join('skills', item);
42
- dstRel = path.join('.claude', 'skills', item);
43
- break;
44
- case 'commands':
45
- srcRel = path.join('commands', `${item}.md`);
46
- dstRel = path.join('.claude', 'commands', `${item}.md`);
47
- break;
48
- case 'hooks':
49
- srcRel = path.join('hooks', `${item}.sh`);
50
- dstRel = path.join('.claude', 'hooks', `${item}.sh`);
51
- break;
52
- case 'policies':
53
- srcRel = path.join('policies', `${item}.md`);
54
- dstRel = path.join('core', 'policies', `${item}.md`);
55
- break;
56
- case 'scripts':
57
- srcRel = path.join('scripts', item);
58
- dstRel = path.join('core', 'scripts', item);
59
- break;
61
+ export function routeContribution(key) {
62
+ return CONTRIBUTION_TABLE[key].wire;
63
+ }
64
+ /**
65
+ * The subset of a pack's declared `contributes` keys that are `wire: 'merge'`
66
+ * (e.g. `mcp`) — the keys that MUST be routed to `registerMcpServers` (US-006)
67
+ * and MUST NOT be symlinked. Unknown keys and non-array/empty values are
68
+ * ignored, mirroring `contributionLinks`. Returned in `contributes` iteration
69
+ * order, de-duplicated.
70
+ */
71
+ export function mergeKeys(contributes) {
72
+ const out = [];
73
+ for (const [key, items] of Object.entries(contributes)) {
74
+ if (!(key in CONTRIBUTION_TABLE))
75
+ continue; // unknown key -> ignore
76
+ if (!Array.isArray(items) || items.length === 0)
77
+ continue;
78
+ if (routeContribution(key) === 'merge' && !out.includes(key))
79
+ out.push(key);
60
80
  }
81
+ return out;
82
+ }
83
+ /**
84
+ * Map one SYMLINK contributes entry to its source/host paths, READING the
85
+ * single-source `CONTRIBUTION_TABLE` (US-003). The src is the expanded pack
86
+ * payload; the dst is `<host-dir>/<payload-basename>` so per-key suffixes
87
+ * (`.md`, `.sh`, none) carry through.
88
+ *
89
+ * Returns `null` for a `wire: 'merge'` key (e.g. `mcp`): a merge contribution
90
+ * is not a symlink and has no `WiredLink`. Callers that only handle symlinks
91
+ * (contributionLinks/unwirePack) skip those keys.
92
+ */
93
+ function linkFor(hqRoot, packDir, key, item) {
94
+ const row = CONTRIBUTION_TABLE[key];
95
+ if (row.wire !== 'symlink')
96
+ return null;
97
+ const payloadRel = payloadFor(key, item); // e.g. commands/foo.md
98
+ // dst keeps the payload's basename (so the suffix carries through) under the
99
+ // host directory declared in the table.
100
+ const dstRel = path.join(row.host, path.basename(payloadRel));
61
101
  return {
62
102
  key,
63
103
  item,
64
- src: path.join(packDir, srcRel),
104
+ src: path.join(packDir, payloadRel),
65
105
  dst: path.join(hqRoot, dstRel),
66
106
  };
67
107
  }
68
108
  /**
69
- * Every symlink a pack's `contributes` block declares. Empty subfields and
70
- * non-array values are ignored, mirroring scan-packages.sh.
109
+ * Every SYMLINK a pack's `contributes` block declares. Empty subfields and
110
+ * non-array values are ignored, mirroring scan-packages.sh. `wire: 'merge'`
111
+ * keys (e.g. `mcp`) produce no symlink and are skipped here -- their host
112
+ * effect (a config merge) is handled separately (US-004/US-005).
71
113
  */
72
114
  export function contributionLinks(hqRoot, packDir, contributes) {
73
115
  const links = [];
74
116
  for (const [key, items] of Object.entries(contributes)) {
117
+ if (!(key in CONTRIBUTION_TABLE))
118
+ continue; // unknown key -> ignore
75
119
  if (!Array.isArray(items))
76
120
  continue;
121
+ // INVARIANT (US-005): merge keys are NEVER symlinked. They route to
122
+ // registerMcpServers (commands/mcp-registration.ts) instead. Skip them
123
+ // explicitly here -- not just via linkFor returning null -- so the symlink
124
+ // path provably excludes them.
125
+ if (routeContribution(key) === 'merge')
126
+ continue;
77
127
  for (const item of items) {
78
128
  if (typeof item !== 'string' || item.length === 0)
79
129
  continue;
80
- links.push(linkFor(hqRoot, packDir, key, item));
130
+ const link = linkFor(hqRoot, packDir, key, item);
131
+ if (link)
132
+ links.push(link); // null for merge keys -- skip
81
133
  }
82
134
  }
83
135
  return links;
@@ -161,6 +213,16 @@ export function listInstalledPacks(hqRoot) {
161
213
  }
162
214
  return out;
163
215
  }
216
+ /**
217
+ * Installed packs that declare `name` in their `requires.packs` (M0). Pure over
218
+ * the supplied list — the uninstall dependents guard calls this with
219
+ * `listInstalledPacks(hqRoot)`. Excludes the pack named `name` itself, so a
220
+ * self-reference never counts as its own dependent.
221
+ */
222
+ export function findDependentPacks(installed, name) {
223
+ return installed.filter((p) => p.name !== name &&
224
+ (p.manifest?.requires?.packs ?? []).some((d) => d.name === name));
225
+ }
164
226
  /**
165
227
  * Remove only the host symlinks that resolve into THIS pack's directory
166
228
  * (status `live` or `broken`). Foreign links and real files are left in place
@@ -196,6 +258,36 @@ export function unwirePack(hqRoot, packDir, contributes) {
196
258
  }
197
259
  return result;
198
260
  }
261
+ /**
262
+ * Un-register the `wire: 'merge'` (MCP) servers a pack declares — the parallel to
263
+ * {@link unwirePack}'s symlink removal. For each name in `mergeKeys(contributes)` →
264
+ * `contributes.<mergeKey>` (today only `mcp`), this delegates to
265
+ * {@link unregisterMcpServers}, which removes ONLY entries stamped with THIS pack's
266
+ * `_hqPack` provenance and SKIPS-AND-WARNS on a foreign/unstamped same-named entry.
267
+ *
268
+ * No-ops cleanly when the pack declares no merge keys (returns `{ servers: [] }`),
269
+ * when the runtime config is absent (Codex-less host → first-class skip; missing
270
+ * ~/.claude.json → `absent`), and on a re-run (already-removed → `absent`). It does
271
+ * NOT throw on a foreign entry — uninstall of one pack must not abort on a server
272
+ * another pack (or the user) owns.
273
+ *
274
+ * @param packName the pack being uninstalled — the provenance to match
275
+ * @param contributes the pack's `contributes` block (its `mcp` list drives removal)
276
+ */
277
+ export function unwirePackMcp(packName, contributes, options) {
278
+ const servers = [];
279
+ for (const key of mergeKeys(contributes)) {
280
+ const names = (contributes[key] ?? []).filter((n) => typeof n === 'string' && n.length > 0);
281
+ if (names.length === 0)
282
+ continue;
283
+ servers.push(...unregisterMcpServers(packName, names, {
284
+ env: options?.env,
285
+ lock: options?.lock,
286
+ stamp: options?.stamp,
287
+ }));
288
+ }
289
+ return { servers };
290
+ }
199
291
  // ---------------------------------------------------------------------------
200
292
  // Host introspection: hqVersion + recommended_packages catalog
201
293
  // ---------------------------------------------------------------------------
@@ -236,4 +328,4 @@ export function readRecommendedPackages(hqRoot) {
236
328
  }
237
329
  }
238
330
  //# sourceMappingURL=pack-contributions.js.map
239
- //# debugId=f5492359-fdf3-5e85-9a81-699c4cd68f6a
331
+ //# debugId=63e778df-65dd-51a4-8a43-3a7d2aa9a451
@@ -1,6 +1,15 @@
1
1
  export declare const DEFAULT_SECRETS_CACHE_TTL_MS: number;
2
2
  export declare function readCache(companyUid: string, name: string): string | null;
3
3
  export declare function writeCache(companyUid: string, name: string, value: string, ttlMs?: number): void;
4
+ /**
5
+ * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
6
+ * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
7
+ * registration) that have no `--company` flag and no network token and so cannot
8
+ * resolve a single active company UID up front: they instead probe every cached
9
+ * scope for a given secret name. Returns `[]` when the cache root is absent or
10
+ * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
11
+ */
12
+ export declare function listSecretCacheScopes(): string[];
4
13
  export declare function removeCacheEntry(companyUid: string, name: string): void;
5
14
  export declare function clearAllCache(): {
6
15
  removed: number;
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fd3738da-3061-5784-bb7c-02021291aecc")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="68c3f1d3-dac4-57d6-8e2b-650c10a807ba")}catch(e){}}();
3
3
  import * as crypto from "node:crypto";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
@@ -140,6 +140,28 @@ export function writeCache(companyUid, name, value, ttlMs = DEFAULT_SECRETS_CACH
140
140
  // Cache write failure is non-fatal
141
141
  }
142
142
  }
143
+ /**
144
+ * List the scope UIDs (`cmp_*` / `prs_*` subdirectories) that currently have a
145
+ * secrets-cache directory on disk. Used by offline callers (e.g. install-time MCP
146
+ * registration) that have no `--company` flag and no network token and so cannot
147
+ * resolve a single active company UID up front: they instead probe every cached
148
+ * scope for a given secret name. Returns `[]` when the cache root is absent or
149
+ * unreadable (the desired graceful-deferral behavior — no scopes, no hits).
150
+ */
151
+ export function listSecretCacheScopes() {
152
+ try {
153
+ return fs
154
+ .readdirSync(CACHE_DIR, { withFileTypes: true })
155
+ .filter((e) => e.isDirectory())
156
+ .map((e) => e.name)
157
+ // Only real entity scopes (cmp_*/prs_*); validateInputs in readCache also
158
+ // rejects anything with `/` or `..`, so this is belt-and-suspenders.
159
+ .filter((name) => !name.startsWith("."));
160
+ }
161
+ catch {
162
+ return [];
163
+ }
164
+ }
143
165
  export function removeCacheEntry(companyUid, name) {
144
166
  if (!validateInputs(companyUid, name))
145
167
  return;
@@ -163,4 +185,4 @@ export function clearAllCache() {
163
185
  return { removed };
164
186
  }
165
187
  //# sourceMappingURL=secrets-cache.js.map
166
- //# debugId=fd3738da-3061-5784-bb7c-02021291aecc
188
+ //# debugId=68c3f1d3-dac4-57d6-8e2b-650c10a807ba
@@ -27,16 +27,49 @@
27
27
  * Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
28
28
  * to silence both check + gate).
29
29
  */
30
+ interface VersionCheckResponse {
31
+ clientId: string;
32
+ currentVersion: string;
33
+ minVersion: string;
34
+ latestVersion: string;
35
+ updateRequired: boolean;
36
+ updateRecommended: boolean;
37
+ updateCommand?: string;
38
+ downloadUrl?: string;
39
+ message?: string;
40
+ }
41
+ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
42
+ export declare function resolveRunningPrefix(): string | null;
43
+ export declare function buildPrefixedInstallArgv(prefix: string): string[];
30
44
  /**
31
45
  * Run the upgrade command in a blocking subprocess. Inherits stdio so the
32
46
  * user sees the npm progress. We do NOT auto-rerun the CLI on completion —
33
47
  * forcing a re-invocation would run twice on the same process and feel
34
48
  * janky; instead we print a clear "rerun your command" message and exit.
35
49
  */
36
- declare function performUpdate(command: string): {
50
+ type UpdateResult = {
37
51
  ok: boolean;
38
52
  detail?: string;
39
53
  };
54
+ type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
55
+ declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
56
+ declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
57
+ declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
58
+ /**
59
+ * Hard enforcement when the server says we're below `minVersion`. Print a
60
+ * red banner, attempt the update, then exit so the user reruns against the
61
+ * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
62
+ * still gets a clear error rather than an opaque silent failure.
63
+ *
64
+ * Exit codes:
65
+ * 0 — update succeeded; user must rerun their command
66
+ * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
67
+ */
68
+ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
69
+ performUpdateString?: (command: string) => UpdateResult;
70
+ resolvePrefix?: () => string | null;
71
+ runner?: UpdateRunner;
72
+ }): never;
40
73
  /**
41
74
  * Public entry point. Call before commander parses argv. Blocks the CLI on
42
75
  * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
@@ -58,7 +91,13 @@ export declare const __test__: {
58
91
  CLIENT_ID: string;
59
92
  ENDPOINT_PATH: string;
60
93
  FETCH_TIMEOUT_MS: number;
94
+ buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
95
+ enforceUpdateRequired: typeof enforceUpdateRequired;
96
+ npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
61
97
  performUpdate: typeof performUpdate;
98
+ performUpdateCommand: typeof performUpdateCommand;
99
+ runUpdateCommand: typeof runUpdateCommand;
100
+ resolveRunningPrefix: typeof resolveRunningPrefix;
62
101
  };
63
102
  export {};
64
103
  //# sourceMappingURL=version-gate.d.ts.map
@@ -28,17 +28,66 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
 
31
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d7d58261-f0e4-56bc-b6eb-4117731744f9")}catch(e){}}();
31
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0f613895-ad4a-5211-bf75-a2e843782c47")}catch(e){}}();
32
32
  import { spawnSync } from "node:child_process";
33
+ import { readFileSync } from "node:fs";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
33
36
  import chalk from "chalk";
34
- import { CLI_VERSION } from "../cli-version.js";
37
+ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
35
38
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
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
  function isOptedOut() {
40
44
  return process.env.HQ_NO_UPDATE_CHECK === "1";
41
45
  }
46
+ function findRunningPackageRoot() {
47
+ let dir = path.dirname(fileURLToPath(import.meta.url));
48
+ while (true) {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf-8"));
51
+ if (pkg.name === CLI_NAME)
52
+ return dir;
53
+ }
54
+ catch {
55
+ // Keep walking; compiled installs usually start under dist/.
56
+ }
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir)
59
+ return null;
60
+ dir = parent;
61
+ }
62
+ }
63
+ export function npmPrefixFromPackageDir(pkgDir) {
64
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
65
+ const segments = normalized.split("/");
66
+ const nodeModulesIndex = segments.lastIndexOf("node_modules");
67
+ if (nodeModulesIndex === -1)
68
+ return null;
69
+ const prefixEnd = segments[nodeModulesIndex - 1] === "lib"
70
+ ? nodeModulesIndex - 1
71
+ : nodeModulesIndex;
72
+ const prefix = segments.slice(0, prefixEnd).join("/");
73
+ if (prefix === "" && normalized.startsWith("/"))
74
+ return "/";
75
+ return prefix || null;
76
+ }
77
+ export function resolveRunningPrefix() {
78
+ try {
79
+ const pkgRoot = findRunningPackageRoot();
80
+ if (!pkgRoot)
81
+ return null;
82
+ return npmPrefixFromPackageDir(pkgRoot);
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ export function buildPrefixedInstallArgv(prefix) {
89
+ return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
90
+ }
42
91
  /**
43
92
  * Hit POST /v1/client-version/check. Returns the parsed body on 200, or
44
93
  * `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
@@ -74,18 +123,7 @@ async function fetchVersionDecision() {
74
123
  return null;
75
124
  }
76
125
  }
77
- /**
78
- * Run the upgrade command in a blocking subprocess. Inherits stdio so the
79
- * user sees the npm progress. We do NOT auto-rerun the CLI on completion —
80
- * forcing a re-invocation would run twice on the same process and feel
81
- * janky; instead we print a clear "rerun your command" message and exit.
82
- */
83
- function performUpdate(command) {
84
- const parts = command.split(/\s+/).filter(Boolean);
85
- if (parts.length === 0)
86
- return { ok: false, detail: "empty command" };
87
- const cmd = parts[0];
88
- const args = parts.slice(1);
126
+ function runUpdateCommand(cmd, args) {
89
127
  try {
90
128
  const result = spawnSync(cmd, args, { stdio: "inherit" });
91
129
  if (result.status !== 0) {
@@ -100,6 +138,17 @@ function performUpdate(command) {
100
138
  return { ok: false, detail: err instanceof Error ? err.message : String(err) };
101
139
  }
102
140
  }
141
+ function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
142
+ return runner(cmd, args);
143
+ }
144
+ function performUpdate(command, runner = runUpdateCommand) {
145
+ const parts = command.split(/\s+/).filter(Boolean);
146
+ if (parts.length === 0)
147
+ return { ok: false, detail: "empty command" };
148
+ const cmd = parts[0];
149
+ const args = parts.slice(1);
150
+ return performUpdateCommand(cmd, args, runner);
151
+ }
103
152
  /**
104
153
  * Soft notify when the server says we're below `latestVersion` but still ≥
105
154
  * `minVersion`. Single chalk-yellow line on stderr; never blocks.
@@ -121,24 +170,40 @@ function nudgeUpdateRecommended(decision) {
121
170
  * 0 — update succeeded; user must rerun their command
122
171
  * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
123
172
  */
124
- function enforceUpdateRequired(decision) {
173
+ function enforceUpdateRequired(decision, deps = {}) {
125
174
  const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
126
175
  console.error(banner);
127
176
  if (decision.message)
128
177
  console.error(chalk.dim(` ${decision.message}`));
129
178
  const command = decision.updateCommand;
130
- if (!command) {
179
+ const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
180
+ if (!command && !prefix) {
131
181
  console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
132
182
  if (decision.downloadUrl) {
133
183
  console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
134
184
  }
135
185
  process.exit(75);
136
186
  }
137
- console.error(chalk.dim(` Running: ${command}`));
138
- const result = performUpdate(command);
187
+ const runner = deps.runner ?? runUpdateCommand;
188
+ const result = prefix
189
+ ? (() => {
190
+ const args = buildPrefixedInstallArgv(prefix);
191
+ console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
192
+ console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
193
+ return performUpdateCommand("npm", args, runner);
194
+ })()
195
+ : (() => {
196
+ console.error(chalk.dim(` Running: ${command}`));
197
+ return deps.performUpdateString
198
+ ? deps.performUpdateString(command)
199
+ : performUpdate(command, runner);
200
+ })();
139
201
  if (!result.ok) {
140
202
  console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
141
- console.error(chalk.dim(` Try manually: ${command}`));
203
+ const manual = prefix
204
+ ? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
205
+ : command;
206
+ console.error(chalk.dim(` Try manually: ${manual}`));
142
207
  process.exit(75);
143
208
  }
144
209
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
@@ -179,7 +244,13 @@ export const __test__ = {
179
244
  CLIENT_ID,
180
245
  ENDPOINT_PATH,
181
246
  FETCH_TIMEOUT_MS,
247
+ buildPrefixedInstallArgv,
248
+ enforceUpdateRequired,
249
+ npmPrefixFromPackageDir,
182
250
  performUpdate,
251
+ performUpdateCommand,
252
+ runUpdateCommand,
253
+ resolveRunningPrefix,
183
254
  };
184
255
  //# sourceMappingURL=version-gate.js.map
185
- //# debugId=d7d58261-f0e4-56bc-b6eb-4117731744f9
256
+ //# debugId=0f613895-ad4a-5211-bf75-a2e843782c47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.50.0",
3
+ "version": "5.50.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "clean": "rm -rf dist"
19
19
  },
20
20
  "dependencies": {
21
- "@indigoai-us/hq-cloud": "^6.12.0",
21
+ "@indigoai-us/hq-cloud": "^6.12.1",
22
22
  "@indigoai-us/hq-onboarding": "^0.1.0",
23
23
  "@sentry/node": "^10.49.0",
24
24
  "chalk": "^5.3.0",
@@ -27,6 +27,7 @@
27
27
  "open": "^10.1.0",
28
28
  "semver": "^7.6.3",
29
29
  "simple-git": "^3.27.0",
30
+ "smol-toml": "1.6.1",
30
31
  "varlock": "1.0.0"
31
32
  },
32
33
  "devDependencies": {