manyplug 1.3.0

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/src/install.js ADDED
@@ -0,0 +1,204 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { execSync } from 'node:child_process';
5
+ import { formatSize } from './ui.js';
6
+ import { loadLocalRegistry, saveRegistry, fetchRemoteRegistry } from './registry-ops.js';
7
+ import { getDirSize, installPluginFromRepo, installNpmDeps } from './utils.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const PLUGINS_DIR = path.join(process.cwd(), 'src', 'plugins');
11
+
12
+ // ------------------------------------------------------------
13
+ // helpers
14
+ // ------------------------------------------------------------
15
+
16
+ function onMirror(mirror, status, err) {
17
+ console.log(` ${status === 'ok' ? '+' : 'x'} ${mirror.name}${err ? ': ' + err : ''}`);
18
+ }
19
+
20
+ function commandExists(cmd) {
21
+ try {
22
+ const check = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
23
+ execSync(check, { stdio: 'pipe' });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // Returns { missing: [], optional: [] } based on manifest.externalDependencies
31
+ function checkExternalDeps(manifest) {
32
+ const ext = manifest.externalDependencies;
33
+ if (!ext || !Object.keys(ext).length) return { missing: [], optional: [] };
34
+
35
+ const missing = [], optional = [];
36
+
37
+ for (const [name, cfg] of Object.entries(ext)) {
38
+ if (commandExists(typeof cfg === 'string' ? cfg : cfg.command)) continue;
39
+ (cfg?.optional ? optional : missing).push(name);
40
+ }
41
+
42
+ return { missing, optional };
43
+ }
44
+
45
+ function reportExternalDeps(manifest) {
46
+ const { missing, optional } = checkExternalDeps(manifest);
47
+ if (missing.length) console.warn(`warn: missing external deps: ${missing.join(', ')}`);
48
+ if (optional.length) console.log(`info: optional deps not found: ${optional.join(', ')}`);
49
+ return { missing, optional };
50
+ }
51
+
52
+ async function installDeps(manifest, targetDir) {
53
+ const deps = manifest.dependencies;
54
+ if (!deps || !Object.keys(deps).length) return;
55
+ console.log(' installing npm deps...');
56
+ await installNpmDeps(deps, targetDir);
57
+ }
58
+
59
+ async function registerPlugin(pluginName, manifest, extra = {}) {
60
+ const registry = await loadLocalRegistry();
61
+ registry.plugins[pluginName] = { ...manifest, ...extra };
62
+ await saveRegistry(registry);
63
+ }
64
+
65
+ // ------------------------------------------------------------
66
+ // install from local path
67
+ // ------------------------------------------------------------
68
+
69
+ async function installFromLocal(sourcePath) {
70
+ const src = path.resolve(sourcePath);
71
+
72
+ if (!await fs.pathExists(src))
73
+ return { success: false, error: `path not found: ${src}` };
74
+
75
+ const manifestPath = path.join(src, 'manyplug.json');
76
+ if (!await fs.pathExists(manifestPath))
77
+ return { success: false, error: 'manyplug.json not found' };
78
+
79
+ let manifest;
80
+ try { manifest = await fs.readJson(manifestPath); }
81
+ catch (e) { return { success: false, error: `invalid manyplug.json: ${e.message}` }; }
82
+
83
+ const name = manifest.name || path.basename(src);
84
+ const dest = path.join(PLUGINS_DIR, name);
85
+
86
+ if (await fs.pathExists(dest))
87
+ return { success: false, error: `${name} already installed` };
88
+
89
+ const size = await getDirSize(src);
90
+ console.log(`installing ${name} src=${src} size=${formatSize(size)}`);
91
+ reportExternalDeps(manifest);
92
+
93
+ try {
94
+ await fs.ensureDir(PLUGINS_DIR);
95
+ await fs.copy(src, dest);
96
+ await installDeps(manifest, dest);
97
+ await registerPlugin(name, manifest, { local: true });
98
+ return { success: true, plugin: name, size };
99
+ } catch (e) {
100
+ return { success: false, error: e.message, plugin: name };
101
+ }
102
+ }
103
+
104
+ // ------------------------------------------------------------
105
+ // install single plugin from remote registry
106
+ // ------------------------------------------------------------
107
+
108
+ async function installFromRegistry(pluginName, manifest, mirror) {
109
+ const t = Date.now();
110
+ const dest = path.join(PLUGINS_DIR, pluginName);
111
+
112
+ try {
113
+ const { size } = await installPluginFromRepo({ plugin: pluginName, repo: mirror }, PLUGINS_DIR);
114
+ await installDeps(manifest, dest);
115
+ await registerPlugin(pluginName, manifest);
116
+ return { success: true, plugin: pluginName, size, duration: Date.now() - t };
117
+ } catch (e) {
118
+ return { success: false, error: e.message, plugin: pluginName };
119
+ }
120
+ }
121
+
122
+ // ------------------------------------------------------------
123
+ // install command (entry point)
124
+ // ------------------------------------------------------------
125
+
126
+ export async function installCommand(pluginsInput, options = {}) {
127
+ const t = Date.now();
128
+ const names = Array.isArray(pluginsInput) ? pluginsInput : (pluginsInput ? [pluginsInput] : []);
129
+
130
+ await fs.ensureDir(PLUGINS_DIR);
131
+
132
+ // -- local install --
133
+ if (options.local) {
134
+ const r = await installFromLocal(options.local);
135
+ if (!r.success) { console.error(r.error); process.exit(1); }
136
+ console.log(`installed ${r.plugin} size=${formatSize(r.size)} time=${elapsed(t)}s`);
137
+ return;
138
+ }
139
+
140
+ if (!names.length) { console.error('usage: manyplug install <plugin>'); process.exit(1); }
141
+
142
+ // -- fetch remote registry --
143
+ process.stdout.write('fetching registry... ');
144
+ let remoteRegistry, mirror;
145
+ try {
146
+ ({ remoteRegistry, selectedMirror: mirror } = await fetchRemoteRegistry(onMirror));
147
+ mirror = mirror.git;
148
+ console.log('ok');
149
+ } catch (e) {
150
+ console.error(`failed: ${e.message}`);
151
+ process.exit(1);
152
+ }
153
+
154
+ // -- classify plugins --
155
+ const toInstall = [], toReinstall = [], notFound = [];
156
+
157
+ for (const name of names) {
158
+ const manifest = remoteRegistry.plugins[name];
159
+ if (!manifest) { notFound.push(name); continue; }
160
+
161
+ const installed = await fs.pathExists(path.join(PLUGINS_DIR, name));
162
+ const entry = { name, version: manifest.version, manifest };
163
+
164
+ if (installed && !options.needed) toReinstall.push(entry);
165
+ else if (!installed) toInstall.push(entry);
166
+ // installed + --needed => skip (nothing pushed)
167
+ }
168
+
169
+ if (notFound.length) console.error(`not found: ${notFound.join(', ')}`);
170
+
171
+ const queue = [...toInstall, ...toReinstall];
172
+ if (!queue.length) { console.log('nothing to do'); process.exit(0); }
173
+
174
+ // -- print plan --
175
+ for (const p of toInstall) console.log(`+ ${p.name}@${p.version}`);
176
+ for (const p of toReinstall) console.log(`~ ${p.name}@${p.version} (reinstall)`);
177
+
178
+ // -- remove stale installs --
179
+ for (const p of toReinstall) {
180
+ await fs.remove(path.join(PLUGINS_DIR, p.name));
181
+ const registry = await loadLocalRegistry();
182
+ delete registry.plugins[p.name];
183
+ await saveRegistry(registry);
184
+ }
185
+
186
+ // -- run queue --
187
+ const results = [];
188
+ for (const p of queue) {
189
+ process.stdout.write(`installing ${p.name}... `);
190
+ const r = await installFromRegistry(p.name, p.manifest, mirror);
191
+ results.push(r);
192
+ console.log(r.success ? 'done' : `FAILED${r.error ? ': ' + r.error : ''}`);
193
+ }
194
+
195
+ // -- summary --
196
+ const ok = results.filter(r => r.success).length;
197
+ const bad = results.length - ok;
198
+ console.log(`\n${ok}/${results.length} installed in ${elapsed(t)}s`);
199
+ if (bad) process.exit(1);
200
+ }
201
+
202
+ // ------------------------------------------------------------
203
+
204
+ function elapsed(since) { return ((Date.now() - since) / 1000).toFixed(2); }
package/src/list.js ADDED
@@ -0,0 +1,90 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { existsSync, readFileSync } from 'fs';
4
+
5
+ const CONF_PATH = path.resolve(process.cwd(), 'manybot.conf');
6
+ const PLUGINS_DIR = path.join(process.cwd(), 'src', 'plugins');
7
+
8
+ // ------------------------------------------------------------
9
+ // conf — reuse same parser as enable-disable reads
10
+ // ------------------------------------------------------------
11
+
12
+ function readEnabled() {
13
+ if (!existsSync(CONF_PATH)) return new Set();
14
+ const match = readFileSync(CONF_PATH, 'utf-8').match(/PLUGINS=\[\s*([\s\S]*?)\s*\]/);
15
+ if (!match) return new Set();
16
+ return new Set(match[1].split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
17
+ }
18
+
19
+ // ------------------------------------------------------------
20
+ // list command
21
+ // ------------------------------------------------------------
22
+
23
+ export async function listCommand(options = {}) {
24
+ if (!await fs.pathExists(PLUGINS_DIR)) {
25
+ console.error(`error: plugins dir not found: ${PLUGINS_DIR}`);
26
+ return;
27
+ }
28
+
29
+ const enabled = readEnabled();
30
+ const entries = (await fs.readdir(PLUGINS_DIR, { withFileTypes: true })).filter(e => e.isDirectory());
31
+ const plugins = [];
32
+
33
+ for (const entry of entries) {
34
+ const dir = path.join(PLUGINS_DIR, entry.name);
35
+ const manifestPath = path.join(dir, 'manyplug.json');
36
+ const hasEntry = await fs.pathExists(path.join(dir, 'index.js'));
37
+ const isEnabled = enabled.has(entry.name.toLowerCase());
38
+
39
+ if (!isEnabled && !options.all) continue;
40
+
41
+ let manifest = {};
42
+ try { manifest = await fs.readJson(manifestPath); }
43
+ catch { manifest = { name: entry.name, version: '?', category: '?', _error: true }; }
44
+
45
+ plugins.push({
46
+ name: manifest.name || entry.name,
47
+ version: manifest.version || '-',
48
+ category: manifest.category || '-',
49
+ service: manifest.service === true,
50
+ local: manifest.local === true,
51
+ enabled: isEnabled,
52
+ hasEntry,
53
+ _error: manifest._error || false,
54
+ });
55
+ }
56
+
57
+ if (!plugins.length) {
58
+ console.log(options.all ? 'no plugins installed' : 'no enabled plugins (use --all to see all)');
59
+ return;
60
+ }
61
+
62
+ // column widths
63
+ const w = {
64
+ name: Math.max(4, ...plugins.map(p => p.name.length)),
65
+ version: Math.max(7, ...plugins.map(p => p.version.length)),
66
+ category: Math.max(8, ...plugins.map(p => p.category.length)),
67
+ };
68
+
69
+ const pad = (s, n) => s.padEnd(n);
70
+ const header = ` ${'name'.padEnd(w.name)} ${'version'.padEnd(w.version)} ${'category'.padEnd(w.category)} type status`;
71
+ console.log(header);
72
+ console.log(' ' + '-'.repeat(header.length - 2));
73
+
74
+ for (const p of plugins) {
75
+ const flag = p.local ? 'L' : p._error ? '!' : ' ';
76
+ const type = p.service ? 'svc' : 'std';
77
+ const status = !p.hasEntry ? 'incomplete' : p.enabled ? 'enabled' : 'disabled';
78
+ console.log(`${flag} ${pad(p.name, w.name)} ${pad(p.version, w.version)} ${pad(p.category, w.category)} ${pad(type, 4)} ${status}`);
79
+ }
80
+
81
+ // summary
82
+ const en = plugins.filter(p => p.enabled).length;
83
+ const dis = plugins.length - en;
84
+ const loc = plugins.filter(p => p.local).length;
85
+ const inc = plugins.filter(p => !p.hasEntry).length;
86
+
87
+ console.log('');
88
+ console.log(`total=${plugins.length} enabled=${en} disabled=${dis}${loc ? ' local=' + loc : ''}${inc ? ' incomplete=' + inc : ''}`);
89
+ console.log('L=local svc=service std=standard !=missing index.js');
90
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const REGISTRY_PATH = path.join(process.cwd(), 'registry.json');
7
+ const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
8
+
9
+ // ------------------------------------------------------------
10
+
11
+ function loadConfig() {
12
+ try { return fs.readJsonSync(CONFIG_PATH); }
13
+ catch (e) { console.error(`error: config.json: ${e.message}`); process.exit(1); }
14
+ }
15
+
16
+ export const MIRRORS = loadConfig().mirrors;
17
+
18
+ // ------------------------------------------------------------
19
+
20
+ export async function loadLocalRegistry() {
21
+ try { return await fs.readJson(REGISTRY_PATH); }
22
+ catch { return { lastUpdated: null, plugins: {} }; }
23
+ }
24
+
25
+ export async function saveRegistry(registry) {
26
+ registry.lastUpdated = new Date().toISOString();
27
+ await fs.writeJson(REGISTRY_PATH, registry, { spaces: 2 });
28
+ }
29
+
30
+ // ------------------------------------------------------------
31
+
32
+ // onMirror(mirror, 'ok'|'fail', errMsg?) — optional progress callback
33
+ export async function fetchRemoteRegistry(onMirror = () => {}) {
34
+ for (const mirror of MIRRORS) {
35
+ try {
36
+ const res = await fetch(`${mirror.fetch}/registry.json`);
37
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
38
+ onMirror(mirror, 'ok');
39
+ return { remoteRegistry: await res.json(), selectedMirror: mirror };
40
+ } catch (e) {
41
+ onMirror(mirror, 'fail', e.message);
42
+ }
43
+ }
44
+ throw new Error('all mirrors failed');
45
+ }
package/src/remove.js ADDED
@@ -0,0 +1,114 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { exec } from 'node:child_process';
4
+ import { formatSize } from './ui.js';
5
+ import { loadLocalRegistry, saveRegistry } from './registry-ops.js';
6
+
7
+ const PLUGINS_DIR = path.join(process.cwd(), 'src', 'plugins');
8
+
9
+ // ------------------------------------------------------------
10
+ // helpers
11
+ // ------------------------------------------------------------
12
+
13
+ function elapsed(since) { return ((Date.now() - since) / 1000).toFixed(2); }
14
+
15
+ function run(cmd, cwd) {
16
+ return new Promise((res, rej) =>
17
+ exec(cmd, { cwd }, (err, stdout) => err ? rej(err) : res(stdout))
18
+ );
19
+ }
20
+
21
+ async function getDirSize(dir) {
22
+ let total = 0;
23
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
24
+ const p = path.join(dir, entry.name);
25
+ total += entry.isDirectory() ? await getDirSize(p) : (await fs.stat(p)).size;
26
+ }
27
+ return total;
28
+ }
29
+
30
+ async function ask(prompt) {
31
+ process.stdout.write(prompt);
32
+ return new Promise(res => process.stdin.once('data', d => res(d.toString().trim().toLowerCase())));
33
+ }
34
+
35
+ // ------------------------------------------------------------
36
+ // remove command
37
+ // ------------------------------------------------------------
38
+
39
+ export async function removeCommand(input, options = {}) {
40
+ const t = Date.now();
41
+ const names = Array.isArray(input) ? input : (input ? [input] : []);
42
+
43
+ if (!names.length) {
44
+ console.error('usage: manyplug remove <plugin> [plugin2...]');
45
+ process.exit(1);
46
+ }
47
+
48
+ const results = [];
49
+
50
+ for (const name of names) {
51
+ const dir = path.join(PLUGINS_DIR, name);
52
+ const manifestPath = path.join(dir, 'manyplug.json');
53
+
54
+ if (!await fs.pathExists(manifestPath)) {
55
+ console.error(`x ${name}: not installed`);
56
+ results.push({ name, success: false });
57
+ continue;
58
+ }
59
+
60
+ let manifest = {};
61
+ try { manifest = await fs.readJson(manifestPath); } catch {}
62
+
63
+ const size = await getDirSize(dir);
64
+ const deps = manifest.dependencies || {};
65
+ const hasDeps = Object.keys(deps).length > 0;
66
+
67
+ console.log(`- ${name}@${manifest.version || '?'} size=${formatSize(size)} path=${path.relative(process.cwd(), dir)}`);
68
+ if (hasDeps) console.log(` deps: ${Object.keys(deps).join(', ')}`);
69
+
70
+ if (!options.yes) {
71
+ const answer = await ask('remove? [y/N] ');
72
+ if (answer !== 'y') {
73
+ console.log(` skipped`);
74
+ results.push({ name, success: false, skipped: true });
75
+ continue;
76
+ }
77
+ }
78
+
79
+ try {
80
+ await fs.remove(dir);
81
+
82
+ const registry = await loadLocalRegistry();
83
+ delete registry.plugins[name];
84
+ await saveRegistry(registry);
85
+
86
+ if (hasDeps && options.removeDeps) {
87
+ process.stdout.write(` uninstalling npm deps... `);
88
+ try {
89
+ await run(`npm uninstall ${Object.keys(deps).join(' ')}`, process.cwd());
90
+ console.log('ok');
91
+ } catch (e) {
92
+ console.log(`warn: ${e.message}`);
93
+ }
94
+ }
95
+
96
+ console.log(` done freed=${formatSize(size)}`);
97
+ results.push({ name, success: true, size });
98
+ } catch (e) {
99
+ console.error(` FAILED: ${e.message}`);
100
+ results.push({ name, success: false, error: e.message });
101
+ }
102
+ }
103
+
104
+ // summary
105
+ const ok = results.filter(r => r.success).length;
106
+ const bad = results.length - ok;
107
+ const freed = results.reduce((a, r) => a + (r.size || 0), 0);
108
+
109
+ if (names.length > 1) {
110
+ console.log(`\n${ok}/${names.length} removed freed=${formatSize(freed)} time=${elapsed(t)}s`);
111
+ }
112
+
113
+ if (bad && !results.every(r => r.skipped)) process.exit(1);
114
+ }
package/src/sync.js ADDED
@@ -0,0 +1,179 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { formatSize } from './ui.js';
5
+ import { loadLocalRegistry, saveRegistry, fetchRemoteRegistry } from './registry-ops.js';
6
+ import { installPluginFromRepo, installNpmDeps } from './utils.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const PLUGINS_DIR = path.join(process.cwd(), 'src', 'plugins');
10
+
11
+ // ------------------------------------------------------------
12
+ // helpers
13
+ // ------------------------------------------------------------
14
+
15
+ function elapsed(since) { return ((Date.now() - since) / 1000).toFixed(2); }
16
+
17
+ function onMirror(mirror, status, err) {
18
+ console.log(` ${status === 'ok' ? '+' : 'x'} ${mirror.name}${err ? ': ' + err : ''}`);
19
+ }
20
+
21
+ async function fetchRegistry() {
22
+ process.stdout.write('fetching registry... ');
23
+ try {
24
+ const r = await fetchRemoteRegistry(onMirror);
25
+ console.log('ok');
26
+ return r;
27
+ } catch (e) {
28
+ console.error(`failed: ${e.message}`);
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ async function installDeps(manifest, targetDir) {
34
+ const deps = manifest.dependencies;
35
+ if (!deps || !Object.keys(deps).length) return;
36
+ await installNpmDeps(deps, targetDir);
37
+ }
38
+
39
+ // ------------------------------------------------------------
40
+ // sync command — reconcile local registry with remote
41
+ // ------------------------------------------------------------
42
+
43
+ export async function syncCommand(options = {}) {
44
+ const t = Date.now();
45
+ const { remoteRegistry, selectedMirror } = await fetchRegistry();
46
+ const local = await loadLocalRegistry();
47
+
48
+ const synced = {};
49
+ const added = [], updated = [], kept = [], localOnly = [], skipped = [];
50
+
51
+ // reconcile local plugins against remote
52
+ for (const [name, lm] of Object.entries(local.plugins || {})) {
53
+ if (lm.local) {
54
+ localOnly.push(name);
55
+ synced[name] = lm;
56
+ continue;
57
+ }
58
+ const rm = remoteRegistry.plugins?.[name];
59
+ if (rm && lm.version !== rm.version) {
60
+ updated.push(`${name} ${lm.version}->${rm.version}`);
61
+ synced[name] = rm;
62
+ } else {
63
+ kept.push(name);
64
+ synced[name] = lm;
65
+ }
66
+ }
67
+
68
+ // plugins in remote not installed locally
69
+ for (const [name, rm] of Object.entries(remoteRegistry.plugins || {})) {
70
+ if (!local.plugins?.[name]) skipped.push(name);
71
+ }
72
+
73
+ // plugins on disk not in registry
74
+ if (await fs.pathExists(PLUGINS_DIR)) {
75
+ for (const entry of await fs.readdir(PLUGINS_DIR, { withFileTypes: true })) {
76
+ if (!entry.isDirectory() || synced[entry.name]) continue;
77
+ const mp = path.join(PLUGINS_DIR, entry.name, 'manyplug.json');
78
+ if (!await fs.pathExists(mp)) continue;
79
+ try {
80
+ const manifest = await fs.readJson(mp);
81
+ added.push(entry.name);
82
+ synced[entry.name] = manifest;
83
+ } catch {}
84
+ }
85
+ }
86
+
87
+ // summary
88
+ if (added.length) console.log(`+ added: ${added.join(', ')}`);
89
+ if (updated.length) console.log(`~ updated: ${updated.join(', ')}`);
90
+ if (kept.length) console.log(`= kept: ${kept.length} plugin(s)`);
91
+ if (localOnly.length) console.log(`L local: ${localOnly.join(', ')}`);
92
+ if (skipped.length) console.log(`- skipped: ${skipped.length} not installed`);
93
+
94
+ console.log(` source: ${selectedMirror.name}`);
95
+ console.log(` synced: ${Object.keys(synced).length} plugin(s)`);
96
+
97
+ const hasChanges = added.length || updated.length;
98
+ if (hasChanges || options.force) {
99
+ process.stdout.write('saving registry... ');
100
+ try {
101
+ await saveRegistry({ plugins: synced });
102
+ console.log('ok');
103
+ } catch (e) {
104
+ console.error(`failed: ${e.message}`);
105
+ process.exit(1);
106
+ }
107
+ }
108
+
109
+ console.log(`done in ${elapsed(t)}s`);
110
+ }
111
+
112
+ // ------------------------------------------------------------
113
+ // update command — install/update all plugins from remote
114
+ // ------------------------------------------------------------
115
+
116
+ async function applyPlugin(name, manifest, mirror, isUpdate) {
117
+ const dest = path.join(PLUGINS_DIR, name);
118
+ process.stdout.write(`${isUpdate ? 'updating' : 'installing'} ${name}... `);
119
+ try {
120
+ if (isUpdate && await fs.pathExists(dest)) await fs.remove(dest);
121
+ const { size } = await installPluginFromRepo({ plugin: name, repo: mirror }, PLUGINS_DIR);
122
+ await installDeps(manifest, dest);
123
+ const registry = await loadLocalRegistry();
124
+ registry.plugins[name] = manifest;
125
+ await saveRegistry(registry);
126
+ console.log(`done (${formatSize(size)})`);
127
+ return { success: true, name, size };
128
+ } catch (e) {
129
+ console.log(`FAILED: ${e.message}`);
130
+ return { success: false, name, error: e.message };
131
+ }
132
+ }
133
+
134
+ export async function updateCommand(options = {}) {
135
+ const t = Date.now();
136
+ const { remoteRegistry, selectedMirror } = await fetchRegistry();
137
+ const local = await loadLocalRegistry();
138
+
139
+ const toInstall = [], toUpdate = [], localOnly = [];
140
+
141
+ for (const [name, rm] of Object.entries(remoteRegistry.plugins || {})) {
142
+ const onDisk = await fs.pathExists(path.join(PLUGINS_DIR, name));
143
+ if (!onDisk) continue; // not installed locally, skip
144
+ const lm = local.plugins?.[name];
145
+ if (lm?.version !== rm.version) toUpdate.push({ name, manifest: rm, from: lm?.version ?? '?', to: rm.version });
146
+ }
147
+ for (const name of Object.keys(local.plugins || {})) {
148
+ if (!remoteRegistry.plugins?.[name]) localOnly.push(name);
149
+ }
150
+
151
+ // plan
152
+ for (const p of toUpdate) console.log(`~ ${p.name} ${p.from}->${p.to}`);
153
+ if (localOnly.length) console.log(`L local only (skipped): ${localOnly.join(', ')}`);
154
+
155
+ const total = toUpdate.length;
156
+ if (!total) { console.log(`nothing to do (${elapsed(t)}s)`); return; }
157
+
158
+ // confirm
159
+ if (!options.yes) {
160
+ process.stdout.write(`${total} plugin(s) will be updated, continue? [y/N] `);
161
+ const answer = await new Promise(res => {
162
+ process.stdin.once('data', d => res(d.toString().trim().toLowerCase()));
163
+ });
164
+ if (answer !== 'y') { console.log('cancelled'); process.exit(0); }
165
+ }
166
+
167
+ // run
168
+ const results = [];
169
+ const mirror = selectedMirror.git;
170
+ for (const p of toUpdate) results.push(await applyPlugin(p.name, p.manifest, mirror, true));
171
+
172
+ // summary
173
+ const ok = results.filter(r => r.success).length;
174
+ const bad = results.length - ok;
175
+ const totalSize = results.reduce((a, r) => a + (r.size || 0), 0);
176
+
177
+ console.log(`\n${ok}/${results.length} updated in ${elapsed(t)}s size=${formatSize(totalSize)}`);
178
+ process.exit(1);
179
+ }