@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.
Files changed (3) hide show
  1. package/README.md +19 -19
  2. package/bin/minitools.mjs +203 -104
  3. 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 extensions to the correct location.
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-extension-example
9
+ npx @finch.app/minitools add @scope/finch-mini-tool-example
10
10
 
11
- # Install a local extension directory
12
- npx @finch.app/minitools add ./my-extension
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-extension.zip
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/extension-mcp --cwd
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/extension-mcp --cwd /path/to/project
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/extension-mcp --global
25
+ npx @finch.app/minitools add @finch.app/mcp-client --global
26
26
 
27
- # List installed extensions
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 extension
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 extension package
38
- npx @finch.app/minitools doctor ./my-extension
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
- ## Extension package
52
+ ## Mini tool package
53
53
 
54
- A Finch extension is an npm-style package with `package.json#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-extension",
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-extension",
65
- "displayName": "My Extension",
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 extension but does not enable or grant permissions. Open Finch → Toolcase → Extensions to review permissions and enable it.
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/extensions — install Finch extensions to the correct location.
3
+ * @finch.app/minitools — install Finch extensions to the correct location.
4
4
  *
5
- * Zero npm dependencies. npm sources are fetched with `npm install --ignore-scripts`
6
- * so third-party install scripts never run during CLI install.
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, statSync,
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
- if (typeof fetch === 'function') {
193
+ try {
215
194
  const res = await fetch(url, { redirect: 'follow' });
216
- if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText} — ${url}`);
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
- } else {
220
- // Fallback: curl (macOS / Linux always has it)
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
- // --- zip URL (e.g. https://github.com/.../archive/main.zip) ---
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 zip file
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 tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
307
- try {
308
- npmInstallToTemp(src, tmp);
309
- const found = findPluginDirs(join(tmp, 'node_modules'), 5);
310
- if (found.length === 0) throw new Error('No package with package.json#finch found in the npm package.');
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
- console.log(`${p.info.id}\t${p.info.version}\t${p.info.displayName}\t${p.path}`);
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', 'extensionType'];
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
- // npm source: reinstall the latest published version.
521
- const spec = source.package ?? id;
522
- const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
523
- try {
524
- npmInstallToTemp(spec, tmp);
525
- const found = findPluginDirs(join(tmp, 'node_modules'), 5).filter((d) => pluginInfo(d)?.id === id);
526
- const fresh = found[0] ?? findPluginDirs(join(tmp, 'node_modules'), 5)[0];
527
- if (!fresh) throw new Error('No matching Finch extension found in npm package.');
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 === '--cwd') {
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/extensions\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`);
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.5",
4
- "description": "CLI shim for installing Finch extensions to the correct location.",
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"