@phi-code-admin/camofox-browser 1.0.0 → 1.0.2

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 (53) hide show
  1. package/AGENTS.md +571 -571
  2. package/Dockerfile +86 -86
  3. package/LICENSE +21 -21
  4. package/README.md +691 -691
  5. package/camofox.config.json +10 -10
  6. package/lib/auth.js +134 -134
  7. package/lib/camoufox-executable.js +189 -189
  8. package/lib/config.js +153 -153
  9. package/lib/cookies.js +119 -119
  10. package/lib/downloads.js +168 -168
  11. package/lib/extract.js +74 -74
  12. package/lib/fly.js +54 -54
  13. package/lib/images.js +88 -88
  14. package/lib/inflight.js +16 -16
  15. package/lib/launcher.js +47 -47
  16. package/lib/macros.js +31 -31
  17. package/lib/metrics.js +184 -184
  18. package/lib/openapi.js +105 -105
  19. package/lib/persistence.js +89 -89
  20. package/lib/plugins.js +178 -175
  21. package/lib/proxy.js +277 -277
  22. package/lib/reporter.js +1102 -1102
  23. package/lib/request-utils.js +59 -59
  24. package/lib/resources.js +76 -76
  25. package/lib/snapshot.js +41 -41
  26. package/lib/tmp-cleanup.js +108 -108
  27. package/lib/tracing.js +137 -137
  28. package/openclaw.plugin.json +268 -268
  29. package/package.json +148 -148
  30. package/plugin.ts +758 -758
  31. package/plugins/persistence/AGENTS.md +37 -37
  32. package/plugins/persistence/README.md +48 -48
  33. package/plugins/persistence/index.js +124 -124
  34. package/plugins/vnc/AGENTS.md +42 -42
  35. package/plugins/vnc/README.md +165 -165
  36. package/plugins/vnc/apt.txt +7 -7
  37. package/plugins/vnc/index.js +142 -142
  38. package/plugins/vnc/spawn.js +8 -8
  39. package/plugins/vnc/vnc-launcher.js +64 -64
  40. package/plugins/vnc/vnc-watcher.sh +82 -82
  41. package/plugins/youtube/AGENTS.md +25 -25
  42. package/plugins/youtube/apt.txt +1 -1
  43. package/plugins/youtube/index.js +206 -206
  44. package/plugins/youtube/post-install.sh +5 -5
  45. package/plugins/youtube/youtube.js +301 -301
  46. package/run.sh +37 -37
  47. package/scripts/exec.js +8 -8
  48. package/scripts/generate-openapi.js +24 -24
  49. package/scripts/install-plugin-deps.sh +63 -63
  50. package/scripts/plugin.js +342 -342
  51. package/scripts/sync-version.js +25 -25
  52. package/server.js +6062 -6059
  53. package/tsconfig.json +12 -12
package/scripts/plugin.js CHANGED
@@ -1,342 +1,342 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * camofox plugin manager -- install, remove, and list plugins.
5
- *
6
- * Usage:
7
- * node scripts/plugin.js install <source> Install a plugin from git URL or local path
8
- * node scripts/plugin.js remove <name> Remove a plugin and its config entry
9
- * node scripts/plugin.js list List installed plugins and their source
10
- *
11
- * Sources:
12
- * git:github.com/user/repo Git shorthand
13
- * https://github.com/user/repo Git URL
14
- * /absolute/path/to/plugin-dir Local directory (copied)
15
- * ./relative/path/to/plugin-dir Local directory (copied)
16
- *
17
- * Plugin name is inferred from the repo/directory name. If the repo root has
18
- * an index.js with register(), it's used directly. If it has a plugins/ subdir,
19
- * each subdirectory is installed as a separate plugin.
20
- *
21
- * After install, the plugin is added to camofox.config.json plugins[] and
22
- * npm dependencies are installed if the plugin has a package.json.
23
- */
24
-
25
- import fs from 'fs';
26
- import path from 'path';
27
- import { execSync } from './exec.js';
28
- import { fileURLToPath } from 'url';
29
-
30
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
- const ROOT = path.join(__dirname, '..');
32
- const PLUGINS_DIR = path.join(ROOT, 'plugins');
33
- const CONFIG_PATH = path.join(ROOT, 'camofox.config.json');
34
-
35
- // -- Config helpers ----------------------------------------------------------
36
-
37
- function readConfig() {
38
- try {
39
- return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
40
- } catch {
41
- return { id: 'camofox-browser', name: 'Camofox Browser', version: '0.0.0', plugins: [] };
42
- }
43
- }
44
-
45
- function writeConfig(config) {
46
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
47
- }
48
-
49
- /**
50
- * Get the set of enabled plugin names from config.
51
- * Handles both array format ["youtube"] and object format { "youtube": { "enabled": true } }.
52
- */
53
- function getEnabledPlugins(config) {
54
- if (!config.plugins) return new Set();
55
- if (Array.isArray(config.plugins)) return new Set(config.plugins);
56
- if (typeof config.plugins === 'object') {
57
- const enabled = new Set();
58
- for (const [name, conf] of Object.entries(config.plugins)) {
59
- if (conf === false || (typeof conf === 'object' && conf.enabled === false)) continue;
60
- enabled.add(name);
61
- }
62
- return enabled;
63
- }
64
- return new Set();
65
- }
66
-
67
- function addToConfig(name) {
68
- const config = readConfig();
69
- if (Array.isArray(config.plugins)) {
70
- if (!config.plugins.includes(name)) {
71
- config.plugins.push(name);
72
- writeConfig(config);
73
- }
74
- } else if (typeof config.plugins === 'object') {
75
- if (!config.plugins[name] || config.plugins[name].enabled === false) {
76
- config.plugins[name] = config.plugins[name] || {};
77
- config.plugins[name].enabled = true;
78
- writeConfig(config);
79
- }
80
- } else {
81
- config.plugins = [name];
82
- writeConfig(config);
83
- }
84
- }
85
-
86
- function removeFromConfig(name) {
87
- const config = readConfig();
88
- if (Array.isArray(config.plugins)) {
89
- config.plugins = config.plugins.filter(p => p !== name);
90
- writeConfig(config);
91
- } else if (typeof config.plugins === 'object' && config.plugins[name] !== undefined) {
92
- delete config.plugins[name];
93
- writeConfig(config);
94
- }
95
- }
96
-
97
- // -- Source parsing ----------------------------------------------------------
98
-
99
- function parseSource(source) {
100
- // Local path
101
- if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) {
102
- const resolved = path.resolve(source);
103
- if (!fs.existsSync(resolved)) {
104
- fatal(`Local path not found: ${resolved}`);
105
- }
106
- if (!fs.statSync(resolved).isDirectory()) {
107
- fatal(`Source must be a directory: ${resolved}`);
108
- }
109
- return { type: 'local', path: resolved, name: path.basename(resolved) };
110
- }
111
-
112
- // Git URL -- https://, ssh://, git@, git:
113
- let gitUrl = source;
114
- if (gitUrl.startsWith('git:')) {
115
- gitUrl = gitUrl.slice(4);
116
- // git:github.com/user/repo -> https://github.com/user/repo
117
- if (!gitUrl.startsWith('http') && !gitUrl.startsWith('ssh://') && !gitUrl.startsWith('git@')) {
118
- gitUrl = `https://${gitUrl}`;
119
- }
120
- }
121
-
122
- // Strip trailing .git
123
- gitUrl = gitUrl.replace(/\.git$/, '');
124
-
125
- // Extract name from URL
126
- const name = gitUrl.split('/').pop().replace(/[^a-zA-Z0-9_-]/g, '');
127
- if (!name) fatal(`Cannot infer plugin name from: ${source}`);
128
-
129
- // Re-add .git for clone
130
- const cloneUrl = gitUrl.endsWith('.git') ? gitUrl : `${gitUrl}.git`;
131
-
132
- return { type: 'git', url: cloneUrl, name };
133
- }
134
-
135
- // -- Install -----------------------------------------------------------------
136
-
137
- function isPluginDir(dir) {
138
- const indexPath = path.join(dir, 'index.js');
139
- if (!fs.existsSync(indexPath)) return false;
140
- const content = fs.readFileSync(indexPath, 'utf-8');
141
- return /\bregister\b/.test(content);
142
- }
143
-
144
- function installFromLocal(srcDir, name) {
145
- const destDir = path.join(PLUGINS_DIR, name);
146
- if (fs.existsSync(destDir)) {
147
- fatal(`Plugin "${name}" already exists. Remove it first: node scripts/plugin.js remove ${name}`);
148
- }
149
- copyDirSync(srcDir, destDir);
150
- return [name];
151
- }
152
-
153
- function installFromGit(url, name) {
154
- const tmpDir = path.join(ROOT, '.tmp-plugin-clone');
155
- try {
156
- if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
157
-
158
- console.log(`Cloning ${url}...`);
159
- execSync(`git clone --depth 1 ${url} ${tmpDir}`, { stdio: 'pipe' });
160
-
161
- // Case 1: Root is a plugin (has index.js with register)
162
- if (isPluginDir(tmpDir)) {
163
- return installFromLocal(tmpDir, name);
164
- }
165
-
166
- // Case 2: Has plugins/ subdir with plugin directories
167
- const pluginsSubdir = path.join(tmpDir, 'plugins');
168
- if (fs.existsSync(pluginsSubdir) && fs.statSync(pluginsSubdir).isDirectory()) {
169
- const installed = [];
170
- for (const entry of fs.readdirSync(pluginsSubdir, { withFileTypes: true })) {
171
- if (!entry.isDirectory()) continue;
172
- if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
173
- const subDir = path.join(pluginsSubdir, entry.name);
174
- if (isPluginDir(subDir)) {
175
- installFromLocal(subDir, entry.name);
176
- installed.push(entry.name);
177
- }
178
- }
179
- if (installed.length === 0) {
180
- fatal(`No plugins found in ${url} -- expected index.js with register() at root or in plugins/*/`);
181
- }
182
- return installed;
183
- }
184
-
185
- fatal(`No plugins found in ${url} -- expected index.js with register() at root or plugins/*/ subdirs`);
186
- } finally {
187
- if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
188
- }
189
- }
190
-
191
- function installPluginDeps(name) {
192
- const pluginDir = path.join(PLUGINS_DIR, name);
193
-
194
- // npm install if package.json exists
195
- const pkgJson = path.join(pluginDir, 'package.json');
196
- if (fs.existsSync(pkgJson)) {
197
- console.log(`Installing npm dependencies for ${name}...`);
198
- execSync('npm install --omit=dev', { cwd: pluginDir, stdio: 'inherit' });
199
- }
200
-
201
- // Check for apt.txt / post-install.sh (just warn -- can't run apt locally)
202
- if (fs.existsSync(path.join(pluginDir, 'apt.txt'))) {
203
- console.log(`WARNING ${name} has apt.txt -- system packages need Docker build or manual install`);
204
- }
205
- if (fs.existsSync(path.join(pluginDir, 'post-install.sh'))) {
206
- console.log(`WARNING ${name} has post-install.sh -- run it manually or rebuild Docker image`);
207
- }
208
- }
209
-
210
- // -- Remove ------------------------------------------------------------------
211
-
212
- function removePlugin(name) {
213
- const pluginDir = path.join(PLUGINS_DIR, name);
214
- if (!fs.existsSync(pluginDir)) {
215
- fatal(`Plugin "${name}" not found in plugins/`);
216
- }
217
-
218
- fs.rmSync(pluginDir, { recursive: true });
219
- removeFromConfig(name);
220
- console.log(`[ok] Removed plugin "${name}"`);
221
- }
222
-
223
- // -- List --------------------------------------------------------------------
224
-
225
- function listPlugins() {
226
- const config = readConfig();
227
- const configPlugins = getEnabledPlugins(config);
228
-
229
- if (!fs.existsSync(PLUGINS_DIR)) {
230
- console.log('No plugins directory.');
231
- return;
232
- }
233
-
234
- const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
235
- const plugins = entries
236
- .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
237
- .map(e => e.name);
238
-
239
- if (plugins.length === 0) {
240
- console.log('No plugins installed.');
241
- return;
242
- }
243
-
244
- console.log('Installed plugins:\n');
245
- for (const name of plugins.sort()) {
246
- const enabled = configPlugins.size === 0 || configPlugins.has(name);
247
- const status = enabled ? '[ok]' : 'o';
248
- const hasTest = fs.existsSync(path.join(PLUGINS_DIR, name, `${name}.test.js`))
249
- || fs.readdirSync(path.join(PLUGINS_DIR, name)).some(f => f.endsWith('.test.js'));
250
- const hasDeps = fs.existsSync(path.join(PLUGINS_DIR, name, 'apt.txt'))
251
- || fs.existsSync(path.join(PLUGINS_DIR, name, 'post-install.sh'));
252
- const hasPkg = fs.existsSync(path.join(PLUGINS_DIR, name, 'package.json'));
253
-
254
- const flags = [
255
- hasTest ? 'tests' : null,
256
- hasDeps ? 'sys-deps' : null,
257
- hasPkg ? 'npm-deps' : null,
258
- ].filter(Boolean).join(', ');
259
-
260
- console.log(` ${status} ${name}${flags ? ` (${flags})` : ''}`);
261
- }
262
-
263
- if (configPlugins.size > 0) {
264
- console.log(`\n${configPlugins.size} plugin(s) enabled in camofox.config.json`);
265
- } else {
266
- console.log('\nNo plugins[] in config -- all plugins are loaded');
267
- }
268
- }
269
-
270
- // -- Helpers -----------------------------------------------------------------
271
-
272
- function copyDirSync(src, dest) {
273
- fs.mkdirSync(dest, { recursive: true });
274
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
275
- const srcPath = path.join(src, entry.name);
276
- const destPath = path.join(dest, entry.name);
277
- // Skip .git, node_modules
278
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
279
- if (entry.isDirectory()) {
280
- copyDirSync(srcPath, destPath);
281
- } else {
282
- fs.copyFileSync(srcPath, destPath);
283
- }
284
- }
285
- }
286
-
287
- function fatal(msg) {
288
- console.error(`Error: ${msg}`);
289
- process.exit(1);
290
- }
291
-
292
- // -- CLI ---------------------------------------------------------------------
293
-
294
- const [,, action, ...args] = process.argv;
295
-
296
- switch (action) {
297
- case 'install': {
298
- const source = args[0];
299
- if (!source) fatal('Usage: plugin install <git-url|local-path>');
300
-
301
- const parsed = parseSource(source);
302
- const installed = parsed.type === 'git'
303
- ? installFromGit(parsed.url, parsed.name)
304
- : installFromLocal(parsed.path, parsed.name);
305
-
306
- for (const name of installed) {
307
- addToConfig(name);
308
- installPluginDeps(name);
309
- }
310
-
311
- console.log(`\n[ok] Installed: ${installed.join(', ')}`);
312
- console.log(' Restart the server to load new plugin(s).');
313
- break;
314
- }
315
-
316
- case 'remove': {
317
- const name = args[0];
318
- if (!name) fatal('Usage: plugin remove <name>');
319
- removePlugin(name);
320
- break;
321
- }
322
-
323
- case 'list':
324
- case 'ls': {
325
- listPlugins();
326
- break;
327
- }
328
-
329
- default:
330
- console.log(`camofox plugin manager
331
-
332
- Usage:
333
- node scripts/plugin.js install <source> Install from git URL or local path
334
- node scripts/plugin.js remove <name> Remove a plugin
335
- node scripts/plugin.js list List installed plugins
336
-
337
- Sources:
338
- git:github.com/user/repo
339
- https://github.com/user/repo
340
- ./path/to/local/plugin`);
341
- if (action) process.exit(1);
342
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * camofox plugin manager -- install, remove, and list plugins.
5
+ *
6
+ * Usage:
7
+ * node scripts/plugin.js install <source> Install a plugin from git URL or local path
8
+ * node scripts/plugin.js remove <name> Remove a plugin and its config entry
9
+ * node scripts/plugin.js list List installed plugins and their source
10
+ *
11
+ * Sources:
12
+ * git:github.com/user/repo Git shorthand
13
+ * https://github.com/user/repo Git URL
14
+ * /absolute/path/to/plugin-dir Local directory (copied)
15
+ * ./relative/path/to/plugin-dir Local directory (copied)
16
+ *
17
+ * Plugin name is inferred from the repo/directory name. If the repo root has
18
+ * an index.js with register(), it's used directly. If it has a plugins/ subdir,
19
+ * each subdirectory is installed as a separate plugin.
20
+ *
21
+ * After install, the plugin is added to camofox.config.json plugins[] and
22
+ * npm dependencies are installed if the plugin has a package.json.
23
+ */
24
+
25
+ import fs from 'fs';
26
+ import path from 'path';
27
+ import { execSync } from './exec.js';
28
+ import { fileURLToPath } from 'url';
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const ROOT = path.join(__dirname, '..');
32
+ const PLUGINS_DIR = path.join(ROOT, 'plugins');
33
+ const CONFIG_PATH = path.join(ROOT, 'camofox.config.json');
34
+
35
+ // -- Config helpers ----------------------------------------------------------
36
+
37
+ function readConfig() {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
40
+ } catch {
41
+ return { id: 'camofox-browser', name: 'Camofox Browser', version: '0.0.0', plugins: [] };
42
+ }
43
+ }
44
+
45
+ function writeConfig(config) {
46
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
47
+ }
48
+
49
+ /**
50
+ * Get the set of enabled plugin names from config.
51
+ * Handles both array format ["youtube"] and object format { "youtube": { "enabled": true } }.
52
+ */
53
+ function getEnabledPlugins(config) {
54
+ if (!config.plugins) return new Set();
55
+ if (Array.isArray(config.plugins)) return new Set(config.plugins);
56
+ if (typeof config.plugins === 'object') {
57
+ const enabled = new Set();
58
+ for (const [name, conf] of Object.entries(config.plugins)) {
59
+ if (conf === false || (typeof conf === 'object' && conf.enabled === false)) continue;
60
+ enabled.add(name);
61
+ }
62
+ return enabled;
63
+ }
64
+ return new Set();
65
+ }
66
+
67
+ function addToConfig(name) {
68
+ const config = readConfig();
69
+ if (Array.isArray(config.plugins)) {
70
+ if (!config.plugins.includes(name)) {
71
+ config.plugins.push(name);
72
+ writeConfig(config);
73
+ }
74
+ } else if (typeof config.plugins === 'object') {
75
+ if (!config.plugins[name] || config.plugins[name].enabled === false) {
76
+ config.plugins[name] = config.plugins[name] || {};
77
+ config.plugins[name].enabled = true;
78
+ writeConfig(config);
79
+ }
80
+ } else {
81
+ config.plugins = [name];
82
+ writeConfig(config);
83
+ }
84
+ }
85
+
86
+ function removeFromConfig(name) {
87
+ const config = readConfig();
88
+ if (Array.isArray(config.plugins)) {
89
+ config.plugins = config.plugins.filter(p => p !== name);
90
+ writeConfig(config);
91
+ } else if (typeof config.plugins === 'object' && config.plugins[name] !== undefined) {
92
+ delete config.plugins[name];
93
+ writeConfig(config);
94
+ }
95
+ }
96
+
97
+ // -- Source parsing ----------------------------------------------------------
98
+
99
+ function parseSource(source) {
100
+ // Local path
101
+ if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) {
102
+ const resolved = path.resolve(source);
103
+ if (!fs.existsSync(resolved)) {
104
+ fatal(`Local path not found: ${resolved}`);
105
+ }
106
+ if (!fs.statSync(resolved).isDirectory()) {
107
+ fatal(`Source must be a directory: ${resolved}`);
108
+ }
109
+ return { type: 'local', path: resolved, name: path.basename(resolved) };
110
+ }
111
+
112
+ // Git URL -- https://, ssh://, git@, git:
113
+ let gitUrl = source;
114
+ if (gitUrl.startsWith('git:')) {
115
+ gitUrl = gitUrl.slice(4);
116
+ // git:github.com/user/repo -> https://github.com/user/repo
117
+ if (!gitUrl.startsWith('http') && !gitUrl.startsWith('ssh://') && !gitUrl.startsWith('git@')) {
118
+ gitUrl = `https://${gitUrl}`;
119
+ }
120
+ }
121
+
122
+ // Strip trailing .git
123
+ gitUrl = gitUrl.replace(/\.git$/, '');
124
+
125
+ // Extract name from URL
126
+ const name = gitUrl.split('/').pop().replace(/[^a-zA-Z0-9_-]/g, '');
127
+ if (!name) fatal(`Cannot infer plugin name from: ${source}`);
128
+
129
+ // Re-add .git for clone
130
+ const cloneUrl = gitUrl.endsWith('.git') ? gitUrl : `${gitUrl}.git`;
131
+
132
+ return { type: 'git', url: cloneUrl, name };
133
+ }
134
+
135
+ // -- Install -----------------------------------------------------------------
136
+
137
+ function isPluginDir(dir) {
138
+ const indexPath = path.join(dir, 'index.js');
139
+ if (!fs.existsSync(indexPath)) return false;
140
+ const content = fs.readFileSync(indexPath, 'utf-8');
141
+ return /\bregister\b/.test(content);
142
+ }
143
+
144
+ function installFromLocal(srcDir, name) {
145
+ const destDir = path.join(PLUGINS_DIR, name);
146
+ if (fs.existsSync(destDir)) {
147
+ fatal(`Plugin "${name}" already exists. Remove it first: node scripts/plugin.js remove ${name}`);
148
+ }
149
+ copyDirSync(srcDir, destDir);
150
+ return [name];
151
+ }
152
+
153
+ function installFromGit(url, name) {
154
+ const tmpDir = path.join(ROOT, '.tmp-plugin-clone');
155
+ try {
156
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
157
+
158
+ console.log(`Cloning ${url}...`);
159
+ execSync(`git clone --depth 1 ${url} ${tmpDir}`, { stdio: 'pipe' });
160
+
161
+ // Case 1: Root is a plugin (has index.js with register)
162
+ if (isPluginDir(tmpDir)) {
163
+ return installFromLocal(tmpDir, name);
164
+ }
165
+
166
+ // Case 2: Has plugins/ subdir with plugin directories
167
+ const pluginsSubdir = path.join(tmpDir, 'plugins');
168
+ if (fs.existsSync(pluginsSubdir) && fs.statSync(pluginsSubdir).isDirectory()) {
169
+ const installed = [];
170
+ for (const entry of fs.readdirSync(pluginsSubdir, { withFileTypes: true })) {
171
+ if (!entry.isDirectory()) continue;
172
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
173
+ const subDir = path.join(pluginsSubdir, entry.name);
174
+ if (isPluginDir(subDir)) {
175
+ installFromLocal(subDir, entry.name);
176
+ installed.push(entry.name);
177
+ }
178
+ }
179
+ if (installed.length === 0) {
180
+ fatal(`No plugins found in ${url} -- expected index.js with register() at root or in plugins/*/`);
181
+ }
182
+ return installed;
183
+ }
184
+
185
+ fatal(`No plugins found in ${url} -- expected index.js with register() at root or plugins/*/ subdirs`);
186
+ } finally {
187
+ if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
188
+ }
189
+ }
190
+
191
+ function installPluginDeps(name) {
192
+ const pluginDir = path.join(PLUGINS_DIR, name);
193
+
194
+ // npm install if package.json exists
195
+ const pkgJson = path.join(pluginDir, 'package.json');
196
+ if (fs.existsSync(pkgJson)) {
197
+ console.log(`Installing npm dependencies for ${name}...`);
198
+ execSync('npm install --omit=dev', { cwd: pluginDir, stdio: 'inherit' });
199
+ }
200
+
201
+ // Check for apt.txt / post-install.sh (just warn -- can't run apt locally)
202
+ if (fs.existsSync(path.join(pluginDir, 'apt.txt'))) {
203
+ console.log(`WARNING ${name} has apt.txt -- system packages need Docker build or manual install`);
204
+ }
205
+ if (fs.existsSync(path.join(pluginDir, 'post-install.sh'))) {
206
+ console.log(`WARNING ${name} has post-install.sh -- run it manually or rebuild Docker image`);
207
+ }
208
+ }
209
+
210
+ // -- Remove ------------------------------------------------------------------
211
+
212
+ function removePlugin(name) {
213
+ const pluginDir = path.join(PLUGINS_DIR, name);
214
+ if (!fs.existsSync(pluginDir)) {
215
+ fatal(`Plugin "${name}" not found in plugins/`);
216
+ }
217
+
218
+ fs.rmSync(pluginDir, { recursive: true });
219
+ removeFromConfig(name);
220
+ console.log(`[ok] Removed plugin "${name}"`);
221
+ }
222
+
223
+ // -- List --------------------------------------------------------------------
224
+
225
+ function listPlugins() {
226
+ const config = readConfig();
227
+ const configPlugins = getEnabledPlugins(config);
228
+
229
+ if (!fs.existsSync(PLUGINS_DIR)) {
230
+ console.log('No plugins directory.');
231
+ return;
232
+ }
233
+
234
+ const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
235
+ const plugins = entries
236
+ .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
237
+ .map(e => e.name);
238
+
239
+ if (plugins.length === 0) {
240
+ console.log('No plugins installed.');
241
+ return;
242
+ }
243
+
244
+ console.log('Installed plugins:\n');
245
+ for (const name of plugins.sort()) {
246
+ const enabled = configPlugins.size === 0 || configPlugins.has(name);
247
+ const status = enabled ? '[ok]' : 'o';
248
+ const hasTest = fs.existsSync(path.join(PLUGINS_DIR, name, `${name}.test.js`))
249
+ || fs.readdirSync(path.join(PLUGINS_DIR, name)).some(f => f.endsWith('.test.js'));
250
+ const hasDeps = fs.existsSync(path.join(PLUGINS_DIR, name, 'apt.txt'))
251
+ || fs.existsSync(path.join(PLUGINS_DIR, name, 'post-install.sh'));
252
+ const hasPkg = fs.existsSync(path.join(PLUGINS_DIR, name, 'package.json'));
253
+
254
+ const flags = [
255
+ hasTest ? 'tests' : null,
256
+ hasDeps ? 'sys-deps' : null,
257
+ hasPkg ? 'npm-deps' : null,
258
+ ].filter(Boolean).join(', ');
259
+
260
+ console.log(` ${status} ${name}${flags ? ` (${flags})` : ''}`);
261
+ }
262
+
263
+ if (configPlugins.size > 0) {
264
+ console.log(`\n${configPlugins.size} plugin(s) enabled in camofox.config.json`);
265
+ } else {
266
+ console.log('\nNo plugins[] in config -- all plugins are loaded');
267
+ }
268
+ }
269
+
270
+ // -- Helpers -----------------------------------------------------------------
271
+
272
+ function copyDirSync(src, dest) {
273
+ fs.mkdirSync(dest, { recursive: true });
274
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
275
+ const srcPath = path.join(src, entry.name);
276
+ const destPath = path.join(dest, entry.name);
277
+ // Skip .git, node_modules
278
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
279
+ if (entry.isDirectory()) {
280
+ copyDirSync(srcPath, destPath);
281
+ } else {
282
+ fs.copyFileSync(srcPath, destPath);
283
+ }
284
+ }
285
+ }
286
+
287
+ function fatal(msg) {
288
+ console.error(`Error: ${msg}`);
289
+ process.exit(1);
290
+ }
291
+
292
+ // -- CLI ---------------------------------------------------------------------
293
+
294
+ const [,, action, ...args] = process.argv;
295
+
296
+ switch (action) {
297
+ case 'install': {
298
+ const source = args[0];
299
+ if (!source) fatal('Usage: plugin install <git-url|local-path>');
300
+
301
+ const parsed = parseSource(source);
302
+ const installed = parsed.type === 'git'
303
+ ? installFromGit(parsed.url, parsed.name)
304
+ : installFromLocal(parsed.path, parsed.name);
305
+
306
+ for (const name of installed) {
307
+ addToConfig(name);
308
+ installPluginDeps(name);
309
+ }
310
+
311
+ console.log(`\n[ok] Installed: ${installed.join(', ')}`);
312
+ console.log(' Restart the server to load new plugin(s).');
313
+ break;
314
+ }
315
+
316
+ case 'remove': {
317
+ const name = args[0];
318
+ if (!name) fatal('Usage: plugin remove <name>');
319
+ removePlugin(name);
320
+ break;
321
+ }
322
+
323
+ case 'list':
324
+ case 'ls': {
325
+ listPlugins();
326
+ break;
327
+ }
328
+
329
+ default:
330
+ console.log(`camofox plugin manager
331
+
332
+ Usage:
333
+ node scripts/plugin.js install <source> Install from git URL or local path
334
+ node scripts/plugin.js remove <name> Remove a plugin
335
+ node scripts/plugin.js list List installed plugins
336
+
337
+ Sources:
338
+ git:github.com/user/repo
339
+ https://github.com/user/repo
340
+ ./path/to/local/plugin`);
341
+ if (action) process.exit(1);
342
+ }