@oh-my-tool/cli 0.3.1 → 0.3.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/package.json +2 -2
- package/src/cli/commands/connections.ts +33 -29
- package/src/cli/commands/extension.ts +2 -2
- package/src/cli/commands/run.ts +3 -9
- package/src/cli/commands/search.ts +8 -2
- package/src/cli/context.ts +5 -5
- package/src/cli/index.ts +24 -7
- package/src/cli/output.ts +13 -13
- package/src/config/config.ts +60 -37
- package/src/extension/install.ts +116 -5
- package/src/extension/loader.ts +1 -1
- package/src/extension/manifest.ts +25 -1
- package/src/integration/manager.ts +4 -4
- package/src/policy/policy.ts +2 -0
- package/src/runtime/provider.ts +17 -0
- package/src/runtime/providers/mcp/oauth-provider.ts +22 -1
- package/src/runtime/providers/mcp/oauth-store.ts +12 -1
- package/src/runtime/providers/mcp/provider.ts +2 -0
- package/src/runtime/providers/native/provider.ts +29 -9
- package/src/runtime/result.ts +12 -5
- package/src/runtime/runtime.ts +79 -25
- package/src/runtime/tool-registry.ts +23 -8
- package/src/version.ts +1 -1
- package/src/core/executor.ts +0 -99
- package/src/core/registry.ts +0 -33
- package/src/core/result.ts +0 -14
- package/src/core/schema.ts +0 -2
- package/src/search/search.ts +0 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-tool/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Oh My Tool CLI - local and enterprise tools for agents",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clack/prompts": "^1.7.0",
|
|
31
31
|
"@modelcontextprotocol/client": "2.0.0",
|
|
32
|
-
"@oh-my-tool/sdk": "0.3.
|
|
32
|
+
"@oh-my-tool/sdk": "0.3.3",
|
|
33
33
|
"open": "11.0.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { loadConfig } from "../../config/config";
|
|
1
|
+
import { loadConfig, sanitizeExtensionConnections, validateConfiguredConnections } from "../../config/config";
|
|
2
2
|
import { createPaths } from "../../paths";
|
|
3
3
|
import { prepareHome } from "../../migration";
|
|
4
4
|
import { withRuntime } from "../context";
|
|
5
|
+
import { discoverExtensions } from "../../extension/discovery";
|
|
5
6
|
|
|
6
7
|
export interface ConnectionSummary {
|
|
7
8
|
extension: string;
|
|
8
9
|
name: string;
|
|
9
|
-
environment
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
database: string;
|
|
13
|
-
username: string;
|
|
14
|
-
tls: boolean;
|
|
15
|
-
secretConfigured: boolean;
|
|
10
|
+
environment?: string;
|
|
11
|
+
settings: Record<string, unknown>;
|
|
12
|
+
secretsConfigured: Record<string, boolean>;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export interface ConnectionListResult {
|
|
@@ -43,20 +40,15 @@ async function configuredConnections(): Promise<ConnectionListResult> {
|
|
|
43
40
|
const paths = createPaths();
|
|
44
41
|
await prepareHome(paths);
|
|
45
42
|
const config = loadConfig(paths.home);
|
|
46
|
-
const
|
|
43
|
+
const sanitized = sanitizeExtensionConnections(config);
|
|
44
|
+
const connections = Object.entries(sanitized)
|
|
47
45
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
48
|
-
.flatMap(([extension, value]) => Object.entries(value
|
|
46
|
+
.flatMap(([extension, value]) => Object.entries(value)
|
|
49
47
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
50
48
|
.map(([name, connection]) => ({
|
|
51
49
|
extension,
|
|
52
50
|
name,
|
|
53
|
-
|
|
54
|
-
host: connection.host,
|
|
55
|
-
port: connection.port,
|
|
56
|
-
database: connection.database,
|
|
57
|
-
username: connection.username,
|
|
58
|
-
tls: connection.tls,
|
|
59
|
-
secretConfigured: connection.secret.length > 0,
|
|
51
|
+
...connection,
|
|
60
52
|
})));
|
|
61
53
|
return { connections, count: connections.length };
|
|
62
54
|
}
|
|
@@ -69,6 +61,7 @@ export async function runConfigCheck(): Promise<ConfigCheckResult> {
|
|
|
69
61
|
const paths = createPaths();
|
|
70
62
|
await prepareHome(paths);
|
|
71
63
|
const config = loadConfig(paths.home);
|
|
64
|
+
validateConfiguredConnections(config, discoverExtensions(paths.home));
|
|
72
65
|
return {
|
|
73
66
|
valid: true,
|
|
74
67
|
connectionCount: Object.values(config.extensions).reduce((count, extension) => count + Object.keys(extension.connections).length, 0),
|
|
@@ -78,21 +71,32 @@ export async function runConfigCheck(): Promise<ConfigCheckResult> {
|
|
|
78
71
|
|
|
79
72
|
export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
|
|
80
73
|
const list = await configuredConnections();
|
|
74
|
+
const paths = createPaths();
|
|
75
|
+
const checkTools = new Map(discoverExtensions(paths.home).map((extension) => [extension.id, extension.manifest.connectionCheckTool]));
|
|
81
76
|
return withRuntime(async (runtime) => {
|
|
82
|
-
const checks: ConnectionCheck[] =
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
checks.push({ extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" });
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
77
|
+
const checks: ConnectionCheck[] = await boundedMap(list.connections, 4, async (connection) => {
|
|
78
|
+
const toolId = checkTools.get(connection.extension);
|
|
79
|
+
if (!toolId) return { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" };
|
|
88
80
|
const started = Date.now();
|
|
89
|
-
const result = await runtime.run(
|
|
90
|
-
|
|
81
|
+
const result = await runtime.run(toolId, { connection: connection.name });
|
|
82
|
+
return result.ok
|
|
91
83
|
? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
|
|
92
|
-
: result.error
|
|
93
|
-
|
|
94
|
-
: { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
|
|
95
|
-
}
|
|
84
|
+
: { extension: connection.extension, name: connection.name, status: "error", code: result.error.code ?? "CHECK_FAILED", durationMs: Date.now() - started };
|
|
85
|
+
});
|
|
96
86
|
return { checks, count: checks.length };
|
|
97
87
|
}, { includeMcp: false });
|
|
98
88
|
}
|
|
89
|
+
|
|
90
|
+
async function boundedMap<T, R>(values: readonly T[], concurrency: number, worker: (value: T) => Promise<R>): Promise<R[]> {
|
|
91
|
+
const results = new Array<R>(values.length);
|
|
92
|
+
let next = 0;
|
|
93
|
+
async function consume(): Promise<void> {
|
|
94
|
+
while (true) {
|
|
95
|
+
const index = next++;
|
|
96
|
+
if (index >= values.length) return;
|
|
97
|
+
results[index] = await worker(values[index]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => consume()));
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { discoverExtensions } from "../../extension/discovery";
|
|
2
|
-
import {
|
|
2
|
+
import { installExtension, type InstalledRef } from "../../extension/install";
|
|
3
3
|
import { homeDir } from "../context";
|
|
4
4
|
|
|
5
5
|
export interface InstalledInfo {
|
|
@@ -19,6 +19,6 @@ export async function runExtensionList(): Promise<InstalledInfo[]> {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function runExtensionInstall(spec: string): Promise<InstalledRef> {
|
|
22
|
-
return
|
|
22
|
+
return installExtension(homeDir(), spec);
|
|
23
23
|
}
|
|
24
24
|
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
import { coerceInput } from "../parseArgs";
|
|
2
2
|
import { withRuntime } from "../context";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ExecutionResult } from "../../runtime/result";
|
|
4
4
|
|
|
5
5
|
export async function runTool(
|
|
6
6
|
toolName: string,
|
|
7
7
|
keyValues: Record<string, string>,
|
|
8
8
|
useStdin: boolean,
|
|
9
|
-
): Promise<
|
|
9
|
+
): Promise<ExecutionResult> {
|
|
10
10
|
let input: Record<string, unknown>;
|
|
11
11
|
if (useStdin) {
|
|
12
12
|
input = await readStdinJson();
|
|
13
13
|
} else {
|
|
14
14
|
input = coerceInput(keyValues);
|
|
15
15
|
}
|
|
16
|
-
return withRuntime(
|
|
17
|
-
const result = await runtime.run(toolName, input);
|
|
18
|
-
if (result.ok) {
|
|
19
|
-
return { ok: true, tool: toolName, data: result.output, meta: result.meta ?? {} };
|
|
20
|
-
}
|
|
21
|
-
return { ok: false, tool: toolName, error: result.error ?? { code: "EXECUTION_FAILED", message: "execution failed" } };
|
|
22
|
-
}, { targetTool: toolName });
|
|
16
|
+
return withRuntime((runtime) => runtime.run(toolName, input), { targetTool: toolName });
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
function readStdinJson(): Promise<Record<string, unknown>> {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { withRuntime } from "../context";
|
|
2
|
+
import type { ToolSearchOptions } from "../../runtime/provider";
|
|
2
3
|
|
|
3
|
-
export async function runSearch(query: string): Promise<{ tools: Array<Record<string, unknown>> }> {
|
|
4
|
+
export async function runSearch(query: string, options: ToolSearchOptions = {}): Promise<{ tools: Array<Record<string, unknown>>; meta: { unavailableProviders: Array<Record<string, unknown>> } }> {
|
|
4
5
|
return withRuntime(async (runtime) => {
|
|
5
|
-
const descriptors = await runtime.search(query);
|
|
6
|
+
const descriptors = await runtime.search(query, options);
|
|
6
7
|
return {
|
|
7
8
|
tools: descriptors.map((descriptor) => ({
|
|
8
9
|
name: descriptor.id,
|
|
@@ -11,6 +12,11 @@ export async function runSearch(query: string): Promise<{ tools: Array<Record<st
|
|
|
11
12
|
risk: descriptor.risk,
|
|
12
13
|
provider: descriptor.provider,
|
|
13
14
|
})),
|
|
15
|
+
meta: {
|
|
16
|
+
unavailableProviders: runtime.providerStatuses()
|
|
17
|
+
.filter((status) => status.status === "unavailable")
|
|
18
|
+
.map(({ id, kind, status, code, message }) => ({ id, kind, status, ...(code === undefined ? {} : { code }), ...(message === undefined ? {} : { message }) })),
|
|
19
|
+
},
|
|
14
20
|
};
|
|
15
21
|
});
|
|
16
22
|
}
|
package/src/cli/context.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { createPaths } from "../paths";
|
|
2
2
|
import { prepareHome } from "../migration";
|
|
3
|
-
import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, type McpEnabledServerConfig } from "../config/config";
|
|
3
|
+
import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, validateConfiguredConnections, type McpEnabledServerConfig } from "../config/config";
|
|
4
4
|
import { SecretsManager } from "../secrets/secrets";
|
|
5
5
|
import { applyLimits, validateConnectionInput } from "../policy/policy";
|
|
6
6
|
import { NativeExtensionProvider } from "../runtime/providers/native/provider";
|
|
7
7
|
import { McpProvider } from "../runtime/providers/mcp/provider";
|
|
8
|
-
import { discoverExtensions } from "../extension/discovery";
|
|
9
8
|
import { createToolRuntime } from "../runtime/runtime";
|
|
10
9
|
import type { ToolDescriptor } from "../runtime/provider";
|
|
11
10
|
|
|
@@ -24,13 +23,14 @@ export async function createRuntime(options: RuntimeOptions = {}) {
|
|
|
24
23
|
const config = loadConfig(paths.home);
|
|
25
24
|
const secrets = new SecretsManager();
|
|
26
25
|
const extensionConnections = sanitizeExtensionConnections(config);
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
const nativeProvider = new NativeExtensionProvider(paths);
|
|
27
|
+
validateConfiguredConnections(config, nativeProvider.installedExtensions());
|
|
28
|
+
const nativeTarget = options.targetTool !== undefined && await nativeProvider.hasTool(options.targetTool);
|
|
29
29
|
const mcpProviders = options.includeMcp === false || nativeTarget ? [] : Object.entries(config.mcp.servers)
|
|
30
30
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
31
31
|
.filter((entry): entry is [string, McpEnabledServerConfig] => entry[1].enabled)
|
|
32
32
|
.map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }));
|
|
33
|
-
const providers = [
|
|
33
|
+
const providers = [nativeProvider, ...mcpProviders];
|
|
34
34
|
return createToolRuntime({
|
|
35
35
|
providers,
|
|
36
36
|
policy: {
|
package/src/cli/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ Usage:
|
|
|
30
30
|
ohmytool connection check check MySQL/Redis connectivity
|
|
31
31
|
ohmytool config check validate configuration
|
|
32
32
|
ohmytool extension list list installed extensions
|
|
33
|
-
ohmytool extension install <path>
|
|
33
|
+
ohmytool extension install <path|package> install a local or npm extension
|
|
34
34
|
ohmytool secret set <name> set a secret (interactive hidden prompt or stdin pipe)
|
|
35
35
|
ohmytool secret list list secret names (Windows only, values never shown)
|
|
36
36
|
ohmytool mcp list list configured MCP servers
|
|
@@ -121,6 +121,23 @@ function parseAgentIds(raw?: string): AgentId[] | undefined {
|
|
|
121
121
|
return [...new Set(values as AgentId[])];
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function parseSearchOptions(options: Record<string, string>): { limit?: number; provider?: string; source?: string; risk?: "read" | "write" | "admin" } {
|
|
125
|
+
const limit = options.limit === undefined ? undefined : Number(options.limit);
|
|
126
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
127
|
+
throw new RuntimeError("INVALID_ARGUMENT", "search --limit must be a positive integer");
|
|
128
|
+
}
|
|
129
|
+
const risk = options.risk;
|
|
130
|
+
if (risk !== undefined && risk !== "read" && risk !== "write" && risk !== "admin") {
|
|
131
|
+
throw new RuntimeError("INVALID_ARGUMENT", `unsupported search risk '${risk}'`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...(limit === undefined ? {} : { limit }),
|
|
135
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
136
|
+
...(options.source === undefined ? {} : { source: options.source }),
|
|
137
|
+
...(risk === undefined ? {} : { risk }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
function printDetected(agents: AgentDetection[]): void {
|
|
125
142
|
console.log("Detected agents:\n");
|
|
126
143
|
for (const agent of agents) console.log(`✓ ${agent.variant ?? agent.displayName}`);
|
|
@@ -169,10 +186,10 @@ function printStatus(results: IntegrationResult[]): void {
|
|
|
169
186
|
const summary = Object.entries(counts).map(([status, n]) => `${n} ${status}`).join(" · ");
|
|
170
187
|
console.log(`\nSummary: ${summary}`);
|
|
171
188
|
if (results.some((item) => item.status === "broken")) {
|
|
172
|
-
console.log("Tip: run `
|
|
189
|
+
console.log("Tip: run `ohmytool integrate repair` to recreate broken links");
|
|
173
190
|
}
|
|
174
191
|
if (results.some((item) => item.status === "conflict")) {
|
|
175
|
-
console.log("Tip: conflict means OMT refuses to touch an unmanaged path; run `
|
|
192
|
+
console.log("Tip: conflict means OMT refuses to touch an unmanaged path; run `ohmytool integrate --force` only if you accept replacing it");
|
|
176
193
|
}
|
|
177
194
|
}
|
|
178
195
|
|
|
@@ -207,7 +224,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
207
224
|
const parsed = parseArgs(argv);
|
|
208
225
|
const cmd = parsed.positional[0];
|
|
209
226
|
const allowedFlags = new Set(["help", "version", "json", "stdin", "yes", "dry-run", "force"]);
|
|
210
|
-
const allowedOptions = new Set(["format", "json", "agents"]);
|
|
227
|
+
const allowedOptions = new Set(["format", "json", "agents", "limit", "provider", "source", "risk"]);
|
|
211
228
|
const unknownFlag = parsed.flags.find((flag) => !allowedFlags.has(flag));
|
|
212
229
|
const unknownOption = Object.keys(parsed.options).find((option) => !allowedOptions.has(option));
|
|
213
230
|
if (unknownFlag) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownFlag}'`);
|
|
@@ -215,7 +232,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
215
232
|
switch (cmd) {
|
|
216
233
|
case "search": {
|
|
217
234
|
const q = parsed.positional.slice(1).join(" ");
|
|
218
|
-
print(await runSearch(q));
|
|
235
|
+
print(await runSearch(q, parseSearchOptions(parsed.options)));
|
|
219
236
|
return 0;
|
|
220
237
|
}
|
|
221
238
|
case "describe": {
|
|
@@ -235,7 +252,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
235
252
|
return 1;
|
|
236
253
|
}
|
|
237
254
|
const result = action === "list" ? await runConnectionList() : await runConnectionCheck();
|
|
238
|
-
const wrapped = { ok: true as const,
|
|
255
|
+
const wrapped = { ok: true as const, toolId: `connection.${action}`, output: result, meta: {} };
|
|
239
256
|
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
240
257
|
return 0;
|
|
241
258
|
}
|
|
@@ -245,7 +262,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
245
262
|
return 1;
|
|
246
263
|
}
|
|
247
264
|
const result = await runConfigCheck();
|
|
248
|
-
const wrapped = { ok: true as const,
|
|
265
|
+
const wrapped = { ok: true as const, toolId: "config.check", output: result, meta: {} };
|
|
249
266
|
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
250
267
|
return 0;
|
|
251
268
|
}
|
package/src/cli/output.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExecutionResult } from "../runtime/result";
|
|
2
2
|
|
|
3
3
|
export type OutputFormat = "text" | "json" | "table" | "csv";
|
|
4
4
|
|
|
5
|
-
export function formatAiResult(result:
|
|
6
|
-
const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.
|
|
5
|
+
export function formatAiResult(result: ExecutionResult): string {
|
|
6
|
+
const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.toolId}`];
|
|
7
7
|
if (!result.ok) {
|
|
8
8
|
lines.push("error:");
|
|
9
9
|
appendObject(lines, result.error, 2);
|
|
10
10
|
return lines.join("\n");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
if (isRowsResult(result.
|
|
13
|
+
if (isRowsResult(result.output)) {
|
|
14
14
|
lines.push("rows:");
|
|
15
|
-
for (const row of result.
|
|
16
|
-
lines.push(`row_count: ${result.
|
|
17
|
-
if (result.
|
|
15
|
+
for (const row of result.output.rows) lines.push(formatRow(row));
|
|
16
|
+
lines.push(`row_count: ${result.output.rows.length}`);
|
|
17
|
+
if (result.output.truncated === true) lines.push("truncated: true");
|
|
18
18
|
appendMeta(lines, result.meta, new Set(["returnedRows"]), false);
|
|
19
19
|
} else {
|
|
20
20
|
lines.push("data:");
|
|
21
|
-
appendValue(lines, result.
|
|
21
|
+
appendValue(lines, result.output, 2);
|
|
22
22
|
appendMeta(lines, result.meta);
|
|
23
23
|
}
|
|
24
24
|
return lines.join("\n");
|
|
@@ -36,17 +36,17 @@ export function formatJson(value: unknown): string {
|
|
|
36
36
|
}, 2) ?? "null";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function formatOutput(result:
|
|
39
|
+
export function formatOutput(result: ExecutionResult, format: OutputFormat): string {
|
|
40
40
|
if (format === "json") return formatJson(result);
|
|
41
41
|
if (format === "csv" || format === "table") return formatDelimited(result, format === "csv" ? "," : "\t");
|
|
42
42
|
return formatAiResult(result);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function formatDelimited(result:
|
|
45
|
+
function formatDelimited(result: ExecutionResult, delimiter: string): string {
|
|
46
46
|
if (!result.ok) return formatAiResult(result);
|
|
47
|
-
const rows = isRowsResult(result.
|
|
48
|
-
? { columns: result.
|
|
49
|
-
: findObjectArray(result.
|
|
47
|
+
const rows = isRowsResult(result.output)
|
|
48
|
+
? { columns: result.output.columns, rows: result.output.rows }
|
|
49
|
+
: findObjectArray(result.output);
|
|
50
50
|
if (!rows) return formatAiResult(result);
|
|
51
51
|
return [rows.columns, ...rows.rows].map((row) => row.map((value) => quoteDelimited(value, delimiter)).join(delimiter)).join("\n");
|
|
52
52
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { RuntimeError } from "../runtime/errors";
|
|
4
|
+
import { validateInput, type Schema } from "../runtime/schema";
|
|
5
|
+
import type { InstalledExtension } from "../extension/discovery";
|
|
4
6
|
|
|
5
7
|
export interface ConnectionConfig {
|
|
6
|
-
environment
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
environment?: string;
|
|
9
|
+
settings: Record<string, unknown>;
|
|
10
|
+
secrets: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SanitizedConnectionConfig {
|
|
14
|
+
environment?: string;
|
|
15
|
+
settings: Record<string, unknown>;
|
|
16
|
+
secretsConfigured: Record<string, boolean>;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export interface McpCommonServerConfig { readonly enabled: true; readonly namespace: string; }
|
|
@@ -44,32 +48,36 @@ function parseConnection(extensionId: string, name: string, value: unknown): Con
|
|
|
44
48
|
const path = `extensions.${extensionId}.connections.${name}`;
|
|
45
49
|
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
46
50
|
const raw = value as Record<string, unknown>;
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
const host = stringField("host");
|
|
54
|
-
if (host.trim().length === 0) invalidConnection(`${path}.host`, "must be a non-empty string");
|
|
55
|
-
const defaultPort = extensionId === "redis" ? 6379 : 3306;
|
|
56
|
-
const port = raw.port === undefined ? defaultPort : raw.port;
|
|
57
|
-
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
58
|
-
invalidConnection(`${path}.port`, "must be an integer from 1 through 65535");
|
|
59
|
-
}
|
|
60
|
-
const tls = raw.tls === undefined ? false : raw.tls;
|
|
61
|
-
if (typeof tls !== "boolean") invalidConnection(`${path}.tls`, "must be a boolean");
|
|
51
|
+
const allowed = new Set(["environment", "settings", "secrets"]);
|
|
52
|
+
for (const key of Object.keys(raw)) if (!allowed.has(key)) invalidConnection(`${path}.${key}`, "unknown connection field");
|
|
53
|
+
const environment = raw.environment;
|
|
54
|
+
if (environment !== undefined && typeof environment !== "string") invalidConnection(`${path}.environment`, "must be a string");
|
|
55
|
+
const settings = parseSettingsMap(raw.settings, `${path}.settings`);
|
|
56
|
+
const secrets = parseSecretMap(raw.secrets, `${path}.secrets`);
|
|
62
57
|
return {
|
|
63
|
-
environment:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
database: stringField("database"),
|
|
67
|
-
username: stringField("username"),
|
|
68
|
-
secret: stringField("secret"),
|
|
69
|
-
tls,
|
|
58
|
+
...(environment === undefined ? {} : { environment }),
|
|
59
|
+
settings,
|
|
60
|
+
secrets,
|
|
70
61
|
};
|
|
71
62
|
}
|
|
72
63
|
|
|
64
|
+
function parseSettingsMap(value: unknown, path: string): Record<string, unknown> {
|
|
65
|
+
if (value === undefined) return {};
|
|
66
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
67
|
+
return { ...(value as Record<string, unknown>) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSecretMap(value: unknown, path: string): Record<string, string> {
|
|
71
|
+
if (value === undefined) return {};
|
|
72
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
73
|
+
const result: Record<string, string> = {};
|
|
74
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
75
|
+
if (typeof entry !== "string" || entry.length === 0) invalidConnection(`${path}.${key}`, "must be a non-empty string");
|
|
76
|
+
result[key] = entry;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function parseStringMap(value: unknown, path: string): Record<string, string> {
|
|
74
82
|
if (value === undefined) return {};
|
|
75
83
|
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(path, "must be a table");
|
|
@@ -191,17 +199,32 @@ export function loadConfig(homeDir: string): Config {
|
|
|
191
199
|
export function getConnectionConfig(cfg: Config, extensionId: string, connection: string): ConnectionConfig | undefined { return cfg.extensions[extensionId]?.connections[connection]; }
|
|
192
200
|
export function listConnections(cfg: Config, extensionId: string): string[] { return Object.keys(cfg.extensions[extensionId]?.connections ?? {}); }
|
|
193
201
|
|
|
194
|
-
export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string,
|
|
202
|
+
export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string, SanitizedConnectionConfig>> {
|
|
195
203
|
return Object.fromEntries(Object.entries(cfg.extensions).map(([extensionId, extension]) => [
|
|
196
204
|
extensionId,
|
|
197
205
|
Object.fromEntries(Object.entries(extension.connections).map(([name, connection]) => [name, {
|
|
198
|
-
environment: connection.environment,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
database: connection.database,
|
|
202
|
-
username: connection.username,
|
|
203
|
-
tls: connection.tls,
|
|
204
|
-
secretConfigured: connection.secret.length > 0,
|
|
206
|
+
...(connection.environment === undefined ? {} : { environment: connection.environment }),
|
|
207
|
+
settings: { ...connection.settings },
|
|
208
|
+
secretsConfigured: Object.fromEntries(Object.keys(connection.secrets).map((key) => [key, true])),
|
|
205
209
|
}])),
|
|
206
210
|
]));
|
|
207
211
|
}
|
|
212
|
+
|
|
213
|
+
export function validateConfiguredConnections(
|
|
214
|
+
cfg: Config,
|
|
215
|
+
installedExtensions: readonly InstalledExtension[],
|
|
216
|
+
): void {
|
|
217
|
+
const manifests = new Map(installedExtensions.map((extension) => [extension.id, extension.manifest]));
|
|
218
|
+
for (const [extensionId, extension] of Object.entries(cfg.extensions)) {
|
|
219
|
+
const schema = manifests.get(extensionId)?.connectionSchema;
|
|
220
|
+
if (schema === undefined) continue;
|
|
221
|
+
for (const [name, connection] of Object.entries(extension.connections)) {
|
|
222
|
+
try {
|
|
223
|
+
validateInput(schema as Schema, connection.settings, { applyDefaults: false });
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
226
|
+
invalidConnection(`extensions.${extensionId}.connections.${name}.settings`, message);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/extension/install.ts
CHANGED
|
@@ -1,24 +1,135 @@
|
|
|
1
|
-
import { cp, mkdir, readFile } from "node:fs/promises";
|
|
2
|
-
import {
|
|
1
|
+
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
3
6
|
import { parseManifest, validateManifest, checkSdkCompatibility } from "./manifest";
|
|
4
7
|
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
const EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
10
|
+
const OFFICIAL_PACKAGE_RE = /^@oh-my-tool\/[a-z0-9][a-z0-9._-]*$/;
|
|
11
|
+
const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
12
|
+
|
|
5
13
|
export interface InstalledRef {
|
|
6
14
|
id: string;
|
|
7
15
|
version: string;
|
|
8
16
|
target: string;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
|
-
export
|
|
19
|
+
export interface NpmExtensionSpec {
|
|
20
|
+
packageName: string;
|
|
21
|
+
npmSpec: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ExtensionInstallError extends Error {}
|
|
26
|
+
|
|
27
|
+
export interface NpmInstallDependencies {
|
|
28
|
+
install(spec: string, tempDir: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeNpmExtensionSpec(spec: string): NpmExtensionSpec {
|
|
32
|
+
const trimmed = spec.trim();
|
|
33
|
+
if (!trimmed) throw new ExtensionInstallError("extension package name must not be empty");
|
|
34
|
+
|
|
35
|
+
let packageName = trimmed;
|
|
36
|
+
let version: string | undefined;
|
|
37
|
+
const separator = trimmed.startsWith("@") ? trimmed.lastIndexOf("@") : trimmed.indexOf("@");
|
|
38
|
+
if (separator > 0) {
|
|
39
|
+
packageName = trimmed.slice(0, separator);
|
|
40
|
+
version = trimmed.slice(separator + 1);
|
|
41
|
+
if (!EXACT_VERSION_RE.test(version)) {
|
|
42
|
+
throw new ExtensionInstallError("npm extension versions must be exact semver values, for example 0.3.1");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!trimmed.startsWith("@")) packageName = `@oh-my-tool/${packageName}`;
|
|
47
|
+
if (!OFFICIAL_PACKAGE_RE.test(packageName)) {
|
|
48
|
+
throw new ExtensionInstallError("only official @oh-my-tool extension packages are supported");
|
|
49
|
+
}
|
|
50
|
+
return { packageName, npmSpec: `${packageName}${version === undefined ? "" : `@${version}`}`, ...(version === undefined ? {} : { version }) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function installExtensionDirectory(home: string, srcDir: string, expectedPackage?: NpmExtensionSpec): Promise<InstalledRef> {
|
|
12
54
|
const manifest = parseManifest(await readFile(join(srcDir, "omt.manifest.json"), "utf8"));
|
|
13
55
|
validateManifest(manifest);
|
|
14
56
|
checkSdkCompatibility(manifest.sdkVersion);
|
|
57
|
+
if (!EXTENSION_ID_RE.test(manifest.id)) throw new ExtensionInstallError(`invalid extension id '${manifest.id}'`);
|
|
58
|
+
if (expectedPackage !== undefined) {
|
|
59
|
+
const packageJson = JSON.parse(await readFile(join(srcDir, "package.json"), "utf8")) as { name?: unknown; version?: unknown };
|
|
60
|
+
if (packageJson.name !== expectedPackage.packageName) {
|
|
61
|
+
throw new ExtensionInstallError(`package manifest mismatch: expected '${expectedPackage.packageName}'`);
|
|
62
|
+
}
|
|
63
|
+
if (packageJson.version !== manifest.version) {
|
|
64
|
+
throw new ExtensionInstallError("package.json and omt.manifest.json versions do not match");
|
|
65
|
+
}
|
|
66
|
+
if (expectedPackage.version !== undefined && packageJson.version !== expectedPackage.version) {
|
|
67
|
+
throw new ExtensionInstallError(`npm package '${expectedPackage.npmSpec}' resolved to an unexpected version`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
15
70
|
const target = join(home, "extensions", manifest.id, manifest.version);
|
|
16
71
|
await mkdir(target, { recursive: true });
|
|
17
72
|
// 显式 force:true:Bun 的 fs.cp 在带 filter 时默认覆盖失效(重装不更新旧文件)
|
|
18
73
|
await cp(srcDir, target, {
|
|
19
74
|
recursive: true,
|
|
20
75
|
force: true,
|
|
21
|
-
filter: (s: string) =>
|
|
76
|
+
filter: (s: string) => basename(s) !== "node_modules",
|
|
22
77
|
});
|
|
23
78
|
return { id: manifest.id, version: manifest.version, target };
|
|
24
|
-
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function installLocalExtension(home: string, srcDir: string): Promise<InstalledRef> {
|
|
82
|
+
return installExtensionDirectory(home, srcDir);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function installNpmExtension(
|
|
86
|
+
home: string,
|
|
87
|
+
spec: string,
|
|
88
|
+
dependencies: Partial<NpmInstallDependencies> = {},
|
|
89
|
+
): Promise<InstalledRef> {
|
|
90
|
+
const normalized = normalizeNpmExtensionSpec(spec);
|
|
91
|
+
const temp = await mkdtemp(join(tmpdir(), "oh-my-tool-npm-"));
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(join(temp, "package.json"), JSON.stringify({ private: true }), "utf8");
|
|
94
|
+
try {
|
|
95
|
+
if (dependencies.install !== undefined) {
|
|
96
|
+
await dependencies.install(normalized.npmSpec, temp);
|
|
97
|
+
} else {
|
|
98
|
+
await execFile("npm", [
|
|
99
|
+
"install",
|
|
100
|
+
"--prefix", temp,
|
|
101
|
+
"--ignore-scripts",
|
|
102
|
+
"--no-save",
|
|
103
|
+
"--no-package-lock",
|
|
104
|
+
"--omit=dev",
|
|
105
|
+
"--registry=https://registry.npmjs.org",
|
|
106
|
+
"--", normalized.npmSpec,
|
|
107
|
+
], { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
throw new ExtensionInstallError(`failed to download npm extension '${normalized.npmSpec}'`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const packageDir = join(temp, "node_modules", ...normalized.packageName.split("/"));
|
|
114
|
+
try {
|
|
115
|
+
await stat(packageDir);
|
|
116
|
+
} catch {
|
|
117
|
+
throw new ExtensionInstallError(`npm extension '${normalized.npmSpec}' was not installed`);
|
|
118
|
+
}
|
|
119
|
+
return await installExtensionDirectory(home, packageDir, normalized);
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(temp, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function installExtension(home: string, spec: string): Promise<InstalledRef> {
|
|
126
|
+
const candidate = resolve(spec);
|
|
127
|
+
let isLocal = false;
|
|
128
|
+
try {
|
|
129
|
+
await stat(join(candidate, "omt.manifest.json"));
|
|
130
|
+
isLocal = true;
|
|
131
|
+
} catch {
|
|
132
|
+
// Not a local extension directory; interpret the argument as an npm spec.
|
|
133
|
+
}
|
|
134
|
+
return isLocal ? installLocalExtension(home, candidate) : installNpmExtension(home, spec);
|
|
135
|
+
}
|
package/src/extension/loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import type { ExtensionDefinition } from "@oh-my-tool/sdk";
|
|
3
|
-
import { OmtError } from "../
|
|
3
|
+
import { RuntimeError as OmtError } from "../runtime/errors";
|
|
4
4
|
import type { InstalledExtension } from "./discovery";
|
|
5
5
|
import { validateHandlers } from "./manifest";
|
|
6
6
|
|