@finch.app/minitools 0.1.6 → 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 +486 -138
- 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
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @finch.app/minitools — install Finch extensions to the correct location.
|
|
4
4
|
*
|
|
5
|
-
* Zero npm dependencies. npm sources are
|
|
6
|
-
* so third-party install scripts never run
|
|
5
|
+
* Zero npm dependencies. npm sources are installed by downloading the registry
|
|
6
|
+
* dist.tarball (.tgz) directly, so third-party install scripts never run.
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
9
|
existsSync, mkdirSync, readdirSync, cpSync, rmSync,
|
|
10
|
-
readFileSync, writeFileSync,
|
|
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,67 +342,12 @@ 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
|
|
|
150
|
-
/**
|
|
151
|
-
* Look for an executable on PATH (via `which`/`where`), falling back to a
|
|
152
|
-
* handful of common install locations that don't always make it onto the
|
|
153
|
-
* PATH inherited by a GUI-launched app (Homebrew, nvm, Volta, Windows
|
|
154
|
-
* installer). Returns the resolved path, or null if not found anywhere.
|
|
155
|
-
*/
|
|
156
|
-
function findExecutable(name) {
|
|
157
|
-
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
158
|
-
const found = spawnSync(finder, [name], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
159
|
-
if (found.status === 0) {
|
|
160
|
-
const first = found.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
161
|
-
if (first) return first;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const candidateDirs = process.platform === 'win32'
|
|
165
|
-
? [
|
|
166
|
-
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs'),
|
|
167
|
-
join(process.env.APPDATA ?? '', 'npm'),
|
|
168
|
-
]
|
|
169
|
-
: (() => {
|
|
170
|
-
const dirs = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.volta', 'bin')];
|
|
171
|
-
const nvmRoot = join(homedir(), '.nvm', 'versions', 'node');
|
|
172
|
-
try {
|
|
173
|
-
for (const version of readdirSync(nvmRoot)) dirs.push(join(nvmRoot, version, 'bin'));
|
|
174
|
-
} catch { /* nvm not installed */ }
|
|
175
|
-
return dirs;
|
|
176
|
-
})();
|
|
177
|
-
const exeName = process.platform === 'win32' ? `${name}.cmd` : name;
|
|
178
|
-
for (const dir of candidateDirs) {
|
|
179
|
-
const candidate = join(dir, exeName);
|
|
180
|
-
if (existsSync(candidate)) return candidate;
|
|
181
|
-
}
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const NODEJS_INSTALL_HINT = 'Install Node.js (which bundles npm) from https://nodejs.org, then try again.';
|
|
186
|
-
|
|
187
|
-
function npmInstallToTemp(spec, tmp) {
|
|
188
|
-
const npmPath = findExecutable('npm');
|
|
189
|
-
if (!npmPath) {
|
|
190
|
-
throw new Error(`This extension is an npm package, but no "npm" executable was found on this machine.\n${NODEJS_INSTALL_HINT}`);
|
|
191
|
-
}
|
|
192
|
-
mkdirSync(tmp, { recursive: true });
|
|
193
|
-
const r = spawnSync(npmPath, ['install', '--ignore-scripts', '--omit=dev', '--prefix', tmp, spec], {
|
|
194
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
195
|
-
encoding: 'utf-8',
|
|
196
|
-
});
|
|
197
|
-
if (r.error) {
|
|
198
|
-
throw new Error(`Failed to run npm (${npmPath}): ${r.error.message}\n${NODEJS_INSTALL_HINT}`);
|
|
199
|
-
}
|
|
200
|
-
if (r.status !== 0) {
|
|
201
|
-
throw new Error(`npm install failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
349
|
function isLocalSource(src) {
|
|
206
|
-
return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~');
|
|
350
|
+
return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(src);
|
|
207
351
|
}
|
|
208
352
|
function isZipUrl(src) {
|
|
209
353
|
return /^https?:\/\/.+\.zip(\?.*)?$/i.test(src);
|
|
@@ -211,6 +355,13 @@ function isZipUrl(src) {
|
|
|
211
355
|
function isZipFile(src) {
|
|
212
356
|
return src.toLowerCase().endsWith('.zip');
|
|
213
357
|
}
|
|
358
|
+
function isTgzUrl(src) {
|
|
359
|
+
return /^https?:\/\/.+\.(?:tgz|tar\.gz)(\?.*)?$/i.test(src);
|
|
360
|
+
}
|
|
361
|
+
function isTgzFile(src) {
|
|
362
|
+
const lower = src.toLowerCase();
|
|
363
|
+
return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
|
|
364
|
+
}
|
|
214
365
|
function expandHome(path) {
|
|
215
366
|
return path.replace(/^~(?=\/|$)/, homedir());
|
|
216
367
|
}
|
|
@@ -219,17 +370,73 @@ function expandHome(path) {
|
|
|
219
370
|
* Download a URL to a local file path using Node's built-in fetch (Node 18+).
|
|
220
371
|
* Falls back to curl/wget if fetch is unavailable.
|
|
221
372
|
*/
|
|
373
|
+
function downloadWithSystemTool(url, destPath, fetchError) {
|
|
374
|
+
const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl';
|
|
375
|
+
const curl = spawnSync(curlExe, ['-fL', '--retry', '2', '--connect-timeout', '20', '-o', destPath, url], {
|
|
376
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
377
|
+
encoding: 'utf-8',
|
|
378
|
+
});
|
|
379
|
+
if (curl.status === 0 && !curl.error) return;
|
|
380
|
+
if (process.platform === 'win32') {
|
|
381
|
+
const ps = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Invoke-WebRequest -Uri $args[0] -OutFile $args[1]', url, destPath], {
|
|
382
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
383
|
+
encoding: 'utf-8',
|
|
384
|
+
});
|
|
385
|
+
if (ps.status === 0 && !ps.error) return;
|
|
386
|
+
throw new Error(`Download failed: ${fetchError?.message ?? fetchError}\n${curl.stderr || curl.stdout || curl.error?.message || 'curl failed'}\n${ps.stderr || ps.stdout || ps.error?.message || 'powershell failed'}`);
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Download failed: ${fetchError?.message ?? fetchError}\n${curl.stderr || curl.stdout || curl.error?.message || 'curl failed'}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
222
391
|
async function downloadFile(url, destPath) {
|
|
223
|
-
|
|
392
|
+
try {
|
|
224
393
|
const res = await fetch(url, { redirect: 'follow' });
|
|
225
|
-
if (!res.ok) throw new Error(
|
|
394
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
226
395
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
227
396
|
writeFileSync(destPath, buf);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
397
|
+
} catch (err) {
|
|
398
|
+
downloadWithSystemTool(url, destPath, err);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
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;
|
|
232
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}`);
|
|
233
440
|
}
|
|
234
441
|
|
|
235
442
|
/**
|
|
@@ -237,6 +444,21 @@ async function downloadFile(url, destPath) {
|
|
|
237
444
|
*/
|
|
238
445
|
function extractZip(zipPath, destDir) {
|
|
239
446
|
mkdirSync(destDir, { recursive: true });
|
|
447
|
+
if (process.platform === 'win32') {
|
|
448
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force', zipPath, destDir], {
|
|
449
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
450
|
+
encoding: 'utf-8',
|
|
451
|
+
});
|
|
452
|
+
if (r.status === 0 && !r.error) return;
|
|
453
|
+
const tar = spawnSync('tar.exe', ['-xf', zipPath, '-C', destDir], {
|
|
454
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
455
|
+
encoding: 'utf-8',
|
|
456
|
+
});
|
|
457
|
+
if (tar.status !== 0 || tar.error) {
|
|
458
|
+
throw new Error(`zip extract failed:\n${r.stderr || tar.stderr || r.stdout || tar.stdout || tar.error?.message || 'unknown error'}`);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
240
462
|
const r = spawnSync('unzip', ['-q', '-o', zipPath, '-d', destDir], {
|
|
241
463
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
464
|
encoding: 'utf-8',
|
|
@@ -247,6 +469,83 @@ function extractZip(zipPath, destDir) {
|
|
|
247
469
|
if (r.status !== 0) throw new Error(`unzip failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
248
470
|
}
|
|
249
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Extract a .tgz/.tar.gz archive using the system tar command. macOS/Linux ship
|
|
474
|
+
* it by default; modern Windows includes bsdtar as tar.exe.
|
|
475
|
+
*/
|
|
476
|
+
function extractTgz(tgzPath, destDir) {
|
|
477
|
+
mkdirSync(destDir, { recursive: true });
|
|
478
|
+
const tarExe = process.platform === 'win32' ? 'tar.exe' : 'tar';
|
|
479
|
+
const r = spawnSync(tarExe, ['-xzf', tgzPath, '-C', destDir], {
|
|
480
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
481
|
+
encoding: 'utf-8',
|
|
482
|
+
});
|
|
483
|
+
if (r.error) {
|
|
484
|
+
throw new Error(`No "tar" command found on this machine: ${r.error.message}`);
|
|
485
|
+
}
|
|
486
|
+
if (r.status !== 0) throw new Error(`tar extract failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function parseNpmSpec(spec) {
|
|
490
|
+
const trimmed = String(spec ?? '').trim();
|
|
491
|
+
if (!trimmed) throw new Error('missing npm package name');
|
|
492
|
+
if (trimmed.startsWith('@')) {
|
|
493
|
+
const versionSep = trimmed.indexOf('@', 1);
|
|
494
|
+
return versionSep > 0
|
|
495
|
+
? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
|
|
496
|
+
: { name: trimmed, version: 'latest' };
|
|
497
|
+
}
|
|
498
|
+
const versionSep = trimmed.lastIndexOf('@');
|
|
499
|
+
return versionSep > 0
|
|
500
|
+
? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
|
|
501
|
+
: { name: trimmed, version: 'latest' };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let registryOverride = null;
|
|
505
|
+
|
|
506
|
+
function configuredRegistryUrl() {
|
|
507
|
+
return (registryOverride || process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function npmMetadataUrl(packageName, version) {
|
|
511
|
+
return `${configuredRegistryUrl()}/${encodeURIComponent(packageName)}/${encodeURIComponent(version || 'latest')}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function officialMiniToolDownloadUrl(packageName, version) {
|
|
515
|
+
return `https://community.finchwork.app/download/minitool/${packageName}/${encodeURIComponent(version)}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function resolveNpmTarball(spec) {
|
|
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
|
+
|
|
528
|
+
const url = npmMetadataUrl(name, version);
|
|
529
|
+
let meta;
|
|
530
|
+
try {
|
|
531
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
532
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
533
|
+
meta = await res.json();
|
|
534
|
+
} catch (err) {
|
|
535
|
+
throw new Error(`npm metadata fetch failed: ${err instanceof Error ? err.message : String(err)} — ${url}`);
|
|
536
|
+
}
|
|
537
|
+
const resolvedVersion = String(meta.version ?? version);
|
|
538
|
+
const tarball = meta?.dist?.tarball;
|
|
539
|
+
if (typeof tarball !== 'string' || !tarball) {
|
|
540
|
+
throw new Error(`No dist.tarball found for ${name}@${version}`);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
package: name,
|
|
544
|
+
version: resolvedVersion,
|
|
545
|
+
tarball: officialMiniToolDownloadUrl(name, resolvedVersion),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
250
549
|
/**
|
|
251
550
|
* Install extensions from a zip file (local path or remote URL).
|
|
252
551
|
* The zip may contain one or more extensions at any nesting level.
|
|
@@ -259,17 +558,20 @@ async function installFromZip(src, dest, isUrl) {
|
|
|
259
558
|
try {
|
|
260
559
|
if (isUrl) {
|
|
261
560
|
console.log(` Downloading ${src} …`);
|
|
262
|
-
await
|
|
561
|
+
await downloadFileWithCache(src, zipPath);
|
|
263
562
|
} else {
|
|
264
563
|
// Local zip — copy to tmp so we have a consistent path
|
|
265
564
|
const abs = resolve(expandHome(src));
|
|
266
565
|
if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
|
|
267
566
|
cpSync(abs, zipPath);
|
|
268
567
|
}
|
|
269
|
-
console.log(' Extracting …');
|
|
568
|
+
// console.log(' Extracting …');
|
|
270
569
|
extractZip(zipPath, extractDir);
|
|
271
570
|
const found = findPluginDirs(extractDir, 5);
|
|
272
|
-
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
|
+
}
|
|
273
575
|
const lockSource = isUrl
|
|
274
576
|
? { type: 'zip', url: src }
|
|
275
577
|
: { type: 'zip', localPath: resolve(expandHome(src)) };
|
|
@@ -279,25 +581,63 @@ async function installFromZip(src, dest, isUrl) {
|
|
|
279
581
|
}
|
|
280
582
|
}
|
|
281
583
|
|
|
584
|
+
async function installFromTgz(src, dest, { isUrl, lockSource }) {
|
|
585
|
+
const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
|
|
586
|
+
const tgzPath = join(tmp, 'extension.tgz');
|
|
587
|
+
const extractDir = join(tmp, 'extracted');
|
|
588
|
+
mkdirSync(tmp, { recursive: true });
|
|
589
|
+
try {
|
|
590
|
+
if (isUrl) {
|
|
591
|
+
console.log(` Downloading ${src} …`);
|
|
592
|
+
await downloadFileWithCache(src, tgzPath);
|
|
593
|
+
} else {
|
|
594
|
+
const abs = resolve(expandHome(src));
|
|
595
|
+
if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
|
|
596
|
+
cpSync(abs, tgzPath);
|
|
597
|
+
}
|
|
598
|
+
// console.log(' Extracting …');
|
|
599
|
+
extractTgz(tgzPath, extractDir);
|
|
600
|
+
const found = findPluginDirs(extractDir, 5);
|
|
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
|
+
}
|
|
605
|
+
const source = lockSource ?? (isUrl
|
|
606
|
+
? { type: 'tgz', url: src }
|
|
607
|
+
: { type: 'tgz', localPath: resolve(expandHome(src)) });
|
|
608
|
+
for (const dir of found) installExtensionDir(dir, dest, source);
|
|
609
|
+
} finally {
|
|
610
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
282
614
|
async function cmdAdd(src, opts) {
|
|
283
615
|
const dest = targetDir(opts);
|
|
284
616
|
|
|
285
|
-
// ---
|
|
617
|
+
// --- archive URL (e.g. https://github.com/.../archive/main.zip or npm tarball .tgz) ---
|
|
286
618
|
if (isZipUrl(src)) {
|
|
287
619
|
await installFromZip(src, dest, true);
|
|
288
620
|
return;
|
|
289
621
|
}
|
|
622
|
+
if (isTgzUrl(src)) {
|
|
623
|
+
await installFromTgz(src, dest, { isUrl: true });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
290
626
|
|
|
291
627
|
// --- local path ---
|
|
292
628
|
if (isLocalSource(src)) {
|
|
293
629
|
const abs = resolve(expandHome(src));
|
|
294
630
|
if (!existsSync(abs)) throw new Error(`path not found: ${abs}`);
|
|
295
631
|
|
|
296
|
-
// Local
|
|
632
|
+
// Local archive file
|
|
297
633
|
if (isZipFile(src)) {
|
|
298
634
|
await installFromZip(src, dest, false);
|
|
299
635
|
return;
|
|
300
636
|
}
|
|
637
|
+
if (isTgzFile(src)) {
|
|
638
|
+
await installFromTgz(src, dest, { isUrl: false });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
301
641
|
|
|
302
642
|
// Local directory
|
|
303
643
|
const direct = pluginInfo(abs);
|
|
@@ -306,23 +646,20 @@ async function cmdAdd(src, opts) {
|
|
|
306
646
|
return;
|
|
307
647
|
}
|
|
308
648
|
const found = findPluginDirs(abs, 3);
|
|
309
|
-
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
|
+
}
|
|
310
653
|
for (const dir of found) installExtensionDir(dir, dest, { type: 'local', localPath: dir });
|
|
311
654
|
return;
|
|
312
655
|
}
|
|
313
656
|
|
|
314
|
-
// --- npm package ---
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// Prefer the top-level package matching the requested spec when possible.
|
|
321
|
-
const first = found[0];
|
|
322
|
-
installExtensionDir(first, dest, { type: 'npm', package: src });
|
|
323
|
-
} finally {
|
|
324
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
325
|
-
}
|
|
657
|
+
// --- npm package: fetch registry metadata, download dist.tarball, then extract locally. ---
|
|
658
|
+
const resolved = await resolveNpmTarball(src);
|
|
659
|
+
await installFromTgz(resolved.tarball, dest, {
|
|
660
|
+
isUrl: true,
|
|
661
|
+
lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
|
|
662
|
+
});
|
|
326
663
|
}
|
|
327
664
|
|
|
328
665
|
function listInstalled(dir) {
|
|
@@ -451,22 +788,20 @@ function lintExtensionSource(root) {
|
|
|
451
788
|
|
|
452
789
|
function cmdDoctor(src = '.') {
|
|
453
790
|
const abs = resolve(expandHome(src));
|
|
454
|
-
const info =
|
|
455
|
-
|
|
456
|
-
if (info
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
}
|
|
461
801
|
|
|
462
802
|
const pkg = readPackageJson(abs);
|
|
463
803
|
const manifest = pkg?.finch ?? {};
|
|
464
|
-
|
|
465
|
-
const recommended = ['name', 'description'];
|
|
466
|
-
const missing = recommended.filter((k) => manifest[k] == null);
|
|
467
|
-
if (manifest.miniToolType == null && manifest.extensionType == null) missing.push('miniToolType');
|
|
468
|
-
if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
|
|
469
|
-
if (manifest.permissions) {
|
|
804
|
+
if (manifest.permissions && typeof manifest.permissions === 'object') {
|
|
470
805
|
const p = manifest.permissions;
|
|
471
806
|
const decl = [
|
|
472
807
|
p.filesystem && p.filesystem !== 'none' ? `filesystem=${p.filesystem}` : null,
|
|
@@ -476,13 +811,20 @@ function cmdDoctor(src = '.') {
|
|
|
476
811
|
if (decl.length) console.log(` permissions: ${decl.join(', ')}(启用时会向用户展示)`);
|
|
477
812
|
}
|
|
478
813
|
|
|
479
|
-
|
|
480
|
-
|
|
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) {
|
|
481
823
|
console.log('✓ No issues found.');
|
|
482
|
-
return;
|
|
483
824
|
}
|
|
484
|
-
|
|
485
|
-
|
|
825
|
+
if (diagnostics.fatal.length > 0) {
|
|
826
|
+
throw new Error('mini tool package validation failed');
|
|
827
|
+
}
|
|
486
828
|
}
|
|
487
829
|
|
|
488
830
|
async function cmdUpdate(id, opts) {
|
|
@@ -517,7 +859,7 @@ async function cmdUpdate(id, opts) {
|
|
|
517
859
|
return;
|
|
518
860
|
}
|
|
519
861
|
|
|
520
|
-
// zip source: re-download / re-extract from the recorded URL or local path.
|
|
862
|
+
// zip/tgz source: re-download / re-extract from the recorded URL or local path.
|
|
521
863
|
if (source.type === 'zip') {
|
|
522
864
|
if (source.url) {
|
|
523
865
|
await installFromZip(source.url, dir, true);
|
|
@@ -528,34 +870,39 @@ async function cmdUpdate(id, opts) {
|
|
|
528
870
|
}
|
|
529
871
|
return;
|
|
530
872
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const info = pluginInfo(fresh);
|
|
541
|
-
rmSync(target, { recursive: true, force: true });
|
|
542
|
-
cpSync(fresh, target, { recursive: true, force: true, dereference: false });
|
|
543
|
-
recordInstall(dir, id, source);
|
|
544
|
-
console.log(`✓ Updated "${info.displayName}" (${id}) → v${info.version}`);
|
|
545
|
-
} finally {
|
|
546
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
873
|
+
if (source.type === 'tgz') {
|
|
874
|
+
if (source.url) {
|
|
875
|
+
await installFromTgz(source.url, dir, { isUrl: true, lockSource: source });
|
|
876
|
+
} else if (source.localPath) {
|
|
877
|
+
await installFromTgz(source.localPath, dir, { isUrl: false, lockSource: source });
|
|
878
|
+
} else {
|
|
879
|
+
throw new Error(`tgz install record for "${id}" has no url or localPath; reinstall with \`add\`.`);
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
547
882
|
}
|
|
883
|
+
|
|
884
|
+
// npm source: reinstall the latest published version by downloading dist.tarball directly.
|
|
885
|
+
const resolved = await resolveNpmTarball(source.package ?? id);
|
|
886
|
+
await installFromTgz(resolved.tarball, dir, {
|
|
887
|
+
isUrl: true,
|
|
888
|
+
lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
|
|
889
|
+
});
|
|
548
890
|
}
|
|
549
891
|
|
|
550
892
|
function parseArgs(argv) {
|
|
551
893
|
const args = [...argv];
|
|
552
894
|
const cmd = args.shift();
|
|
553
|
-
const opts = { global: false };
|
|
895
|
+
const opts = { global: false, registry: null };
|
|
554
896
|
const rest = [];
|
|
555
897
|
for (let i = 0; i < args.length; i += 1) {
|
|
556
898
|
const a = args[i];
|
|
557
899
|
if (a === '--global' || a === '-g') opts.global = true;
|
|
558
|
-
else if (a === '--
|
|
900
|
+
else if (a === '--registry') {
|
|
901
|
+
const value = args[i + 1];
|
|
902
|
+
if (!value || value.startsWith('--')) throw new Error('--registry requires a URL');
|
|
903
|
+
opts.registry = value;
|
|
904
|
+
i += 1;
|
|
905
|
+
} else if (a === '--cwd') {
|
|
559
906
|
// Removed: extensions no longer support project/--cwd scope. Consume any
|
|
560
907
|
// following path argument so old invocations fail loudly instead of
|
|
561
908
|
// silently being parsed as the install source.
|
|
@@ -566,12 +913,13 @@ function parseArgs(argv) {
|
|
|
566
913
|
}
|
|
567
914
|
|
|
568
915
|
function help() {
|
|
569
|
-
console.log(`npx @finch.app/minitools\n\nUsage:\n add <npm-package|local-path|url.zip> [--global]\n update <id> [--global]\n list [--global]\n remove <id> [--global]\n enable <id>\n disable <id>\n where\n doctor [path]\n\nInstall locations:\n default workspace.json#finchHomeDir/.finch/extensions/ (personal — default)\n --global ~/.finch/extensions/ (global)\n\nThere is no project/--cwd scope — extensions only install to personal or global.\n`);
|
|
916
|
+
console.log(`npx @finch.app/minitools\n\nUsage:\n add <npm-package|local-path|url.zip|url.tgz> [--global] [--registry <url>]\n update <id> [--global] [--registry <url>]\n list [--global]\n remove <id> [--global]\n enable <id>\n disable <id>\n where\n doctor [path]\n\nInstall locations:\n default workspace.json#finchHomeDir/.finch/extensions/ (personal — default)\n --global ~/.finch/extensions/ (global)\n\nRegistry:\n --registry <url> overrides npm registry for npm package metadata/tarball downloads.\n If omitted, npm_config_registry is used, then https://registry.npmjs.org.\n\nThere is no project/--cwd scope — extensions only install to personal or global.\n`);
|
|
570
917
|
}
|
|
571
918
|
|
|
572
919
|
(async () => {
|
|
573
920
|
try {
|
|
574
921
|
const { cmd, rest, opts } = parseArgs(process.argv.slice(2));
|
|
922
|
+
registryOverride = opts.registry;
|
|
575
923
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
|
|
576
924
|
if (cmd === 'add') {
|
|
577
925
|
if (!rest[0]) throw new Error('missing source');
|