@proofofwork-agency/toolpin 0.2.3
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/CONTRIBUTING.md +117 -0
- package/LICENSE +183 -0
- package/README.md +323 -0
- package/SECURITY.md +61 -0
- package/action.yml +134 -0
- package/dist/canonicalJson.js +38 -0
- package/dist/capabilities.js +139 -0
- package/dist/ci.js +26 -0
- package/dist/cli.js +1843 -0
- package/dist/clientSupport.js +76 -0
- package/dist/codexToml.js +213 -0
- package/dist/config.js +337 -0
- package/dist/constants.js +3 -0
- package/dist/continueYaml.js +76 -0
- package/dist/doctor.js +163 -0
- package/dist/install.js +191 -0
- package/dist/installed.js +405 -0
- package/dist/integrity.js +14 -0
- package/dist/inventory.js +169 -0
- package/dist/packageIntegrity.js +153 -0
- package/dist/plan.js +595 -0
- package/dist/policy.js +310 -0
- package/dist/registry.js +1610 -0
- package/dist/runtimeAdvisory.js +80 -0
- package/dist/safeFetch.js +157 -0
- package/dist/sarif.js +162 -0
- package/dist/scan.js +113 -0
- package/dist/search.js +44 -0
- package/dist/secrets.js +165 -0
- package/dist/signing.js +146 -0
- package/dist/tester.js +240 -0
- package/dist/trust.js +528 -0
- package/dist/tui/app.js +1731 -0
- package/dist/tui/command.js +50 -0
- package/dist/tui/configSnippet.js +11 -0
- package/dist/tui/constants.js +37 -0
- package/dist/tui/format.js +31 -0
- package/dist/tui/installedState.js +23 -0
- package/dist/tui/layout.js +65 -0
- package/dist/tui/selectors.js +282 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/ui/trust.js +77 -0
- package/dist/tui/views/installed.js +82 -0
- package/dist/tui/views/panels.js +637 -0
- package/dist/tui.js +12 -0
- package/dist/types.js +1 -0
- package/dist/verificationTrust.js +103 -0
- package/dist/verify.js +537 -0
- package/dist/version.js +1 -0
- package/dist/versions.js +127 -0
- package/docs/assets/readme/terminal-demo.svg +174 -0
- package/docs/assets/readme/tui-browse-overview.jpg +0 -0
- package/docs/assets/readme/tui-config-preview.jpg +0 -0
- package/docs/assets/readme/tui-help.jpg +0 -0
- package/docs/assets/readme/tui-installed-inventory.jpg +0 -0
- package/docs/how-to/catch-drift-in-ci.md +189 -0
- package/docs/how-to/custom-registries.md +156 -0
- package/docs/how-to/toolpin-curated-registry.md +153 -0
- package/package.json +76 -0
- package/registry/README.md +92 -0
- package/registry/v0/servers +115 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { TUI_COMMANDS } from "./constants.js";
|
|
2
|
+
export function commandRequiresServer(commandId) {
|
|
3
|
+
return TUI_COMMANDS.find((command) => command.id === commandId)?.requiresServer === true;
|
|
4
|
+
}
|
|
5
|
+
export function commandLineFor(commandId, state, server) {
|
|
6
|
+
const source = `--source ${state.sourceMode}`;
|
|
7
|
+
const live = state.dataMode === "live" ? " --live" : "";
|
|
8
|
+
const serverName = server ? shellQuote(server.name) : "<server-name>";
|
|
9
|
+
switch (commandId) {
|
|
10
|
+
case "ingest":
|
|
11
|
+
return `toolpin ingest ${source} --pages 6`;
|
|
12
|
+
case "installed":
|
|
13
|
+
return "toolpin list --scope all --json";
|
|
14
|
+
case "sources":
|
|
15
|
+
return "toolpin registry list";
|
|
16
|
+
case "search":
|
|
17
|
+
return `toolpin search ${state.query.trim() ? shellQuote(state.query.trim()) : "<query>"} ${source}${live}`;
|
|
18
|
+
case "more-results":
|
|
19
|
+
return "toolpin tui # show more matching results";
|
|
20
|
+
case "reset-view":
|
|
21
|
+
return "toolpin tui # reset view defaults";
|
|
22
|
+
case "info":
|
|
23
|
+
return `toolpin info ${serverName} ${source}${live}`;
|
|
24
|
+
case "audit":
|
|
25
|
+
return `toolpin audit ${serverName} ${source}${live}`;
|
|
26
|
+
case "plan":
|
|
27
|
+
return `toolpin plan ${serverName} --client ${state.client} ${source}${live}`;
|
|
28
|
+
case "install":
|
|
29
|
+
return `toolpin install ${serverName} --client ${state.client} --scope ${state.installScope} ${source}${live}`;
|
|
30
|
+
case "remove":
|
|
31
|
+
return `toolpin remove ${serverName} --client ${state.client} --scope ${state.installScope} --file mcp-lock.json`;
|
|
32
|
+
case "doctor":
|
|
33
|
+
return `toolpin doctor --scope ${state.installScope} --file mcp-lock.json`;
|
|
34
|
+
case "ci":
|
|
35
|
+
return `toolpin ci --file mcp-lock.json ${source}${live}`;
|
|
36
|
+
case "test":
|
|
37
|
+
return `toolpin test ${serverName} ${source}${live} --timeout 15000`;
|
|
38
|
+
case "lock":
|
|
39
|
+
return `toolpin lock ${serverName} --client ${state.client} ${source}${live} --file mcp-lock.json`;
|
|
40
|
+
case "export-config":
|
|
41
|
+
return `toolpin export-config ${serverName} --client ${state.client} ${source}${live}`;
|
|
42
|
+
case "tui":
|
|
43
|
+
return "toolpin tui";
|
|
44
|
+
case "help":
|
|
45
|
+
return "toolpin help";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function shellQuote(value) {
|
|
49
|
+
return /^[a-zA-Z0-9_./:@-]+$/.test(value) ? value : JSON.stringify(value);
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { codexTomlFromClientConfig } from "../codexToml.js";
|
|
2
|
+
import { continueYamlFromClientConfig } from "../continueYaml.js";
|
|
3
|
+
export function formatClientConfigSnippet(client, config) {
|
|
4
|
+
if (client === "codex") {
|
|
5
|
+
return { extension: "toml", content: `${codexTomlFromClientConfig(config)}\n` };
|
|
6
|
+
}
|
|
7
|
+
if (client === "continue") {
|
|
8
|
+
return { extension: "yaml", content: continueYamlFromClientConfig(config) };
|
|
9
|
+
}
|
|
10
|
+
return { extension: "json", content: `${JSON.stringify(config, null, 2)}\n` };
|
|
11
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ALL_CLIENTS } from "../config.js";
|
|
2
|
+
export const VIEWS = ["discover", "installed", "sources", "details", "plan", "config", "help"];
|
|
3
|
+
export const SERVER_VIEWS = new Set(["details", "plan", "config"]);
|
|
4
|
+
export const CLIENTS = [...ALL_CLIENTS.filter((client) => client !== "generic"), "all"];
|
|
5
|
+
export const TUI_COMMANDS = [
|
|
6
|
+
{ id: "ingest", label: "Ingest registries", description: "Fetch registry metadata and refresh .toolpin/registry-cache.json." },
|
|
7
|
+
{ id: "installed", label: "Installed servers", description: "Show installed MCP servers, lock drift, versions, updates, and lifecycle actions." },
|
|
8
|
+
{ id: "sources", label: "Registry sources", description: "Show usable sources, disabled integrations, auth, and cache coverage." },
|
|
9
|
+
{ id: "search", label: "Search servers", description: "Edit the current search query." },
|
|
10
|
+
{ id: "more-results", label: "Show more results", description: "Increase the TUI result window by 50 matches." },
|
|
11
|
+
{ id: "reset-view", label: "Reset view defaults", description: "Reset search/source/result count/client/scope to defaults." },
|
|
12
|
+
{ id: "info", label: "Server info", description: "Open selected server metadata and trust summary.", requiresServer: true },
|
|
13
|
+
{ id: "audit", label: "Audit trust", description: "Show selected server tier, evidence, score, badges, and issues.", requiresServer: true },
|
|
14
|
+
{ id: "plan", label: "Install plan", description: "Preview target, trust, secrets, and config writes.", requiresServer: true },
|
|
15
|
+
{ id: "install", label: "Install server", description: "Open the install wizard: choose scope and client, then write config + lockfile.", requiresServer: true },
|
|
16
|
+
{ id: "remove", label: "Remove server", description: "Delete selected server from active client config and lockfile.", requiresServer: true },
|
|
17
|
+
{ id: "doctor", label: "Check config drift", description: "Compare mcp-lock.json against active-scope client configs." },
|
|
18
|
+
{ id: "test", label: "Test server", description: "Connect and run MCP tools/list.", requiresServer: true },
|
|
19
|
+
{ id: "ci", label: "Frozen lock check", description: "Re-resolve lockfile entries and reject metadata drift." },
|
|
20
|
+
{ id: "lock", label: "Write lockfile", description: "Write selected server to mcp-lock.json.", requiresServer: true },
|
|
21
|
+
{ id: "export-config", label: "Export config", description: "Save client config snippets under .toolpin/.", requiresServer: true },
|
|
22
|
+
{ id: "tui", label: "Open TUI", description: "Current interactive session." },
|
|
23
|
+
{ id: "help", label: "Help", description: "Open keyboard and command reference." },
|
|
24
|
+
];
|
|
25
|
+
export const BLUE = "#8aa7ff";
|
|
26
|
+
export const ACCENT = "#22d3ee";
|
|
27
|
+
export const MUTED = "#8b8b94";
|
|
28
|
+
export const CHROME = "#52525b";
|
|
29
|
+
export const SURFACE = "#252525";
|
|
30
|
+
export const SURFACE_2 = "#202023";
|
|
31
|
+
export const OK = "#4ade80";
|
|
32
|
+
export const WARN = "#fbbf24";
|
|
33
|
+
export const ERR = "#f87171";
|
|
34
|
+
export const MENU_ROW = 6;
|
|
35
|
+
export const LIST_ROW_START = 8;
|
|
36
|
+
export const DEFAULT_RESULT_LIMIT = 50;
|
|
37
|
+
export const RESULT_LIMIT_STEP = 50;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function unique(values) {
|
|
2
|
+
return [...new Set(values)];
|
|
3
|
+
}
|
|
4
|
+
export function asObject(value) {
|
|
5
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
export function safeJson(factory) {
|
|
8
|
+
try {
|
|
9
|
+
return factory();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function safeFileName(value) {
|
|
16
|
+
return value.replace(/[^a-z0-9._-]+/gi, "_");
|
|
17
|
+
}
|
|
18
|
+
export function shortPath(value) {
|
|
19
|
+
const home = process.env.HOME;
|
|
20
|
+
const pathValue = home && value.startsWith(home) ? `~${value.slice(home.length)}` : value;
|
|
21
|
+
const parts = pathValue.split("/");
|
|
22
|
+
return parts.length > 4 ? `.../${parts.slice(-3).join("/")}` : pathValue;
|
|
23
|
+
}
|
|
24
|
+
export function clamp(value, min, max) {
|
|
25
|
+
return Math.max(min, Math.min(max, value));
|
|
26
|
+
}
|
|
27
|
+
export function truncate(value, maxLength) {
|
|
28
|
+
if (maxLength <= 0)
|
|
29
|
+
return "";
|
|
30
|
+
return value.length > maxLength ? `${value.slice(0, Math.max(0, maxLength - 3))}...` : value;
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { installedId, loadInstalledServerStates } from "../installed.js";
|
|
2
|
+
export function installedViewReducer(state, action) {
|
|
3
|
+
switch (action.type) {
|
|
4
|
+
case "loading":
|
|
5
|
+
return { ...state, loading: true };
|
|
6
|
+
case "loaded":
|
|
7
|
+
return {
|
|
8
|
+
...state,
|
|
9
|
+
rows: action.rows,
|
|
10
|
+
selected: clamp(state.selected, 0, Math.max(0, action.rows.length - 1)),
|
|
11
|
+
loading: false,
|
|
12
|
+
};
|
|
13
|
+
case "select":
|
|
14
|
+
return { ...state, selected: clamp(action.selected, 0, Math.max(0, state.rows.length - 1)) };
|
|
15
|
+
case "move":
|
|
16
|
+
return { ...state, selected: clamp(state.selected + action.delta, 0, Math.max(0, state.rows.length - 1)) };
|
|
17
|
+
case "scope":
|
|
18
|
+
return { ...state, scope: action.scope, selected: 0, loading: true };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function clamp(value, min, max) {
|
|
22
|
+
return Math.max(min, Math.min(max, value));
|
|
23
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { LIST_ROW_START, MENU_ROW } from "./constants.js";
|
|
2
|
+
import { truncate } from "./format.js";
|
|
3
|
+
export function buildTuiHitZones({ width, listHeight, selectedIndex, resultCount, hasSelection, selectedLabel, listActive, }) {
|
|
4
|
+
const visibleCount = Math.max(2, listHeight - 2);
|
|
5
|
+
const listStart = listWindowStart(selectedIndex, visibleCount, resultCount);
|
|
6
|
+
const menuLayout = computeMenuLayout({ width, hasSelection, selectedLabel });
|
|
7
|
+
return {
|
|
8
|
+
menuY: MENU_ROW,
|
|
9
|
+
menu: menuLayout.segments,
|
|
10
|
+
list: listActive ? {
|
|
11
|
+
fromY: LIST_ROW_START,
|
|
12
|
+
toY: LIST_ROW_START + visibleCount - 1,
|
|
13
|
+
start: listStart,
|
|
14
|
+
total: resultCount,
|
|
15
|
+
} : undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function computeMenuLayout({ width, hasSelection, selectedLabel }) {
|
|
19
|
+
const contentStart = 3;
|
|
20
|
+
const helpLabel = "Help";
|
|
21
|
+
const helpTo = Math.max(contentStart + helpLabel.length - 1, width - 2);
|
|
22
|
+
const helpFrom = Math.max(contentStart, helpTo - helpLabel.length + 1);
|
|
23
|
+
const labelWidth = Math.max(4, Math.min(28, width - 78));
|
|
24
|
+
const chosenLabel = truncate(selectedLabel || "select a server", labelWidth);
|
|
25
|
+
const segments = [];
|
|
26
|
+
let cursor = contentStart;
|
|
27
|
+
const push = (view, label, enabled) => {
|
|
28
|
+
segments.push({ view, label, from: cursor, to: cursor + label.length - 1, enabled });
|
|
29
|
+
cursor += label.length;
|
|
30
|
+
};
|
|
31
|
+
push("discover", "Browse", true);
|
|
32
|
+
cursor += " ".length;
|
|
33
|
+
push("installed", "Installed", true);
|
|
34
|
+
cursor += " | ".length;
|
|
35
|
+
cursor += "Selected: ".length;
|
|
36
|
+
const selectedFrom = cursor;
|
|
37
|
+
const selectedTo = cursor + chosenLabel.length - 1;
|
|
38
|
+
cursor += chosenLabel.length;
|
|
39
|
+
if (hasSelection) {
|
|
40
|
+
cursor += " | ".length;
|
|
41
|
+
push("details", "Overview", true);
|
|
42
|
+
cursor += " ".length;
|
|
43
|
+
push("plan", "Install", true);
|
|
44
|
+
cursor += " ".length;
|
|
45
|
+
push("config", "Config", true);
|
|
46
|
+
}
|
|
47
|
+
segments.push({ view: "help", label: helpLabel, from: helpFrom, to: helpTo, enabled: true });
|
|
48
|
+
return { selectedLabel: chosenLabel, selectedFrom, selectedTo, segments };
|
|
49
|
+
}
|
|
50
|
+
export function hitTestTui(x, y, zones) {
|
|
51
|
+
if (y === zones.menuY) {
|
|
52
|
+
const zone = zones.menu.find((entry) => x >= entry.from && x <= entry.to);
|
|
53
|
+
return zone?.enabled ? { kind: "view", view: zone.view } : undefined;
|
|
54
|
+
}
|
|
55
|
+
if (zones.list && y >= zones.list.fromY && y <= zones.list.toY) {
|
|
56
|
+
const index = zones.list.start + (y - zones.list.fromY);
|
|
57
|
+
return index < zones.list.total ? { kind: "server", index } : undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
export function listWindowStart(selected, visibleCount, total) {
|
|
62
|
+
const maxStart = Math.max(0, total - visibleCount);
|
|
63
|
+
const preferred = selected < visibleCount ? 0 : selected - visibleCount + 1;
|
|
64
|
+
return Math.max(0, Math.min(preferred, maxStart));
|
|
65
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { clientsForScope, PROJECT_CLIENTS } from "../config.js";
|
|
2
|
+
import { clientSupportBlock, clientSupportFor } from "../clientSupport.js";
|
|
3
|
+
import { resolveConfigTarget } from "../install.js";
|
|
4
|
+
import { lockKey } from "../plan.js";
|
|
5
|
+
import { compareRegistrySources, latestOnly, normalizeEntries, registrySourceIdRank } from "../registry.js";
|
|
6
|
+
import { searchServers } from "../search.js";
|
|
7
|
+
import { scoreServer, trustRankingScore } from "../trust.js";
|
|
8
|
+
import { compareVersionStatus, knownVersions } from "../versions.js";
|
|
9
|
+
import { CLIENTS, RESULT_LIMIT_STEP, VIEWS } from "./constants.js";
|
|
10
|
+
import { asObject, safeJson, shortPath, unique } from "./format.js";
|
|
11
|
+
export function nextView(view) {
|
|
12
|
+
return VIEWS[(VIEWS.indexOf(view) + 1) % VIEWS.length] ?? "discover";
|
|
13
|
+
}
|
|
14
|
+
export function switchView(state, view) {
|
|
15
|
+
const commandLog = commandLogBelongsToView(state.commandLog, view) ? state.commandLog : undefined;
|
|
16
|
+
return {
|
|
17
|
+
...state,
|
|
18
|
+
view,
|
|
19
|
+
commandLog,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function commandLogForView(state) {
|
|
23
|
+
return commandLogBelongsToView(state.commandLog, state.view) ? state.commandLog : undefined;
|
|
24
|
+
}
|
|
25
|
+
export function commandLogBelongsToView(log, view) {
|
|
26
|
+
if (!log)
|
|
27
|
+
return false;
|
|
28
|
+
if (view === "installed")
|
|
29
|
+
return ["installed", "remove", "install", "update", "adopt", "test", "doctor", "versions"].includes(log.title);
|
|
30
|
+
if (view === "sources")
|
|
31
|
+
return ["sources", "ingest", "search", "results", "reset"].includes(log.title);
|
|
32
|
+
if (view === "details")
|
|
33
|
+
return ["info", "audit", "test", "versions"].includes(log.title);
|
|
34
|
+
if (view === "plan")
|
|
35
|
+
return ["install", "lock", "plan", "versions"].includes(log.title);
|
|
36
|
+
if (view === "config")
|
|
37
|
+
return ["export-config", "config", "versions"].includes(log.title);
|
|
38
|
+
if (view === "discover")
|
|
39
|
+
return ["ingest", "search", "results", "reset"].includes(log.title);
|
|
40
|
+
return log.title === "help";
|
|
41
|
+
}
|
|
42
|
+
export function nextClient(client) {
|
|
43
|
+
return CLIENTS[(CLIENTS.indexOf(client) + 1) % CLIENTS.length] ?? "claude";
|
|
44
|
+
}
|
|
45
|
+
export function selectedClients(client) {
|
|
46
|
+
return client === "all" ? PROJECT_CLIENTS : [client];
|
|
47
|
+
}
|
|
48
|
+
export function selectedClientsForScope(client, scope) {
|
|
49
|
+
return client === "all" ? clientsForScope(scope) : [client];
|
|
50
|
+
}
|
|
51
|
+
export function installClientChoicesForScope(scope, preferredClient) {
|
|
52
|
+
const choices = [...clientsForScope(scope), "all"];
|
|
53
|
+
if (!choices.includes(preferredClient))
|
|
54
|
+
return choices;
|
|
55
|
+
return [preferredClient, ...choices.filter((client) => client !== preferredClient)];
|
|
56
|
+
}
|
|
57
|
+
export function installClientChoicesForServerScope(scope, preferredClient, server) {
|
|
58
|
+
const clients = directInstallClientsForServerScope(scope, server);
|
|
59
|
+
const choices = [...clients, ...(clients.length > 1 ? ["all"] : [])];
|
|
60
|
+
if (!choices.includes(preferredClient))
|
|
61
|
+
return choices;
|
|
62
|
+
return [preferredClient, ...choices.filter((client) => client !== preferredClient)];
|
|
63
|
+
}
|
|
64
|
+
export function nextClientForServerScope(client, scope, server) {
|
|
65
|
+
const supported = supportedClientsForServerScope(scope, server);
|
|
66
|
+
const choices = supported.length
|
|
67
|
+
? [...supported, ...(supported.length > 1 ? ["all"] : [])]
|
|
68
|
+
: installClientChoicesForScope(scope, client);
|
|
69
|
+
return choices[(choices.indexOf(client) + 1) % choices.length] ?? nextClient(client);
|
|
70
|
+
}
|
|
71
|
+
export function selectedInstallClientsForServerScope(client, scope, server) {
|
|
72
|
+
const installable = directInstallClientsForServerScope(scope, server);
|
|
73
|
+
if (client === "all")
|
|
74
|
+
return installable;
|
|
75
|
+
return installable.includes(client) ? [client] : [];
|
|
76
|
+
}
|
|
77
|
+
export function directInstallClientsForServerScope(scope, server) {
|
|
78
|
+
const candidates = clientsForScope(scope);
|
|
79
|
+
if (!server || !clientSupportBlock(server))
|
|
80
|
+
return candidates;
|
|
81
|
+
return candidates.filter((client) => clientSupportFor(server, client)?.status === "toolpin-installable");
|
|
82
|
+
}
|
|
83
|
+
export function supportedClientsForServerScope(scope, server) {
|
|
84
|
+
const candidates = clientsForScope(scope);
|
|
85
|
+
if (!server || !clientSupportBlock(server))
|
|
86
|
+
return candidates;
|
|
87
|
+
return candidates.filter((client) => clientSupportFor(server, client)?.status !== "unsupported");
|
|
88
|
+
}
|
|
89
|
+
export function clientSupportSummary(server, scope) {
|
|
90
|
+
if (!clientSupportBlock(server))
|
|
91
|
+
return "not declared; ToolPin will try standard MCP config";
|
|
92
|
+
const clients = clientsForScope(scope);
|
|
93
|
+
const groups = {};
|
|
94
|
+
for (const client of clients) {
|
|
95
|
+
const status = clientSupportFor(server, client)?.status ?? "unsupported";
|
|
96
|
+
groups[status] = [...(groups[status] ?? []), client];
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
groups["toolpin-installable"]?.length ? `direct ${groups["toolpin-installable"].join(", ")}` : "",
|
|
100
|
+
groups["external-setup"]?.length ? `external ${groups["external-setup"].join(", ")}` : "",
|
|
101
|
+
groups.unsupported?.length ? `unsupported ${groups.unsupported.join(", ")}` : "",
|
|
102
|
+
].filter(Boolean).join("; ");
|
|
103
|
+
}
|
|
104
|
+
export function selectedServerVersion(servers, defaultServer, selectedVersion) {
|
|
105
|
+
if (!selectedVersion || selectedVersion === defaultServer.version)
|
|
106
|
+
return defaultServer;
|
|
107
|
+
return servers.find((server) => server.name === defaultServer.name && server.version === selectedVersion) ?? defaultServer;
|
|
108
|
+
}
|
|
109
|
+
export function initialInstallVersionIndex(versions, selectedVersion) {
|
|
110
|
+
return Math.max(0, versions.findIndex((entry) => entry.version === selectedVersion));
|
|
111
|
+
}
|
|
112
|
+
export function pruneVersionSelections(selections, servers) {
|
|
113
|
+
const available = new Set(servers.map((server) => `${server.name}@${server.version}`));
|
|
114
|
+
return Object.fromEntries(Object.entries(selections).filter(([name, version]) => available.has(`${name}@${version}`)));
|
|
115
|
+
}
|
|
116
|
+
export function buildTuiVersionInfo(servers, serverName, selectedVersion, lockfile, client, installScope) {
|
|
117
|
+
const entries = knownVersions(servers, serverName);
|
|
118
|
+
const latestVersion = entries[0]?.version ?? "unknown";
|
|
119
|
+
const targetClients = selectedClientsForScope(client, installScope);
|
|
120
|
+
const lockedEntries = targetClients
|
|
121
|
+
.map((targetClient) => {
|
|
122
|
+
const keyed = lockfile?.servers[lockKey(serverName, targetClient)];
|
|
123
|
+
const legacy = lockfile?.servers[serverName];
|
|
124
|
+
return keyed ?? (legacy?.client === targetClient ? legacy : undefined);
|
|
125
|
+
})
|
|
126
|
+
.filter((entry) => Boolean(entry?.name === serverName));
|
|
127
|
+
const lockedVersions = unique(lockedEntries.map((entry) => entry.version));
|
|
128
|
+
const lockedLabel = lockedVersions.length === 0
|
|
129
|
+
? "none"
|
|
130
|
+
: lockedVersions.length === 1
|
|
131
|
+
? lockedVersions[0]
|
|
132
|
+
: `mixed ${lockedVersions.join(", ")}`;
|
|
133
|
+
let status;
|
|
134
|
+
if (!entries.length) {
|
|
135
|
+
status = "unknown";
|
|
136
|
+
}
|
|
137
|
+
else if (!lockedVersions.length) {
|
|
138
|
+
status = "not locked";
|
|
139
|
+
}
|
|
140
|
+
else if (lockedVersions.some((lockedVersion) => compareVersionStatus(latestVersion, lockedVersion) === undefined)) {
|
|
141
|
+
status = "unknown";
|
|
142
|
+
}
|
|
143
|
+
else if (lockedVersions.some((lockedVersion) => (compareVersionStatus(latestVersion, lockedVersion) ?? 0) > 0)) {
|
|
144
|
+
status = "update available";
|
|
145
|
+
}
|
|
146
|
+
else if (lockedVersions.some((lockedVersion) => (compareVersionStatus(latestVersion, lockedVersion) ?? 0) < 0)) {
|
|
147
|
+
status = "ahead of registry";
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
status = "current";
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
selectedVersion,
|
|
154
|
+
latestVersion,
|
|
155
|
+
lockedLabel,
|
|
156
|
+
status,
|
|
157
|
+
versions: entries.map((entry) => entry.version),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function installClientLabel(client, targetClients) {
|
|
161
|
+
return client === "all" ? `all supported clients (${targetClients.join(", ")})` : `client ${targetClients[0] ?? client}`;
|
|
162
|
+
}
|
|
163
|
+
export function scopeLabel(scope) {
|
|
164
|
+
return scope === "project" ? "project scope (this folder)" : "global scope (current user)";
|
|
165
|
+
}
|
|
166
|
+
export function formatVersionChoices(versionInfo, limit) {
|
|
167
|
+
const versions = versionInfo.versions.slice(0, limit).map((version) => {
|
|
168
|
+
const suffix = version === versionInfo.latestVersion ? " latest" : "";
|
|
169
|
+
return version === versionInfo.selectedVersion ? `[${version}${suffix}]` : `${version}${suffix}`;
|
|
170
|
+
});
|
|
171
|
+
const remaining = Math.max(0, versionInfo.versions.length - versions.length);
|
|
172
|
+
return remaining ? `${versions.join(", ")} +${remaining} more` : versions.join(", ");
|
|
173
|
+
}
|
|
174
|
+
export function configTargetLabel(client, scope) {
|
|
175
|
+
const target = safeJson(() => resolveConfigTarget(client, scope));
|
|
176
|
+
if ("error" in asObject(target))
|
|
177
|
+
return String(asObject(target).error);
|
|
178
|
+
return shortPath(target.file);
|
|
179
|
+
}
|
|
180
|
+
export function nextSource(source, registrySources = []) {
|
|
181
|
+
const enabled = registrySources
|
|
182
|
+
.filter((entry) => entry.enabled)
|
|
183
|
+
.sort(compareRegistrySources)
|
|
184
|
+
.map((entry) => entry.id);
|
|
185
|
+
const sources = ["all", ...(enabled.length ? enabled : ["toolpin", "official", "docker"])];
|
|
186
|
+
return sources[(sources.indexOf(source) + 1) % sources.length] ?? "all";
|
|
187
|
+
}
|
|
188
|
+
const BROWSE_SORT_MODES = ["source-first", "alpha-asc", "alpha-desc", "source-last", "relevance"];
|
|
189
|
+
export function nextBrowseSortMode(mode) {
|
|
190
|
+
return BROWSE_SORT_MODES[(BROWSE_SORT_MODES.indexOf(mode) + 1) % BROWSE_SORT_MODES.length] ?? "source-first";
|
|
191
|
+
}
|
|
192
|
+
export function browseSortLabel(mode) {
|
|
193
|
+
if (mode === "source-first")
|
|
194
|
+
return "source-first";
|
|
195
|
+
if (mode === "alpha-asc")
|
|
196
|
+
return "alpha A-Z";
|
|
197
|
+
if (mode === "alpha-desc")
|
|
198
|
+
return "alpha Z-A";
|
|
199
|
+
if (mode === "source-last")
|
|
200
|
+
return "source-last";
|
|
201
|
+
return "relevance";
|
|
202
|
+
}
|
|
203
|
+
export function filterBySource(servers, source) {
|
|
204
|
+
return source === "all" ? servers : servers.filter((server) => server.registrySource === source);
|
|
205
|
+
}
|
|
206
|
+
export function filterByEnabledSources(servers, source, sources) {
|
|
207
|
+
const enabled = new Set(sources.filter((entry) => entry.enabled).map((entry) => entry.id));
|
|
208
|
+
return filterBySource(servers, source).filter((server) => source === "all" ? enabled.has(server.registrySource) : true);
|
|
209
|
+
}
|
|
210
|
+
const CACHE_COVERED_EMPTY_STATUSES = new Set(["auth-missing", "stale", "fetch-error", "disabled"]);
|
|
211
|
+
export function cacheCoverage(entries, source, registrySources = []) {
|
|
212
|
+
const entrySources = new Set(normalizeEntries(entries).map((server) => server.registrySource));
|
|
213
|
+
const sourcesById = new Map(registrySources.map((entry) => [entry.id, entry]));
|
|
214
|
+
const enabledSources = registrySources.length
|
|
215
|
+
? registrySources.filter((entry) => entry.enabled).map((entry) => entry.id)
|
|
216
|
+
: ["official", "docker"];
|
|
217
|
+
const expected = source === "all" ? enabledSources : [source];
|
|
218
|
+
const missing = expected.filter((sourceId) => {
|
|
219
|
+
if (entrySources.has(sourceId))
|
|
220
|
+
return false;
|
|
221
|
+
const sourceInfo = sourcesById.get(sourceId);
|
|
222
|
+
return !(sourceInfo?.cacheEntries === 0 && sourceInfo.status && CACHE_COVERED_EMPTY_STATUSES.has(sourceInfo.status));
|
|
223
|
+
});
|
|
224
|
+
return { covered: missing.length === 0, missing };
|
|
225
|
+
}
|
|
226
|
+
export function cacheHasSource(entries, source, registrySources = []) {
|
|
227
|
+
return cacheCoverage(entries, source, registrySources).covered;
|
|
228
|
+
}
|
|
229
|
+
export function browseSearchResults(servers, query, browseVersionMode, browseSortMode = "source-first") {
|
|
230
|
+
const candidates = browseVersionMode === "all" ? servers : latestOnly(servers);
|
|
231
|
+
if (!query.trim()) {
|
|
232
|
+
return sortBrowseResults(candidates.map((server) => ({ server, relevance: 0, trust: scoreServer(server) })), browseSortMode);
|
|
233
|
+
}
|
|
234
|
+
return sortBrowseResults(searchServers(candidates, query, candidates.length), browseSortMode);
|
|
235
|
+
}
|
|
236
|
+
export function sortBrowseResults(results, mode) {
|
|
237
|
+
return [...results].sort((left, right) => compareBrowseResult(left, right, mode));
|
|
238
|
+
}
|
|
239
|
+
function compareBrowseResult(left, right, mode) {
|
|
240
|
+
if (mode === "source-first") {
|
|
241
|
+
return sourceRank(left) - sourceRank(right)
|
|
242
|
+
|| relevanceRank(right) - relevanceRank(left)
|
|
243
|
+
|| compareBrowseName(left, right);
|
|
244
|
+
}
|
|
245
|
+
if (mode === "source-last") {
|
|
246
|
+
return sourceRank(right) - sourceRank(left)
|
|
247
|
+
|| relevanceRank(right) - relevanceRank(left)
|
|
248
|
+
|| compareBrowseName(left, right);
|
|
249
|
+
}
|
|
250
|
+
if (mode === "alpha-asc") {
|
|
251
|
+
return compareBrowseName(left, right)
|
|
252
|
+
|| sourceRank(left) - sourceRank(right)
|
|
253
|
+
|| relevanceRank(right) - relevanceRank(left);
|
|
254
|
+
}
|
|
255
|
+
if (mode === "alpha-desc") {
|
|
256
|
+
return compareBrowseName(right, left)
|
|
257
|
+
|| sourceRank(left) - sourceRank(right)
|
|
258
|
+
|| relevanceRank(right) - relevanceRank(left);
|
|
259
|
+
}
|
|
260
|
+
return relevanceRank(right) - relevanceRank(left)
|
|
261
|
+
|| sourceRank(left) - sourceRank(right)
|
|
262
|
+
|| compareBrowseName(left, right);
|
|
263
|
+
}
|
|
264
|
+
function sourceRank(result) {
|
|
265
|
+
return registrySourceIdRank(result.server.registrySource);
|
|
266
|
+
}
|
|
267
|
+
function relevanceRank(result) {
|
|
268
|
+
return result.relevance + trustRankingScore(result.trust) / 100;
|
|
269
|
+
}
|
|
270
|
+
function compareBrowseName(left, right) {
|
|
271
|
+
const leftLabel = left.server.title || left.server.name;
|
|
272
|
+
const rightLabel = right.server.title || right.server.name;
|
|
273
|
+
return leftLabel.localeCompare(rightLabel)
|
|
274
|
+
|| left.server.name.localeCompare(right.server.name)
|
|
275
|
+
|| left.server.version.localeCompare(right.server.version);
|
|
276
|
+
}
|
|
277
|
+
export function nextResultLimit(currentLimit, totalMatches) {
|
|
278
|
+
return Math.min(Math.max(totalMatches, currentLimit), currentLimit + RESULT_LIMIT_STEP);
|
|
279
|
+
}
|
|
280
|
+
export function persistentRefreshOptions(source) {
|
|
281
|
+
return { source, limit: 500, maxPages: 25 };
|
|
282
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { classifyTrust, trustTier } from "../../trust.js";
|
|
2
|
+
export function trustBand(score) {
|
|
3
|
+
if (score >= 70)
|
|
4
|
+
return "high";
|
|
5
|
+
if (score >= 40)
|
|
6
|
+
return "medium";
|
|
7
|
+
return "low";
|
|
8
|
+
}
|
|
9
|
+
export function riskTone(score) {
|
|
10
|
+
const band = trustBand(score);
|
|
11
|
+
if (band === "high")
|
|
12
|
+
return { label: "COMPLETE", band };
|
|
13
|
+
if (band === "medium")
|
|
14
|
+
return { label: "REVIEW", band };
|
|
15
|
+
return { label: "INCOMPLETE", band };
|
|
16
|
+
}
|
|
17
|
+
export function trustRiskTone(report) {
|
|
18
|
+
const tier = trustTier(report);
|
|
19
|
+
if (tier === "verified")
|
|
20
|
+
return { label: "EVIDENCE OK", band: "high", tier };
|
|
21
|
+
if (tier === "conditional")
|
|
22
|
+
return { label: "REVIEW", band: "medium", tier };
|
|
23
|
+
if (tier === "unverified")
|
|
24
|
+
return { label: "UNVERIFIED", band: "low", tier };
|
|
25
|
+
return { label: "BLOCKED", band: "low", tier };
|
|
26
|
+
}
|
|
27
|
+
export function trustTierScore(report) {
|
|
28
|
+
const tier = trustTier(report);
|
|
29
|
+
if (tier === "verified")
|
|
30
|
+
return 100;
|
|
31
|
+
if (tier === "conditional")
|
|
32
|
+
return 67;
|
|
33
|
+
if (tier === "unverified")
|
|
34
|
+
return 34;
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
export const TRUST_BAR_CELLS = 9;
|
|
38
|
+
export function trustBarCells(score) {
|
|
39
|
+
const filled = Math.max(0, Math.min(TRUST_BAR_CELLS, Math.round((score / 100) * TRUST_BAR_CELLS)));
|
|
40
|
+
return { filled, empty: TRUST_BAR_CELLS - filled };
|
|
41
|
+
}
|
|
42
|
+
export function trustDimensions(report) {
|
|
43
|
+
if (report.pillars) {
|
|
44
|
+
return [
|
|
45
|
+
dimension("provenance", report.pillars.provenance),
|
|
46
|
+
dimension("integrity", report.pillars.integrity),
|
|
47
|
+
dimension("reputation", report.pillars.reputation),
|
|
48
|
+
dimension("metadata", report.pillars.metadataCompleteness),
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
const classified = classifyTrust(report.score, report.issues, report.evidence);
|
|
52
|
+
const gates = report.gates ?? classified.gates;
|
|
53
|
+
const gatedBy = report.gatedBy ?? classified.gatedBy;
|
|
54
|
+
const hasRepo = report.badges.includes("source repo");
|
|
55
|
+
const hasDeclaredAttestation = report.badges.some((badge) => badge.endsWith("-declared"));
|
|
56
|
+
const hasCapabilityPin = report.badges.includes("capability-pinned");
|
|
57
|
+
const hasPassedEvidence = (report.evidence ?? []).some((entry) => entry.status === "passed");
|
|
58
|
+
const hasFailedEvidence = (report.evidence ?? []).some((entry) => entry.status === "failed");
|
|
59
|
+
const integrityBlocked = gates.some((gate) => ["no_install_target", "insecure_remote", "invalid_remote_url"].includes(gate.code))
|
|
60
|
+
|| gatedBy.some((code) => ["no_install_target", "insecure_remote", "invalid_remote_url"].includes(code));
|
|
61
|
+
const integrityUnverified = gates.length > 0 || gatedBy.length > 0 || hasFailedEvidence;
|
|
62
|
+
const hasIntegritySignal = hasPassedEvidence || report.badges.some((badge) => ["digest-pinned", "fileSha256", "https remote", "pinned version"].includes(badge));
|
|
63
|
+
const provenanceScore = hasRepo ? (hasCapabilityPin || hasDeclaredAttestation ? 100 : 80) : 30;
|
|
64
|
+
const integrityScore = integrityBlocked ? 0 : integrityUnverified ? 25 : hasIntegritySignal ? 85 : 50;
|
|
65
|
+
const reputationScore = report.badges.includes("description-scan-advisory") ? 45 : 60;
|
|
66
|
+
const metadataScore = report.metadataCompleteness ?? report.score;
|
|
67
|
+
return [
|
|
68
|
+
dimension("provenance", provenanceScore),
|
|
69
|
+
dimension("integrity", integrityScore),
|
|
70
|
+
dimension("reputation", reputationScore),
|
|
71
|
+
dimension("metadata", metadataScore),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
function dimension(label, score) {
|
|
75
|
+
const normalized = Math.max(0, Math.min(100, Math.round(score)));
|
|
76
|
+
return { label, score: normalized, tone: trustBand(normalized) };
|
|
77
|
+
}
|