@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
|
@@ -101,6 +101,54 @@ interface StatusResult {
|
|
|
101
101
|
// Config Reading
|
|
102
102
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
103
103
|
|
|
104
|
+
const isStringArray = (value: unknown): boolean =>
|
|
105
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Client settings that never change how the command runs: timeouts, labels
|
|
109
|
+
* and approval prompts. Anything else (cwd, url, headers, another transport,
|
|
110
|
+
* unknown keys) keeps an entry fail-closed, because the connector verifier
|
|
111
|
+
* starts the registered command.
|
|
112
|
+
*/
|
|
113
|
+
const INERT_ENTRY_KEYS: Record<string, (value: unknown) => boolean> = {
|
|
114
|
+
type: (value) => value === "stdio",
|
|
115
|
+
timeout: (value) => typeof value === "number",
|
|
116
|
+
startup_timeout_sec: (value) => typeof value === "number",
|
|
117
|
+
tool_timeout_sec: (value) => typeof value === "number",
|
|
118
|
+
description: (value) => typeof value === "string",
|
|
119
|
+
autoApprove: isStringArray,
|
|
120
|
+
alwaysAllow: isStringArray,
|
|
121
|
+
enabled: (value) => typeof value === "boolean",
|
|
122
|
+
disabled: (value) => typeof value === "boolean",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Keys other than the launch keys are all known and inert. */
|
|
126
|
+
function hasOnlyInertExtras(
|
|
127
|
+
record: Record<string, unknown>,
|
|
128
|
+
launchKeys: readonly string[],
|
|
129
|
+
allowed: Record<string, (value: unknown) => boolean>
|
|
130
|
+
): boolean {
|
|
131
|
+
return Object.entries(record).every(
|
|
132
|
+
([key, value]) =>
|
|
133
|
+
launchKeys.includes(key) || (allowed[key]?.(value) ?? false)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** OpenCode writes `type: "local"` and `enabled` itself. */
|
|
138
|
+
const OPENCODE_INERT_KEYS: Record<string, (value: unknown) => boolean> = {
|
|
139
|
+
timeout: (value) => typeof value === "number",
|
|
140
|
+
enabled: (value) => typeof value === "boolean",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** A registration the client has switched off. */
|
|
144
|
+
function isDisabledEntry(entry: unknown): boolean {
|
|
145
|
+
if (!entry || typeof entry !== "object") {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const record = entry as Record<string, unknown>;
|
|
149
|
+
return record.enabled === false || record.disabled === true;
|
|
150
|
+
}
|
|
151
|
+
|
|
104
152
|
/**
|
|
105
153
|
* Normalize entry to standard format for display.
|
|
106
154
|
*/
|
|
@@ -120,12 +168,10 @@ function normalizeEntry(
|
|
|
120
168
|
!Array.isArray(record.command) ||
|
|
121
169
|
record.command.length === 0 ||
|
|
122
170
|
!record.command.every((part) => typeof part === "string") ||
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
key !== "enabled" &&
|
|
128
|
-
key !== "environment"
|
|
171
|
+
!hasOnlyInertExtras(
|
|
172
|
+
record,
|
|
173
|
+
["type", "command", "environment"],
|
|
174
|
+
OPENCODE_INERT_KEYS
|
|
129
175
|
)
|
|
130
176
|
) {
|
|
131
177
|
return null;
|
|
@@ -149,9 +195,7 @@ function normalizeEntry(
|
|
|
149
195
|
record.command.length === 0 ||
|
|
150
196
|
!Array.isArray(record.args) ||
|
|
151
197
|
!record.args.every((argument) => typeof argument === "string") ||
|
|
152
|
-
|
|
153
|
-
(key) => key !== "command" && key !== "args" && key !== "env"
|
|
154
|
-
)
|
|
198
|
+
!hasOnlyInertExtras(record, ["command", "args", "env"], INERT_ENTRY_KEYS)
|
|
155
199
|
) {
|
|
156
200
|
return null;
|
|
157
201
|
}
|
|
@@ -211,12 +255,8 @@ export async function checkMcpTargetStatus(
|
|
|
211
255
|
return { target, scope, configPath, configured: false };
|
|
212
256
|
}
|
|
213
257
|
const entry = parsed.entry;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
entry &&
|
|
217
|
-
typeof entry === "object" &&
|
|
218
|
-
(entry as { enabled?: unknown }).enabled === false
|
|
219
|
-
) {
|
|
258
|
+
// A registration the client has switched off is not configured.
|
|
259
|
+
if (isDisabledEntry(entry)) {
|
|
220
260
|
return { target, scope, configPath, configured: false };
|
|
221
261
|
}
|
|
222
262
|
const configIdentity = entryIdentity(entry);
|
package/src/cli/commands/mcp.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import type { McpToolProfile } from "../../mcp/tool-profile";
|
|
8
8
|
import type { GlobalOptions } from "../context";
|
|
9
9
|
|
|
10
|
+
import { warnIfBunBelowFloor } from "../../app/bun-runtime";
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* Start the MCP server.
|
|
12
14
|
* Reads global options for --index and --config flags.
|
|
@@ -15,6 +17,8 @@ export async function mcpCommand(
|
|
|
15
17
|
options: GlobalOptions,
|
|
16
18
|
commandOptions: { enableWrite?: boolean; toolProfile?: McpToolProfile } = {}
|
|
17
19
|
): Promise<void> {
|
|
20
|
+
// stderr only: stdout carries the MCP protocol.
|
|
21
|
+
warnIfBunBelowFloor("mcp");
|
|
18
22
|
const { startMcpServer } = await import("../../mcp/server.js");
|
|
19
23
|
await startMcpServer({
|
|
20
24
|
indexName: options.index,
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* @module src/cli/commands/serve
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { warnIfBunBelowFloor } from "../../app/bun-runtime";
|
|
9
|
+
|
|
8
10
|
export type { ServeOptions, ServeResult } from "../../serve";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -14,6 +16,7 @@ export type { ServeOptions, ServeResult } from "../../serve";
|
|
|
14
16
|
export async function serve(
|
|
15
17
|
options: import("../../serve").ServeOptions = {}
|
|
16
18
|
): Promise<import("../../serve").ServeResult> {
|
|
19
|
+
warnIfBunBelowFloor("serve");
|
|
17
20
|
const { startServer } = await import("../../serve");
|
|
18
21
|
return startServer(options);
|
|
19
22
|
}
|
|
@@ -44,6 +44,12 @@ import {
|
|
|
44
44
|
resolveProcessPaths,
|
|
45
45
|
statusProcess,
|
|
46
46
|
} from "../detach";
|
|
47
|
+
import {
|
|
48
|
+
checkRegistrationRuntimes,
|
|
49
|
+
checkResidentRuntimes,
|
|
50
|
+
formatRuntimeIssue,
|
|
51
|
+
type RuntimeIssue,
|
|
52
|
+
} from "../runtime-health";
|
|
47
53
|
|
|
48
54
|
/**
|
|
49
55
|
* Options for status command.
|
|
@@ -70,6 +76,8 @@ export type StatusResult =
|
|
|
70
76
|
contentTypeBoost: ContentTypeBoostStatus;
|
|
71
77
|
memory: MemoryStatus;
|
|
72
78
|
backgroundIssues: ResidentBackgroundIssue[];
|
|
79
|
+
/** Registrations and residents on a missing, old or other Bun (fn-213). */
|
|
80
|
+
runtimeIssues?: RuntimeIssue[];
|
|
73
81
|
/** Files whose latest conversion stopped at the per-file budget. */
|
|
74
82
|
budgetStops: BudgetStop[];
|
|
75
83
|
}
|
|
@@ -433,6 +441,10 @@ export async function status(
|
|
|
433
441
|
contentTypeBoost: buildContentTypeBoostStatus(config.contentTypes ?? []),
|
|
434
442
|
memory: await buildMemoryStatus(store, config.collections),
|
|
435
443
|
backgroundIssues: await collectResidentIssues(),
|
|
444
|
+
runtimeIssues: [
|
|
445
|
+
...(await checkRegistrationRuntimes()),
|
|
446
|
+
...(await checkResidentRuntimes()),
|
|
447
|
+
],
|
|
436
448
|
budgetStops: await collectBudgetStops(store),
|
|
437
449
|
};
|
|
438
450
|
} finally {
|
|
@@ -486,6 +498,9 @@ export function formatStatus(
|
|
|
486
498
|
...(result.backgroundIssues.length
|
|
487
499
|
? { backgroundIssues: result.backgroundIssues }
|
|
488
500
|
: {}),
|
|
501
|
+
...(result.runtimeIssues?.length
|
|
502
|
+
? { runtimeIssues: result.runtimeIssues }
|
|
503
|
+
: {}),
|
|
489
504
|
},
|
|
490
505
|
null,
|
|
491
506
|
2
|
|
@@ -524,5 +539,14 @@ export function formatStatus(
|
|
|
524
539
|
]
|
|
525
540
|
: []),
|
|
526
541
|
...(issueLines.length ? ["", "Background issues:", ...issueLines] : []),
|
|
542
|
+
...(result.runtimeIssues?.length
|
|
543
|
+
? [
|
|
544
|
+
"",
|
|
545
|
+
"Bun runtime:",
|
|
546
|
+
...result.runtimeIssues.map(
|
|
547
|
+
(issue) => ` [${issue.severity}] ${formatRuntimeIssue(issue)}`
|
|
548
|
+
),
|
|
549
|
+
]
|
|
550
|
+
: []),
|
|
527
551
|
].join("\n");
|
|
528
552
|
}
|
package/src/cli/detach.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface PidFilePayload {
|
|
|
56
56
|
version: string;
|
|
57
57
|
started_at: string;
|
|
58
58
|
port?: number | null;
|
|
59
|
+
/** Bun the resident runs on (fn-213); absent in older pid-files. */
|
|
60
|
+
bun_version?: string;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
/** Result of a `--status` call (matches process-status@1.0). */
|
|
@@ -246,6 +248,9 @@ export async function readPidFile(
|
|
|
246
248
|
version: record.version,
|
|
247
249
|
started_at: record.started_at,
|
|
248
250
|
port,
|
|
251
|
+
...(typeof record.bun_version === "string" && record.bun_version
|
|
252
|
+
? { bun_version: record.bun_version }
|
|
253
|
+
: {}),
|
|
249
254
|
};
|
|
250
255
|
}
|
|
251
256
|
|
|
@@ -610,6 +615,11 @@ export async function spawnDetached(
|
|
|
610
615
|
version: VERSION,
|
|
611
616
|
started_at: new Date().toISOString(),
|
|
612
617
|
port: options.port ?? null,
|
|
618
|
+
// The child runs on this process's Bun unless a caller replaced the
|
|
619
|
+
// command.
|
|
620
|
+
...(options.cmdPrefix || options.execPath
|
|
621
|
+
? {}
|
|
622
|
+
: { bun_version: Bun.version }),
|
|
613
623
|
};
|
|
614
624
|
|
|
615
625
|
// If the pid-file write fails (disk full, permission race, bad path on
|
package/src/cli/program.ts
CHANGED
|
@@ -2644,7 +2644,7 @@ function wireMcpCommand(program: Command): void {
|
|
|
2644
2644
|
"advertised tool set the registration starts with: core (7 read tools + capture/remember with --enable-write) or full (default)"
|
|
2645
2645
|
)
|
|
2646
2646
|
.option("--json", "JSON output")
|
|
2647
|
-
.action(async (cmdOpts: Record<string, unknown
|
|
2647
|
+
.action(async (cmdOpts: Record<string, unknown>, command: Command) => {
|
|
2648
2648
|
const target = cmdOpts.target as string;
|
|
2649
2649
|
const toolProfile = parseToolProfileOption(cmdOpts.toolProfile);
|
|
2650
2650
|
const requestedScope = cmdOpts.scope;
|
|
@@ -2687,9 +2687,14 @@ function wireMcpCommand(program: Command): void {
|
|
|
2687
2687
|
scope: scope as "user" | "project",
|
|
2688
2688
|
force: Boolean(cmdOpts.force),
|
|
2689
2689
|
dryRun: Boolean(cmdOpts.dryRun),
|
|
2690
|
-
|
|
2690
|
+
// Only options the user passed: --force keeps the rest of an
|
|
2691
|
+
// existing registration (--index has a default, so check its source).
|
|
2692
|
+
enableWrite: cmdOpts.enableWrite === true ? true : undefined,
|
|
2691
2693
|
toolProfile,
|
|
2692
|
-
indexName:
|
|
2694
|
+
indexName:
|
|
2695
|
+
command.getOptionValueSourceWithGlobals("index") === "cli"
|
|
2696
|
+
? globals.index
|
|
2697
|
+
: undefined,
|
|
2693
2698
|
configPath: globals.config,
|
|
2694
2699
|
// Pass undefined if not set, so global --json can take effect
|
|
2695
2700
|
json: cmdOpts.json === true ? true : undefined,
|
|
@@ -2865,23 +2870,59 @@ function wireManagementCommands(program: Command): void {
|
|
|
2865
2870
|
});
|
|
2866
2871
|
});
|
|
2867
2872
|
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2873
|
+
addWriteLeaseFlags(
|
|
2874
|
+
collectionCmd
|
|
2875
|
+
.command("remove <name>")
|
|
2876
|
+
.description("Remove a collection and its indexed documents")
|
|
2877
|
+
).action(async (name: string, cmdOpts: Record<string, unknown>) => {
|
|
2878
|
+
const { collectionRemove } = await import("./commands/collection");
|
|
2879
|
+
const lease = parseWriteLeaseFlags(cmdOpts);
|
|
2880
|
+
const result = await withCliWriteLease(
|
|
2881
|
+
{
|
|
2882
|
+
indexName: getGlobals().index,
|
|
2883
|
+
lockWaitMs: lease.lockWaitMs,
|
|
2884
|
+
noWait: lease.noWait,
|
|
2885
|
+
},
|
|
2886
|
+
async () => {
|
|
2887
|
+
await collectionRemove(name, {
|
|
2888
|
+
configPath: getGlobals().config,
|
|
2889
|
+
indexName: getGlobals().index,
|
|
2890
|
+
});
|
|
2891
|
+
return { success: true as const };
|
|
2892
|
+
}
|
|
2893
|
+
);
|
|
2894
|
+
throwIfWriteLeaseBusy(result, false);
|
|
2895
|
+
});
|
|
2875
2896
|
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2897
|
+
addWriteLeaseFlags(
|
|
2898
|
+
collectionCmd
|
|
2899
|
+
.command("rename <old> <new>")
|
|
2900
|
+
.description("Rename a collection (run gno update afterwards)")
|
|
2901
|
+
).action(
|
|
2902
|
+
async (
|
|
2903
|
+
oldName: string,
|
|
2904
|
+
newName: string,
|
|
2905
|
+
cmdOpts: Record<string, unknown>
|
|
2906
|
+
) => {
|
|
2880
2907
|
const { collectionRename } = await import("./commands/collection");
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2908
|
+
const lease = parseWriteLeaseFlags(cmdOpts);
|
|
2909
|
+
const result = await withCliWriteLease(
|
|
2910
|
+
{
|
|
2911
|
+
indexName: getGlobals().index,
|
|
2912
|
+
lockWaitMs: lease.lockWaitMs,
|
|
2913
|
+
noWait: lease.noWait,
|
|
2914
|
+
},
|
|
2915
|
+
async () => {
|
|
2916
|
+
await collectionRename(oldName, newName, {
|
|
2917
|
+
configPath: getGlobals().config,
|
|
2918
|
+
indexName: getGlobals().index,
|
|
2919
|
+
});
|
|
2920
|
+
return { success: true as const };
|
|
2921
|
+
}
|
|
2922
|
+
);
|
|
2923
|
+
throwIfWriteLeaseBusy(result, false);
|
|
2924
|
+
}
|
|
2925
|
+
);
|
|
2885
2926
|
|
|
2886
2927
|
addWriteLeaseFlags(
|
|
2887
2928
|
collectionCmd
|
|
@@ -3615,7 +3656,11 @@ function wireManagementCommands(program: Command): void {
|
|
|
3615
3656
|
lockWaitMs: lease.lockWaitMs,
|
|
3616
3657
|
noWait: lease.noWait,
|
|
3617
3658
|
},
|
|
3618
|
-
() =>
|
|
3659
|
+
() =>
|
|
3660
|
+
cleanup({
|
|
3661
|
+
configPath: getGlobals().config,
|
|
3662
|
+
indexName: getGlobals().index,
|
|
3663
|
+
})
|
|
3619
3664
|
);
|
|
3620
3665
|
throwIfWriteLeaseBusy(result, false);
|
|
3621
3666
|
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Bun the GNO processes on this machine run on (fn-213).
|
|
3
|
+
*
|
|
4
|
+
* Two read-only checks for `gno doctor` and `gno status`:
|
|
5
|
+
* - client registrations whose interpreter or entrypoint is missing, below
|
|
6
|
+
* the supported Bun, or a different Bun than the one running the check;
|
|
7
|
+
* - running residents (`serve`, `daemon`) on a Bun below the floor or older
|
|
8
|
+
* than the one running the check, including residents started by another
|
|
9
|
+
* GNO version, whose runtime is read from their `/api/status`.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here starts a registered command or signals a process; an
|
|
12
|
+
* interpreter is only asked for `--version`, under a short timeout.
|
|
13
|
+
*
|
|
14
|
+
* @module src/cli/runtime-health
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// node:fs realpathSync: synchronous link resolution; Bun has no realpath API.
|
|
18
|
+
import { realpathSync } from "node:fs";
|
|
19
|
+
// node:path basename: no Bun path utilities.
|
|
20
|
+
import { basename } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { McpScope, McpTarget } from "./commands/mcp/paths";
|
|
23
|
+
|
|
24
|
+
import { BUN_FLOOR, bunMeetsFloor } from "../app/bun-runtime";
|
|
25
|
+
import { VERSION } from "../app/constants";
|
|
26
|
+
import { getServersKey } from "./commands/mcp/config";
|
|
27
|
+
import { resolveMcpConfigLocation } from "./commands/mcp/config-discovery";
|
|
28
|
+
import {
|
|
29
|
+
getJsoncServerEntry,
|
|
30
|
+
getTomlServerEntry,
|
|
31
|
+
getYamlServerEntry,
|
|
32
|
+
} from "./commands/mcp/config-editors";
|
|
33
|
+
import { getTargetDisplayName, resolveAllMcpPaths } from "./commands/mcp/paths";
|
|
34
|
+
import { readExistingRegistration } from "./commands/mcp/repair-entry";
|
|
35
|
+
import {
|
|
36
|
+
type DetachKind,
|
|
37
|
+
isProcessAlive,
|
|
38
|
+
readPidFile,
|
|
39
|
+
resolveProcessPaths,
|
|
40
|
+
} from "./detach";
|
|
41
|
+
|
|
42
|
+
export type RuntimeIssueSeverity = "error" | "warn" | "info";
|
|
43
|
+
|
|
44
|
+
export interface RuntimeIssue {
|
|
45
|
+
kind: "registration" | "resident";
|
|
46
|
+
severity: RuntimeIssueSeverity;
|
|
47
|
+
/** What it is about: a client registration or a resident process. */
|
|
48
|
+
subject: string;
|
|
49
|
+
message: string;
|
|
50
|
+
/** Command or steps that repair it. */
|
|
51
|
+
repair: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How long an interpreter may take to answer `--version`. */
|
|
55
|
+
const VERSION_PROBE_TIMEOUT_MS = 2000;
|
|
56
|
+
/** How long a resident may take to answer `/api/status`. */
|
|
57
|
+
const RESIDENT_PROBE_TIMEOUT_MS = 2000;
|
|
58
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
|
|
59
|
+
/** A Bun version in a version manager's install path. */
|
|
60
|
+
const VERSION_IN_PATH =
|
|
61
|
+
/[\\/](?:installs|tools|Cellar)[\\/]bun[\\/](\d+\.\d+\.\d+[^\\/]*)[\\/]/;
|
|
62
|
+
|
|
63
|
+
const realpathOrNull = (path: string): string | null => {
|
|
64
|
+
try {
|
|
65
|
+
return realpathSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The Bun version of an interpreter: from its install path, else `--version`. */
|
|
72
|
+
async function interpreterVersion(path: string): Promise<string | null> {
|
|
73
|
+
const resolved = realpathOrNull(path) ?? path;
|
|
74
|
+
const fromPath = VERSION_IN_PATH.exec(resolved)?.[1];
|
|
75
|
+
if (fromPath) {
|
|
76
|
+
return fromPath;
|
|
77
|
+
}
|
|
78
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
79
|
+
try {
|
|
80
|
+
const probe = Bun.spawn([path, "--version"], {
|
|
81
|
+
stdin: "ignore",
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "ignore",
|
|
84
|
+
});
|
|
85
|
+
// Bound the read, not only the process: a child of the probe can hold
|
|
86
|
+
// stdout open after the probe itself is killed.
|
|
87
|
+
const timedOut = new Promise<null>((resolve) => {
|
|
88
|
+
timer = setTimeout(() => resolve(null), VERSION_PROBE_TIMEOUT_MS);
|
|
89
|
+
});
|
|
90
|
+
const output = await Promise.race([
|
|
91
|
+
new Response(probe.stdout).text(),
|
|
92
|
+
timedOut,
|
|
93
|
+
]);
|
|
94
|
+
if (output === null) {
|
|
95
|
+
probe.kill(9);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const version = output.trim();
|
|
99
|
+
return SEMVER.test(version) ? version : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const isGnoExecutable = (command: string): boolean =>
|
|
108
|
+
["gno", "gno.exe", "gno.cmd"].includes(basename(command).toLowerCase());
|
|
109
|
+
|
|
110
|
+
async function readRegistrationEntry(
|
|
111
|
+
configPath: string,
|
|
112
|
+
format: string
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const file = Bun.file(configPath);
|
|
115
|
+
if (!(await file.exists())) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const content = await file.text();
|
|
119
|
+
if (!content.trim()) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const parsed =
|
|
123
|
+
format === "codex_toml"
|
|
124
|
+
? getTomlServerEntry(content, configPath)
|
|
125
|
+
: format === "yaml_standard"
|
|
126
|
+
? getYamlServerEntry(
|
|
127
|
+
content,
|
|
128
|
+
configPath,
|
|
129
|
+
getServersKey("yaml_standard")
|
|
130
|
+
)
|
|
131
|
+
: getJsoncServerEntry(
|
|
132
|
+
content,
|
|
133
|
+
configPath,
|
|
134
|
+
getServersKey(format as Parameters<typeof getServersKey>[0])
|
|
135
|
+
);
|
|
136
|
+
return parsed.exists ? parsed.entry : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const repairCommand = (target: McpTarget, scope: McpScope): string =>
|
|
140
|
+
`gno mcp install --target ${target} --scope ${scope} --force`;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Registrations whose launch is broken or on another Bun. Unreadable or
|
|
144
|
+
* malformed configs are left to `gno mcp status`.
|
|
145
|
+
*/
|
|
146
|
+
export async function checkRegistrationRuntimes(
|
|
147
|
+
overrides: { cwd?: string; homeDir?: string } = {}
|
|
148
|
+
): Promise<RuntimeIssue[]> {
|
|
149
|
+
const issues: RuntimeIssue[] = [];
|
|
150
|
+
const runningBun = realpathOrNull(process.execPath);
|
|
151
|
+
const versions = new Map<string, Promise<string | null>>();
|
|
152
|
+
for (const { target, scope, paths } of resolveAllMcpPaths(
|
|
153
|
+
"all",
|
|
154
|
+
"all",
|
|
155
|
+
overrides
|
|
156
|
+
)) {
|
|
157
|
+
let entry: unknown;
|
|
158
|
+
let configPath = paths.configPath;
|
|
159
|
+
try {
|
|
160
|
+
configPath = await resolveMcpConfigLocation(paths);
|
|
161
|
+
entry = await readRegistrationEntry(configPath, paths.configFormat);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const registration =
|
|
166
|
+
entry === undefined
|
|
167
|
+
? null
|
|
168
|
+
: readExistingRegistration(entry, paths.configFormat);
|
|
169
|
+
if (
|
|
170
|
+
!registration ||
|
|
171
|
+
registration.extra.enabled === false ||
|
|
172
|
+
registration.extra.disabled === true
|
|
173
|
+
) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const { command, args } = registration;
|
|
177
|
+
const viaBun = args[0] === "run" && args.length >= 2;
|
|
178
|
+
if (!viaBun && !isGnoExecutable(command)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const subject = `${getTargetDisplayName(target)} (${scope}) ${configPath}`;
|
|
182
|
+
const repair = repairCommand(target, scope);
|
|
183
|
+
const launchFiles = viaBun ? [command, args[1] ?? ""] : [command];
|
|
184
|
+
const missing = launchFiles.filter((path) => !realpathOrNull(path));
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
issues.push({
|
|
187
|
+
kind: "registration",
|
|
188
|
+
severity: "error",
|
|
189
|
+
subject,
|
|
190
|
+
message: `launches ${missing.join(" and ")}, which no longer exists`,
|
|
191
|
+
repair,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!viaBun) {
|
|
196
|
+
// The gno launcher finds Bun on PATH when it starts.
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const resolved = realpathOrNull(command) ?? command;
|
|
200
|
+
if (!versions.has(resolved)) {
|
|
201
|
+
versions.set(resolved, interpreterVersion(command));
|
|
202
|
+
}
|
|
203
|
+
const version = await versions.get(resolved);
|
|
204
|
+
if (version && !bunMeetsFloor(version)) {
|
|
205
|
+
issues.push({
|
|
206
|
+
kind: "registration",
|
|
207
|
+
severity: "error",
|
|
208
|
+
subject,
|
|
209
|
+
message: `runs Bun ${version} (${command}), older than the supported minimum ${BUN_FLOOR}`,
|
|
210
|
+
repair,
|
|
211
|
+
});
|
|
212
|
+
} else if (resolved !== runningBun) {
|
|
213
|
+
issues.push({
|
|
214
|
+
kind: "registration",
|
|
215
|
+
severity: "info",
|
|
216
|
+
subject,
|
|
217
|
+
message: `runs ${version ? `Bun ${version}` : "a Bun of unknown version"} (${command}), not this Bun ${Bun.version}`,
|
|
218
|
+
repair,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The Bun a resident reports through `/api/status`, if it is GNO. */
|
|
226
|
+
async function residentBunVersion(port: number): Promise<string | null> {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(`http://127.0.0.1:${port}/api/status`, {
|
|
229
|
+
signal: AbortSignal.timeout(RESIDENT_PROBE_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
if (!response.ok) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const body = (await response.json()) as {
|
|
235
|
+
indexName?: unknown;
|
|
236
|
+
bootstrap?: { runtime?: { kind?: unknown; currentVersion?: unknown } };
|
|
237
|
+
};
|
|
238
|
+
const runtime = body?.bootstrap?.runtime;
|
|
239
|
+
// Only a GNO status payload counts; anything else on the port is ignored.
|
|
240
|
+
if (
|
|
241
|
+
typeof body?.indexName !== "string" ||
|
|
242
|
+
runtime?.kind !== "bun" ||
|
|
243
|
+
typeof runtime.currentVersion !== "string" ||
|
|
244
|
+
!SEMVER.test(runtime.currentVersion)
|
|
245
|
+
) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return runtime.currentVersion;
|
|
249
|
+
} catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function restartSteps(
|
|
255
|
+
kind: DetachKind,
|
|
256
|
+
pid: number,
|
|
257
|
+
sameVersion: boolean
|
|
258
|
+
): string {
|
|
259
|
+
if (sameVersion) {
|
|
260
|
+
return `restart it: gno ${kind} --stop, then start it again (for example gno ${kind} --detach)`;
|
|
261
|
+
}
|
|
262
|
+
const stop =
|
|
263
|
+
process.platform === "win32" ? `taskkill /PID ${pid}` : `kill ${pid}`;
|
|
264
|
+
return `it was started by another GNO version, so gno ${kind} --stop leaves it alone; stop it with ${stop}, then start it again (for example gno ${kind} --detach)`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Running residents on an unsupported or older Bun. A resident started by
|
|
269
|
+
* another GNO version is read through its HTTP status; it is never signalled.
|
|
270
|
+
*/
|
|
271
|
+
export async function checkResidentRuntimes(): Promise<RuntimeIssue[]> {
|
|
272
|
+
const issues: RuntimeIssue[] = [];
|
|
273
|
+
for (const kind of ["serve", "daemon"] as const) {
|
|
274
|
+
const { pidFile } = resolveProcessPaths(kind);
|
|
275
|
+
let payload: Awaited<ReturnType<typeof readPidFile>>;
|
|
276
|
+
try {
|
|
277
|
+
payload = await readPidFile(pidFile);
|
|
278
|
+
} catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (!payload || payload.cmd !== kind || !isProcessAlive(payload.pid)) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const sameVersion = payload.version === VERSION;
|
|
285
|
+
let version = payload.bun_version ?? null;
|
|
286
|
+
if (!version && typeof payload.port === "number") {
|
|
287
|
+
version = await residentBunVersion(payload.port);
|
|
288
|
+
}
|
|
289
|
+
const subject = `gno ${kind} (pid ${payload.pid}, GNO ${payload.version})`;
|
|
290
|
+
const repair = restartSteps(kind, payload.pid, sameVersion);
|
|
291
|
+
if (!version) {
|
|
292
|
+
if (!sameVersion) {
|
|
293
|
+
issues.push({
|
|
294
|
+
kind: "resident",
|
|
295
|
+
severity: "info",
|
|
296
|
+
subject,
|
|
297
|
+
message:
|
|
298
|
+
"is running from another GNO version; its Bun version is unknown",
|
|
299
|
+
repair,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!bunMeetsFloor(version)) {
|
|
305
|
+
issues.push({
|
|
306
|
+
kind: "resident",
|
|
307
|
+
severity: "error",
|
|
308
|
+
subject,
|
|
309
|
+
message: `runs Bun ${version}, older than the supported minimum ${BUN_FLOOR}`,
|
|
310
|
+
repair,
|
|
311
|
+
});
|
|
312
|
+
} else if (Bun.semver.order(version, Bun.version) < 0) {
|
|
313
|
+
issues.push({
|
|
314
|
+
kind: "resident",
|
|
315
|
+
severity: "warn",
|
|
316
|
+
subject,
|
|
317
|
+
message: `runs Bun ${version}, older than this Bun ${Bun.version}`,
|
|
318
|
+
repair,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return issues;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** One line per issue for terminal output. */
|
|
326
|
+
export function formatRuntimeIssue(issue: RuntimeIssue): string {
|
|
327
|
+
return `${issue.subject}: ${issue.message}. Repair: ${issue.repair}`;
|
|
328
|
+
}
|
|
@@ -61,6 +61,22 @@ export const NETWORK_BOUNDARY_INVENTORY = [
|
|
|
61
61
|
action: null,
|
|
62
62
|
enforcement: "no_collection_data",
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
id: "registration-bun-version-probe",
|
|
66
|
+
key: "src/cli/runtime-health.ts::child_process#1",
|
|
67
|
+
path: "src/cli/runtime-health.ts",
|
|
68
|
+
primitive: "child_process",
|
|
69
|
+
action: null,
|
|
70
|
+
enforcement: "no_collection_data",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "resident-bun-version-probe",
|
|
74
|
+
key: "src/cli/runtime-health.ts::fetch#1",
|
|
75
|
+
path: "src/cli/runtime-health.ts",
|
|
76
|
+
primitive: "fetch",
|
|
77
|
+
action: null,
|
|
78
|
+
enforcement: "no_collection_data",
|
|
79
|
+
},
|
|
64
80
|
{
|
|
65
81
|
id: "terminal-pager",
|
|
66
82
|
key: "src/cli/pager.ts::child_process#1",
|