@finch.app/minitools 0.1.6 → 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 (2) hide show
  1. package/bin/minitools.mjs +186 -99
  2. package/package.json +1 -1
package/bin/minitools.mjs CHANGED
@@ -2,12 +2,12 @@
2
2
  /**
3
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';
@@ -147,63 +147,8 @@ function installExtensionDir(srcDir, destRoot, lockSource) {
147
147
  return info.id;
148
148
  }
149
149
 
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
150
  function isLocalSource(src) {
206
- return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~');
151
+ return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(src);
207
152
  }
208
153
  function isZipUrl(src) {
209
154
  return /^https?:\/\/.+\.zip(\?.*)?$/i.test(src);
@@ -211,6 +156,13 @@ function isZipUrl(src) {
211
156
  function isZipFile(src) {
212
157
  return src.toLowerCase().endsWith('.zip');
213
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
+ }
214
166
  function expandHome(path) {
215
167
  return path.replace(/^~(?=\/|$)/, homedir());
216
168
  }
@@ -219,16 +171,32 @@ function expandHome(path) {
219
171
  * Download a URL to a local file path using Node's built-in fetch (Node 18+).
220
172
  * Falls back to curl/wget if fetch is unavailable.
221
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
+
222
192
  async function downloadFile(url, destPath) {
223
- if (typeof fetch === 'function') {
193
+ try {
224
194
  const res = await fetch(url, { redirect: 'follow' });
225
- if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText} — ${url}`);
195
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
226
196
  const buf = Buffer.from(await res.arrayBuffer());
227
197
  writeFileSync(destPath, buf);
228
- } else {
229
- // Fallback: curl (macOS / Linux always has it)
230
- const r = spawnSync('curl', ['-fsSL', '-o', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
231
- if (r.status !== 0) throw new Error(`curl failed:\n${r.stderr || r.stdout}`);
198
+ } catch (err) {
199
+ downloadWithSystemTool(url, destPath, err);
232
200
  }
233
201
  }
234
202
 
@@ -237,6 +205,21 @@ async function downloadFile(url, destPath) {
237
205
  */
238
206
  function extractZip(zipPath, destDir) {
239
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
+ }
240
223
  const r = spawnSync('unzip', ['-q', '-o', zipPath, '-d', destDir], {
241
224
  stdio: ['ignore', 'pipe', 'pipe'],
242
225
  encoding: 'utf-8',
@@ -247,6 +230,75 @@ function extractZip(zipPath, destDir) {
247
230
  if (r.status !== 0) throw new Error(`unzip failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
248
231
  }
249
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
+
250
302
  /**
251
303
  * Install extensions from a zip file (local path or remote URL).
252
304
  * The zip may contain one or more extensions at any nesting level.
@@ -279,25 +331,60 @@ async function installFromZip(src, dest, isUrl) {
279
331
  }
280
332
  }
281
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
+
282
361
  async function cmdAdd(src, opts) {
283
362
  const dest = targetDir(opts);
284
363
 
285
- // --- 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) ---
286
365
  if (isZipUrl(src)) {
287
366
  await installFromZip(src, dest, true);
288
367
  return;
289
368
  }
369
+ if (isTgzUrl(src)) {
370
+ await installFromTgz(src, dest, { isUrl: true });
371
+ return;
372
+ }
290
373
 
291
374
  // --- local path ---
292
375
  if (isLocalSource(src)) {
293
376
  const abs = resolve(expandHome(src));
294
377
  if (!existsSync(abs)) throw new Error(`path not found: ${abs}`);
295
378
 
296
- // Local zip file
379
+ // Local archive file
297
380
  if (isZipFile(src)) {
298
381
  await installFromZip(src, dest, false);
299
382
  return;
300
383
  }
384
+ if (isTgzFile(src)) {
385
+ await installFromTgz(src, dest, { isUrl: false });
386
+ return;
387
+ }
301
388
 
302
389
  // Local directory
303
390
  const direct = pluginInfo(abs);
@@ -311,18 +398,12 @@ async function cmdAdd(src, opts) {
311
398
  return;
312
399
  }
313
400
 
314
- // --- npm package ---
315
- const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
316
- try {
317
- npmInstallToTemp(src, tmp);
318
- const found = findPluginDirs(join(tmp, 'node_modules'), 5);
319
- if (found.length === 0) throw new Error('No package with package.json#finch found in the npm package.');
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
- }
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
+ });
326
407
  }
327
408
 
328
409
  function listInstalled(dir) {
@@ -517,7 +598,7 @@ async function cmdUpdate(id, opts) {
517
598
  return;
518
599
  }
519
600
 
520
- // 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.
521
602
  if (source.type === 'zip') {
522
603
  if (source.url) {
523
604
  await installFromZip(source.url, dir, true);
@@ -528,34 +609,39 @@ async function cmdUpdate(id, opts) {
528
609
  }
529
610
  return;
530
611
  }
531
-
532
- // npm source: reinstall the latest published version.
533
- const spec = source.package ?? id;
534
- const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
535
- try {
536
- npmInstallToTemp(spec, tmp);
537
- const found = findPluginDirs(join(tmp, 'node_modules'), 5).filter((d) => pluginInfo(d)?.id === id);
538
- const fresh = found[0] ?? findPluginDirs(join(tmp, 'node_modules'), 5)[0];
539
- if (!fresh) throw new Error('No matching Finch extension found in npm package.');
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 */ }
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;
547
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
+ });
548
629
  }
549
630
 
550
631
  function parseArgs(argv) {
551
632
  const args = [...argv];
552
633
  const cmd = args.shift();
553
- const opts = { global: false };
634
+ const opts = { global: false, registry: null };
554
635
  const rest = [];
555
636
  for (let i = 0; i < args.length; i += 1) {
556
637
  const a = args[i];
557
638
  if (a === '--global' || a === '-g') opts.global = true;
558
- 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') {
559
645
  // Removed: extensions no longer support project/--cwd scope. Consume any
560
646
  // following path argument so old invocations fail loudly instead of
561
647
  // silently being parsed as the install source.
@@ -566,12 +652,13 @@ function parseArgs(argv) {
566
652
  }
567
653
 
568
654
  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`);
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`);
570
656
  }
571
657
 
572
658
  (async () => {
573
659
  try {
574
660
  const { cmd, rest, opts } = parseArgs(process.argv.slice(2));
661
+ registryOverride = opts.registry;
575
662
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
576
663
  if (cmd === 'add') {
577
664
  if (!rest[0]) throw new Error('missing source');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finch.app/minitools",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI shim for installing Finch mini tools to the correct location.",
5
5
  "type": "module",
6
6
  "bin": {