@bli-cockpit/cli 0.2.50 → 0.2.51

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.
@@ -42,6 +42,13 @@
42
42
  * env = { COCKPIT_DASHBOARD_URL = "https://…" }
43
43
  * ```
44
44
  *
45
+ * **The printed `command` is a bare name and is never written as-is.** The bin
46
+ * cannot know where it was installed, and it lives nested inside
47
+ * `@bli-cockpit/cli`'s `node_modules` rather than on PATH — so
48
+ * `withResolvedBinPath` re-points every command at the absolute path this
49
+ * machine resolved before anything is written. Skipping that step registered
50
+ * three hooks that answered `command not found` on every turn.
51
+ *
45
52
  * Nothing here touches the filesystem. The halves that do are
46
53
  * `memory-install-claude.ts` and `memory-install-codex.ts`.
47
54
  */
@@ -207,6 +214,54 @@ export function parsePrintedMemoryInstallConfig(stdout) {
207
214
  permissions_allow: allow ?? [...MEMORY_AUTO_APPROVE_TOOLS],
208
215
  };
209
216
  }
217
+ /**
218
+ * Put THIS machine's resolved bin path into a config the bin printed.
219
+ *
220
+ * The printed contract says `command: "bli-memory-mcp"` — a bare name, because
221
+ * the bin has no idea where it was installed. `bli-memory-mcp` is a DEPENDENCY
222
+ * of `@bli-cockpit/cli`, nested inside its `node_modules`, and therefore **not
223
+ * on anybody's PATH**: a host handed the bare name answers `command not found`
224
+ * on every SessionStart, every prompt and every Stop. The installer is the only
225
+ * thing that knows the absolute path, so path qualification happens here and
226
+ * the printed strings are never written verbatim (BLI-3580; found by running
227
+ * the registered hook command in a simulated global install, not by reading
228
+ * the code).
229
+ *
230
+ * What the printed config still owns: which hook EVENTS exist, their
231
+ * subcommands, their timeouts, the server id and the allow-list. What this
232
+ * function owns: where the program is, and the per-platform launch shape.
233
+ *
234
+ * Returns null when a printed hook command cannot be understood well enough to
235
+ * re-point — the caller then falls back to the built-in template, which is
236
+ * always path-qualified. Guessing at a command we cannot parse would write a
237
+ * hook that runs something else.
238
+ */
239
+ export function withResolvedBinPath(config, options) {
240
+ const quoted = shellQuoteBinPath(options.binPath);
241
+ const hooks = [];
242
+ for (const hook of config.hooks) {
243
+ const tail = hookSubcommandTail(hook.command);
244
+ if (!tail)
245
+ return null;
246
+ hooks.push({ ...hook, command: `${quoted} ${tail}` });
247
+ }
248
+ const entry = memoryMcpServerEntry(options);
249
+ return {
250
+ ...config,
251
+ // The bin's own env wins nothing and loses nothing: it prints `{}`, and the
252
+ // dashboard URL is a machine fact the installer holds.
253
+ mcp_server: { ...entry, env: { ...config.mcp_server.env, ...entry.env } },
254
+ hooks,
255
+ };
256
+ }
257
+ /**
258
+ * `…/bli-memory-mcp hook prompt` → `hook prompt`. The subcommand is the part we
259
+ * keep; everything before it is a path that may be wrong for this machine.
260
+ */
261
+ function hookSubcommandTail(command) {
262
+ const match = /(^|\s)(hook\s+\S+.*)$/u.exec(command.trim());
263
+ return match?.[2]?.trim() ?? null;
264
+ }
210
265
  function normalizeStringArray(value) {
211
266
  if (value === undefined)
212
267
  return [];
@@ -17,8 +17,10 @@
17
17
  *
18
18
  * - **Where the server is.** `bli-memory-mcp` ships as a DEPENDENCY of
19
19
  * `@bli-cockpit/cli`, so the canonical lookup is the `node_modules/.bin` on
20
- * the way up from this CLI's own entry point; PATH is the fallback. Nobody
21
- * ever installs a second global package. See `resolveMemoryMcpBin`.
20
+ * the way up from THIS MODULE's own file realpath-ed first, because a
21
+ * global `cockpit` is a symlink and walking up from the symlink finds
22
+ * nothing; PATH is the fallback. Nobody ever installs a second global
23
+ * package. See `resolveMemoryMcpBin`, which carries the receipt.
22
24
  * - **No server, no write.** If the bin does not resolve, not one file is
23
25
  * opened and the outcome is `skipped bin_missing` — see `no_bin_no_write`
24
26
  * below for why a written-but-inert registration is the worse option.
@@ -32,10 +34,12 @@
32
34
  * - **Never claim an install it did not read back** (BLI-2541). Both halves
33
35
  * re-read and re-parse; this module only aggregates what they proved.
34
36
  */
37
+ import fs from "node:fs";
35
38
  import os from "node:os";
36
39
  import path from "node:path";
40
+ import { fileURLToPath } from "node:url";
37
41
  import { writeLine } from "./cli-io.js";
38
- import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, } from "./memory-install-contract.js";
42
+ import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
39
43
  import { defaultMemoryFileIo, } from "./memory-install-files.js";
40
44
  import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
41
45
  import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
@@ -140,6 +144,7 @@ async function resolveMemoryConfig(command, io, platform, deps) {
140
144
  platform,
141
145
  fileExists: deps.fileExists,
142
146
  cliEntryPoint: deps.cliEntryPoint,
147
+ realpath: deps.realpath,
143
148
  });
144
149
  if (!found) {
145
150
  return {
@@ -172,10 +177,35 @@ async function resolveMemoryConfig(command, io, platform, deps) {
172
177
  }
173
178
  const printed = await printedMemoryConfig(io, found.path);
174
179
  if (printed) {
180
+ // The bin prints a BARE command name — it cannot know where it was
181
+ // installed, and it is nested inside the CLI's node_modules rather than on
182
+ // PATH. Path-qualifying it here is what makes the registration runnable at
183
+ // all; see `withResolvedBinPath`.
184
+ const qualified = withResolvedBinPath(printed, {
185
+ binPath: found.path,
186
+ platform,
187
+ dashboardUrl,
188
+ });
189
+ if (qualified) {
190
+ return {
191
+ config: qualified,
192
+ source: "bin",
193
+ binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
194
+ bin_source: found.source,
195
+ };
196
+ }
197
+ // A printed hook command this installer cannot re-point. The template is
198
+ // always path-qualified, so it is the safe answer — and the reason says
199
+ // which of the two fallbacks happened.
175
200
  return {
176
- config: printed,
177
- source: "bin",
178
- binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
201
+ config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
202
+ source: "template",
203
+ binTarget: {
204
+ target: "bin",
205
+ status: "already",
206
+ reason: "bin_printed_config_unqualifiable",
207
+ detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
208
+ },
179
209
  bin_source: found.source,
180
210
  };
181
211
  }
@@ -222,12 +252,11 @@ async function printedMemoryConfig(io, binPath) {
222
252
  *
223
253
  * **1. Beside the CLI that is running.** `bli-memory-mcp` ships as a DEPENDENCY
224
254
  * of `@bli-cockpit/cli`, so installing the CLI installs the server, and npm
225
- * links its bin into a `node_modules/.bin` on the path from this entry point up
226
- * to the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is
227
- * nested, `…/lib/node_modules/.bin` when npm hoists it. Walking up from
228
- * `process.argv[1]` finds it either way, and it is the ONLY lookup that cannot
229
- * find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for a
230
- * second package.
255
+ * links its bin into a `node_modules/.bin` on the path from this package up to
256
+ * the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is nested,
257
+ * `…/lib/node_modules/.bin` when npm hoists it. It is the ONLY lookup that
258
+ * cannot find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for
259
+ * a second package.
231
260
  *
232
261
  * **2. PATH, as a fallback**, for a linked checkout or a hand-installed server.
233
262
  * Done in this process rather than through `which`/`where`: it spawns nothing,
@@ -236,6 +265,26 @@ async function printedMemoryConfig(io, binPath) {
236
265
  * standard layouts, which is why the caller passes a PATH that already includes
237
266
  * it (`envWithNodeRuntimeOnPath`) — the launchd tick's PATH is otherwise
238
267
  * `/usr/bin:/bin:/usr/sbin:/sbin` and would find nothing.
268
+ *
269
+ * **What step 1 got wrong on the first real machine** (BLI-3580, CLI 0.2.50):
270
+ * it walked up from `process.argv[1]`, resolved with `path.resolve` and no
271
+ * symlink following. For a global install `argv[1]` is the SHIM — on Edward's
272
+ * Mac `/opt/homebrew/bin/cockpit`, a symlink into
273
+ * `/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/cli.js`. The walk
274
+ * therefore started in `/opt/homebrew/bin`, went up through `/opt` to `/`, and
275
+ * never came within reach of the nested `.bin` that was sitting right there.
276
+ * Every machine reported `skipped bin_missing` with the server installed.
277
+ *
278
+ * Two changes, both in `besideAnchors` / `resolveBesideCli`:
279
+ *
280
+ * - The anchor is **this module's own file** first (`import.meta.url`), which
281
+ * is inside the installed package by construction and is never a shim. The
282
+ * entry point (injected, or `argv[1]`) stays as a second anchor for a build
283
+ * layout where this module has been bundled somewhere else.
284
+ * - Every anchor is **realpath-ed** before the walk, so a symlinked entry
285
+ * lands in the real tree. A path that will not resolve is used as given —
286
+ * the same bounded silence `container-tag.ts` uses, because a path that
287
+ * cannot be realpath-ed still identifies a directory well enough to look in.
239
288
  */
240
289
  export async function resolveMemoryMcpBin(options) {
241
290
  const exists = options.fileExists ?? defaultFileExists;
@@ -245,28 +294,66 @@ export async function resolveMemoryMcpBin(options) {
245
294
  const onPath = await resolveOnPath(options, exists);
246
295
  return onPath ? { path: onPath, source: "path" } : null;
247
296
  }
248
- async function resolveBesideCli(options, exists) {
297
+ /**
298
+ * Where to start walking, in order of trustworthiness. This module's own
299
+ * location first: it is inside the installed package and cannot be a shim.
300
+ */
301
+ function besideAnchors(options) {
302
+ const anchors = [];
303
+ const own = currentModulePath();
304
+ if (own)
305
+ anchors.push(own);
249
306
  const entry = options.cliEntryPoint ?? process.argv[1];
250
- if (!entry)
307
+ if (entry)
308
+ anchors.push(entry);
309
+ return anchors;
310
+ }
311
+ function currentModulePath() {
312
+ try {
313
+ return fileURLToPath(import.meta.url);
314
+ }
315
+ catch {
316
+ // A bundler that dropped `import.meta` support. The entry-point anchor
317
+ // still covers it, so this is a narrowing rather than a failure.
251
318
  return null;
319
+ }
320
+ }
321
+ async function resolveBesideCli(options, exists) {
252
322
  const platformPath = options.platform === "win32" ? path.win32 : path.posix;
253
323
  const extensions = binExtensions(options.platform);
254
- let directory = platformPath.dirname(platformPath.resolve(entry));
255
- // Bounded walk: deep enough for `…/node_modules/@scope/pkg/dist/cli.js` plus
256
- // a hoisted root above it, and it stops at the filesystem root anyway.
257
- for (let depth = 0; depth < 12; depth += 1) {
258
- for (const extension of extensions) {
259
- const candidate = platformPath.join(directory, "node_modules", ".bin", `${MEMORY_MCP_BIN}${extension}`);
260
- if (await exists(candidate))
261
- return candidate;
324
+ const realpath = options.realpath ?? defaultRealpath;
325
+ for (const anchor of besideAnchors(options)) {
326
+ let directory = platformPath.dirname(realpath(platformPath.resolve(anchor)));
327
+ // Bounded walk: deep enough for `…/node_modules/@scope/pkg/dist/cli.js`
328
+ // plus a hoisted root above it, and it stops at the filesystem root anyway.
329
+ for (let depth = 0; depth < 12; depth += 1) {
330
+ for (const extension of extensions) {
331
+ const candidate = platformPath.join(directory, "node_modules", ".bin", `${MEMORY_MCP_BIN}${extension}`);
332
+ if (await exists(candidate))
333
+ return candidate;
334
+ }
335
+ const parent = platformPath.dirname(directory);
336
+ if (parent === directory)
337
+ break;
338
+ directory = parent;
262
339
  }
263
- const parent = platformPath.dirname(directory);
264
- if (parent === directory)
265
- break;
266
- directory = parent;
267
340
  }
268
341
  return null;
269
342
  }
343
+ /**
344
+ * `realpathSync.native` follows the symlink npm writes for a global bin. A
345
+ * path that does not resolve — a Windows path being reasoned about from a Mac
346
+ * in a test, a directory that has since moved — comes back unchanged rather
347
+ * than throwing, because the walk above can still look inside it.
348
+ */
349
+ function defaultRealpath(value) {
350
+ try {
351
+ return fs.realpathSync.native(value);
352
+ }
353
+ catch {
354
+ return value;
355
+ }
356
+ }
270
357
  async function resolveOnPath(options, exists) {
271
358
  // The TARGET platform's path rules, not the running one's. On a real machine
272
359
  // they are the same; asking for them explicitly is what lets the Windows
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.50");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.51");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.0",
30
+ "@bli-cockpit/memory-mcp": "0.1.1",
31
31
  "@bli-cockpit/telemetry-core": "0.1.26"
32
32
  }
33
33
  }