promptgraph-mcp 2.7.1 → 2.8.1
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/commands/marketplace.js +19 -17
- package/github-import.js +126 -0
- package/marketplace.js +113 -54
- package/package.json +1 -1
- package/tui.js +13 -22
package/commands/marketplace.js
CHANGED
|
@@ -25,7 +25,7 @@ export default async function handler(args, bin) {
|
|
|
25
25
|
error('marketplace TUI requires an interactive terminal');
|
|
26
26
|
process.exit(1);
|
|
27
27
|
}
|
|
28
|
-
const { browseMarketplace, browseBundles, installSkill, installBundle } = await import('../marketplace.js');
|
|
28
|
+
const { browseMarketplace, browseBundles, installSkill, installBundle, installBundleBg, validateAndPruneMarketplace } = await import('../marketplace.js');
|
|
29
29
|
const { loadConfig: _lcMkt } = await import('../config.js');
|
|
30
30
|
const { getDb: _getDbMkt } = await import('../db.js');
|
|
31
31
|
const { spinner: spin2 } = await import('../cli.js');
|
|
@@ -81,30 +81,32 @@ export default async function handler(args, bin) {
|
|
|
81
81
|
const { loadConfig: _lcR, saveConfig: _scR, SKILLS_STORE_DIR: _ssR } = await import('../config.js');
|
|
82
82
|
const { getDb: _getDbR } = await import('../db.js');
|
|
83
83
|
|
|
84
|
-
const { validateAndPruneMarketplace } = await import('../marketplace.js');
|
|
85
|
-
|
|
86
84
|
await runTUI(
|
|
87
85
|
Array.isArray(skills) ? skills : [],
|
|
88
86
|
bundlesWithCounts,
|
|
89
|
-
async (item) => {
|
|
87
|
+
async (item, onStatus) => {
|
|
90
88
|
if (item.type === 'bundle') {
|
|
91
|
-
const r = await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
const r = await installBundleBg(item.id, async (err, result) => {
|
|
90
|
+
if (err) { onStatus(false, err.message?.slice(0, 60) || 'Install failed'); return; }
|
|
91
|
+
installedSet.add(item.id);
|
|
92
|
+
const { getCachedCount } = await import('../bundle-counts.js');
|
|
93
|
+
const cached = getCachedCount(item.repo_url);
|
|
94
|
+
if (cached !== null) item.skillCount = cached;
|
|
95
|
+
validateAndPruneMarketplace();
|
|
96
|
+
onStatus(true, `Installed ${item.name}`);
|
|
97
|
+
});
|
|
98
|
+
if (r?.error) {
|
|
99
|
+
if (!r.dedup) onStatus(false, r.error.slice(0, 60));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
onStatus(null, `Queued ${item.name}…`);
|
|
98
103
|
} else {
|
|
99
104
|
const r = await installSkill(item.code || item.id);
|
|
100
|
-
if (r?.error)
|
|
105
|
+
if (r?.error) { onStatus(false, r.error.slice(0, 60)); return; }
|
|
101
106
|
installedSet.add(item.id);
|
|
102
107
|
if (item.code) installedSet.add(item.code);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const pruneResult = validateAndPruneMarketplace();
|
|
106
|
-
if (pruneResult.removed.length > 0) {
|
|
107
|
-
console.log(`Pruned ${pruneResult.removed.length} invalid files from marketplace.`);
|
|
108
|
+
validateAndPruneMarketplace();
|
|
109
|
+
onStatus(true, `Installed ${item.name}`);
|
|
108
110
|
}
|
|
109
111
|
},
|
|
110
112
|
installedSet,
|
package/github-import.js
CHANGED
|
@@ -617,3 +617,129 @@ export async function importFromGitHub(repoUrl) {
|
|
|
617
617
|
await indexSource(skillsDir, repoSource);
|
|
618
618
|
console.log(`Done! Imported from ${repoName}/${label}`);
|
|
619
619
|
}
|
|
620
|
+
|
|
621
|
+
// ── light version (no git clone) ───────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
const SKIP_DOCS_RE = /^(readme|license|changelog|contributing|code.?of.?conduct|security|authors|credits|install|faq|index|overview|summary|todo|notes|template|copying|warranty|funding|roadmap|claude|bugs?\b|feature.?request)/i;
|
|
624
|
+
|
|
625
|
+
export async function importFromGitHubLight(repoUrl) {
|
|
626
|
+
if (!repoUrl) throw new Error('Missing repoUrl');
|
|
627
|
+
|
|
628
|
+
const url = repoUrl.startsWith('http') ? repoUrl : `https://github.com/${repoUrl}`;
|
|
629
|
+
const ownerRepo = url.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
|
|
630
|
+
const repoName = ownerRepo.replace('/', '-');
|
|
631
|
+
const destBase = path.join(SKILLS_STORE_DIR, 'github', repoName);
|
|
632
|
+
|
|
633
|
+
const ok = await repoExists(ownerRepo);
|
|
634
|
+
if (!ok) throw new Error(`Repository not found (404): ${url}`);
|
|
635
|
+
|
|
636
|
+
const detected = await detectSkillsDirFromAPI(ownerRepo);
|
|
637
|
+
const subdir = detected?.subdir || null;
|
|
638
|
+
|
|
639
|
+
let branch = 'main';
|
|
640
|
+
try {
|
|
641
|
+
const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
|
|
642
|
+
branch = JSON.parse(repoJson).default_branch || 'main';
|
|
643
|
+
} catch {}
|
|
644
|
+
|
|
645
|
+
let mdFiles;
|
|
646
|
+
if (subdir) {
|
|
647
|
+
const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
|
|
648
|
+
const tree = JSON.parse(treeJson);
|
|
649
|
+
const prefix = subdir + '/';
|
|
650
|
+
mdFiles = (tree.tree || [])
|
|
651
|
+
.filter(f => f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md'))
|
|
652
|
+
.map(f => ({
|
|
653
|
+
relativePath: f.path.replace(prefix, ''),
|
|
654
|
+
fullPath: f.path,
|
|
655
|
+
download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`,
|
|
656
|
+
}));
|
|
657
|
+
} else {
|
|
658
|
+
const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents`);
|
|
659
|
+
const entries = JSON.parse(json);
|
|
660
|
+
mdFiles = entries
|
|
661
|
+
.filter(e => e.type === 'file' && e.name.endsWith('.md'))
|
|
662
|
+
.map(e => ({
|
|
663
|
+
relativePath: e.name,
|
|
664
|
+
fullPath: e.name,
|
|
665
|
+
download_url: e.download_url,
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (mdFiles.length === 0) throw new Error(`No .md files found in ${ownerRepo}`);
|
|
670
|
+
|
|
671
|
+
const beforeFilter = mdFiles.length;
|
|
672
|
+
mdFiles = mdFiles.filter(f => !SKIP_DOCS_RE.test(f.relativePath.replace(/\.md$/i, '')));
|
|
673
|
+
if (mdFiles.length === 0) throw new Error(`All ${beforeFilter} .md files filtered as docs (README, LICENSE, etc.)`);
|
|
674
|
+
|
|
675
|
+
if (fs.existsSync(destBase)) fs.rmSync(destBase, { recursive: true, force: true });
|
|
676
|
+
fs.mkdirSync(destBase, { recursive: true });
|
|
677
|
+
|
|
678
|
+
let downloaded = 0;
|
|
679
|
+
for (const file of mdFiles) {
|
|
680
|
+
const destPath = path.join(destBase, file.fullPath);
|
|
681
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
682
|
+
try {
|
|
683
|
+
const content = await streamDownload(file.download_url);
|
|
684
|
+
fs.writeFileSync(destPath, content);
|
|
685
|
+
downloaded++;
|
|
686
|
+
} catch {
|
|
687
|
+
// skip failed downloads
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (downloaded === 0) {
|
|
692
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
693
|
+
throw new Error('Failed to download any files');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const allMd = globSync(`${destBase}/**/*.md`);
|
|
697
|
+
let removed = 0;
|
|
698
|
+
for (const fp of allMd) {
|
|
699
|
+
if (!isSkillFile(fp)) { try { fs.unlinkSync(fp); removed++; } catch {} }
|
|
700
|
+
}
|
|
701
|
+
if (removed > 0) removeEmptyDirs(destBase);
|
|
702
|
+
|
|
703
|
+
const remaining = globSync(`${destBase}/**/*.md`);
|
|
704
|
+
let removedV = 0;
|
|
705
|
+
for (const fp of remaining) {
|
|
706
|
+
const v = validateSkill(fp);
|
|
707
|
+
if (!v.ok) { try { fs.unlinkSync(fp); removedV++; } catch {} }
|
|
708
|
+
}
|
|
709
|
+
if (removedV > 0) removeEmptyDirs(destBase);
|
|
710
|
+
|
|
711
|
+
await classifierCleanup(destBase);
|
|
712
|
+
|
|
713
|
+
const realCount = globSync(`${destBase}/**/*.md`).length;
|
|
714
|
+
const cacheKey = url.replace(/\.git$/, '');
|
|
715
|
+
const cachePath = path.join(PROMPTGRAPH_DIR, 'skill-counts.json');
|
|
716
|
+
try {
|
|
717
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
718
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8') || '{}');
|
|
719
|
+
cache[cacheKey] = { count: realCount, ts: Date.now() };
|
|
720
|
+
fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2));
|
|
721
|
+
} catch {}
|
|
722
|
+
|
|
723
|
+
if (realCount < 1) {
|
|
724
|
+
fs.rmSync(destBase, { recursive: true, force: true });
|
|
725
|
+
throw new Error('No valid skills in repo — all files were filtered out');
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const { dir: localDir, label: localLabel } = detectSkillsDirLocal(destBase);
|
|
729
|
+
const skillsDir = subdir ? path.join(destBase, subdir) : localDir;
|
|
730
|
+
const label = subdir || localLabel;
|
|
731
|
+
const mdFilesInSkills = globSync(`${skillsDir}/**/*.md`);
|
|
732
|
+
|
|
733
|
+
const config = loadConfig();
|
|
734
|
+
const repoSource = `github:${repoName}`;
|
|
735
|
+
if (!config.sources.find(s => s.dir === skillsDir)) {
|
|
736
|
+
const oldIdx = config.sources.findIndex(s => s.source === repoSource);
|
|
737
|
+
if (oldIdx !== -1) config.sources.splice(oldIdx, 1);
|
|
738
|
+
config.sources.push({ dir: skillsDir, source: repoSource });
|
|
739
|
+
saveConfig(config);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
console.log();
|
|
743
|
+
await indexSource(skillsDir, repoSource);
|
|
744
|
+
console.log(`Done! Imported from ${repoName}/${label} (${realCount} skills, no git clone)`);
|
|
745
|
+
}
|
package/marketplace.js
CHANGED
|
@@ -8,7 +8,7 @@ import { getDb } from './db.js';
|
|
|
8
8
|
import { globSync } from 'glob';
|
|
9
9
|
import { validateSkill, validateBundle } from './validator.js';
|
|
10
10
|
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
|
|
11
|
-
import {
|
|
11
|
+
import { importFromGitHubLight, validateRepoSkills } from './github-import.js';
|
|
12
12
|
import { isSkillFile } from './parser.js';
|
|
13
13
|
|
|
14
14
|
const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
|
|
@@ -353,67 +353,126 @@ function ensureMarketplaceSource() {
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
-
|
|
356
|
+
// ── helpers extracted from installBundle ─────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
async function _findBundle(bundleId) {
|
|
359
|
+
const text = await fetchText(REGISTRY_URL);
|
|
360
|
+
const registry = JSON.parse(text);
|
|
361
|
+
const q = String(bundleId).trim().toLowerCase();
|
|
362
|
+
const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
|
|
363
|
+
const bundle = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok).find(b =>
|
|
364
|
+
(b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
|
|
365
|
+
);
|
|
366
|
+
if (!bundle) return { error: `No bundle matching "${bundleId}"` };
|
|
367
|
+
return { bundle, validSkills };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function _execRepoInstall(bundle) {
|
|
357
371
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (e.message && e.message.includes('No valid skills')) {
|
|
372
|
-
markDeadRepo(bundle.repo_url);
|
|
373
|
-
}
|
|
374
|
-
throw e;
|
|
375
|
-
}
|
|
376
|
-
// Update skill count cache with real on-disk count (post-filters)
|
|
377
|
-
const real = localSkillCount(bundle.repo_url);
|
|
378
|
-
if (real !== null) {
|
|
379
|
-
const cache = readSkillCountCache();
|
|
380
|
-
cache[bundle.repo_url] = { count: real, ts: Date.now() };
|
|
381
|
-
writeSkillCountCache(cache);
|
|
382
|
-
}
|
|
383
|
-
return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
|
|
384
|
-
}
|
|
372
|
+
await importFromGitHubLight(bundle.repo_url);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
if (e.message && e.message.includes('No valid skills')) markDeadRepo(bundle.repo_url);
|
|
375
|
+
throw e;
|
|
376
|
+
}
|
|
377
|
+
const real = localSkillCount(bundle.repo_url);
|
|
378
|
+
if (real !== null) {
|
|
379
|
+
const cache = readSkillCountCache();
|
|
380
|
+
cache[bundle.repo_url] = { count: real, ts: Date.now() };
|
|
381
|
+
writeSkillCountCache(cache);
|
|
382
|
+
}
|
|
383
|
+
return { success: true, bundle: bundle.name, type: 'repo_import', repo_url: bundle.repo_url };
|
|
384
|
+
}
|
|
385
385
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
}
|
|
386
|
+
async function _execSkillsInstall(bundle, validSkills) {
|
|
387
|
+
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
388
|
+
ensureMarketplaceSource();
|
|
389
|
+
const installed = [];
|
|
390
|
+
const failed = [];
|
|
391
|
+
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
392
|
+
for (const skillId of bundle.skills || []) {
|
|
393
|
+
const skill = validSkills.find(s => s.id === skillId);
|
|
394
|
+
if (!skill?.raw_url) { failed.push(skillId); continue; }
|
|
395
|
+
try {
|
|
396
|
+
if (installed.length > 0) await delay(300);
|
|
397
|
+
const content = await fetchText(skill.raw_url);
|
|
398
|
+
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
|
|
399
|
+
const resolvedDest = path.resolve(dest);
|
|
400
|
+
if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) { failed.push(skillId); continue; }
|
|
401
|
+
const v = writeSkillAtomic(dest, content);
|
|
402
|
+
if (!v.ok) { failed.push(skillId); continue; }
|
|
403
|
+
installed.push(skillId);
|
|
404
|
+
} catch { failed.push(skillId); }
|
|
405
|
+
}
|
|
406
|
+
return { success: true, bundle: bundle.name, installed, failed, dir: SKILLS_DIR };
|
|
407
|
+
}
|
|
410
408
|
|
|
411
|
-
|
|
409
|
+
export async function installBundle(bundleId) {
|
|
410
|
+
try {
|
|
411
|
+
const found = await _findBundle(bundleId);
|
|
412
|
+
if (found.error) return { error: found.error };
|
|
413
|
+
const { bundle, validSkills } = found;
|
|
414
|
+
return bundle.repo_url
|
|
415
|
+
? await _execRepoInstall(bundle)
|
|
416
|
+
: await _execSkillsInstall(bundle, validSkills);
|
|
412
417
|
} catch (e) {
|
|
413
418
|
return { error: e.message };
|
|
414
419
|
}
|
|
415
420
|
}
|
|
416
421
|
|
|
422
|
+
// ── Background install queue ──────────────────────────────────────────────────
|
|
423
|
+
// Allows the TUI to queue multiple installs without blocking.
|
|
424
|
+
|
|
425
|
+
const _bgQueue = [];
|
|
426
|
+
let _bgRunning = false;
|
|
427
|
+
let _bgCurrentId = null;
|
|
428
|
+
const _bgPendingKeys = new Set();
|
|
429
|
+
|
|
430
|
+
async function _bgProcess() {
|
|
431
|
+
if (_bgRunning) return;
|
|
432
|
+
_bgRunning = true;
|
|
433
|
+
while (_bgQueue.length > 0) {
|
|
434
|
+
const { bundle, validSkills, onDone, _dedupKey } = _bgQueue.shift();
|
|
435
|
+
if (_dedupKey) _bgPendingKeys.delete(_dedupKey);
|
|
436
|
+
_bgCurrentId = bundle.id;
|
|
437
|
+
try {
|
|
438
|
+
const result = bundle.repo_url
|
|
439
|
+
? await _execRepoInstall(bundle)
|
|
440
|
+
: await _execSkillsInstall(bundle, validSkills);
|
|
441
|
+
await onDone?.(null, result);
|
|
442
|
+
} catch (e) {
|
|
443
|
+
await onDone?.(e, null);
|
|
444
|
+
} finally {
|
|
445
|
+
_bgCurrentId = null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
_bgRunning = false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Install a bundle in the background, returning immediately.
|
|
452
|
+
// The actual work is serialized through an internal queue.
|
|
453
|
+
// onDone(err, result) is called when the queue finishes processing this bundle.
|
|
454
|
+
export async function installBundleBg(bundleId, onDone) {
|
|
455
|
+
const q = String(bundleId).trim().toLowerCase();
|
|
456
|
+
// Synchronous dedup check BEFORE any await — prevents race on double-Enter
|
|
457
|
+
if (_bgPendingKeys.has(q)) {
|
|
458
|
+
return { dedup: true, error: `"${bundleId}" is already queued or installing` };
|
|
459
|
+
}
|
|
460
|
+
_bgPendingKeys.add(q);
|
|
461
|
+
|
|
462
|
+
const found = await _findBundle(bundleId);
|
|
463
|
+
if (found.error) { _bgPendingKeys.delete(q); return found; }
|
|
464
|
+
const { bundle, validSkills } = found;
|
|
465
|
+
|
|
466
|
+
if (_bgCurrentId === bundle.id || _bgQueue.some(e => e.bundle.id === bundle.id)) {
|
|
467
|
+
_bgPendingKeys.delete(q);
|
|
468
|
+
return { dedup: true, error: `"${bundle.name}" is already queued or installing` };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
_bgQueue.push({ bundle, validSkills, onDone, _dedupKey: q });
|
|
472
|
+
_bgProcess();
|
|
473
|
+
return { queued: true, id: bundle.id, name: bundle.name };
|
|
474
|
+
}
|
|
475
|
+
|
|
417
476
|
function ghPublish(filePath, desc) {
|
|
418
477
|
try {
|
|
419
478
|
const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
|
package/package.json
CHANGED
package/tui.js
CHANGED
|
@@ -116,12 +116,12 @@ function render(state, installedSet = new Set()) {
|
|
|
116
116
|
write(searchLabel + searchVal + CLEAR_EOL + '\n');
|
|
117
117
|
|
|
118
118
|
// Row 4: separator (with optional status inline or spinner)
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
write(dim('─'.repeat(4)) +
|
|
119
|
+
if (status) {
|
|
120
|
+
let line;
|
|
121
|
+
if (status.ok === true) line = green(' ✓ ' + status.msg);
|
|
122
|
+
else if (status.ok === false) line = red(' ✗ ' + status.msg);
|
|
123
|
+
else line = cyan(' ⟳ ' + (status.msg || ''));
|
|
124
|
+
write(dim('─'.repeat(4)) + line + CLEAR_EOL + '\n');
|
|
125
125
|
} else {
|
|
126
126
|
write(dim('─'.repeat(cols)) + CLEAR_EOL + '\n');
|
|
127
127
|
}
|
|
@@ -248,7 +248,6 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
|
|
|
248
248
|
scroll: 0,
|
|
249
249
|
items: allItems,
|
|
250
250
|
status: null,
|
|
251
|
-
installing: false,
|
|
252
251
|
};
|
|
253
252
|
|
|
254
253
|
function refresh(q, t) {
|
|
@@ -391,21 +390,13 @@ export async function runTUI(allSkills, allBundles, installFn, installedSet = ne
|
|
|
391
390
|
|
|
392
391
|
if (key.name === 'return' || key.name === 'i') {
|
|
393
392
|
const sel = state.items[state.cursor];
|
|
394
|
-
if (!sel
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
clearInterval(spinInterval);
|
|
402
|
-
setStatus(true, `Installed ${sel.id}`);
|
|
403
|
-
} catch (e) {
|
|
404
|
-
clearInterval(spinInterval);
|
|
405
|
-
setStatus(false, e.message.slice(0, 60));
|
|
406
|
-
} finally {
|
|
407
|
-
state.installing = false;
|
|
408
|
-
}
|
|
393
|
+
if (!sel) return;
|
|
394
|
+
// Fire install in background — TUI stays responsive for queuing more
|
|
395
|
+
installFn(sel, (ok, msg) => {
|
|
396
|
+
setStatus(ok, msg);
|
|
397
|
+
}).catch(e => {
|
|
398
|
+
setStatus(false, e.message?.slice(0, 60) || 'Install failed');
|
|
399
|
+
});
|
|
409
400
|
return;
|
|
410
401
|
}
|
|
411
402
|
|