pi-checkup 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +343 -2
- package/bin/checkup.js +233 -0
- package/package.json +47 -4
- package/scripts/lint.js +43 -0
- package/src/checks/binaries.js +355 -0
- package/src/checks/config.js +312 -0
- package/src/checks/index.js +165 -0
- package/src/checks/network.js +543 -0
- package/src/checks/packages.js +367 -0
- package/src/checks/registry.js +338 -0
- package/src/checks/runtime.js +360 -0
- package/src/context.js +79 -0
- package/src/index.js +109 -0
- package/src/render.js +159 -0
- package/src/repair.js +367 -0
- package/src/util.js +839 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Registry checks — what is actually loaded right now.
|
|
2
|
+
//
|
|
3
|
+
// These are the checks a shell script cannot do: `pi list` shows which packages
|
|
4
|
+
// are installed, but only the running session knows which tools and commands
|
|
5
|
+
// they really registered, under what names, and who lost a collision.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { normalizePath, ownerOf, readJson, redactSource, resolvePackageDir, scrubCredentials, sourceOf, truncateList } from "../util.js";
|
|
8
|
+
|
|
9
|
+
export const group = "Registry";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Call into the Pi API, keeping "it threw" distinguishable from "it returned
|
|
13
|
+
* nothing". Swallowing the error produced a green "0 registered across 0
|
|
14
|
+
* sources" over a registry that was never read.
|
|
15
|
+
*/
|
|
16
|
+
function call(fn) {
|
|
17
|
+
try {
|
|
18
|
+
const value = fn();
|
|
19
|
+
// A non-array is a shape this code does not understand, not an empty
|
|
20
|
+
// registry: treating it as [] would print "0 registered across 0 sources"
|
|
21
|
+
// as a pass over a registry that was never read.
|
|
22
|
+
if (Array.isArray(value)) return { ok: true, value };
|
|
23
|
+
return { ok: false, error: `unexpected registry shape (${value === null ? "null" : typeof value})` };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Scrubbed before it is shortened, not after: slicing first can cut a
|
|
26
|
+
// `//user:pass@` in half, and then there is no userinfo left to recognise
|
|
27
|
+
// when the text is printed.
|
|
28
|
+
return { ok: false, error: scrubCredentials(String(e?.message ?? e)).slice(0, 120) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function run({ pi, hasRuntime, settings, agentDir: root }) {
|
|
33
|
+
const results = [];
|
|
34
|
+
|
|
35
|
+
// Without a running session there is no registry to audit. Say so once rather
|
|
36
|
+
// than emitting five empty checks that look like passes.
|
|
37
|
+
if (!hasRuntime) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
group,
|
|
41
|
+
id: "runtime",
|
|
42
|
+
title: "live registry",
|
|
43
|
+
status: "skip",
|
|
44
|
+
detail: "no Pi session — registry checks need a running process",
|
|
45
|
+
hint: "Run /checkup from inside Pi to audit loaded tools and commands.",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const toolsResult = call(() => pi.getAllTools());
|
|
51
|
+
const commandsResult = call(() => pi.getCommands());
|
|
52
|
+
const serversResult = call(() => pi.getMcpServers());
|
|
53
|
+
const tools = toolsResult.ok ? toolsResult.value : [];
|
|
54
|
+
const commands = commandsResult.ok ? commandsResult.value : [];
|
|
55
|
+
const servers = serversResult.ok ? serversResult.value : [];
|
|
56
|
+
const registryReadable = toolsResult.ok && commandsResult.ok;
|
|
57
|
+
|
|
58
|
+
// --- attribution -------------------------------------------------------
|
|
59
|
+
// Only entries that carry a name: a nameless one still counted towards an
|
|
60
|
+
// owner's total, so the row said "(3)" and then listed two names.
|
|
61
|
+
const named = (list) => list.filter((item) => typeof item?.name === "string" && item.name);
|
|
62
|
+
const toolsByOwner = new Map();
|
|
63
|
+
for (const tool of named(tools)) {
|
|
64
|
+
const owner = ownerOf(tool.sourceInfo?.path);
|
|
65
|
+
if (!toolsByOwner.has(owner)) toolsByOwner.set(owner, []);
|
|
66
|
+
toolsByOwner.get(owner).push(tool.name);
|
|
67
|
+
}
|
|
68
|
+
const commandsByOwner = new Map();
|
|
69
|
+
for (const command of named(commands)) {
|
|
70
|
+
const owner = ownerOf(command.sourceInfo?.path);
|
|
71
|
+
if (!commandsByOwner.has(owner)) commandsByOwner.set(owner, []);
|
|
72
|
+
commandsByOwner.get(owner).push(command.name);
|
|
73
|
+
}
|
|
74
|
+
const toolsNamed = [...toolsByOwner.values()].reduce((n, list) => n + list.length, 0);
|
|
75
|
+
|
|
76
|
+
// `sourceInfo.path` is a third-party field: a number here threw out of run()
|
|
77
|
+
// and replaced the whole Registry group with one crash row.
|
|
78
|
+
const builtinTools = tools.filter((t) => typeof t?.sourceInfo?.path === "string" && t.sourceInfo.path.startsWith("builtin:")).length;
|
|
79
|
+
results.push(
|
|
80
|
+
toolsResult.ok
|
|
81
|
+
? {
|
|
82
|
+
group,
|
|
83
|
+
id: "tools",
|
|
84
|
+
title: "tools",
|
|
85
|
+
// A readable but empty registry is not a pass: the session may have
|
|
86
|
+
// started with tools disabled, and "0 registered across 0 sources"
|
|
87
|
+
// with a tick reads like everything is accounted for.
|
|
88
|
+
status: tools.length ? "ok" : "skip",
|
|
89
|
+
detail: `${tools.length} registered (${tools.length - builtinTools} extension, ${builtinTools} builtin) across ${toolsByOwner.size} sources`,
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
group,
|
|
93
|
+
id: "tools",
|
|
94
|
+
title: "tools",
|
|
95
|
+
status: "warn",
|
|
96
|
+
// Scrubbed: the rejection can quote a URL with a token in it, and this
|
|
97
|
+
// detail goes to the terminal, the notification and --json.
|
|
98
|
+
detail: `could not read the tool registry (${scrubCredentials(toolsResult.error)})`,
|
|
99
|
+
hint: "The Pi API may have changed, or an extension failed mid-load. Run `pi --verbose`.",
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
// A failed getMcpServers() is not "0 servers": reporting the zero would state a
|
|
103
|
+
// fact the check never learned.
|
|
104
|
+
const serverCount = serversResult.ok ? `${servers.length} MCP server(s)` : "MCP servers unreadable";
|
|
105
|
+
if (commandsResult.ok) {
|
|
106
|
+
results.push({
|
|
107
|
+
group,
|
|
108
|
+
id: "commands",
|
|
109
|
+
title: "commands",
|
|
110
|
+
// Same rule as the tools row: a readable but empty registry is not a
|
|
111
|
+
// pass. "0 registered across 0 sources" with a tick claims an answer
|
|
112
|
+
// the check does not have.
|
|
113
|
+
status: commands.length ? "ok" : "skip",
|
|
114
|
+
detail: `${commands.length} registered across ${commandsByOwner.size} sources, ${serverCount}`,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
results.push({
|
|
118
|
+
group,
|
|
119
|
+
id: "commands",
|
|
120
|
+
title: "commands",
|
|
121
|
+
status: "warn",
|
|
122
|
+
detail: `could not read the command registry (${scrubCredentials(commandsResult.error)})`,
|
|
123
|
+
hint: "The Pi API may have changed, or an extension failed mid-load. Run `pi --verbose`.",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Which package owns which names — the answer `pi list` cannot give. Shown
|
|
128
|
+
// under --verbose, since the summary line above already covers the default view.
|
|
129
|
+
const attribution = [...toolsByOwner]
|
|
130
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
131
|
+
.slice(0, 12)
|
|
132
|
+
.map(([owner, names]) => `${owner}: ${truncateList(names, 6)}`)
|
|
133
|
+
.join("; ");
|
|
134
|
+
if (registryReadable && attribution) {
|
|
135
|
+
// The owners themselves belong in the default view: "12 sources own 40
|
|
136
|
+
// tools" answers nothing a user asked, while naming them is the whole point
|
|
137
|
+
// of an attribution row. The per-owner tool names stay behind --verbose.
|
|
138
|
+
const ranked = [...toolsByOwner].sort((a, b) => b[1].length - a[1].length).map(([owner, names]) => `${owner} (${names.length})`);
|
|
139
|
+
results.push({
|
|
140
|
+
group,
|
|
141
|
+
id: "attribution",
|
|
142
|
+
title: "attribution",
|
|
143
|
+
status: "ok",
|
|
144
|
+
detail: `${toolsNamed} tool(s) from ${toolsByOwner.size} source(s): ${truncateList(ranked, 4)}`,
|
|
145
|
+
hint: `Run with --verbose for the tool names. ${attribution}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- collisions --------------------------------------------------------
|
|
150
|
+
// A collision means one extension silently stops being reachable: the later
|
|
151
|
+
// registration wins and the earlier tool or command is gone without an error.
|
|
152
|
+
// Without a readable registry every list is empty, so "no collisions" would be
|
|
153
|
+
// a green row over a registry that was never read.
|
|
154
|
+
const duplicateTools = findDuplicates(tools);
|
|
155
|
+
results.push(
|
|
156
|
+
!toolsResult.ok
|
|
157
|
+
? {
|
|
158
|
+
group,
|
|
159
|
+
id: "tool-collisions",
|
|
160
|
+
title: "tool collisions",
|
|
161
|
+
status: "skip",
|
|
162
|
+
detail: "the tool registry could not be read",
|
|
163
|
+
}
|
|
164
|
+
: duplicateTools.length
|
|
165
|
+
? {
|
|
166
|
+
group,
|
|
167
|
+
id: "tool-collisions",
|
|
168
|
+
title: "tool collisions",
|
|
169
|
+
status: "fail",
|
|
170
|
+
detail: duplicateTools.map(([name, owners]) => `${name} claimed by ${owners.join(" + ")}`).join("; "),
|
|
171
|
+
hint: "Rename one of them, or disable the package you do not need.",
|
|
172
|
+
}
|
|
173
|
+
: !comparable(tools)
|
|
174
|
+
? {
|
|
175
|
+
group,
|
|
176
|
+
id: "tool-collisions",
|
|
177
|
+
title: "tool collisions",
|
|
178
|
+
status: "skip",
|
|
179
|
+
// The tools row above may have just reported registrations, so
|
|
180
|
+
// "nothing registered" would contradict it: the entries exist and
|
|
181
|
+
// simply carry no name to compare.
|
|
182
|
+
detail: "no registered entry carries a name to compare",
|
|
183
|
+
}
|
|
184
|
+
: { group, id: "tool-collisions", title: "tool collisions", status: "ok", detail: "none" },
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const duplicateCommands = findDuplicates(commands);
|
|
188
|
+
results.push(
|
|
189
|
+
!commandsResult.ok
|
|
190
|
+
? {
|
|
191
|
+
group,
|
|
192
|
+
id: "command-collisions",
|
|
193
|
+
title: "command collisions",
|
|
194
|
+
status: "skip",
|
|
195
|
+
detail: "the command registry could not be read",
|
|
196
|
+
}
|
|
197
|
+
: duplicateCommands.length
|
|
198
|
+
? {
|
|
199
|
+
group,
|
|
200
|
+
id: "command-collisions",
|
|
201
|
+
title: "command collisions",
|
|
202
|
+
status: "fail",
|
|
203
|
+
detail: duplicateCommands.map(([name, owners]) => `/${name} claimed by ${owners.join(" + ")}`).join("; "),
|
|
204
|
+
hint: "Only one handler will run. Rename or remove one of the packages.",
|
|
205
|
+
}
|
|
206
|
+
: !comparable(commands)
|
|
207
|
+
? {
|
|
208
|
+
group,
|
|
209
|
+
id: "command-collisions",
|
|
210
|
+
title: "command collisions",
|
|
211
|
+
status: "skip",
|
|
212
|
+
detail: "no registered entry carries a name to compare",
|
|
213
|
+
}
|
|
214
|
+
: { group, id: "command-collisions", title: "command collisions", status: "ok", detail: "none" },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// --- silent failures ---------------------------------------------------
|
|
218
|
+
// A package that declares extensions but registered nothing is the most
|
|
219
|
+
// common failure mode that produces no error message anywhere.
|
|
220
|
+
//
|
|
221
|
+
// Compared by resolved file path rather than by name: a local package is
|
|
222
|
+
// declared as `./local-ext` in settings but attributed as `local:local-ext`
|
|
223
|
+
// by ownerOf(), and an npm package can register from a nested directory. Path
|
|
224
|
+
// containment is the only comparison that holds for every source kind.
|
|
225
|
+
const loadedPaths = [...tools, ...commands]
|
|
226
|
+
.map((item) => item?.sourceInfo?.path)
|
|
227
|
+
.filter((p) => typeof p === "string" && !p.startsWith("builtin:"))
|
|
228
|
+
.map(normalizePath);
|
|
229
|
+
|
|
230
|
+
// Without a readable registry every declared package looks silent, so the
|
|
231
|
+
// comparison is only meaningful when both halves loaded.
|
|
232
|
+
if (!registryReadable) {
|
|
233
|
+
results.push({
|
|
234
|
+
group,
|
|
235
|
+
id: "silent",
|
|
236
|
+
title: "silent packages",
|
|
237
|
+
status: "skip",
|
|
238
|
+
detail: "cannot compare against the registry that failed to load",
|
|
239
|
+
});
|
|
240
|
+
return results;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const candidates = declaredPackages(settings, root);
|
|
244
|
+
const silent = candidates
|
|
245
|
+
.filter((pkg) => !loadedPaths.some((loaded) => loaded === pkg.dir || loaded.startsWith(`${pkg.dir}/`)))
|
|
246
|
+
.map((pkg) => pkg.label);
|
|
247
|
+
results.push(
|
|
248
|
+
!candidates.length
|
|
249
|
+
? {
|
|
250
|
+
group,
|
|
251
|
+
id: "silent",
|
|
252
|
+
title: "silent packages",
|
|
253
|
+
status: "skip",
|
|
254
|
+
detail: "no inspectable package declares an extension",
|
|
255
|
+
}
|
|
256
|
+
: silent.length
|
|
257
|
+
? {
|
|
258
|
+
group,
|
|
259
|
+
id: "silent",
|
|
260
|
+
title: "silent packages",
|
|
261
|
+
status: "warn",
|
|
262
|
+
// Already redacted where the label was built, with the scheme still
|
|
263
|
+
// attached; re-redacting a bare name would erase it.
|
|
264
|
+
detail: `${truncateList(silent, 6)} declared an extension but registered no tool or command`,
|
|
265
|
+
hint: "May be event-only by design, or may have failed to load. Restart `pi --verbose` to see extension load errors.",
|
|
266
|
+
}
|
|
267
|
+
: { group, id: "silent", title: "silent packages", status: "ok", detail: "none" },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
return results;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Is there anything to compare? A registry whose entries carry no `name` cannot
|
|
275
|
+
* collide, and `findDuplicates` drops those, so counting the array instead would
|
|
276
|
+
* print "none" over zero compared objects.
|
|
277
|
+
*/
|
|
278
|
+
function comparable(items) {
|
|
279
|
+
return items.some((item) => item?.name);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function findDuplicates(items) {
|
|
283
|
+
// Keyed by registration, not by owner label: two packages that register the
|
|
284
|
+
// same name but carry no sourceInfo.path both resolve to "unknown", and
|
|
285
|
+
// de-duplicating by label collapsed them into a single owner — the collision
|
|
286
|
+
// then rendered as "none".
|
|
287
|
+
const byName = new Map();
|
|
288
|
+
let unattributed = 0;
|
|
289
|
+
for (const item of items) {
|
|
290
|
+
const name = item?.name;
|
|
291
|
+
if (!name) continue;
|
|
292
|
+
const sourcePath = item?.sourceInfo?.path;
|
|
293
|
+
const key = typeof sourcePath === "string" && sourcePath ? sourcePath : `unknown#${unattributed++}`;
|
|
294
|
+
const owner = ownerOf(sourcePath);
|
|
295
|
+
if (!byName.has(name)) byName.set(name, new Map());
|
|
296
|
+
byName.get(name).set(key, owner);
|
|
297
|
+
}
|
|
298
|
+
return [...byName].filter(([, owners]) => owners.size > 1).map(([name, owners]) => [name, [...owners.values()]]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Packages from settings whose manifests declare an extension entry.
|
|
303
|
+
* Returns the resolved directory, because the declared source string and the
|
|
304
|
+
* path Pi reports in sourceInfo are not in the same namespace.
|
|
305
|
+
*/
|
|
306
|
+
function declaredPackages(settings, root) {
|
|
307
|
+
const out = [];
|
|
308
|
+
// Guard the type: a hand-edited string here would be walked character by
|
|
309
|
+
// character, and every character is itself a string.
|
|
310
|
+
const specs = Array.isArray(settings?.packages) ? settings.packages : [];
|
|
311
|
+
for (const spec of specs) {
|
|
312
|
+
const source = sourceOf(spec);
|
|
313
|
+
if (!source) continue;
|
|
314
|
+
const dir = resolvePackageDir(source, root);
|
|
315
|
+
// git:/https:/bare sources are resolved by Pi itself; the Packages group
|
|
316
|
+
// already reports those as unverifiable rather than guessing here.
|
|
317
|
+
if (!dir) continue;
|
|
318
|
+
const manifest = readJson(path.join(dir, "package.json"));
|
|
319
|
+
if (!manifest.ok) continue;
|
|
320
|
+
// An empty list declares no extension, so loading nothing is correct.
|
|
321
|
+
// A single string is as valid as a list here; both declare at least one
|
|
322
|
+
// extension, and only "declares none" means silence is expected.
|
|
323
|
+
const declared = manifest.data?.pi?.extensions;
|
|
324
|
+
let count = 1;
|
|
325
|
+
if (Array.isArray(declared)) count = declared.length;
|
|
326
|
+
// `null`, `false` and `""` declare nothing, exactly as packages.js reads
|
|
327
|
+
// them. Treating null as one extension reported "declared an extension but
|
|
328
|
+
// registered no tool or command" for a package that declared none.
|
|
329
|
+
else if (!declared) count = 0;
|
|
330
|
+
if (!count) continue;
|
|
331
|
+
// Redacted while the scheme is still attached: `redactSource` can only
|
|
332
|
+
// recognise a source by its scheme or path shape, so stripping `npm:`
|
|
333
|
+
// first turned every bare package name into "(unparsable value)" in the
|
|
334
|
+
// one row a user runs this command to read.
|
|
335
|
+
out.push({ label: redactSource(source).replace(/^npm:/, ""), dir: normalizePath(dir) });
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|