@bli-cockpit/cli 0.2.57 → 0.2.58

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,8 @@ export const rootCommandNames = new Set([
42
42
  "clean",
43
43
  "docs",
44
44
  "msg",
45
+ "issue",
46
+ "project",
45
47
  "release",
46
48
  ]);
47
49
  export function localCommandHelp(command) {
@@ -82,6 +84,8 @@ export function localCommandHelp(command) {
82
84
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
83
85
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>] [--parent <id>|--clear-parent] [--visibility org|private] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
84
86
  " cockpit msg [channels|read <channel>|send <channel>|thread <id> --channel <channel>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
87
+ " cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
88
+ " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
85
89
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
86
90
  "",
87
91
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
@@ -616,6 +620,36 @@ function localSubcommandHelp(command) {
616
620
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
617
621
  ],
618
622
  ],
623
+ [
624
+ "issue",
625
+ [
626
+ "Usage: cockpit issue [list|show <id>|create|update <id>|move <id> <state>|comment <id>|history <id>] [flags]",
627
+ "",
628
+ "Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
629
+ "list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first.",
630
+ "show <id> — one issue: title, state, priority, assignee, description, and every comment on it.",
631
+ "create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--assignee me|<uuid>] [--parent <id>] [--file <path>] — the DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description.",
632
+ "update <id> [--title \"<t>\"] [--priority 0-4] [--assignee me|<uuid>] [--project <name|id>] [--parent <id>] [--file <path>|--body-stdin] — only the fields you pass change. State does NOT move here; use move.",
633
+ "move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
634
+ "comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
635
+ "history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
636
+ "A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
637
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
638
+ "A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
639
+ "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
640
+ ],
641
+ ],
642
+ [
643
+ "project",
644
+ [
645
+ "Usage: cockpit project [list] [--archived] [--json]",
646
+ "",
647
+ "The projects issues are filed under. Bare `cockpit project` lists them.",
648
+ "list [--archived] — id, active/archived, name. --archived includes archived projects.",
649
+ "There is no create/update/delete verb: `GET /api/work/projects` is the whole of Tower's project door today.",
650
+ "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
651
+ ],
652
+ ],
619
653
  [
620
654
  "release",
621
655
  [
@@ -34,6 +34,8 @@ import { runMemoryInstall } from "./memory-install.js";
34
34
  import { runClean } from "./clean.js";
35
35
  import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
+ import { runIssue } from "./issue.js";
38
+ import { runProject } from "./project.js";
37
39
  import { parseLocalArgs } from "./local-args.js";
38
40
  // `./local.js` is the published entry point for this command surface: the
39
41
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -137,6 +139,10 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
137
139
  return await runDocs(command, io);
138
140
  case "msg":
139
141
  return await runMsg(command, io);
142
+ case "issue":
143
+ return await runIssue(command, io);
144
+ case "project":
145
+ return await runProject(command, io);
140
146
  case "release":
141
147
  return await runRelease(command, io);
142
148
  }
@@ -0,0 +1,140 @@
1
+ import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
2
+ import { resolveMcpBin } from "./mcp-bin-resolve.js";
3
+ import { resolveDashboardUrl } from "./memory-install-report.js";
4
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
5
+ /**
6
+ * `bli-memory-mcp` ships as a DEPENDENCY of `@bli-cockpit/cli` — see
7
+ * `mcp-bin-resolve.ts` (BLI-3706's generalisation of this BLI-3580 lookup,
8
+ * shared with `bli-tower`'s registration) for the full two-step walk and the
9
+ * BLI-3580 story of why it starts at that module's own file rather than
10
+ * `process.argv[1]`.
11
+ */
12
+ export async function resolveMemoryMcpBin(options) {
13
+ return resolveMcpBin({ ...options, binName: MEMORY_MCP_BIN });
14
+ }
15
+ /**
16
+ * **`no_bin_no_write`.** When `bli-memory-mcp` cannot be resolved, this command
17
+ * writes NOTHING — not the MCP entry, not the hooks, not the Codex table, not
18
+ * the skills.
19
+ *
20
+ * An earlier revision wrote the registration anyway, on the reasoning that a
21
+ * correct shape waiting for the package is better than nothing. It is not:
22
+ * Claude Code RUNS a registered hook. Three hooks pointing at a binary that
23
+ * does not exist would print a hook failure on every SessionStart, every
24
+ * prompt and every Stop, on every intern machine, until the package shipped —
25
+ * a self-inflicted outage in the one surface people look at all day.
26
+ *
27
+ * So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
28
+ * self-heal retries tomorrow, and the first tick after the package lands does
29
+ * the whole registration at once.
30
+ */
31
+ export async function resolveMemoryConfig(command, io, platform, deps) {
32
+ const dashboardUrl = await resolveDashboardUrl(command, deps);
33
+ const found = await resolveMemoryMcpBin({
34
+ env: envWithNodeRuntimeOnPath(io.env ?? process.env),
35
+ platform,
36
+ fileExists: deps.fileExists,
37
+ cliEntryPoint: deps.cliEntryPoint,
38
+ realpath: deps.realpath,
39
+ });
40
+ if (!found) {
41
+ return {
42
+ config: null,
43
+ source: "none",
44
+ binTarget: {
45
+ target: "bin",
46
+ status: "skipped",
47
+ reason: "bin_missing",
48
+ detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
49
+ },
50
+ };
51
+ }
52
+ if (isUnsafeBinPath(found.path)) {
53
+ // A hook command is a shell string by the platform's design. A path that
54
+ // cannot be quoted safely is not escaped cleverly, and it is not swapped
55
+ // for a bare name that may resolve to something else either — the install
56
+ // refuses and says why.
57
+ return {
58
+ config: null,
59
+ source: "none",
60
+ binTarget: {
61
+ target: "bin",
62
+ status: "failed",
63
+ reason: "bin_path_unsafe",
64
+ detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
65
+ },
66
+ bin_source: found.source,
67
+ };
68
+ }
69
+ const printed = await printedMemoryConfig(io, found.path);
70
+ if (printed) {
71
+ // The bin prints a BARE command name — it cannot know where it was
72
+ // installed, and it is nested inside the CLI's node_modules rather than on
73
+ // PATH. Path-qualifying it here is what makes the registration runnable at
74
+ // all; see `withResolvedBinPath`.
75
+ const qualified = withResolvedBinPath(printed, {
76
+ binPath: found.path,
77
+ platform,
78
+ dashboardUrl,
79
+ });
80
+ if (qualified) {
81
+ return {
82
+ config: qualified,
83
+ source: "bin",
84
+ binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
85
+ bin_source: found.source,
86
+ };
87
+ }
88
+ // A printed hook command this installer cannot re-point. The template is
89
+ // always path-qualified, so it is the safe answer — and the reason says
90
+ // which of the two fallbacks happened.
91
+ return {
92
+ config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
93
+ source: "template",
94
+ binTarget: {
95
+ target: "bin",
96
+ status: "already",
97
+ reason: "bin_printed_config_unqualifiable",
98
+ detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
99
+ },
100
+ bin_source: found.source,
101
+ };
102
+ }
103
+ return {
104
+ config: builtinMemoryInstallConfig({
105
+ binPath: found.path,
106
+ platform,
107
+ dashboardUrl,
108
+ }),
109
+ source: "template",
110
+ binTarget: {
111
+ target: "bin",
112
+ status: "already",
113
+ reason: "bin_present_template_used",
114
+ detail: "the bin did not print a usable --print-config; the built-in shape was used",
115
+ },
116
+ bin_source: found.source,
117
+ };
118
+ }
119
+ /**
120
+ * Asks the server for its own shape. Arguments go as an ARRAY — nothing is
121
+ * interpolated into a command line — and on Windows the captured runner routes
122
+ * a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
123
+ */
124
+ async function printedMemoryConfig(io, binPath) {
125
+ const exec = io.exec;
126
+ if (!exec)
127
+ return null;
128
+ try {
129
+ const result = await exec(binPath, ["--print-config", "--claude"]);
130
+ if (result.code !== 0)
131
+ return null;
132
+ return parsePrintedMemoryInstallConfig(result.stdout);
133
+ }
134
+ catch {
135
+ // Deliberately silent here: the caller reports
136
+ // `bin_present_template_used`, which is the same information with a name
137
+ // on it, and this path is reached on every machine that has an older bin.
138
+ return null;
139
+ }
140
+ }
@@ -0,0 +1,89 @@
1
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
2
+ export async function resolveDashboardUrl(command, deps) {
3
+ if (command.dashboardUrl)
4
+ return command.dashboardUrl;
5
+ const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
6
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
7
+ return config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
8
+ }
9
+ export function aggregate(targets) {
10
+ const failed = targets.filter((target) => target.status === "failed");
11
+ if (failed.length > 0) {
12
+ return {
13
+ status: "failed",
14
+ // The first named reason, not a count: an operator needs the reason, and
15
+ // the per-target list beside it carries the rest.
16
+ reason: failed[0]?.reason ?? "unknown_failure",
17
+ };
18
+ }
19
+ if (targets.some((target) => target.status === "would_install")) {
20
+ return { status: "would_install", reason: "dry_run" };
21
+ }
22
+ if (targets.some((target) => target.status === "installed")) {
23
+ return { status: "installed", reason: "wrote_entry" };
24
+ }
25
+ if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
26
+ return { status: "missing", reason: "entry_absent" };
27
+ }
28
+ // BLI-3706: checked BEFORE `skipped`, not after. `bli-tower` registering
29
+ // beside `bli-memory` on this same command means a machine can be fully
30
+ // "already" registered for one server while the other's bin is not here
31
+ // yet — that machine's OWN state must not read as "skipped" (nothing is
32
+ // happening) when something plainly already is. `skipped` only wins the
33
+ // whole outcome when NOTHING on this machine has ever reached "already"
34
+ // either — the original one-server case (a fresh machine, bin missing,
35
+ // nothing written at all) still returns "skipped" via the fallback below.
36
+ if (targets.some((target) => target.status === "already")) {
37
+ return { status: "already", reason: "already_current" };
38
+ }
39
+ const skipped = targets.find((target) => target.status === "skipped");
40
+ if (skipped) {
41
+ // Not a failure and not a success: nothing was written, on purpose, and
42
+ // the next daily run will try again. `bin_missing` is the only one today.
43
+ return { status: "skipped", reason: skipped.reason };
44
+ }
45
+ return { status: "already", reason: "already_current" };
46
+ }
47
+ /**
48
+ * Both branches log, and neither carries a path — a home directory names a
49
+ * person, and this line ends up in `sync.err.log` on every machine.
50
+ */
51
+ export function logMemoryOutcome(outcome, platform) {
52
+ const fields = {
53
+ status: outcome.status,
54
+ reason: outcome.reason,
55
+ config_source: outcome.config_source,
56
+ bin_found: outcome.bin_found,
57
+ platform,
58
+ installed_count: outcome.targets.filter((target) => target.status === "installed").length,
59
+ already_count: outcome.targets.filter((target) => target.status === "already").length,
60
+ failed: outcome.targets
61
+ .filter((target) => target.status === "failed")
62
+ .map((target) => `${target.target}:${target.reason}`),
63
+ };
64
+ // stderr on both branches: launchd captures it to sync.err.log, and stdout is
65
+ // reserved for `--json`.
66
+ console.error(outcome.status === "failed"
67
+ ? "[memory-install] BLI Memory is not fully registered on this machine"
68
+ : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
69
+ }
70
+ export function memoryOutcomeLines(outcome) {
71
+ const headline = outcome.status === "installed"
72
+ ? "BLI Memory registered on this machine."
73
+ : outcome.status === "already"
74
+ ? "BLI Memory is already registered on this machine."
75
+ : outcome.status === "would_install"
76
+ ? "BLI Memory would be registered (dry run; nothing was written)."
77
+ : outcome.status === "missing"
78
+ ? "BLI Memory is not registered on this machine."
79
+ : outcome.status === "skipped"
80
+ ? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
81
+ : `BLI Memory is not fully registered: ${outcome.reason}.`;
82
+ const lines = [headline];
83
+ for (const target of outcome.targets) {
84
+ const where = target.path ? ` ${target.path}` : "";
85
+ const detail = target.detail ? ` — ${target.detail}` : "";
86
+ lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
87
+ }
88
+ return lines;
89
+ }
@@ -13,37 +13,43 @@
13
13
  * memory-install-claude.ts ~/.claude.json (user-scope MCP) + ~/.claude/settings.json (hooks, allow-list)
14
14
  * memory-install-codex.ts ~/.codex/config.toml (one table) + ~/.codex/skills/bli-memory/
15
15
  *
16
- * What this module owns is the decisions around them:
16
+ * What this module owns is the top-level decision sequence
17
+ * (BLI-3717 readability pass, 629 lines to a table of contents). Every other
18
+ * responsibility lives in a `memory-install-*.ts` sibling:
19
+ *
20
+ * - `memory-install-config.ts` — finding `bli-memory-mcp` on this machine
21
+ * (`resolveMemoryMcpBin`, over the shared `mcp-bin-resolve.ts` walk) and
22
+ * deciding which config shape to write (`resolveMemoryConfig`). **No
23
+ * server, no write.** If the bin does not resolve, not one file is opened
24
+ * and the outcome is `skipped bin_missing` — see `no_bin_no_write` there
25
+ * for why a written-but-inert registration is the worse option.
26
+ * `--print-config --claude` wins when the bin answers it; the built-in
27
+ * template of the same shape is the fallback for an older server, and it
28
+ * is only ever used with a bin that actually resolved.
29
+ * - `memory-install-report.ts` — the dashboard URL, aggregating per-target
30
+ * results into one outcome, and rendering it for a log line or a
31
+ * terminal.
32
+ *
33
+ * Two rules bind the whole sequence:
17
34
  *
18
- * - **Where the server is.** `bli-memory-mcp` ships as a DEPENDENCY of
19
- * `@bli-cockpit/cli`, so the canonical lookup is the `node_modules/.bin` on
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.
24
- * - **No server, no write.** If the bin does not resolve, not one file is
25
- * opened and the outcome is `skipped bin_missing` — see `no_bin_no_write`
26
- * below for why a written-but-inert registration is the worse option.
27
- * - **Which config shape.** `bli-memory-mcp --print-config --claude` wins when
28
- * the bin answers it; the built-in template of the same shape is the
29
- * fallback for an older server, and it is only ever used with a bin that
30
- * actually resolved.
31
35
  * - **Never throw at the caller.** The sync tick calls this. A failure is a
32
36
  * named receipt, never an exception that could cost a machine its
33
37
  * collection tick.
34
38
  * - **Never claim an install it did not read back** (BLI-2541). Both halves
35
39
  * re-read and re-parse; this module only aggregates what they proved.
40
+ *
41
+ * Every public name is still importable from this file.
36
42
  */
37
43
  import os from "node:os";
38
44
  import { writeLine } from "./cli-io.js";
39
- import { resolveMcpBin } from "./mcp-bin-resolve.js";
40
45
  import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-install.js";
41
- import { builtinMemoryInstallConfig, isUnsafeBinPath, MEMORY_MCP_BIN, parsePrintedMemoryInstallConfig, withResolvedBinPath, } from "./memory-install-contract.js";
42
46
  import { defaultMemoryFileIo, } from "./memory-install-files.js";
43
47
  import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
44
48
  import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
45
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
46
- import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
49
+ import { resolveMemoryConfig } from "./memory-install-config.js";
50
+ import { aggregate, logMemoryOutcome, memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
51
+ export { resolveMemoryMcpBin } from "./memory-install-config.js";
52
+ export { memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report.js";
47
53
  export async function runMemoryInstall(command, io, deps = {}) {
48
54
  const outcome = command.action === "status"
49
55
  ? await inspectMemoryIntegration(command, io, deps)
@@ -145,261 +151,4 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
145
151
  ...(resolved.bin_source ? { bin_source: resolved.bin_source } : {}),
146
152
  targets,
147
153
  };
148
- }
149
- /**
150
- * **`no_bin_no_write`.** When `bli-memory-mcp` cannot be resolved, this command
151
- * writes NOTHING — not the MCP entry, not the hooks, not the Codex table, not
152
- * the skills.
153
- *
154
- * An earlier revision wrote the registration anyway, on the reasoning that a
155
- * correct shape waiting for the package is better than nothing. It is not:
156
- * Claude Code RUNS a registered hook. Three hooks pointing at a binary that
157
- * does not exist would print a hook failure on every SessionStart, every
158
- * prompt and every Stop, on every intern machine, until the package shipped —
159
- * a self-inflicted outage in the one surface people look at all day.
160
- *
161
- * So an absent bin is `skipped bin_missing`: a receipt, not a write. The daily
162
- * self-heal retries tomorrow, and the first tick after the package lands does
163
- * the whole registration at once.
164
- */
165
- async function resolveMemoryConfig(command, io, platform, deps) {
166
- const dashboardUrl = await resolveDashboardUrl(command, deps);
167
- const found = await resolveMemoryMcpBin({
168
- env: envWithNodeRuntimeOnPath(io.env ?? process.env),
169
- platform,
170
- fileExists: deps.fileExists,
171
- cliEntryPoint: deps.cliEntryPoint,
172
- realpath: deps.realpath,
173
- });
174
- if (!found) {
175
- return {
176
- config: null,
177
- source: "none",
178
- binTarget: {
179
- target: "bin",
180
- status: "skipped",
181
- reason: "bin_missing",
182
- detail: `${MEMORY_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
183
- },
184
- };
185
- }
186
- if (isUnsafeBinPath(found.path)) {
187
- // A hook command is a shell string by the platform's design. A path that
188
- // cannot be quoted safely is not escaped cleverly, and it is not swapped
189
- // for a bare name that may resolve to something else either — the install
190
- // refuses and says why.
191
- return {
192
- config: null,
193
- source: "none",
194
- binTarget: {
195
- target: "bin",
196
- status: "failed",
197
- reason: "bin_path_unsafe",
198
- detail: "the resolved bin path contains characters that cannot appear in a hook command; nothing was written",
199
- },
200
- bin_source: found.source,
201
- };
202
- }
203
- const printed = await printedMemoryConfig(io, found.path);
204
- if (printed) {
205
- // The bin prints a BARE command name — it cannot know where it was
206
- // installed, and it is nested inside the CLI's node_modules rather than on
207
- // PATH. Path-qualifying it here is what makes the registration runnable at
208
- // all; see `withResolvedBinPath`.
209
- const qualified = withResolvedBinPath(printed, {
210
- binPath: found.path,
211
- platform,
212
- dashboardUrl,
213
- });
214
- if (qualified) {
215
- return {
216
- config: qualified,
217
- source: "bin",
218
- binTarget: { target: "bin", status: "already", reason: "bin_printed_config" },
219
- bin_source: found.source,
220
- };
221
- }
222
- // A printed hook command this installer cannot re-point. The template is
223
- // always path-qualified, so it is the safe answer — and the reason says
224
- // which of the two fallbacks happened.
225
- return {
226
- config: builtinMemoryInstallConfig({ binPath: found.path, platform, dashboardUrl }),
227
- source: "template",
228
- binTarget: {
229
- target: "bin",
230
- status: "already",
231
- reason: "bin_printed_config_unqualifiable",
232
- detail: "the bin printed a hook command this installer could not re-point at the resolved path; the built-in shape was used",
233
- },
234
- bin_source: found.source,
235
- };
236
- }
237
- return {
238
- config: builtinMemoryInstallConfig({
239
- binPath: found.path,
240
- platform,
241
- dashboardUrl,
242
- }),
243
- source: "template",
244
- binTarget: {
245
- target: "bin",
246
- status: "already",
247
- reason: "bin_present_template_used",
248
- detail: "the bin did not print a usable --print-config; the built-in shape was used",
249
- },
250
- bin_source: found.source,
251
- };
252
- }
253
- /**
254
- * Asks the server for its own shape. Arguments go as an ARRAY — nothing is
255
- * interpolated into a command line — and on Windows the captured runner routes
256
- * a `.cmd` shim through ComSpec with its own escaping (process-runner.ts).
257
- */
258
- async function printedMemoryConfig(io, binPath) {
259
- const exec = io.exec;
260
- if (!exec)
261
- return null;
262
- try {
263
- const result = await exec(binPath, ["--print-config", "--claude"]);
264
- if (result.code !== 0)
265
- return null;
266
- return parsePrintedMemoryInstallConfig(result.stdout);
267
- }
268
- catch {
269
- // Deliberately silent here: the caller reports
270
- // `bin_present_template_used`, which is the same information with a name
271
- // on it, and this path is reached on every machine that has an older bin.
272
- return null;
273
- }
274
- }
275
- /**
276
- * Two steps, in this order, and the first is the canonical one.
277
- *
278
- * **1. Beside the CLI that is running.** `bli-memory-mcp` ships as a DEPENDENCY
279
- * of `@bli-cockpit/cli`, so installing the CLI installs the server, and npm
280
- * links its bin into a `node_modules/.bin` on the path from this package up to
281
- * the install root — `…/@bli-cockpit/cli/node_modules/.bin` when it is nested,
282
- * `…/lib/node_modules/.bin` when npm hoists it. It is the ONLY lookup that
283
- * cannot find somebody else's `bli-memory-mcp`. Nobody ever runs `npm i -g` for
284
- * a second package.
285
- *
286
- * **2. PATH, as a fallback**, for a linked checkout or a hand-installed server.
287
- * Done in this process rather than through `which`/`where`: it spawns nothing,
288
- * behaves the same on both host families, and is testable without a fixture
289
- * binary. The npm global bin sits beside the running node binary in the
290
- * standard layouts, which is why the caller passes a PATH that already includes
291
- * it (`envWithNodeRuntimeOnPath`) — the launchd tick's PATH is otherwise
292
- * `/usr/bin:/bin:/usr/sbin:/sbin` and would find nothing.
293
- *
294
- * **What step 1 got wrong on the first real machine** (BLI-3580, CLI 0.2.50):
295
- * it walked up from `process.argv[1]`, resolved with `path.resolve` and no
296
- * symlink following. For a global install `argv[1]` is the SHIM — on Edward's
297
- * Mac `/opt/homebrew/bin/cockpit`, a symlink into
298
- * `/opt/homebrew/lib/node_modules/@bli-cockpit/cli/dist/cli.js`. The walk
299
- * therefore started in `/opt/homebrew/bin`, went up through `/opt` to `/`, and
300
- * never came within reach of the nested `.bin` that was sitting right there.
301
- * Every machine reported `skipped bin_missing` with the server installed.
302
- *
303
- * Two changes, both in `besideAnchors` / `resolveBesideCli`:
304
- *
305
- * - The anchor is **this module's own file** first (`import.meta.url`), which
306
- * is inside the installed package by construction and is never a shim. The
307
- * entry point (injected, or `argv[1]`) stays as a second anchor for a build
308
- * layout where this module has been bundled somewhere else.
309
- * - Every anchor is **realpath-ed** before the walk, so a symlinked entry
310
- * lands in the real tree. A path that will not resolve is used as given —
311
- * the same bounded silence `container-tag.ts` uses, because a path that
312
- * cannot be realpath-ed still identifies a directory well enough to look in.
313
- */
314
- export async function resolveMemoryMcpBin(options) {
315
- return resolveMcpBin({ ...options, binName: MEMORY_MCP_BIN });
316
- }
317
- /** Exported for `tower-mcp-install.ts` (BLI-3706): the two registrations share this machine's one dashboard URL. */
318
- export async function resolveDashboardUrl(command, deps) {
319
- if (command.dashboardUrl)
320
- return command.dashboardUrl;
321
- const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir);
322
- const config = await readLocalCollectorConfig(paths).catch(() => null);
323
- return config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
324
- }
325
- function aggregate(targets) {
326
- const failed = targets.filter((target) => target.status === "failed");
327
- if (failed.length > 0) {
328
- return {
329
- status: "failed",
330
- // The first named reason, not a count: an operator needs the reason, and
331
- // the per-target list beside it carries the rest.
332
- reason: failed[0]?.reason ?? "unknown_failure",
333
- };
334
- }
335
- if (targets.some((target) => target.status === "would_install")) {
336
- return { status: "would_install", reason: "dry_run" };
337
- }
338
- if (targets.some((target) => target.status === "installed")) {
339
- return { status: "installed", reason: "wrote_entry" };
340
- }
341
- if (targets.some((target) => target.status === "mismatch" || target.status === "missing")) {
342
- return { status: "missing", reason: "entry_absent" };
343
- }
344
- // BLI-3706: checked BEFORE `skipped`, not after. `bli-tower` registering
345
- // beside `bli-memory` on this same command means a machine can be fully
346
- // "already" registered for one server while the other's bin is not here
347
- // yet — that machine's OWN state must not read as "skipped" (nothing is
348
- // happening) when something plainly already is. `skipped` only wins the
349
- // whole outcome when NOTHING on this machine has ever reached "already"
350
- // either — the original one-server case (a fresh machine, bin missing,
351
- // nothing written at all) still returns "skipped" via the fallback below.
352
- if (targets.some((target) => target.status === "already")) {
353
- return { status: "already", reason: "already_current" };
354
- }
355
- const skipped = targets.find((target) => target.status === "skipped");
356
- if (skipped) {
357
- // Not a failure and not a success: nothing was written, on purpose, and
358
- // the next daily run will try again. `bin_missing` is the only one today.
359
- return { status: "skipped", reason: skipped.reason };
360
- }
361
- return { status: "already", reason: "already_current" };
362
- }
363
- /**
364
- * Both branches log, and neither carries a path — a home directory names a
365
- * person, and this line ends up in `sync.err.log` on every machine.
366
- */
367
- function logMemoryOutcome(outcome, platform) {
368
- const fields = {
369
- status: outcome.status,
370
- reason: outcome.reason,
371
- config_source: outcome.config_source,
372
- bin_found: outcome.bin_found,
373
- platform,
374
- installed_count: outcome.targets.filter((target) => target.status === "installed").length,
375
- already_count: outcome.targets.filter((target) => target.status === "already").length,
376
- failed: outcome.targets
377
- .filter((target) => target.status === "failed")
378
- .map((target) => `${target.target}:${target.reason}`),
379
- };
380
- // stderr on both branches: launchd captures it to sync.err.log, and stdout is
381
- // reserved for `--json`.
382
- console.error(outcome.status === "failed"
383
- ? "[memory-install] BLI Memory is not fully registered on this machine"
384
- : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
385
- }
386
- export function memoryOutcomeLines(outcome) {
387
- const headline = outcome.status === "installed"
388
- ? "BLI Memory registered on this machine."
389
- : outcome.status === "already"
390
- ? "BLI Memory is already registered on this machine."
391
- : outcome.status === "would_install"
392
- ? "BLI Memory would be registered (dry run; nothing was written)."
393
- : outcome.status === "missing"
394
- ? "BLI Memory is not registered on this machine."
395
- : outcome.status === "skipped"
396
- ? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
397
- : `BLI Memory is not fully registered: ${outcome.reason}.`;
398
- const lines = [headline];
399
- for (const target of outcome.targets) {
400
- const where = target.path ? ` ${target.path}` : "";
401
- const detail = target.detail ? ` — ${target.detail}` : "";
402
- lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
403
- }
404
- return lines;
405
154
  }