@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-tool/cli",
3
- "version": "0.3.1",
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.1",
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: string;
10
- host: string;
11
- port: number;
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 connections = Object.entries(config.extensions)
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.connections)
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
- environment: connection.environment,
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
- for (const connection of list.connections) {
84
- if (connection.extension !== "redis" && connection.extension !== "mysql") {
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(`${connection.extension}.ping`, { connection: connection.name });
90
- checks.push(result.ok
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?.code === "TOOL_NOT_FOUND"
93
- ? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED", durationMs: Date.now() - started }
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 { installLocalExtension, type InstalledRef } from "../../extension/install";
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 installLocalExtension(homeDir(), spec);
22
+ return installExtension(homeDir(), spec);
23
23
  }
24
24
 
@@ -1,25 +1,19 @@
1
1
  import { coerceInput } from "../parseArgs";
2
2
  import { withRuntime } from "../context";
3
- import type { OmtResult } from "../../core/result";
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<OmtResult> {
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(async (runtime) => {
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
  }
@@ -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 nativeTarget = options.targetTool !== undefined && discoverExtensions(paths.home)
28
- .some((extension) => extension.manifest.tools.some((tool) => tool.name === options.targetTool));
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 = [new NativeExtensionProvider(paths), ...mcpProviders];
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> install an extension from a local dir
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 `omt integrate repair` to recreate broken links");
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 `omt integrate --force` only if you accept replacing it");
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, tool: `connection.${action}`, data: result, meta: {} };
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, tool: "config.check", data: result, meta: {} };
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 { OmtResult } from "../core/result";
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: OmtResult): string {
6
- const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.tool}`];
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.data)) {
13
+ if (isRowsResult(result.output)) {
14
14
  lines.push("rows:");
15
- for (const row of result.data.rows) lines.push(formatRow(row));
16
- lines.push(`row_count: ${result.data.rows.length}`);
17
- if (result.data.truncated === true) lines.push("truncated: true");
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.data, 2);
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: OmtResult, format: OutputFormat): string {
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: OmtResult, delimiter: string): string {
45
+ function formatDelimited(result: ExecutionResult, delimiter: string): string {
46
46
  if (!result.ok) return formatAiResult(result);
47
- const rows = isRowsResult(result.data)
48
- ? { columns: result.data.columns, rows: result.data.rows }
49
- : findObjectArray(result.data);
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
  }
@@ -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: string;
7
- host: string;
8
- port: number;
9
- database: string;
10
- username: string;
11
- secret: string;
12
- tls: boolean;
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 stringField = (key: string, fallback = ""): string => {
48
- const entry = raw[key];
49
- if (entry === undefined) return fallback;
50
- if (typeof entry !== "string") invalidConnection(`${path}.${key}`, "must be a string");
51
- return entry;
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: stringField("environment"),
64
- host,
65
- port,
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, Omit<ConnectionConfig, "secret"> & { secretConfigured: boolean }> > {
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
- host: connection.host,
200
- port: connection.port,
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
+ }
@@ -1,24 +1,135 @@
1
- import { cp, mkdir, readFile } from "node:fs/promises";
2
- import { join } from "node:path";
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 async function installLocalExtension(home: string, srcDir: string): Promise<InstalledRef> {
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) => !s.includes("node_modules"),
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
+ }
@@ -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 "../core/registry";
3
+ import { RuntimeError as OmtError } from "../runtime/errors";
4
4
  import type { InstalledExtension } from "./discovery";
5
5
  import { validateHandlers } from "./manifest";
6
6