@finch.app/minitools 0.1.8 → 0.1.9
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/README.md +14 -13
- package/bin/minitools.mjs +303 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,26 +15,21 @@ npx @finch.app/minitools add ./my-tool
|
|
|
15
15
|
npx @finch.app/minitools add ./my-tool.zip
|
|
16
16
|
npx @finch.app/minitools add https://github.com/user/repo/archive/refs/heads/main.zip
|
|
17
17
|
|
|
18
|
-
# Install to the current project
|
|
19
|
-
npx @finch.app/minitools add @finch.app/mcp-client --cwd
|
|
20
|
-
|
|
21
|
-
# Install to a specific project path
|
|
22
|
-
npx @finch.app/minitools add @finch.app/mcp-client --cwd /path/to/project
|
|
23
|
-
|
|
24
18
|
# Install globally (~/.finch/extensions/)
|
|
19
|
+
# Default installs go to the personal Finch workspace extension directory.
|
|
25
20
|
npx @finch.app/minitools add @finch.app/mcp-client --global
|
|
26
21
|
|
|
27
22
|
# List installed mini tools (id, version, enabled/disabled, name, path)
|
|
28
23
|
npx @finch.app/minitools list
|
|
29
24
|
npx @finch.app/minitools list --global
|
|
30
25
|
|
|
31
|
-
# Remove
|
|
26
|
+
# Remove a mini tool
|
|
32
27
|
npx @finch.app/minitools remove mcp-client
|
|
33
28
|
|
|
34
29
|
# Show install paths
|
|
35
30
|
npx @finch.app/minitools where
|
|
36
31
|
|
|
37
|
-
# Validate
|
|
32
|
+
# Validate a mini tool package
|
|
38
33
|
npx @finch.app/minitools doctor ./my-tool
|
|
39
34
|
```
|
|
40
35
|
|
|
@@ -42,12 +37,14 @@ npx @finch.app/minitools doctor ./my-tool
|
|
|
42
37
|
|
|
43
38
|
| Flag | Path | Scope |
|
|
44
39
|
|---|---|---|
|
|
45
|
-
| *(default)* | `<workspace.json#finchHomeDir>/.finch/extensions/<id>/` |
|
|
46
|
-
| `--cwd` | `<process.cwd()>/.finch/extensions/<id>/` | Current project |
|
|
47
|
-
| `--cwd path` | `<path>/.finch/extensions/<id>/` | Specific project |
|
|
40
|
+
| *(default)* | `<workspace.json#finchHomeDir>/.finch/extensions/<id>/` | Personal Finch workspace |
|
|
48
41
|
| `--global` | `~/.finch/extensions/<id>/` | All Finch sessions |
|
|
49
42
|
|
|
50
|
-
The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`.
|
|
43
|
+
The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`. There is no project/`--cwd` scope for mini tools; use the personal default or `--global`.
|
|
44
|
+
|
|
45
|
+
## Registry and downloads
|
|
46
|
+
|
|
47
|
+
Pinned npm specs such as `@scope/tool@1.2.3` skip npm registry metadata and download directly from `https://community.finchwork.app/download/minitool/<package>/<version>`. Unpinned specs such as `@scope/tool` or `@scope/tool@latest` still use npm registry metadata to resolve the concrete version first; `--registry <url>` only affects that metadata lookup, not the final package download.
|
|
51
48
|
|
|
52
49
|
## Mini tool package
|
|
53
50
|
|
|
@@ -62,11 +59,15 @@ A Finch mini tool is an npm-style package with `package.json#finch`:
|
|
|
62
59
|
"finch": {
|
|
63
60
|
"manifestVersion": 1,
|
|
64
61
|
"id": "my-tool",
|
|
65
|
-
"
|
|
62
|
+
"name": "My Tool",
|
|
66
63
|
"main": "dist/index.js",
|
|
67
64
|
"activationEvents": ["onStartup"]
|
|
68
65
|
}
|
|
69
66
|
}
|
|
70
67
|
```
|
|
71
68
|
|
|
69
|
+
`doctor` reports fatal issues and warnings. `add` / `update` block packages with fatal validation issues such as a missing `package.json#finch`, invalid `id`, unsupported `manifestVersion`, malformed contribution declarations, or a missing entry file.
|
|
70
|
+
|
|
71
|
+
Downloads served by `https://community.finchwork.app/download/minitool/<package>/<version>` are cached under `~/.finch/cache/minitools/` by package and version, so reinstalling or updating the same version avoids another network download.
|
|
72
|
+
|
|
72
73
|
`add` installs the mini tool but does not enable or grant permissions. Open Finch → Toolcase → Tools to review permissions and enable it.
|
package/bin/minitools.mjs
CHANGED
|
@@ -9,12 +9,15 @@ import {
|
|
|
9
9
|
existsSync, mkdirSync, readdirSync, cpSync, rmSync,
|
|
10
10
|
readFileSync, writeFileSync,
|
|
11
11
|
} from 'node:fs';
|
|
12
|
-
import { join, resolve, basename } from 'node:path';
|
|
12
|
+
import { isAbsolute, join, resolve, basename } from 'node:path';
|
|
13
13
|
import { homedir, tmpdir } from 'node:os';
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
15
|
import { randomUUID } from 'node:crypto';
|
|
16
16
|
|
|
17
17
|
const LOCK_FILE = '.plugins-lock.json';
|
|
18
|
+
const SUPPORTED_MANIFEST_VERSION = 1;
|
|
19
|
+
const EXTENSION_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
20
|
+
const KNOWN_EXTENSION_TYPES = new Set(['official', 'community', 'local']);
|
|
18
21
|
const LEGACY_NPM_PACKAGE_RENAMES = new Map([
|
|
19
22
|
['@finch.app/mcp-bridge', '@finch.app/mcp-client'],
|
|
20
23
|
]);
|
|
@@ -56,6 +59,9 @@ function targetDir(opts) {
|
|
|
56
59
|
function pluginsStatePath() {
|
|
57
60
|
return join(finchRuntimeHome(), 'extensions.json');
|
|
58
61
|
}
|
|
62
|
+
function miniToolCacheDir() {
|
|
63
|
+
return join(finchRuntimeHome(), 'cache', 'minitools');
|
|
64
|
+
}
|
|
59
65
|
function lockPath(dir) {
|
|
60
66
|
return join(dir, LOCK_FILE);
|
|
61
67
|
}
|
|
@@ -90,30 +96,221 @@ function readPackageJson(dir) {
|
|
|
90
96
|
try { return JSON.parse(readFileSync(file, 'utf-8')); } catch { return null; }
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
function
|
|
99
|
+
function localizedValue(value, fallback = '') {
|
|
100
|
+
if (typeof value === 'string') return value;
|
|
101
|
+
if (value && typeof value === 'object') {
|
|
102
|
+
return value.default ?? value['en-US'] ?? value['zh-CN'] ?? fallback;
|
|
103
|
+
}
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isLocalizedString(value) {
|
|
108
|
+
if (value == null) return true;
|
|
109
|
+
if (typeof value === 'string') return true;
|
|
110
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
111
|
+
return Object.entries(value).every(([locale, text]) => (
|
|
112
|
+
typeof locale === 'string' && typeof text === 'string'
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateStringField(value, field, diagnostics, { required = false, localized = false } = {}) {
|
|
117
|
+
if (value == null) {
|
|
118
|
+
if (required) diagnostics.fatal.push(`${field} 缺失`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const ok = localized ? isLocalizedString(value) : typeof value === 'string';
|
|
122
|
+
if (!ok) diagnostics.fatal.push(`${field} 必须是${localized ? '字符串或本地化字符串对象' : '字符串'}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function validateStringArray(value, field, diagnostics) {
|
|
126
|
+
if (value == null) return;
|
|
127
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
|
|
128
|
+
diagnostics.warning.push(`${field} 应为非空字符串数组`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateObject(value, field, diagnostics) {
|
|
133
|
+
if (value == null) return true;
|
|
134
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
135
|
+
diagnostics.warning.push(`${field} 应为对象`);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateContributes(contributes, diagnostics) {
|
|
142
|
+
if (contributes == null) return;
|
|
143
|
+
if (!validateObject(contributes, 'finch.contributes', diagnostics)) return;
|
|
144
|
+
|
|
145
|
+
if (contributes.tools != null && typeof contributes.tools !== 'boolean') {
|
|
146
|
+
diagnostics.warning.push('finch.contributes.tools 应为 boolean');
|
|
147
|
+
}
|
|
148
|
+
if (contributes.skills != null && typeof contributes.skills !== 'boolean') {
|
|
149
|
+
diagnostics.warning.push('finch.contributes.skills 应为 boolean');
|
|
150
|
+
}
|
|
151
|
+
if (contributes.composerActions != null) {
|
|
152
|
+
if (!Array.isArray(contributes.composerActions)) {
|
|
153
|
+
diagnostics.fatal.push('finch.contributes.composerActions 必须是数组');
|
|
154
|
+
} else {
|
|
155
|
+
for (const [index, action] of contributes.composerActions.entries()) {
|
|
156
|
+
const prefix = `finch.contributes.composerActions[${index}]`;
|
|
157
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
158
|
+
diagnostics.fatal.push(`${prefix} 必须是对象`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (typeof action.id !== 'string' || !action.id.trim()) {
|
|
162
|
+
diagnostics.fatal.push(`${prefix}.id 缺失或不是字符串`);
|
|
163
|
+
}
|
|
164
|
+
validateStringField(action.icon, `${prefix}.icon`, diagnostics);
|
|
165
|
+
validateStringField(action.tooltip, `${prefix}.tooltip`, diagnostics, { localized: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (contributes.iconPacks != null) {
|
|
170
|
+
if (!Array.isArray(contributes.iconPacks)) {
|
|
171
|
+
diagnostics.fatal.push('finch.contributes.iconPacks 必须是数组');
|
|
172
|
+
} else {
|
|
173
|
+
for (const [index, pack] of contributes.iconPacks.entries()) {
|
|
174
|
+
const prefix = `finch.contributes.iconPacks[${index}]`;
|
|
175
|
+
if (!pack || typeof pack !== 'object' || Array.isArray(pack)) {
|
|
176
|
+
diagnostics.fatal.push(`${prefix} 必须是对象`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (typeof pack.id !== 'string' || !pack.id.trim()) diagnostics.fatal.push(`${prefix}.id 缺失或不是字符串`);
|
|
180
|
+
validateStringField(pack.label, `${prefix}.label`, diagnostics, { localized: true });
|
|
181
|
+
validateStringField(pack.description, `${prefix}.description`, diagnostics, { localized: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (contributes.icons != null && validateObject(contributes.icons, 'finch.contributes.icons', diagnostics)) {
|
|
186
|
+
for (const [iconId, icon] of Object.entries(contributes.icons)) {
|
|
187
|
+
if (!icon || typeof icon !== 'object' || Array.isArray(icon)) {
|
|
188
|
+
diagnostics.fatal.push(`finch.contributes.icons.${iconId} 必须是对象`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (typeof icon.svg !== 'string' || !icon.svg.trim()) diagnostics.fatal.push(`finch.contributes.icons.${iconId}.svg 缺失或不是字符串`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (contributes.mcpServers != null && !Array.isArray(contributes.mcpServers)) {
|
|
195
|
+
diagnostics.fatal.push('finch.contributes.mcpServers 必须是数组');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validatePermissions(permissions, diagnostics) {
|
|
200
|
+
if (permissions == null) return;
|
|
201
|
+
if (!validateObject(permissions, 'finch.permissions', diagnostics)) return;
|
|
202
|
+
if (permissions.filesystem != null && !['none', 'read', 'write'].includes(permissions.filesystem)) {
|
|
203
|
+
diagnostics.warning.push('finch.permissions.filesystem 建议使用 none/read/write');
|
|
204
|
+
}
|
|
205
|
+
if (permissions.network != null && typeof permissions.network !== 'boolean') {
|
|
206
|
+
diagnostics.warning.push('finch.permissions.network 应为 boolean');
|
|
207
|
+
}
|
|
208
|
+
if (permissions.shell != null && typeof permissions.shell !== 'boolean') {
|
|
209
|
+
diagnostics.warning.push('finch.permissions.shell 应为 boolean');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateCapabilitySpec(spec, field, diagnostics) {
|
|
214
|
+
if (spec == null) return;
|
|
215
|
+
if (!validateObject(spec, field, diagnostics)) return;
|
|
216
|
+
validateStringArray(spec.capabilities, `${field}.capabilities`, diagnostics);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateMiniToolPackage(dir, { lintSource = false } = {}) {
|
|
220
|
+
const diagnostics = { fatal: [], warning: [] };
|
|
94
221
|
const pkg = readPackageJson(dir);
|
|
95
|
-
|
|
96
|
-
|
|
222
|
+
if (!pkg) {
|
|
223
|
+
diagnostics.fatal.push('缺少 package.json,或 package.json 不是合法 JSON');
|
|
224
|
+
return { diagnostics, info: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const manifest = pkg.finch;
|
|
228
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
229
|
+
diagnostics.fatal.push('缺少 package.json#finch,或 finch manifest 不是对象');
|
|
230
|
+
return { diagnostics, info: null };
|
|
231
|
+
}
|
|
232
|
+
|
|
97
233
|
const id = String(manifest.id ?? pkg.name ?? '').trim();
|
|
98
|
-
if (!id)
|
|
99
|
-
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
234
|
+
if (!id) {
|
|
235
|
+
diagnostics.fatal.push('package.json#finch 缺少 id');
|
|
236
|
+
} else if (!EXTENSION_ID_RE.test(id) || id === '.' || id === '..') {
|
|
237
|
+
diagnostics.fatal.push(`package.json#finch.id 不合法: ${id}(只能使用字母、数字、点、下划线、短横线,且不能包含路径分隔符)`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (manifest.manifestVersion !== undefined && manifest.manifestVersion !== SUPPORTED_MANIFEST_VERSION) {
|
|
241
|
+
diagnostics.fatal.push(`不支持的 manifestVersion: ${manifest.manifestVersion}(当前 Finch 支持 ${SUPPORTED_MANIFEST_VERSION})`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
validateStringField(manifest.name, 'finch.name', diagnostics, { localized: true });
|
|
245
|
+
validateStringField(manifest.displayName, 'finch.displayName', diagnostics, { localized: true });
|
|
246
|
+
validateStringField(manifest.description, 'finch.description', diagnostics, { localized: true });
|
|
247
|
+
validateStringField(manifest.systemPrompt, 'finch.systemPrompt', diagnostics, { localized: true });
|
|
248
|
+
validateStringArray(manifest.categories, 'finch.categories', diagnostics);
|
|
249
|
+
|
|
250
|
+
const extensionType = manifest.miniToolType ?? manifest.extensionType;
|
|
251
|
+
if (extensionType != null && (typeof extensionType !== 'string' || !extensionType.trim())) {
|
|
252
|
+
diagnostics.warning.push('finch.miniToolType/extensionType 应为字符串');
|
|
253
|
+
} else if (typeof extensionType === 'string' && !KNOWN_EXTENSION_TYPES.has(extensionType)) {
|
|
254
|
+
diagnostics.warning.push(`未知 extensionType: ${extensionType}(常见值为 official/community/local)`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (manifest.install != null && validateObject(manifest.install, 'finch.install', diagnostics)) {
|
|
258
|
+
const scope = manifest.install.preferredScope;
|
|
259
|
+
if (scope != null && scope !== 'global' && scope !== 'personal') {
|
|
260
|
+
diagnostics.warning.push('finch.install.preferredScope 应为 global 或 personal');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
validatePermissions(manifest.permissions, diagnostics);
|
|
265
|
+
validateCapabilitySpec(manifest.provides, 'finch.provides', diagnostics);
|
|
266
|
+
validateCapabilitySpec(manifest.requires, 'finch.requires', diagnostics);
|
|
267
|
+
validateContributes(manifest.contributes, diagnostics);
|
|
268
|
+
|
|
269
|
+
const mainValue = manifest.main ?? pkg.main ?? 'dist/index.js';
|
|
270
|
+
if (typeof mainValue !== 'string' || !mainValue.trim()) {
|
|
271
|
+
diagnostics.fatal.push('入口 main 必须是非空字符串(finch.main 或 package.json#main)');
|
|
272
|
+
}
|
|
273
|
+
const main = typeof mainValue === 'string' && mainValue.trim() ? mainValue.trim() : 'dist/index.js';
|
|
274
|
+
const entry = isAbsolute(main) ? main : join(dir, main);
|
|
275
|
+
if (!existsSync(entry)) diagnostics.fatal.push(`入口文件不存在: ${main}(请先构建小工具)`);
|
|
276
|
+
|
|
277
|
+
const recommended = [];
|
|
278
|
+
if (manifest.name == null && manifest.displayName == null) recommended.push('name');
|
|
279
|
+
if (manifest.description == null) recommended.push('description');
|
|
280
|
+
if (manifest.miniToolType == null && manifest.extensionType == null) recommended.push('miniToolType');
|
|
281
|
+
if (recommended.length) diagnostics.warning.push(`manifest 建议补充字段: ${recommended.join(', ')}`);
|
|
282
|
+
|
|
283
|
+
if (lintSource) diagnostics.warning.push(...lintExtensionSource(dir));
|
|
284
|
+
|
|
103
285
|
const nameField = manifest.name ?? manifest.displayName;
|
|
286
|
+
const displayName = localizedValue(nameField, pkg.name ?? id).trim() || id;
|
|
104
287
|
return {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
288
|
+
diagnostics,
|
|
289
|
+
info: {
|
|
290
|
+
id,
|
|
291
|
+
name: pkg.name ?? id,
|
|
292
|
+
version: pkg.version ?? '0.0.0',
|
|
293
|
+
displayName,
|
|
294
|
+
main,
|
|
295
|
+
},
|
|
112
296
|
};
|
|
113
297
|
}
|
|
114
298
|
|
|
299
|
+
function pluginInfo(dir) {
|
|
300
|
+
const result = validateMiniToolPackage(dir);
|
|
301
|
+
if (!result.info && result.diagnostics.fatal.length > 0) {
|
|
302
|
+
const pkg = readPackageJson(dir);
|
|
303
|
+
return pkg && Object.prototype.hasOwnProperty.call(pkg, 'finch')
|
|
304
|
+
? { error: result.diagnostics.fatal[0] }
|
|
305
|
+
: null;
|
|
306
|
+
}
|
|
307
|
+
if (result.diagnostics.fatal.length > 0) return { error: result.diagnostics.fatal[0], id: result.info?.id };
|
|
308
|
+
return result.info;
|
|
309
|
+
}
|
|
310
|
+
|
|
115
311
|
function findPluginDirs(root, maxDepth = 4) {
|
|
116
312
|
const found = [];
|
|
313
|
+
const invalid = [];
|
|
117
314
|
const seen = new Set();
|
|
118
315
|
function visit(dir, depth) {
|
|
119
316
|
const key = dir.toLowerCase();
|
|
@@ -121,6 +318,7 @@ function findPluginDirs(root, maxDepth = 4) {
|
|
|
121
318
|
seen.add(key);
|
|
122
319
|
const info = pluginInfo(dir);
|
|
123
320
|
if (info && !info.error) found.push(dir);
|
|
321
|
+
else if (info?.error) invalid.push({ dir, error: info.error });
|
|
124
322
|
if (depth <= 0) return;
|
|
125
323
|
let entries = [];
|
|
126
324
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
@@ -131,6 +329,7 @@ function findPluginDirs(root, maxDepth = 4) {
|
|
|
131
329
|
}
|
|
132
330
|
}
|
|
133
331
|
visit(root, maxDepth);
|
|
332
|
+
found.invalid = invalid;
|
|
134
333
|
return found;
|
|
135
334
|
}
|
|
136
335
|
|
|
@@ -143,7 +342,7 @@ function installExtensionDir(srcDir, destRoot, lockSource) {
|
|
|
143
342
|
cpSync(srcDir, dest, { recursive: true, force: true, dereference: false });
|
|
144
343
|
recordInstall(destRoot, info.id, lockSource);
|
|
145
344
|
console.log(`✓ Added "${info.displayName}" (${info.id}) → ${dest}`);
|
|
146
|
-
console.log(' Installed only. Open Finch → Toolcase →
|
|
345
|
+
console.log(' Installed only. Open Finch → Toolcase → Mini Tool to review permissions and enable.');
|
|
147
346
|
return info.id;
|
|
148
347
|
}
|
|
149
348
|
|
|
@@ -200,6 +399,46 @@ async function downloadFile(url, destPath) {
|
|
|
200
399
|
}
|
|
201
400
|
}
|
|
202
401
|
|
|
402
|
+
function communityMiniToolFromUrl(url) {
|
|
403
|
+
let parsed;
|
|
404
|
+
try { parsed = new URL(url); } catch { return null; }
|
|
405
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== 'community.finchwork.app') return null;
|
|
406
|
+
const parts = parsed.pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
|
|
407
|
+
if (parts[0] !== 'download' || parts[1] !== 'minitool' || parts.length < 4) return null;
|
|
408
|
+
const version = parts.at(-1);
|
|
409
|
+
const packageName = parts.slice(2, -1).join('/');
|
|
410
|
+
if (!packageName || !version || version === 'latest') return null;
|
|
411
|
+
return { packageName, version };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function safeCacheSegment(value) {
|
|
415
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function communityMiniToolCachePath(meta) {
|
|
419
|
+
return join(miniToolCacheDir(), `${safeCacheSegment(meta.packageName)}-${safeCacheSegment(meta.version)}.tgz`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function downloadFileWithCache(url, destPath) {
|
|
423
|
+
const communityMeta = communityMiniToolFromUrl(url);
|
|
424
|
+
if (!communityMeta) {
|
|
425
|
+
await downloadFile(url, destPath);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const cachePath = communityMiniToolCachePath(communityMeta);
|
|
430
|
+
if (existsSync(cachePath)) {
|
|
431
|
+
cpSync(cachePath, destPath);
|
|
432
|
+
console.log(` Using cached package ${communityMeta.packageName}@${communityMeta.version}`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await downloadFile(url, destPath);
|
|
437
|
+
mkdirSync(miniToolCacheDir(), { recursive: true });
|
|
438
|
+
cpSync(destPath, cachePath);
|
|
439
|
+
console.log(` Cached package ${communityMeta.packageName}@${communityMeta.version}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
203
442
|
/**
|
|
204
443
|
* Extract a zip archive to destDir using the system `unzip` command (macOS / Linux built-in).
|
|
205
444
|
*/
|
|
@@ -278,6 +517,14 @@ function officialMiniToolDownloadUrl(packageName, version) {
|
|
|
278
517
|
|
|
279
518
|
async function resolveNpmTarball(spec) {
|
|
280
519
|
const { name, version } = parseNpmSpec(spec);
|
|
520
|
+
if (version && version !== 'latest') {
|
|
521
|
+
return {
|
|
522
|
+
package: name,
|
|
523
|
+
version,
|
|
524
|
+
tarball: officialMiniToolDownloadUrl(name, version),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
281
528
|
const url = npmMetadataUrl(name, version);
|
|
282
529
|
let meta;
|
|
283
530
|
try {
|
|
@@ -311,17 +558,20 @@ async function installFromZip(src, dest, isUrl) {
|
|
|
311
558
|
try {
|
|
312
559
|
if (isUrl) {
|
|
313
560
|
console.log(` Downloading ${src} …`);
|
|
314
|
-
await
|
|
561
|
+
await downloadFileWithCache(src, zipPath);
|
|
315
562
|
} else {
|
|
316
563
|
// Local zip — copy to tmp so we have a consistent path
|
|
317
564
|
const abs = resolve(expandHome(src));
|
|
318
565
|
if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
|
|
319
566
|
cpSync(abs, zipPath);
|
|
320
567
|
}
|
|
321
|
-
console.log(' Extracting …');
|
|
568
|
+
// console.log(' Extracting …');
|
|
322
569
|
extractZip(zipPath, extractDir);
|
|
323
570
|
const found = findPluginDirs(extractDir, 5);
|
|
324
|
-
if (found.length === 0)
|
|
571
|
+
if (found.length === 0) {
|
|
572
|
+
if (found.invalid?.length) throw new Error(`Invalid Finch extension inside the zip archive: ${found.invalid[0].error}`);
|
|
573
|
+
throw new Error('No Finch extension found inside the zip archive.');
|
|
574
|
+
}
|
|
325
575
|
const lockSource = isUrl
|
|
326
576
|
? { type: 'zip', url: src }
|
|
327
577
|
: { type: 'zip', localPath: resolve(expandHome(src)) };
|
|
@@ -339,16 +589,19 @@ async function installFromTgz(src, dest, { isUrl, lockSource }) {
|
|
|
339
589
|
try {
|
|
340
590
|
if (isUrl) {
|
|
341
591
|
console.log(` Downloading ${src} …`);
|
|
342
|
-
await
|
|
592
|
+
await downloadFileWithCache(src, tgzPath);
|
|
343
593
|
} else {
|
|
344
594
|
const abs = resolve(expandHome(src));
|
|
345
595
|
if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
|
|
346
596
|
cpSync(abs, tgzPath);
|
|
347
597
|
}
|
|
348
|
-
console.log(' Extracting …');
|
|
598
|
+
// console.log(' Extracting …');
|
|
349
599
|
extractTgz(tgzPath, extractDir);
|
|
350
600
|
const found = findPluginDirs(extractDir, 5);
|
|
351
|
-
if (found.length === 0)
|
|
601
|
+
if (found.length === 0) {
|
|
602
|
+
if (found.invalid?.length) throw new Error(`Invalid Finch extension inside the tgz archive: ${found.invalid[0].error}`);
|
|
603
|
+
throw new Error('No Finch extension found inside the tgz archive.');
|
|
604
|
+
}
|
|
352
605
|
const source = lockSource ?? (isUrl
|
|
353
606
|
? { type: 'tgz', url: src }
|
|
354
607
|
: { type: 'tgz', localPath: resolve(expandHome(src)) });
|
|
@@ -393,7 +646,10 @@ async function cmdAdd(src, opts) {
|
|
|
393
646
|
return;
|
|
394
647
|
}
|
|
395
648
|
const found = findPluginDirs(abs, 3);
|
|
396
|
-
if (found.length === 0)
|
|
649
|
+
if (found.length === 0) {
|
|
650
|
+
if (found.invalid?.length) throw new Error(`Invalid Finch extension in the given directory: ${found.invalid[0].error}`);
|
|
651
|
+
throw new Error('No Finch extension found in the given directory.');
|
|
652
|
+
}
|
|
397
653
|
for (const dir of found) installExtensionDir(dir, dest, { type: 'local', localPath: dir });
|
|
398
654
|
return;
|
|
399
655
|
}
|
|
@@ -532,22 +788,20 @@ function lintExtensionSource(root) {
|
|
|
532
788
|
|
|
533
789
|
function cmdDoctor(src = '.') {
|
|
534
790
|
const abs = resolve(expandHome(src));
|
|
535
|
-
const info =
|
|
536
|
-
|
|
537
|
-
if (info
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
791
|
+
const { diagnostics, info } = validateMiniToolPackage(abs, { lintSource: true });
|
|
792
|
+
|
|
793
|
+
if (info) {
|
|
794
|
+
console.log(`Finch mini tool: ${info.displayName}`);
|
|
795
|
+
console.log(` id: ${info.id || '(missing)'}`);
|
|
796
|
+
console.log(` version: ${info.version}`);
|
|
797
|
+
console.log(` main: ${info.main}`);
|
|
798
|
+
} else {
|
|
799
|
+
console.log(`Finch mini tool: ${abs}`);
|
|
800
|
+
}
|
|
542
801
|
|
|
543
802
|
const pkg = readPackageJson(abs);
|
|
544
803
|
const manifest = pkg?.finch ?? {};
|
|
545
|
-
|
|
546
|
-
const recommended = ['name', 'description'];
|
|
547
|
-
const missing = recommended.filter((k) => manifest[k] == null);
|
|
548
|
-
if (manifest.miniToolType == null && manifest.extensionType == null) missing.push('miniToolType');
|
|
549
|
-
if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
|
|
550
|
-
if (manifest.permissions) {
|
|
804
|
+
if (manifest.permissions && typeof manifest.permissions === 'object') {
|
|
551
805
|
const p = manifest.permissions;
|
|
552
806
|
const decl = [
|
|
553
807
|
p.filesystem && p.filesystem !== 'none' ? `filesystem=${p.filesystem}` : null,
|
|
@@ -557,13 +811,20 @@ function cmdDoctor(src = '.') {
|
|
|
557
811
|
if (decl.length) console.log(` permissions: ${decl.join(', ')}(启用时会向用户展示)`);
|
|
558
812
|
}
|
|
559
813
|
|
|
560
|
-
|
|
561
|
-
|
|
814
|
+
if (diagnostics.fatal.length > 0) {
|
|
815
|
+
console.log(`\n✖ ${diagnostics.fatal.length} fatal issue(s):`);
|
|
816
|
+
for (const issue of diagnostics.fatal) console.log(` - ${issue}`);
|
|
817
|
+
}
|
|
818
|
+
if (diagnostics.warning.length > 0) {
|
|
819
|
+
console.log(`\n⚠ ${diagnostics.warning.length} warning(s):`);
|
|
820
|
+
for (const issue of diagnostics.warning) console.log(` - ${issue}`);
|
|
821
|
+
}
|
|
822
|
+
if (diagnostics.fatal.length === 0 && diagnostics.warning.length === 0) {
|
|
562
823
|
console.log('✓ No issues found.');
|
|
563
|
-
return;
|
|
564
824
|
}
|
|
565
|
-
|
|
566
|
-
|
|
825
|
+
if (diagnostics.fatal.length > 0) {
|
|
826
|
+
throw new Error('mini tool package validation failed');
|
|
827
|
+
}
|
|
567
828
|
}
|
|
568
829
|
|
|
569
830
|
async function cmdUpdate(id, opts) {
|