@oh-my-tool/cli 0.3.1 → 0.3.2

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.2",
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.2",
33
33
  "open": "11.0.0"
34
34
  },
35
35
  "engines": {
@@ -81,16 +81,12 @@ export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
81
81
  return withRuntime(async (runtime) => {
82
82
  const checks: ConnectionCheck[] = [];
83
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
- }
88
84
  const started = Date.now();
89
85
  const result = await runtime.run(`${connection.extension}.ping`, { connection: connection.name });
90
86
  checks.push(result.ok
91
87
  ? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
92
88
  : result.error?.code === "TOOL_NOT_FOUND"
93
- ? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED", durationMs: Date.now() - started }
89
+ ? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" }
94
90
  : { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
95
91
  }
96
92
  return { checks, count: checks.length };
@@ -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
 
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
@@ -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
+ }
@@ -20,12 +20,18 @@ export function parseManifest(raw: string): ExtensionManifest {
20
20
  }
21
21
 
22
22
  export function validateManifest(manifest: ExtensionManifest): void {
23
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(manifest.id)) {
24
+ throw new ManifestError("manifest 'id' must contain only lowercase letters, numbers, underscores, and hyphens");
25
+ }
23
26
  if (!manifest.name || typeof manifest.name !== "string") {
24
27
  throw new ManifestError("manifest must contain a string 'name'");
25
28
  }
26
29
  if (!manifest.version || typeof manifest.version !== "string") {
27
30
  throw new ManifestError("manifest must contain a string 'version'");
28
31
  }
32
+ if (!FULL_VERSION_RE.test(manifest.version)) {
33
+ throw new ManifestError(`invalid manifest version '${manifest.version}'`);
34
+ }
29
35
  if (!manifest.sdkVersion || typeof manifest.sdkVersion !== "string") {
30
36
  throw new ManifestError("manifest must contain a string 'sdkVersion'");
31
37
  }
@@ -112,4 +118,4 @@ export function validateHandlers(
112
118
  );
113
119
  }
114
120
  }
115
- }
121
+ }
@@ -33,11 +33,13 @@ import {
33
33
  export interface McpOAuthProviderOptions {
34
34
  readonly redirectUrl?: URL;
35
35
  readonly interactive?: boolean;
36
+ readonly forceDynamicRegistration?: boolean;
36
37
  }
37
38
 
38
39
  export interface McpOAuthClientProvider extends OAuthClientProvider {
39
40
  readonly redirectUrl: URL;
40
41
  readonly secretValues: readonly string[];
42
+ readonly clientConfigurationFingerprint: string;
41
43
  authorizationUrl(): URL | undefined;
42
44
  authorizationState(): string | undefined;
43
45
  clearVerifier(): Promise<void>;
@@ -65,6 +67,7 @@ function oauthAuthRequired(serverId: string): RuntimeError {
65
67
 
66
68
  class PersistentMcpOAuthProvider implements McpOAuthClientProvider {
67
69
  readonly clientMetadata: OAuthClientMetadata;
70
+ readonly clientConfigurationFingerprint: string;
68
71
  private pendingAuthorizationUrl: URL | undefined;
69
72
  private pendingState: string | undefined;
70
73
 
@@ -85,6 +88,12 @@ class PersistentMcpOAuthProvider implements McpOAuthClientProvider {
85
88
  client_name: "Oh My Tool",
86
89
  ...(config.auth.scopes.length === 0 ? {} : { scope: config.auth.scopes.join(" ") }),
87
90
  };
91
+ this.clientConfigurationFingerprint = JSON.stringify({
92
+ redirectUrl: redirectUrl.toString(),
93
+ clientId: config.auth.clientId ?? null,
94
+ clientSecretConfigured: config.auth.clientSecretSecret !== undefined,
95
+ metadata: this.clientMetadata,
96
+ });
88
97
  }
89
98
 
90
99
  state(): string {
@@ -172,6 +181,9 @@ export async function createMcpOAuthProvider(
172
181
  const interactive = options.interactive ?? false;
173
182
  const store = createMcpOAuthStore(serverId, secrets);
174
183
  if (!interactive && await store.tokens() === undefined) throw oauthAuthRequired(serverId);
184
+ if (options.forceDynamicRegistration === true && config.auth.clientId === undefined) {
185
+ await store.clear("client");
186
+ }
175
187
  let preRegisteredClient: StoredOAuthClientInformation | undefined;
176
188
  const secretValues: string[] = [];
177
189
  if (config.auth.clientId !== undefined) {
@@ -191,7 +203,7 @@ export async function createMcpOAuthProvider(
191
203
  client_secret: clientSecret,
192
204
  };
193
205
  }
194
- return new PersistentMcpOAuthProvider(
206
+ const provider = new PersistentMcpOAuthProvider(
195
207
  serverId,
196
208
  config,
197
209
  store,
@@ -200,6 +212,14 @@ export async function createMcpOAuthProvider(
200
212
  secretValues,
201
213
  interactive,
202
214
  );
215
+ const previousFingerprint = await store.clientConfiguration();
216
+ if (previousFingerprint !== provider.clientConfigurationFingerprint) {
217
+ if (previousFingerprint !== undefined) {
218
+ await store.clear("client");
219
+ }
220
+ }
221
+ await store.saveClientConfiguration(provider.clientConfigurationFingerprint);
222
+ return provider;
203
223
  }
204
224
 
205
225
  function oauthConfig(serverId: string, config: McpHttpServerConfig): OAuthMcpServerConfig {
@@ -287,6 +307,7 @@ export async function authorizeMcpServer(
287
307
  const activeProvider = await createMcpOAuthProvider(serverId, validated, secrets, {
288
308
  redirectUrl: callback.redirectUrl,
289
309
  interactive: true,
310
+ forceDynamicRegistration: true,
290
311
  });
291
312
  provider = activeProvider;
292
313
  firstClient = createClient({ name: "oh-my-tool", version: VERSION });
@@ -11,6 +11,8 @@ export interface McpOAuthStore {
11
11
  saveTokens(tokens: StoredOAuthTokens): Promise<void>;
12
12
  clientInformation(): Promise<StoredOAuthClientInformation | undefined>;
13
13
  saveClientInformation(info: StoredOAuthClientInformation): Promise<void>;
14
+ clientConfiguration(): Promise<string | undefined>;
15
+ saveClientConfiguration(fingerprint: string): Promise<void>;
14
16
  codeVerifier(): Promise<string | undefined>;
15
17
  saveCodeVerifier(value: string): Promise<void>;
16
18
  discoveryState(): Promise<OAuthDiscoveryState | undefined>;
@@ -19,13 +21,14 @@ export interface McpOAuthStore {
19
21
  clearAll(): Promise<void>;
20
22
  }
21
23
 
22
- type CredentialScope = "tokens" | "client" | "verifier" | "discovery";
24
+ type CredentialScope = "tokens" | "client" | "client-config" | "verifier" | "discovery";
23
25
 
24
26
  function credentialNames(serverId: string): Record<CredentialScope, string> {
25
27
  const prefix = `mcp:${serverId}:oauth`;
26
28
  return {
27
29
  tokens: `${prefix}:tokens`,
28
30
  client: `${prefix}:client`,
31
+ "client-config": `${prefix}:client-config`,
29
32
  verifier: `${prefix}:verifier`,
30
33
  discovery: `${prefix}:discovery`,
31
34
  };
@@ -72,6 +75,14 @@ export class SecretMcpOAuthStore implements McpOAuthStore {
72
75
  return this.secrets.set(this.names.client, JSON.stringify(info));
73
76
  }
74
77
 
78
+ clientConfiguration(): Promise<string | undefined> {
79
+ return this.secrets.get(this.names["client-config"]);
80
+ }
81
+
82
+ saveClientConfiguration(fingerprint: string): Promise<void> {
83
+ return this.secrets.set(this.names["client-config"], fingerprint);
84
+ }
85
+
75
86
  codeVerifier(): Promise<string | undefined> {
76
87
  return this.secrets.get(this.names.verifier);
77
88
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.3.1";
1
+ export const VERSION = "0.3.2";