@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.
- package/README.md +2 -2
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.2.zip → gno-browser-clipper-v2.10.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.10.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/bunfig.toml +5 -1
- package/package.json +3 -2
- package/spec/cli.md +102 -12
- package/spec/mcp.md +1 -1
- package/spec/output-schemas/mcp-remove-result.schema.json +2 -2
- package/src/app/bun-runtime.ts +41 -0
- package/src/cli/commands/cleanup.ts +9 -1
- package/src/cli/commands/collection/remove.ts +50 -31
- package/src/cli/commands/collection/rename.ts +36 -8
- package/src/cli/commands/daemon.ts +3 -0
- package/src/cli/commands/doctor.ts +61 -0
- package/src/cli/commands/mcp/config-editors.ts +56 -8
- package/src/cli/commands/mcp/install.ts +159 -24
- package/src/cli/commands/mcp/launch-path.ts +87 -0
- package/src/cli/commands/mcp/paths.ts +30 -6
- package/src/cli/commands/mcp/repair-entry.ts +160 -0
- package/src/cli/commands/mcp/status.ts +55 -15
- package/src/cli/commands/mcp.ts +4 -0
- package/src/cli/commands/serve.ts +3 -0
- package/src/cli/commands/status.ts +24 -0
- package/src/cli/detach.ts +10 -0
- package/src/cli/program.ts +64 -19
- package/src/cli/runtime-health.ts +328 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/ingestion/file-processor.ts +118 -17
- package/src/ingestion/prepare-worker.ts +20 -2
- package/src/mcp/tools/remove-collection.ts +2 -1
- package/src/serve/public/components/BootstrapStatus.tsx +3 -3
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/routes/api.ts +3 -4
- package/src/serve/status.ts +2 -1
- package/src/store/sqlite/adapter.ts +17 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
471
|
-
const
|
|
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
|
+
}
|