@finch.app/minitools 0.1.5 → 0.1.8
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 +19 -19
- package/bin/minitools.mjs +203 -104
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
1
|
# @finch.app/minitools
|
|
2
2
|
|
|
3
|
-
CLI shim for installing Finch
|
|
3
|
+
CLI shim for installing Finch mini tools to the correct location.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
# Install from npm
|
|
9
|
-
npx @finch.app/minitools add @scope/finch-
|
|
9
|
+
npx @finch.app/minitools add @scope/finch-mini-tool-example
|
|
10
10
|
|
|
11
|
-
# Install a local
|
|
12
|
-
npx @finch.app/minitools add ./my-
|
|
11
|
+
# Install a local mini tool directory
|
|
12
|
+
npx @finch.app/minitools add ./my-tool
|
|
13
13
|
|
|
14
14
|
# Install from a zip file (local or URL)
|
|
15
|
-
npx @finch.app/minitools add ./my-
|
|
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
18
|
# Install to the current project
|
|
19
|
-
npx @finch.app/minitools add @finch/
|
|
19
|
+
npx @finch.app/minitools add @finch.app/mcp-client --cwd
|
|
20
20
|
|
|
21
21
|
# Install to a specific project path
|
|
22
|
-
npx @finch.app/minitools add @finch/
|
|
22
|
+
npx @finch.app/minitools add @finch.app/mcp-client --cwd /path/to/project
|
|
23
23
|
|
|
24
24
|
# Install globally (~/.finch/extensions/)
|
|
25
|
-
npx @finch.app/minitools add @finch/
|
|
25
|
+
npx @finch.app/minitools add @finch.app/mcp-client --global
|
|
26
26
|
|
|
27
|
-
# List installed
|
|
27
|
+
# List installed mini tools (id, version, enabled/disabled, name, path)
|
|
28
28
|
npx @finch.app/minitools list
|
|
29
29
|
npx @finch.app/minitools list --global
|
|
30
30
|
|
|
31
|
-
# Remove an
|
|
32
|
-
npx @finch.app/minitools remove mcp
|
|
31
|
+
# Remove an mini tool
|
|
32
|
+
npx @finch.app/minitools remove mcp-client
|
|
33
33
|
|
|
34
34
|
# Show install paths
|
|
35
35
|
npx @finch.app/minitools where
|
|
36
36
|
|
|
37
|
-
# Validate an
|
|
38
|
-
npx @finch.app/minitools doctor ./my-
|
|
37
|
+
# Validate an mini tool package
|
|
38
|
+
npx @finch.app/minitools doctor ./my-tool
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
## Install locations
|
|
@@ -49,24 +49,24 @@ npx @finch.app/minitools doctor ./my-extension
|
|
|
49
49
|
|
|
50
50
|
The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`.
|
|
51
51
|
|
|
52
|
-
##
|
|
52
|
+
## Mini tool package
|
|
53
53
|
|
|
54
|
-
A Finch
|
|
54
|
+
A Finch mini tool is an npm-style package with `package.json#finch`:
|
|
55
55
|
|
|
56
56
|
```json
|
|
57
57
|
{
|
|
58
|
-
"name": "my-
|
|
58
|
+
"name": "my-tool",
|
|
59
59
|
"version": "1.0.0",
|
|
60
60
|
"type": "module",
|
|
61
61
|
"main": "dist/index.js",
|
|
62
62
|
"finch": {
|
|
63
63
|
"manifestVersion": 1,
|
|
64
|
-
"id": "my-
|
|
65
|
-
"displayName": "My
|
|
64
|
+
"id": "my-tool",
|
|
65
|
+
"displayName": "My Tool",
|
|
66
66
|
"main": "dist/index.js",
|
|
67
67
|
"activationEvents": ["onStartup"]
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`add` installs the
|
|
72
|
+
`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
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* @finch.app/
|
|
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
12
|
import { join, resolve, basename } from 'node:path';
|
|
13
13
|
import { homedir, tmpdir } from 'node:os';
|
|
@@ -15,6 +15,15 @@ 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 LEGACY_NPM_PACKAGE_RENAMES = new Map([
|
|
19
|
+
['@finch.app/mcp-bridge', '@finch.app/mcp-client'],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function normalizeInstallSource(source) {
|
|
23
|
+
if (source?.type !== 'npm' || typeof source.package !== 'string') return source;
|
|
24
|
+
const nextPackage = LEGACY_NPM_PACKAGE_RENAMES.get(source.package);
|
|
25
|
+
return nextPackage ? { ...source, package: nextPackage } : source;
|
|
26
|
+
}
|
|
18
27
|
|
|
19
28
|
function finchRuntimeHome() {
|
|
20
29
|
return process.env.FINCH_RUNTIME_HOME ?? join(homedir(), '.finch');
|
|
@@ -138,63 +147,8 @@ function installExtensionDir(srcDir, destRoot, lockSource) {
|
|
|
138
147
|
return info.id;
|
|
139
148
|
}
|
|
140
149
|
|
|
141
|
-
/**
|
|
142
|
-
* Look for an executable on PATH (via `which`/`where`), falling back to a
|
|
143
|
-
* handful of common install locations that don't always make it onto the
|
|
144
|
-
* PATH inherited by a GUI-launched app (Homebrew, nvm, Volta, Windows
|
|
145
|
-
* installer). Returns the resolved path, or null if not found anywhere.
|
|
146
|
-
*/
|
|
147
|
-
function findExecutable(name) {
|
|
148
|
-
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
149
|
-
const found = spawnSync(finder, [name], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
|
|
150
|
-
if (found.status === 0) {
|
|
151
|
-
const first = found.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
152
|
-
if (first) return first;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const candidateDirs = process.platform === 'win32'
|
|
156
|
-
? [
|
|
157
|
-
join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs'),
|
|
158
|
-
join(process.env.APPDATA ?? '', 'npm'),
|
|
159
|
-
]
|
|
160
|
-
: (() => {
|
|
161
|
-
const dirs = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.volta', 'bin')];
|
|
162
|
-
const nvmRoot = join(homedir(), '.nvm', 'versions', 'node');
|
|
163
|
-
try {
|
|
164
|
-
for (const version of readdirSync(nvmRoot)) dirs.push(join(nvmRoot, version, 'bin'));
|
|
165
|
-
} catch { /* nvm not installed */ }
|
|
166
|
-
return dirs;
|
|
167
|
-
})();
|
|
168
|
-
const exeName = process.platform === 'win32' ? `${name}.cmd` : name;
|
|
169
|
-
for (const dir of candidateDirs) {
|
|
170
|
-
const candidate = join(dir, exeName);
|
|
171
|
-
if (existsSync(candidate)) return candidate;
|
|
172
|
-
}
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const NODEJS_INSTALL_HINT = 'Install Node.js (which bundles npm) from https://nodejs.org, then try again.';
|
|
177
|
-
|
|
178
|
-
function npmInstallToTemp(spec, tmp) {
|
|
179
|
-
const npmPath = findExecutable('npm');
|
|
180
|
-
if (!npmPath) {
|
|
181
|
-
throw new Error(`This extension is an npm package, but no "npm" executable was found on this machine.\n${NODEJS_INSTALL_HINT}`);
|
|
182
|
-
}
|
|
183
|
-
mkdirSync(tmp, { recursive: true });
|
|
184
|
-
const r = spawnSync(npmPath, ['install', '--ignore-scripts', '--omit=dev', '--prefix', tmp, spec], {
|
|
185
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
186
|
-
encoding: 'utf-8',
|
|
187
|
-
});
|
|
188
|
-
if (r.error) {
|
|
189
|
-
throw new Error(`Failed to run npm (${npmPath}): ${r.error.message}\n${NODEJS_INSTALL_HINT}`);
|
|
190
|
-
}
|
|
191
|
-
if (r.status !== 0) {
|
|
192
|
-
throw new Error(`npm install failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
150
|
function isLocalSource(src) {
|
|
197
|
-
return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~');
|
|
151
|
+
return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(src);
|
|
198
152
|
}
|
|
199
153
|
function isZipUrl(src) {
|
|
200
154
|
return /^https?:\/\/.+\.zip(\?.*)?$/i.test(src);
|
|
@@ -202,6 +156,13 @@ function isZipUrl(src) {
|
|
|
202
156
|
function isZipFile(src) {
|
|
203
157
|
return src.toLowerCase().endsWith('.zip');
|
|
204
158
|
}
|
|
159
|
+
function isTgzUrl(src) {
|
|
160
|
+
return /^https?:\/\/.+\.(?:tgz|tar\.gz)(\?.*)?$/i.test(src);
|
|
161
|
+
}
|
|
162
|
+
function isTgzFile(src) {
|
|
163
|
+
const lower = src.toLowerCase();
|
|
164
|
+
return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
|
|
165
|
+
}
|
|
205
166
|
function expandHome(path) {
|
|
206
167
|
return path.replace(/^~(?=\/|$)/, homedir());
|
|
207
168
|
}
|
|
@@ -210,16 +171,32 @@ function expandHome(path) {
|
|
|
210
171
|
* Download a URL to a local file path using Node's built-in fetch (Node 18+).
|
|
211
172
|
* Falls back to curl/wget if fetch is unavailable.
|
|
212
173
|
*/
|
|
174
|
+
function downloadWithSystemTool(url, destPath, fetchError) {
|
|
175
|
+
const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl';
|
|
176
|
+
const curl = spawnSync(curlExe, ['-fL', '--retry', '2', '--connect-timeout', '20', '-o', destPath, url], {
|
|
177
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
178
|
+
encoding: 'utf-8',
|
|
179
|
+
});
|
|
180
|
+
if (curl.status === 0 && !curl.error) return;
|
|
181
|
+
if (process.platform === 'win32') {
|
|
182
|
+
const ps = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Invoke-WebRequest -Uri $args[0] -OutFile $args[1]', url, destPath], {
|
|
183
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
184
|
+
encoding: 'utf-8',
|
|
185
|
+
});
|
|
186
|
+
if (ps.status === 0 && !ps.error) return;
|
|
187
|
+
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'}`);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(`Download failed: ${fetchError?.message ?? fetchError}\n${curl.stderr || curl.stdout || curl.error?.message || 'curl failed'}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
213
192
|
async function downloadFile(url, destPath) {
|
|
214
|
-
|
|
193
|
+
try {
|
|
215
194
|
const res = await fetch(url, { redirect: 'follow' });
|
|
216
|
-
if (!res.ok) throw new Error(
|
|
195
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
217
196
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
218
197
|
writeFileSync(destPath, buf);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const r = spawnSync('curl', ['-fsSL', '-o', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
222
|
-
if (r.status !== 0) throw new Error(`curl failed:\n${r.stderr || r.stdout}`);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
downloadWithSystemTool(url, destPath, err);
|
|
223
200
|
}
|
|
224
201
|
}
|
|
225
202
|
|
|
@@ -228,6 +205,21 @@ async function downloadFile(url, destPath) {
|
|
|
228
205
|
*/
|
|
229
206
|
function extractZip(zipPath, destDir) {
|
|
230
207
|
mkdirSync(destDir, { recursive: true });
|
|
208
|
+
if (process.platform === 'win32') {
|
|
209
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force', zipPath, destDir], {
|
|
210
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
211
|
+
encoding: 'utf-8',
|
|
212
|
+
});
|
|
213
|
+
if (r.status === 0 && !r.error) return;
|
|
214
|
+
const tar = spawnSync('tar.exe', ['-xf', zipPath, '-C', destDir], {
|
|
215
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
216
|
+
encoding: 'utf-8',
|
|
217
|
+
});
|
|
218
|
+
if (tar.status !== 0 || tar.error) {
|
|
219
|
+
throw new Error(`zip extract failed:\n${r.stderr || tar.stderr || r.stdout || tar.stdout || tar.error?.message || 'unknown error'}`);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
231
223
|
const r = spawnSync('unzip', ['-q', '-o', zipPath, '-d', destDir], {
|
|
232
224
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
233
225
|
encoding: 'utf-8',
|
|
@@ -238,6 +230,75 @@ function extractZip(zipPath, destDir) {
|
|
|
238
230
|
if (r.status !== 0) throw new Error(`unzip failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
239
231
|
}
|
|
240
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Extract a .tgz/.tar.gz archive using the system tar command. macOS/Linux ship
|
|
235
|
+
* it by default; modern Windows includes bsdtar as tar.exe.
|
|
236
|
+
*/
|
|
237
|
+
function extractTgz(tgzPath, destDir) {
|
|
238
|
+
mkdirSync(destDir, { recursive: true });
|
|
239
|
+
const tarExe = process.platform === 'win32' ? 'tar.exe' : 'tar';
|
|
240
|
+
const r = spawnSync(tarExe, ['-xzf', tgzPath, '-C', destDir], {
|
|
241
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
|
+
encoding: 'utf-8',
|
|
243
|
+
});
|
|
244
|
+
if (r.error) {
|
|
245
|
+
throw new Error(`No "tar" command found on this machine: ${r.error.message}`);
|
|
246
|
+
}
|
|
247
|
+
if (r.status !== 0) throw new Error(`tar extract failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function parseNpmSpec(spec) {
|
|
251
|
+
const trimmed = String(spec ?? '').trim();
|
|
252
|
+
if (!trimmed) throw new Error('missing npm package name');
|
|
253
|
+
if (trimmed.startsWith('@')) {
|
|
254
|
+
const versionSep = trimmed.indexOf('@', 1);
|
|
255
|
+
return versionSep > 0
|
|
256
|
+
? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
|
|
257
|
+
: { name: trimmed, version: 'latest' };
|
|
258
|
+
}
|
|
259
|
+
const versionSep = trimmed.lastIndexOf('@');
|
|
260
|
+
return versionSep > 0
|
|
261
|
+
? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
|
|
262
|
+
: { name: trimmed, version: 'latest' };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let registryOverride = null;
|
|
266
|
+
|
|
267
|
+
function configuredRegistryUrl() {
|
|
268
|
+
return (registryOverride || process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, '');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function npmMetadataUrl(packageName, version) {
|
|
272
|
+
return `${configuredRegistryUrl()}/${encodeURIComponent(packageName)}/${encodeURIComponent(version || 'latest')}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function officialMiniToolDownloadUrl(packageName, version) {
|
|
276
|
+
return `https://community.finchwork.app/download/minitool/${packageName}/${encodeURIComponent(version)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function resolveNpmTarball(spec) {
|
|
280
|
+
const { name, version } = parseNpmSpec(spec);
|
|
281
|
+
const url = npmMetadataUrl(name, version);
|
|
282
|
+
let meta;
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
285
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
286
|
+
meta = await res.json();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
throw new Error(`npm metadata fetch failed: ${err instanceof Error ? err.message : String(err)} — ${url}`);
|
|
289
|
+
}
|
|
290
|
+
const resolvedVersion = String(meta.version ?? version);
|
|
291
|
+
const tarball = meta?.dist?.tarball;
|
|
292
|
+
if (typeof tarball !== 'string' || !tarball) {
|
|
293
|
+
throw new Error(`No dist.tarball found for ${name}@${version}`);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
package: name,
|
|
297
|
+
version: resolvedVersion,
|
|
298
|
+
tarball: officialMiniToolDownloadUrl(name, resolvedVersion),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
241
302
|
/**
|
|
242
303
|
* Install extensions from a zip file (local path or remote URL).
|
|
243
304
|
* The zip may contain one or more extensions at any nesting level.
|
|
@@ -270,25 +331,60 @@ async function installFromZip(src, dest, isUrl) {
|
|
|
270
331
|
}
|
|
271
332
|
}
|
|
272
333
|
|
|
334
|
+
async function installFromTgz(src, dest, { isUrl, lockSource }) {
|
|
335
|
+
const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
|
|
336
|
+
const tgzPath = join(tmp, 'extension.tgz');
|
|
337
|
+
const extractDir = join(tmp, 'extracted');
|
|
338
|
+
mkdirSync(tmp, { recursive: true });
|
|
339
|
+
try {
|
|
340
|
+
if (isUrl) {
|
|
341
|
+
console.log(` Downloading ${src} …`);
|
|
342
|
+
await downloadFile(src, tgzPath);
|
|
343
|
+
} else {
|
|
344
|
+
const abs = resolve(expandHome(src));
|
|
345
|
+
if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
|
|
346
|
+
cpSync(abs, tgzPath);
|
|
347
|
+
}
|
|
348
|
+
console.log(' Extracting …');
|
|
349
|
+
extractTgz(tgzPath, extractDir);
|
|
350
|
+
const found = findPluginDirs(extractDir, 5);
|
|
351
|
+
if (found.length === 0) throw new Error('No Finch extension found inside the tgz archive.');
|
|
352
|
+
const source = lockSource ?? (isUrl
|
|
353
|
+
? { type: 'tgz', url: src }
|
|
354
|
+
: { type: 'tgz', localPath: resolve(expandHome(src)) });
|
|
355
|
+
for (const dir of found) installExtensionDir(dir, dest, source);
|
|
356
|
+
} finally {
|
|
357
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
273
361
|
async function cmdAdd(src, opts) {
|
|
274
362
|
const dest = targetDir(opts);
|
|
275
363
|
|
|
276
|
-
// ---
|
|
364
|
+
// --- archive URL (e.g. https://github.com/.../archive/main.zip or npm tarball .tgz) ---
|
|
277
365
|
if (isZipUrl(src)) {
|
|
278
366
|
await installFromZip(src, dest, true);
|
|
279
367
|
return;
|
|
280
368
|
}
|
|
369
|
+
if (isTgzUrl(src)) {
|
|
370
|
+
await installFromTgz(src, dest, { isUrl: true });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
281
373
|
|
|
282
374
|
// --- local path ---
|
|
283
375
|
if (isLocalSource(src)) {
|
|
284
376
|
const abs = resolve(expandHome(src));
|
|
285
377
|
if (!existsSync(abs)) throw new Error(`path not found: ${abs}`);
|
|
286
378
|
|
|
287
|
-
// Local
|
|
379
|
+
// Local archive file
|
|
288
380
|
if (isZipFile(src)) {
|
|
289
381
|
await installFromZip(src, dest, false);
|
|
290
382
|
return;
|
|
291
383
|
}
|
|
384
|
+
if (isTgzFile(src)) {
|
|
385
|
+
await installFromTgz(src, dest, { isUrl: false });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
292
388
|
|
|
293
389
|
// Local directory
|
|
294
390
|
const direct = pluginInfo(abs);
|
|
@@ -302,18 +398,12 @@ async function cmdAdd(src, opts) {
|
|
|
302
398
|
return;
|
|
303
399
|
}
|
|
304
400
|
|
|
305
|
-
// --- npm package ---
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// Prefer the top-level package matching the requested spec when possible.
|
|
312
|
-
const first = found[0];
|
|
313
|
-
installExtensionDir(first, dest, { type: 'npm', package: src });
|
|
314
|
-
} finally {
|
|
315
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
316
|
-
}
|
|
401
|
+
// --- npm package: fetch registry metadata, download dist.tarball, then extract locally. ---
|
|
402
|
+
const resolved = await resolveNpmTarball(src);
|
|
403
|
+
await installFromTgz(resolved.tarball, dest, {
|
|
404
|
+
isUrl: true,
|
|
405
|
+
lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
|
|
406
|
+
});
|
|
317
407
|
}
|
|
318
408
|
|
|
319
409
|
function listInstalled(dir) {
|
|
@@ -331,8 +421,10 @@ function cmdList(opts) {
|
|
|
331
421
|
console.log(`No extensions installed in ${dir}`);
|
|
332
422
|
return;
|
|
333
423
|
}
|
|
424
|
+
const pluginState = normalizePluginState(readJson(pluginsStatePath(), {}));
|
|
334
425
|
for (const p of plugins) {
|
|
335
|
-
|
|
426
|
+
const status = pluginState[p.info.id]?.enabled ? 'enabled' : 'disabled';
|
|
427
|
+
console.log(`${p.info.id}\t${p.info.version}\t${status}\t${p.info.displayName}\t${p.path}`);
|
|
336
428
|
}
|
|
337
429
|
}
|
|
338
430
|
|
|
@@ -451,8 +543,9 @@ function cmdDoctor(src = '.') {
|
|
|
451
543
|
const pkg = readPackageJson(abs);
|
|
452
544
|
const manifest = pkg?.finch ?? {};
|
|
453
545
|
// Surface recommended manifest metadata that's missing (non-fatal).
|
|
454
|
-
const recommended = ['name', 'description'
|
|
546
|
+
const recommended = ['name', 'description'];
|
|
455
547
|
const missing = recommended.filter((k) => manifest[k] == null);
|
|
548
|
+
if (manifest.miniToolType == null && manifest.extensionType == null) missing.push('miniToolType');
|
|
456
549
|
if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
|
|
457
550
|
if (manifest.permissions) {
|
|
458
551
|
const p = manifest.permissions;
|
|
@@ -477,7 +570,7 @@ async function cmdUpdate(id, opts) {
|
|
|
477
570
|
const dir = targetDir(opts);
|
|
478
571
|
const target = join(dir, id);
|
|
479
572
|
if (!existsSync(target)) throw new Error(`extension not found: ${id}`);
|
|
480
|
-
let source = readLock(dir)[id];
|
|
573
|
+
let source = normalizeInstallSource(readLock(dir)[id]);
|
|
481
574
|
if (!source) {
|
|
482
575
|
// No install record — this happens for bundled first-party extensions that
|
|
483
576
|
// were deployed by copying (e.g. the MCP bridge), not via `add`. Fall back to
|
|
@@ -486,7 +579,7 @@ async function cmdUpdate(id, opts) {
|
|
|
486
579
|
const pkg = readPackageJson(target);
|
|
487
580
|
const pkgName = typeof pkg?.name === 'string' ? pkg.name.trim() : '';
|
|
488
581
|
if (pkgName) {
|
|
489
|
-
source = { type: 'npm', package: pkgName };
|
|
582
|
+
source = normalizeInstallSource({ type: 'npm', package: pkgName });
|
|
490
583
|
} else {
|
|
491
584
|
throw new Error(`no install record for "${id}"; reinstall it with \`add\` to enable updates.`);
|
|
492
585
|
}
|
|
@@ -505,7 +598,7 @@ async function cmdUpdate(id, opts) {
|
|
|
505
598
|
return;
|
|
506
599
|
}
|
|
507
600
|
|
|
508
|
-
// zip source: re-download / re-extract from the recorded URL or local path.
|
|
601
|
+
// zip/tgz source: re-download / re-extract from the recorded URL or local path.
|
|
509
602
|
if (source.type === 'zip') {
|
|
510
603
|
if (source.url) {
|
|
511
604
|
await installFromZip(source.url, dir, true);
|
|
@@ -516,34 +609,39 @@ async function cmdUpdate(id, opts) {
|
|
|
516
609
|
}
|
|
517
610
|
return;
|
|
518
611
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const info = pluginInfo(fresh);
|
|
529
|
-
rmSync(target, { recursive: true, force: true });
|
|
530
|
-
cpSync(fresh, target, { recursive: true, force: true, dereference: false });
|
|
531
|
-
recordInstall(dir, id, source);
|
|
532
|
-
console.log(`✓ Updated "${info.displayName}" (${id}) → v${info.version}`);
|
|
533
|
-
} finally {
|
|
534
|
-
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
612
|
+
if (source.type === 'tgz') {
|
|
613
|
+
if (source.url) {
|
|
614
|
+
await installFromTgz(source.url, dir, { isUrl: true, lockSource: source });
|
|
615
|
+
} else if (source.localPath) {
|
|
616
|
+
await installFromTgz(source.localPath, dir, { isUrl: false, lockSource: source });
|
|
617
|
+
} else {
|
|
618
|
+
throw new Error(`tgz install record for "${id}" has no url or localPath; reinstall with \`add\`.`);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
535
621
|
}
|
|
622
|
+
|
|
623
|
+
// npm source: reinstall the latest published version by downloading dist.tarball directly.
|
|
624
|
+
const resolved = await resolveNpmTarball(source.package ?? id);
|
|
625
|
+
await installFromTgz(resolved.tarball, dir, {
|
|
626
|
+
isUrl: true,
|
|
627
|
+
lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
|
|
628
|
+
});
|
|
536
629
|
}
|
|
537
630
|
|
|
538
631
|
function parseArgs(argv) {
|
|
539
632
|
const args = [...argv];
|
|
540
633
|
const cmd = args.shift();
|
|
541
|
-
const opts = { global: false };
|
|
634
|
+
const opts = { global: false, registry: null };
|
|
542
635
|
const rest = [];
|
|
543
636
|
for (let i = 0; i < args.length; i += 1) {
|
|
544
637
|
const a = args[i];
|
|
545
638
|
if (a === '--global' || a === '-g') opts.global = true;
|
|
546
|
-
else if (a === '--
|
|
639
|
+
else if (a === '--registry') {
|
|
640
|
+
const value = args[i + 1];
|
|
641
|
+
if (!value || value.startsWith('--')) throw new Error('--registry requires a URL');
|
|
642
|
+
opts.registry = value;
|
|
643
|
+
i += 1;
|
|
644
|
+
} else if (a === '--cwd') {
|
|
547
645
|
// Removed: extensions no longer support project/--cwd scope. Consume any
|
|
548
646
|
// following path argument so old invocations fail loudly instead of
|
|
549
647
|
// silently being parsed as the install source.
|
|
@@ -554,12 +652,13 @@ function parseArgs(argv) {
|
|
|
554
652
|
}
|
|
555
653
|
|
|
556
654
|
function help() {
|
|
557
|
-
console.log(`npx @finch.app/
|
|
655
|
+
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`);
|
|
558
656
|
}
|
|
559
657
|
|
|
560
658
|
(async () => {
|
|
561
659
|
try {
|
|
562
660
|
const { cmd, rest, opts } = parseArgs(process.argv.slice(2));
|
|
661
|
+
registryOverride = opts.registry;
|
|
563
662
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
|
|
564
663
|
if (cmd === 'add') {
|
|
565
664
|
if (!rest[0]) throw new Error('missing source');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@finch.app/minitools",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "CLI shim for installing Finch
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "CLI shim for installing Finch mini tools to the correct location.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"finch-minitools": "bin/minitools.mjs"
|