@gmickel/gno 2.9.2 → 2.10.1

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 (38) hide show
  1. package/README.md +2 -2
  2. package/assets/spa-production.json.gz +0 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v2.9.2.zip → gno-browser-clipper-v2.10.1.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v2.10.1.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/bunfig.toml +5 -1
  7. package/package.json +3 -2
  8. package/spec/cli.md +102 -12
  9. package/spec/mcp.md +1 -1
  10. package/spec/output-schemas/mcp-remove-result.schema.json +2 -2
  11. package/src/app/bun-runtime.ts +41 -0
  12. package/src/cli/commands/cleanup.ts +9 -1
  13. package/src/cli/commands/collection/remove.ts +50 -31
  14. package/src/cli/commands/collection/rename.ts +36 -8
  15. package/src/cli/commands/daemon.ts +3 -0
  16. package/src/cli/commands/doctor.ts +61 -0
  17. package/src/cli/commands/mcp/config-editors.ts +56 -8
  18. package/src/cli/commands/mcp/install.ts +159 -24
  19. package/src/cli/commands/mcp/launch-path.ts +87 -0
  20. package/src/cli/commands/mcp/paths.ts +30 -6
  21. package/src/cli/commands/mcp/repair-entry.ts +160 -0
  22. package/src/cli/commands/mcp/status.ts +55 -15
  23. package/src/cli/commands/mcp.ts +4 -0
  24. package/src/cli/commands/serve.ts +3 -0
  25. package/src/cli/commands/status.ts +24 -0
  26. package/src/cli/detach.ts +10 -0
  27. package/src/cli/program.ts +64 -19
  28. package/src/cli/runtime-health.ts +328 -0
  29. package/src/core/network-boundary-inventory.ts +16 -0
  30. package/src/ingestion/file-processor.ts +118 -17
  31. package/src/ingestion/prepare-worker.ts +20 -2
  32. package/src/mcp/tools/remove-collection.ts +2 -1
  33. package/src/serve/public/components/BootstrapStatus.tsx +3 -3
  34. package/src/serve/public/globals.built.css +1 -1
  35. package/src/serve/routes/api.ts +3 -4
  36. package/src/serve/status.ts +2 -1
  37. package/src/store/sqlite/adapter.ts +17 -4
  38. package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +0 -1
@@ -371,19 +371,66 @@ function tomlString(value: string): string {
371
371
  return JSON.stringify(value);
372
372
  }
373
373
 
374
- function serializeTomlEntry(entry: StandardMcpEntry, newline: string): string {
374
+ const TOML_BARE_KEY = /^[A-Za-z0-9_-]+$/;
375
+
376
+ function tomlKey(key: string): string {
377
+ return TOML_BARE_KEY.test(key) ? key : tomlString(key);
378
+ }
379
+
380
+ /** A kept key's value as TOML; null for values this writer cannot express. */
381
+ function tomlValue(value: unknown): string | null {
382
+ if (typeof value === "string") {
383
+ return tomlString(value);
384
+ }
385
+ if (
386
+ typeof value === "boolean" ||
387
+ (typeof value === "number" && Number.isFinite(value))
388
+ ) {
389
+ return String(value);
390
+ }
391
+ if (Array.isArray(value)) {
392
+ const items = value.map(tomlValue);
393
+ return items.every((item) => item !== null)
394
+ ? `[${items.join(", ")}]`
395
+ : null;
396
+ }
397
+ return null;
398
+ }
399
+
400
+ /** Keys of an existing GNO entry kept by a repair (see repair-entry.ts). */
401
+ export interface TomlEntryExtras {
402
+ env?: Record<string, string>;
403
+ extra?: Record<string, unknown>;
404
+ }
405
+
406
+ function serializeTomlEntry(
407
+ entry: StandardMcpEntry,
408
+ newline: string,
409
+ configPath: string,
410
+ extras: TomlEntryExtras = {}
411
+ ): string {
375
412
  const args = entry.args.map(tomlString).join(", ");
376
413
  const lines = [
377
414
  "[mcp_servers.gno]",
378
415
  `command = ${tomlString(entry.command)}`,
379
416
  `args = [${args}]`,
380
417
  ];
381
- if (entry.env && Object.keys(entry.env).length > 0) {
418
+ for (const [key, value] of Object.entries(extras.extra ?? {})) {
419
+ const serialized = tomlValue(value);
420
+ if (serialized === null) {
421
+ throw new CliError(
422
+ "RUNTIME",
423
+ `Cannot keep "${key}" of the GNO MCP entry in ${configPath}; remove the entry with gno mcp uninstall, or edit the key, then install again.`
424
+ );
425
+ }
426
+ lines.push(`${tomlKey(key)} = ${serialized}`);
427
+ }
428
+ const env = { ...entry.env, ...extras.env };
429
+ if (Object.keys(env).length > 0) {
382
430
  lines.push("", "[mcp_servers.gno.env]");
383
- for (const key of ["GNO_DATA_DIR", "GNO_CACHE_DIR"] as const) {
384
- const value = entry.env[key];
431
+ for (const [key, value] of Object.entries(env)) {
385
432
  if (value) {
386
- lines.push(`${key} = ${tomlString(value)}`);
433
+ lines.push(`${tomlKey(key)} = ${tomlString(value)}`);
387
434
  }
388
435
  }
389
436
  }
@@ -393,7 +440,8 @@ function serializeTomlEntry(entry: StandardMcpEntry, newline: string): string {
393
440
  export function setTomlServerEntry(
394
441
  content: string,
395
442
  configPath: string,
396
- entry: StandardMcpEntry
443
+ entry: StandardMcpEntry,
444
+ extras?: TomlEntryExtras
397
445
  ): string {
398
446
  const parsed = getTomlServerEntry(content, configPath);
399
447
  const removed = removeTomlGnoSections(content, configPath);
@@ -405,8 +453,8 @@ export function setTomlServerEntry(
405
453
  }
406
454
  const prefix = removed.content.trimEnd();
407
455
  const updated = prefix
408
- ? `${prefix}${removed.newline}${removed.newline}${serializeTomlEntry(entry, removed.newline)}`
409
- : serializeTomlEntry(entry, removed.newline);
456
+ ? `${prefix}${removed.newline}${removed.newline}${serializeTomlEntry(entry, removed.newline, configPath, extras)}`
457
+ : serializeTomlEntry(entry, removed.newline, configPath, extras);
410
458
  parseTomlRoot(updated, configPath);
411
459
  return updated;
412
460
  }
@@ -29,14 +29,22 @@ import {
29
29
  } from "./config.js";
30
30
  import {
31
31
  buildMcpServerEntry,
32
+ resolveLaunchPaths,
32
33
  getDefaultTargetScope,
33
34
  getTargetScopes,
34
35
  getTargetDisplayName,
36
+ type McpConfigFormat,
35
37
  type McpScope,
36
38
  type McpServerEntry,
37
39
  type McpTarget,
38
40
  resolveMcpConfigPath,
39
41
  } from "./paths.js";
42
+ import {
43
+ type ExplicitInstallValues,
44
+ readExistingRegistration,
45
+ type RepairedRegistration,
46
+ repairRegistration,
47
+ } from "./repair-entry.js";
40
48
  import { normalizeMcpServerEntryForInstall } from "./server-entry.js";
41
49
 
42
50
  // ─────────────────────────────────────────────────────────────────────────────
@@ -71,6 +79,8 @@ export interface McpInstallResult {
71
79
  configPath: string;
72
80
  action: "created" | "updated" | "dry_run_create" | "dry_run_update";
73
81
  serverEntry: McpServerEntry;
82
+ /** `--force` kept the existing entry's settings and repaired its launch path. */
83
+ repaired?: boolean;
74
84
  }
75
85
 
76
86
  // ─────────────────────────────────────────────────────────────────────────────
@@ -89,6 +99,12 @@ export async function installMcpToTarget(
89
99
  dryRun?: boolean;
90
100
  cwd?: string;
91
101
  homeDir?: string;
102
+ /**
103
+ * With `force` over an existing entry: keep its settings and apply only
104
+ * these explicit options (repair-entry.ts). Without it, the entry is
105
+ * replaced.
106
+ */
107
+ preserveExisting?: ExplicitInstallValues;
92
108
  }
93
109
  ): Promise<McpInstallResult> {
94
110
  const { force = false, dryRun = false, cwd, homeDir } = options;
@@ -120,23 +136,45 @@ export async function installMcpToTarget(
120
136
  );
121
137
  }
122
138
 
123
- const entry = buildEntry(
124
- normalizedEntry.command,
125
- normalizedEntry.args,
126
- configFormat,
127
- normalizedEntry.env
128
- );
129
- const standardEntry = {
130
- command: normalizedEntry.command,
131
- args: normalizedEntry.args,
132
- ...(normalizedEntry.env ? { env: normalizedEntry.env } : {}),
133
- };
134
- const updated =
135
- configFormat === "codex_toml"
136
- ? setTomlServerEntry(content, configPath, standardEntry)
137
- : configFormat === "yaml_standard"
138
- ? setYamlServerEntry(content, configPath, serversKey, standardEntry)
139
- : setJsoncServerEntry(content, configPath, serversKey, entry);
139
+ const existing =
140
+ alreadyExists && options.preserveExisting
141
+ ? readExistingRegistration(parsed.entry, configFormat)
142
+ : null;
143
+ const repaired =
144
+ existing && options.preserveExisting
145
+ ? repairRegistration(existing, normalizedEntry, options.preserveExisting)
146
+ : null;
147
+ if (repaired) {
148
+ // Same structural rules as a fresh entry for what this command writes.
149
+ normalizeMcpServerEntryForInstall({
150
+ command: repaired.command,
151
+ args: repaired.args,
152
+ });
153
+ }
154
+ const updated = repaired
155
+ ? writeRepairedEntry(
156
+ content,
157
+ configPath,
158
+ configFormat,
159
+ serversKey,
160
+ repaired
161
+ )
162
+ : writeFreshEntry(
163
+ content,
164
+ configPath,
165
+ configFormat,
166
+ serversKey,
167
+ normalizedEntry
168
+ );
169
+ const writtenEntry: McpServerEntry = repaired
170
+ ? {
171
+ command: repaired.command,
172
+ args: repaired.args,
173
+ ...(Object.keys(repaired.env).length > 0
174
+ ? { env: repaired.env as McpServerEntry["env"] }
175
+ : {}),
176
+ }
177
+ : normalizedEntry;
140
178
 
141
179
  const wouldCreate = !alreadyExists;
142
180
  if (dryRun) {
@@ -145,7 +183,8 @@ export async function installMcpToTarget(
145
183
  scope,
146
184
  configPath,
147
185
  action: wouldCreate ? "dry_run_create" : "dry_run_update",
148
- serverEntry: normalizedEntry,
186
+ serverEntry: writtenEntry,
187
+ ...(repaired ? { repaired: true } : {}),
149
188
  };
150
189
  }
151
190
 
@@ -157,8 +196,73 @@ export async function installMcpToTarget(
157
196
  scope,
158
197
  configPath,
159
198
  action,
160
- serverEntry: normalizedEntry,
199
+ serverEntry: writtenEntry,
200
+ ...(repaired ? { repaired: true } : {}),
201
+ };
202
+ }
203
+
204
+ function writeFreshEntry(
205
+ content: string,
206
+ configPath: string,
207
+ configFormat: McpConfigFormat,
208
+ serversKey: string,
209
+ normalizedEntry: McpServerEntry
210
+ ): string {
211
+ const standardEntry = {
212
+ command: normalizedEntry.command,
213
+ args: normalizedEntry.args,
214
+ ...(normalizedEntry.env ? { env: normalizedEntry.env } : {}),
215
+ };
216
+ if (configFormat === "codex_toml") {
217
+ return setTomlServerEntry(content, configPath, standardEntry);
218
+ }
219
+ if (configFormat === "yaml_standard") {
220
+ return setYamlServerEntry(content, configPath, serversKey, standardEntry);
221
+ }
222
+ const entry = buildEntry(
223
+ normalizedEntry.command,
224
+ normalizedEntry.args,
225
+ configFormat,
226
+ normalizedEntry.env
227
+ );
228
+ return setJsoncServerEntry(content, configPath, serversKey, entry);
229
+ }
230
+
231
+ /** Write a repaired entry, keeping the keys GNO does not manage. */
232
+ function writeRepairedEntry(
233
+ content: string,
234
+ configPath: string,
235
+ configFormat: McpConfigFormat,
236
+ serversKey: string,
237
+ repaired: RepairedRegistration
238
+ ): string {
239
+ const hasEnv = Object.keys(repaired.env).length > 0;
240
+ if (configFormat === "codex_toml") {
241
+ return setTomlServerEntry(
242
+ content,
243
+ configPath,
244
+ { command: repaired.command, args: repaired.args },
245
+ { env: repaired.env, extra: repaired.extra }
246
+ );
247
+ }
248
+ if (configFormat === "mcp") {
249
+ return setJsoncServerEntry(content, configPath, serversKey, {
250
+ type: "local",
251
+ enabled: true,
252
+ ...repaired.extra,
253
+ command: [repaired.command, ...repaired.args],
254
+ ...(hasEnv ? { environment: repaired.env } : {}),
255
+ });
256
+ }
257
+ const entry = {
258
+ ...repaired.extra,
259
+ command: repaired.command,
260
+ args: repaired.args,
261
+ ...(hasEnv ? { env: repaired.env } : {}),
161
262
  };
263
+ return configFormat === "yaml_standard"
264
+ ? setYamlServerEntry(content, configPath, serversKey, entry)
265
+ : setJsoncServerEntry(content, configPath, serversKey, entry);
162
266
  }
163
267
 
164
268
  /**
@@ -200,6 +304,14 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
200
304
  opts.configPath ?? paths.configFile,
201
305
  opts.cwd
202
306
  );
307
+ // Only what the user passed overrides an existing entry under --force;
308
+ // the defaults above never do.
309
+ const explicit: ExplicitInstallValues = {
310
+ ...(opts.indexName !== undefined ? { indexName } : {}),
311
+ ...(opts.configPath !== undefined ? { configPath } : {}),
312
+ ...(toolProfile !== undefined ? { toolProfile } : {}),
313
+ ...(opts.enableWrite ? { enableWrite: true } : {}),
314
+ };
203
315
  const globals = safeGetGlobals();
204
316
  const json = opts.json ?? globals.json;
205
317
  const quiet = opts.quiet ?? globals.quiet;
@@ -228,11 +340,18 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
228
340
  dryRun,
229
341
  cwd: opts.cwd,
230
342
  homeDir: opts.homeDir,
343
+ preserveExisting: explicit,
231
344
  });
345
+ const launch = resolveLaunchPaths();
346
+ const pinnedPaths = [launch.bun, launch.entrypoint]
347
+ .filter((path) => path.pinned)
348
+ .map((path) => path.path);
232
349
 
233
350
  // Output
234
351
  if (json) {
235
- process.stdout.write(`${JSON.stringify({ installed: result }, null, 2)}\n`);
352
+ process.stdout.write(
353
+ `${JSON.stringify({ installed: { ...result, ...(pinnedPaths.length > 0 ? { pinnedPaths } : {}) } }, null, 2)}\n`
354
+ );
236
355
  return;
237
356
  }
238
357
 
@@ -247,8 +366,9 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
247
366
  `Would ${dryRunVerb} gno in ${getTargetDisplayName(target)}:\n`
248
367
  );
249
368
  process.stdout.write(` Config: ${result.configPath}\n`);
250
- process.stdout.write(` Command: ${serverEntry.command}\n`);
251
- process.stdout.write(` Args: ${serverEntry.args.join(" ")}\n`);
369
+ process.stdout.write(` Command: ${result.serverEntry.command}\n`);
370
+ process.stdout.write(` Args: ${result.serverEntry.args.join(" ")}\n`);
371
+ writePinNote(pinnedPaths);
252
372
  return;
253
373
  }
254
374
 
@@ -256,8 +376,23 @@ export async function installMcp(opts: InstallOptions = {}): Promise<void> {
256
376
  process.stdout.write(
257
377
  `${verb} gno MCP server in ${getTargetDisplayName(target)}.\n`
258
378
  );
259
- process.stdout.write(` Config: ${result.configPath}\n\n`);
379
+ process.stdout.write(` Config: ${result.configPath}\n`);
380
+ if (result.repaired) {
381
+ process.stdout.write(
382
+ " Kept the existing settings; updated the Bun and GNO paths.\n"
383
+ );
384
+ }
385
+ writePinNote(pinnedPaths);
260
386
  process.stdout.write(
261
- `Restart ${getTargetDisplayName(target)} to load the server.\n`
387
+ `\nRestart ${getTargetDisplayName(target)} to load the server.\n`
262
388
  );
263
389
  }
390
+
391
+ /** Say when a launch path stays on one installed version. */
392
+ function writePinNote(pinnedPaths: string[]): void {
393
+ for (const path of pinnedPaths) {
394
+ process.stdout.write(
395
+ ` Note: ${path} is inside a versioned install directory with no version-independent link to it; this registration keeps using that version after an upgrade. Run gno mcp install --force again after upgrading (gno doctor reports it).\n`
396
+ );
397
+ }
398
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Version-independent launch paths for client registrations (fn-213).
3
+ *
4
+ * `process.execPath` and the GNO entrypoint are absolute, and under a
5
+ * version manager they sit in a versioned install directory
6
+ * (`installs/bun/1.3.14/...`, `Cellar/bun/1.4.2/...`). A registration that
7
+ * names that directory keeps starting the old runtime after an upgrade, and
8
+ * fails once the version is removed. Each manager also keeps a link that
9
+ * follows its upgrades; a registration names that link instead, but only
10
+ * when it resolves to the very file in use now. Shims that pick a version
11
+ * from the shell environment (asdf, proto, mise shims) are never used: GUI
12
+ * clients start servers without that environment.
13
+ *
14
+ * @module src/cli/commands/mcp/launch-path
15
+ */
16
+
17
+ // node:fs realpathSync: synchronous link resolution; Bun has no realpath API.
18
+ import { realpathSync } from "node:fs";
19
+
20
+ /** mise, asdf and proto: `<root>/{installs,tools}/bun/<version>/...`. */
21
+ const MANAGER_VERSION_DIR = /([\\/])(installs|tools)\1bun\1([^\\/]+)(?=[\\/])/;
22
+ /** Homebrew: `<prefix>/Cellar/bun/<version>/...`. */
23
+ const HOMEBREW_VERSION_DIR = /([\\/])Cellar\1bun\1[^\\/]+(?=[\\/])/;
24
+ /** Version-independent directory names a manager keeps itself. */
25
+ const ALIAS_VERSIONS = new Set(["latest", "current", "system"]);
26
+
27
+ /**
28
+ * The version-independent links a manager keeps for `path`, or null when
29
+ * `path` is not inside a versioned install directory.
30
+ */
31
+ export function versionIndependentCandidates(path: string): string[] | null {
32
+ const cellar = HOMEBREW_VERSION_DIR.exec(path);
33
+ if (cellar) {
34
+ const separator = cellar[1] ?? "/";
35
+ return [
36
+ path.replace(HOMEBREW_VERSION_DIR, `${separator}opt${separator}bun`),
37
+ ];
38
+ }
39
+ const manager = MANAGER_VERSION_DIR.exec(path);
40
+ if (!manager || ALIAS_VERSIONS.has(manager[3] ?? "")) {
41
+ return null;
42
+ }
43
+ const separator = manager[1] ?? "/";
44
+ return [
45
+ path.replace(
46
+ MANAGER_VERSION_DIR,
47
+ `${separator}${manager[2]}${separator}bun${separator}latest`
48
+ ),
49
+ ];
50
+ }
51
+
52
+ const realpathOrNull = (path: string): string | null => {
53
+ try {
54
+ return realpathSync(path);
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+
60
+ export interface LaunchPath {
61
+ /** The path to write. */
62
+ path: string;
63
+ /**
64
+ * True when `path` is inside a versioned install directory and no
65
+ * version-independent link resolves to it: the registration stays on this
66
+ * version until it is reinstalled.
67
+ */
68
+ pinned: boolean;
69
+ }
70
+
71
+ /** Prefer a version-independent link that resolves to the same file. */
72
+ export function stableLaunchPath(
73
+ path: string,
74
+ realpath: (path: string) => string | null = realpathOrNull
75
+ ): LaunchPath {
76
+ const candidates = versionIndependentCandidates(path);
77
+ if (!candidates) {
78
+ return { path, pinned: false };
79
+ }
80
+ const target = realpath(path);
81
+ for (const candidate of candidates) {
82
+ if (target !== null && realpath(candidate) === target) {
83
+ return { path: candidate, pinned: false };
84
+ }
85
+ }
86
+ return { path, pinned: true };
87
+ }
@@ -18,6 +18,8 @@ import {
18
18
  type McpToolProfile,
19
19
  isMcpToolProfile,
20
20
  } from "../../../mcp/tool-profile.js";
21
+ import { isBunfsPath } from "../../../serve/spa-production-build";
22
+ import { type LaunchPath, stableLaunchPath } from "./launch-path.js";
21
23
  import { getTargetDisplayName } from "./target-display.js";
22
24
 
23
25
  export { getTargetDisplayName } from "./target-display.js";
@@ -447,14 +449,35 @@ export function resolveAllMcpPaths(
447
449
  // Bun & GNO Path Discovery
448
450
  // ─────────────────────────────────────────────────────────────────────────────
449
451
 
452
+ /**
453
+ * The Bun and GNO paths a registration launches, preferring links that
454
+ * follow version-manager upgrades (see launch-path.ts). A standalone
455
+ * compiled executable is left alone: its execPath is GNO itself.
456
+ */
457
+ export function resolveLaunchPaths(): {
458
+ bun: LaunchPath;
459
+ entrypoint: LaunchPath;
460
+ } {
461
+ const entrypoint = getCurrentGnoEntrypoint();
462
+ if (isBunfsPath(import.meta.path)) {
463
+ return {
464
+ bun: { path: process.execPath, pinned: false },
465
+ entrypoint: { path: entrypoint, pinned: false },
466
+ };
467
+ }
468
+ return {
469
+ bun: stableLaunchPath(process.execPath),
470
+ entrypoint: stableLaunchPath(entrypoint),
471
+ };
472
+ }
473
+
450
474
  /**
451
475
  * Find absolute path to bun executable.
452
- * Cross-platform: uses process.execPath since we're already running under Bun.
476
+ * Cross-platform: starts from process.execPath since we're already running
477
+ * under Bun, then prefers a version-independent link to the same binary.
453
478
  */
454
479
  export function findBunPath(): string {
455
- // process.execPath is the absolute path to the Bun binary running this process.
456
- // This is cross-platform and doesn't require shelling out to `which`.
457
- return process.execPath;
480
+ return resolveLaunchPaths().bun.path;
458
481
  }
459
482
 
460
483
  /**
@@ -467,8 +490,9 @@ export function buildMcpServerEntry(
467
490
  if (options.indexName !== undefined) {
468
491
  assertValidIndexName(options.indexName);
469
492
  }
470
- const bunPath = findBunPath();
471
- const args = ["run", getCurrentGnoEntrypoint()];
493
+ const launch = resolveLaunchPaths();
494
+ const bunPath = launch.bun.path;
495
+ const args = ["run", launch.entrypoint.path];
472
496
  appendMcpArguments(args, options);
473
497
  const dirs = resolveDirs();
474
498
  return {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `gno mcp install --force` over an existing registration (fn-213).
3
+ *
4
+ * The launch path (the interpreter and the `run <entrypoint>` prefix) is
5
+ * always rewritten. Every other argument, environment variable and entry key
6
+ * is kept, except the options the user passed explicitly in this invocation,
7
+ * which override their values. Defaults the installer fills in for omitted
8
+ * options never override an existing value.
9
+ *
10
+ * @module src/cli/commands/mcp/repair-entry
11
+ */
12
+
13
+ // node:path basename: no Bun path utilities.
14
+ import { basename } from "node:path";
15
+
16
+ import type { McpToolProfile } from "../../../mcp/tool-profile.js";
17
+ import type { McpConfigFormat, McpServerEntry } from "./paths.js";
18
+
19
+ import { isPlainRecord } from "./config-editors.js";
20
+
21
+ /** Install options the user passed explicitly (undefined = not passed). */
22
+ export interface ExplicitInstallValues {
23
+ indexName?: string;
24
+ configPath?: string;
25
+ toolProfile?: McpToolProfile;
26
+ enableWrite?: boolean;
27
+ }
28
+
29
+ /** An existing registration split into what GNO writes and everything else. */
30
+ export interface ExistingRegistration {
31
+ command: string;
32
+ args: string[];
33
+ env: Record<string, string>;
34
+ /** Entry keys GNO does not manage, kept verbatim. */
35
+ extra: Record<string, unknown>;
36
+ }
37
+
38
+ const isStringArray = (value: unknown): value is string[] =>
39
+ Array.isArray(value) && value.every((item) => typeof item === "string");
40
+
41
+ const stringRecord = (value: unknown): Record<string, string> | null => {
42
+ if (value === undefined) {
43
+ return {};
44
+ }
45
+ if (!isPlainRecord(value)) {
46
+ return null;
47
+ }
48
+ const entries = Object.entries(value);
49
+ return entries.every(([, item]) => typeof item === "string")
50
+ ? (Object.fromEntries(entries) as Record<string, string>)
51
+ : null;
52
+ };
53
+
54
+ /** The entry `--force` writes when it repairs an existing registration. */
55
+ export interface RepairedRegistration {
56
+ command: string;
57
+ args: string[];
58
+ env: Record<string, string>;
59
+ extra: Record<string, unknown>;
60
+ }
61
+
62
+ /** Read an existing entry; null when it is not a command registration. */
63
+ export function readExistingRegistration(
64
+ entry: unknown,
65
+ format: McpConfigFormat
66
+ ): ExistingRegistration | null {
67
+ if (!isPlainRecord(entry)) {
68
+ return null;
69
+ }
70
+ if (format === "mcp") {
71
+ const { command, environment, ...extra } = entry;
72
+ const env = stringRecord(environment);
73
+ if (!isStringArray(command) || command.length === 0 || env === null) {
74
+ return null;
75
+ }
76
+ const [executable = "", ...args] = command;
77
+ return { command: executable, args, env, extra };
78
+ }
79
+ const { command, args, env: rawEnv, ...extra } = entry;
80
+ const env = stringRecord(rawEnv);
81
+ if (typeof command !== "string" || !isStringArray(args) || env === null) {
82
+ return null;
83
+ }
84
+ return { command, args, env, extra };
85
+ }
86
+
87
+ const isGnoExecutable = (command: string): boolean =>
88
+ ["gno", "gno.exe", "gno.cmd"].includes(basename(command).toLowerCase());
89
+
90
+ /**
91
+ * The arguments after the launch prefix, or null when the registration is
92
+ * not a recognizable GNO MCP launch (it is then replaced as before).
93
+ */
94
+ function argumentsAfterLaunch(
95
+ registration: ExistingRegistration
96
+ ): string[] | null {
97
+ const { command, args } = registration;
98
+ let rest: string[] | null = null;
99
+ if (args[0] === "run" && args.length >= 2) {
100
+ rest = args.slice(2);
101
+ } else if (isGnoExecutable(command)) {
102
+ rest = args;
103
+ }
104
+ return rest?.includes("mcp") ? rest : null;
105
+ }
106
+
107
+ /** Set `flag value` in `list`, replacing `flag v` or `flag=v` in place. */
108
+ function setFlag(list: string[], flag: string, value: string): string[] {
109
+ const position = list.findIndex(
110
+ (item) => item === flag || item.startsWith(`${flag}=`)
111
+ );
112
+ if (position === -1) {
113
+ return [...list, flag, value];
114
+ }
115
+ const next = [...list];
116
+ if (next[position] === flag) {
117
+ next.splice(position, 2, flag, value);
118
+ } else {
119
+ next.splice(position, 1, flag, value);
120
+ }
121
+ return next;
122
+ }
123
+
124
+ /**
125
+ * The entry `--force` writes over `existing`: `fresh`'s launch path, the
126
+ * existing arguments and environment with explicit options applied, and the
127
+ * existing keys GNO does not manage. Null when `existing` is not a GNO MCP
128
+ * launch, so the caller writes `fresh` as before.
129
+ */
130
+ export function repairRegistration(
131
+ existing: ExistingRegistration,
132
+ fresh: McpServerEntry,
133
+ explicit: ExplicitInstallValues
134
+ ): RepairedRegistration | null {
135
+ const rest = argumentsAfterLaunch(existing);
136
+ if (!rest) {
137
+ return null;
138
+ }
139
+ const mcpPosition = rest.indexOf("mcp");
140
+ let globals = rest.slice(0, mcpPosition);
141
+ let subcommand = rest.slice(mcpPosition + 1);
142
+ if (explicit.indexName !== undefined) {
143
+ globals = setFlag(globals, "--index", explicit.indexName);
144
+ }
145
+ if (explicit.configPath !== undefined) {
146
+ globals = setFlag(globals, "--config", explicit.configPath);
147
+ }
148
+ if (explicit.toolProfile !== undefined) {
149
+ subcommand = setFlag(subcommand, "--tool-profile", explicit.toolProfile);
150
+ }
151
+ if (explicit.enableWrite && !subcommand.includes("--enable-write")) {
152
+ subcommand = [...subcommand, "--enable-write"];
153
+ }
154
+ return {
155
+ command: fresh.command,
156
+ args: [...fresh.args.slice(0, 2), ...globals, "mcp", ...subcommand],
157
+ env: { ...fresh.env, ...existing.env },
158
+ extra: existing.extra,
159
+ };
160
+ }