@askjo/camofox-browser 1.5.1 → 1.6.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/Dockerfile +17 -2
- package/README.md +20 -0
- package/camofox.config.json +10 -0
- package/lib/auth.js +71 -0
- package/lib/config.js +1 -0
- package/lib/cookies.js +38 -1
- package/lib/downloads.js +10 -2
- package/lib/inflight.js +16 -0
- package/lib/metrics.js +29 -0
- package/lib/persistence.js +89 -0
- package/lib/plugins.js +174 -0
- package/lib/tmp-cleanup.js +40 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -1
- package/plugin.ts +8 -1
- package/plugins/persistence/AGENTS.md +37 -0
- package/plugins/persistence/README.md +48 -0
- package/plugins/persistence/index.js +120 -0
- package/plugins/persistence/persistence.test.js +117 -0
- package/plugins/persistence/plugin.test.js +98 -0
- package/plugins/vnc/AGENTS.md +42 -0
- package/plugins/vnc/README.md +165 -0
- package/plugins/vnc/apt.txt +7 -0
- package/plugins/vnc/index.js +142 -0
- package/plugins/vnc/vnc-launcher.js +64 -0
- package/plugins/vnc/vnc-watcher.sh +82 -0
- package/plugins/vnc/vnc.test.js +204 -0
- package/plugins/youtube/AGENTS.md +25 -0
- package/plugins/youtube/apt.txt +1 -0
- package/plugins/youtube/index.js +206 -0
- package/plugins/youtube/post-install.sh +5 -0
- package/plugins/youtube/youtube.test.js +41 -0
- package/scripts/install-plugin-deps.sh +63 -0
- package/scripts/plugin.js +342 -0
- package/scripts/plugin.test.js +117 -0
- package/server.js +286 -328
- /package/{lib → plugins/youtube}/youtube.js +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube transcript plugin.
|
|
3
|
+
*
|
|
4
|
+
* Extracts video transcripts via yt-dlp (preferred) with browser fallback.
|
|
5
|
+
* Registers POST /youtube/transcript.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { detectYtDlp, hasYtDlp, ensureYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './youtube.js';
|
|
9
|
+
import { classifyError } from '../../lib/request-utils.js';
|
|
10
|
+
|
|
11
|
+
export async function register(app, ctx, pluginConfig = {}) {
|
|
12
|
+
const { log, config, sessions, ensureBrowser, getSession,
|
|
13
|
+
withUserLimit, safePageClose, normalizeUserId,
|
|
14
|
+
validateUrl, safeError, buildProxyUrl, proxyPool,
|
|
15
|
+
failuresTotal } = ctx;
|
|
16
|
+
|
|
17
|
+
const NAVIGATE_TIMEOUT_MS = config.navigateTimeoutMs;
|
|
18
|
+
|
|
19
|
+
// Detect yt-dlp binary at load time
|
|
20
|
+
await detectYtDlp(log);
|
|
21
|
+
|
|
22
|
+
// Auth is on by default; set { "auth": false } in camofox.config.json to disable
|
|
23
|
+
// Auth off by default — matches pre-plugin behavior. Set { "auth": true } to require auth.
|
|
24
|
+
const middleware = pluginConfig.auth === true ? ctx.auth() : (_req, _res, next) => next();
|
|
25
|
+
|
|
26
|
+
app.post('/youtube/transcript', middleware, async (req, res) => {
|
|
27
|
+
const reqId = req.reqId;
|
|
28
|
+
try {
|
|
29
|
+
const { url, languages = ['en'] } = req.body;
|
|
30
|
+
if (!url) return res.status(400).json({ error: 'url is required' });
|
|
31
|
+
|
|
32
|
+
const urlErr = validateUrl(url);
|
|
33
|
+
if (urlErr) return res.status(400).json({ error: urlErr });
|
|
34
|
+
|
|
35
|
+
const videoIdMatch = url.match(
|
|
36
|
+
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/
|
|
37
|
+
);
|
|
38
|
+
if (!videoIdMatch) {
|
|
39
|
+
return res.status(400).json({ error: 'Could not extract YouTube video ID from URL' });
|
|
40
|
+
}
|
|
41
|
+
const videoId = videoIdMatch[1];
|
|
42
|
+
const lang = languages[0] || 'en';
|
|
43
|
+
|
|
44
|
+
// Re-detect yt-dlp if startup detection failed (transient issue)
|
|
45
|
+
await ensureYtDlp(log);
|
|
46
|
+
|
|
47
|
+
const ytDlpProxyUrl = buildProxyUrl(proxyPool, config.proxy);
|
|
48
|
+
log('info', 'youtube transcript: starting', { reqId, videoId, lang, method: hasYtDlp() ? 'yt-dlp' : 'browser', hasProxy: !!ytDlpProxyUrl });
|
|
49
|
+
|
|
50
|
+
let result;
|
|
51
|
+
if (hasYtDlp()) {
|
|
52
|
+
try {
|
|
53
|
+
result = await ytDlpTranscript(reqId, url, videoId, lang, ytDlpProxyUrl);
|
|
54
|
+
} catch (ytErr) {
|
|
55
|
+
log('warn', 'yt-dlp threw, falling back to browser', { reqId, error: ytErr.message });
|
|
56
|
+
result = null;
|
|
57
|
+
}
|
|
58
|
+
// If yt-dlp returned an error result (e.g. no captions) or threw, try browser
|
|
59
|
+
if (!result || result.status !== 'ok') {
|
|
60
|
+
if (result) log('warn', 'yt-dlp returned error, falling back to browser', { reqId, status: result.status, code: result.code });
|
|
61
|
+
result = await browserTranscript(reqId, url, videoId, lang);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
result = await browserTranscript(reqId, url, videoId, lang);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log('info', 'youtube transcript: done', { reqId, videoId, status: result.status, words: result.total_words });
|
|
68
|
+
res.json(result);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
failuresTotal.labels(classifyError(err), 'youtube_transcript').inc();
|
|
71
|
+
log('error', 'youtube transcript failed', { reqId, error: err.message, stack: err.stack });
|
|
72
|
+
res.status(500).json({ error: safeError(err) });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Browser fallback — play video, intercept timedtext network response
|
|
77
|
+
async function browserTranscript(reqId, url, videoId, lang) {
|
|
78
|
+
return await withUserLimit('__yt_transcript__', async () => {
|
|
79
|
+
await ensureBrowser();
|
|
80
|
+
const session = await getSession('__yt_transcript__');
|
|
81
|
+
const page = await session.context.newPage();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await page.addInitScript(() => {
|
|
85
|
+
const origPlay = HTMLMediaElement.prototype.play;
|
|
86
|
+
HTMLMediaElement.prototype.play = function() { this.volume = 0; this.muted = true; return origPlay.call(this); };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let interceptedCaptions = null;
|
|
90
|
+
page.on('response', async (response) => {
|
|
91
|
+
const respUrl = response.url();
|
|
92
|
+
if (respUrl.includes('/api/timedtext') && respUrl.includes(`v=${videoId}`) && !interceptedCaptions) {
|
|
93
|
+
try {
|
|
94
|
+
const body = await response.text();
|
|
95
|
+
if (body && body.length > 0) interceptedCaptions = body;
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
|
|
101
|
+
await page.waitForTimeout(2000);
|
|
102
|
+
|
|
103
|
+
// Extract caption track URLs and metadata from ytInitialPlayerResponse
|
|
104
|
+
const meta = await page.evaluate(() => {
|
|
105
|
+
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
|
|
106
|
+
if (!r) return { title: '', tracks: [] };
|
|
107
|
+
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
|
|
108
|
+
return {
|
|
109
|
+
title: r?.videoDetails?.title || '',
|
|
110
|
+
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
|
|
115
|
+
|
|
116
|
+
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
|
|
117
|
+
if (meta.tracks && meta.tracks.length > 0) {
|
|
118
|
+
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
|
|
119
|
+
if (track && track.url) {
|
|
120
|
+
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
|
|
121
|
+
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
|
|
122
|
+
try {
|
|
123
|
+
const captionResp = await page.evaluate(async (fetchUrl) => {
|
|
124
|
+
const resp = await fetch(fetchUrl);
|
|
125
|
+
return resp.ok ? await resp.text() : null;
|
|
126
|
+
}, captionUrl);
|
|
127
|
+
if (captionResp && captionResp.length > 0) {
|
|
128
|
+
let transcriptText = null;
|
|
129
|
+
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
|
|
130
|
+
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
|
|
131
|
+
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
|
|
132
|
+
if (transcriptText && transcriptText.trim()) {
|
|
133
|
+
return {
|
|
134
|
+
status: 'ok', transcript: transcriptText,
|
|
135
|
+
video_url: url, video_id: videoId, video_title: meta.title,
|
|
136
|
+
language: track.code, total_words: transcriptText.split(/\s+/).length,
|
|
137
|
+
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch (fetchErr) {
|
|
142
|
+
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Strategy B: Play video and intercept timedtext network response
|
|
148
|
+
await page.evaluate(() => {
|
|
149
|
+
const v = document.querySelector('video');
|
|
150
|
+
if (v) { v.muted = true; v.play().catch(() => {}); }
|
|
151
|
+
}).catch(() => {});
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < 40 && !interceptedCaptions; i++) {
|
|
154
|
+
await page.waitForTimeout(500);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!interceptedCaptions) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'error', code: 404,
|
|
160
|
+
message: 'No captions available for this video',
|
|
161
|
+
video_url: url, video_id: videoId, title: meta.title,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
log('info', 'youtube transcript: intercepted captions', { reqId, len: interceptedCaptions.length });
|
|
166
|
+
|
|
167
|
+
let transcriptText = null;
|
|
168
|
+
if (interceptedCaptions.trimStart().startsWith('{')) transcriptText = parseJson3(interceptedCaptions);
|
|
169
|
+
else if (interceptedCaptions.includes('WEBVTT')) transcriptText = parseVtt(interceptedCaptions);
|
|
170
|
+
else if (interceptedCaptions.includes('<text')) transcriptText = parseXml(interceptedCaptions);
|
|
171
|
+
|
|
172
|
+
if (!transcriptText || !transcriptText.trim()) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'error', code: 404,
|
|
175
|
+
message: 'Caption data intercepted but could not be parsed',
|
|
176
|
+
video_url: url, video_id: videoId, title: meta.title,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
status: 'ok', transcript: transcriptText,
|
|
182
|
+
video_url: url, video_id: videoId, video_title: meta.title,
|
|
183
|
+
language: lang, total_words: transcriptText.split(/\s+/).length,
|
|
184
|
+
available_languages: meta.languages,
|
|
185
|
+
};
|
|
186
|
+
} finally {
|
|
187
|
+
await safePageClose(page);
|
|
188
|
+
// Clean up transcript session if no live pages remain
|
|
189
|
+
const ytKey = normalizeUserId('__yt_transcript__');
|
|
190
|
+
const ytSession = sessions.get(ytKey);
|
|
191
|
+
if (ytSession && !ytSession._closing) {
|
|
192
|
+
try {
|
|
193
|
+
const remainingPages = ytSession.context.pages();
|
|
194
|
+
if (remainingPages.length === 0) {
|
|
195
|
+
ytSession._closing = true;
|
|
196
|
+
ytSession.context.close().catch(() => {});
|
|
197
|
+
sessions.delete(ytKey);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
sessions.delete(ytKey);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { parseJson3, parseVtt, parseXml } from './youtube.js';
|
|
2
|
+
|
|
3
|
+
describe('YouTube transcript parsers', () => {
|
|
4
|
+
test('parseJson3 extracts timestamped text', () => {
|
|
5
|
+
const json3 = JSON.stringify({
|
|
6
|
+
events: [
|
|
7
|
+
{ tStartMs: 0, segs: [{ utf8: 'Hello' }] },
|
|
8
|
+
{ tStartMs: 65000, segs: [{ utf8: 'World' }] },
|
|
9
|
+
],
|
|
10
|
+
});
|
|
11
|
+
const result = parseJson3(json3);
|
|
12
|
+
expect(result).toBe('[00:00] Hello\n[01:05] World');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parseVtt extracts text from VTT', () => {
|
|
16
|
+
const vtt = `WEBVTT
|
|
17
|
+
|
|
18
|
+
00:00:01.000 --> 00:00:04.000
|
|
19
|
+
Hello there
|
|
20
|
+
|
|
21
|
+
00:01:05.000 --> 00:01:09.000
|
|
22
|
+
General Kenobi`;
|
|
23
|
+
const result = parseVtt(vtt);
|
|
24
|
+
expect(result).toContain('[00:01] Hello there');
|
|
25
|
+
expect(result).toContain('[01:05] General Kenobi');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('parseXml extracts text from XML captions', () => {
|
|
29
|
+
const xml = '<text start="0" dur="3">First line</text><text start="65.5" dur="2">Second line</text>';
|
|
30
|
+
const result = parseXml(xml);
|
|
31
|
+
expect(result).toBe('[00:00] First line\n[01:05] Second line');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('parseJson3 handles empty events', () => {
|
|
35
|
+
expect(parseJson3(JSON.stringify({ events: [] }))).toBe('');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('parseJson3 handles malformed JSON', () => {
|
|
39
|
+
expect(parseJson3('not json')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Install system packages declared by plugins listed in camofox.config.json.
|
|
3
|
+
# Each plugin can have an apt.txt (one package per line) and a post-install.sh.
|
|
4
|
+
# If no config file or no plugins key, installs deps for all plugins in plugins/.
|
|
5
|
+
|
|
6
|
+
set -e
|
|
7
|
+
|
|
8
|
+
CONFIG="/app/camofox.config.json"
|
|
9
|
+
PLUGINS_DIR="/app/plugins"
|
|
10
|
+
|
|
11
|
+
# Read plugin list from camofox.config.json, or fall back to all plugin dirs
|
|
12
|
+
if [ -f "$CONFIG" ] && command -v node >/dev/null 2>&1; then
|
|
13
|
+
PLUGIN_LIST=$(node -e "
|
|
14
|
+
const c = JSON.parse(require('fs').readFileSync('$CONFIG','utf-8'));
|
|
15
|
+
if (Array.isArray(c.plugins)) {
|
|
16
|
+
console.log(c.plugins.join(' '));
|
|
17
|
+
} else if (c.plugins && typeof c.plugins === 'object') {
|
|
18
|
+
console.log(Object.entries(c.plugins)
|
|
19
|
+
.filter(([, v]) => v && v.enabled !== false)
|
|
20
|
+
.map(([k]) => k)
|
|
21
|
+
.join(' '));
|
|
22
|
+
}
|
|
23
|
+
" 2>/dev/null || echo "")
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
if [ -z "$PLUGIN_LIST" ]; then
|
|
27
|
+
# No config or no plugins key — use all plugin directories
|
|
28
|
+
PLUGIN_LIST=""
|
|
29
|
+
for d in "$PLUGINS_DIR"/*/; do
|
|
30
|
+
[ -d "$d" ] || continue
|
|
31
|
+
name=$(basename "$d")
|
|
32
|
+
case "$name" in _*|.*) continue ;; esac
|
|
33
|
+
PLUGIN_LIST="$PLUGIN_LIST $name"
|
|
34
|
+
done
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
echo "[install-plugin-deps] Plugins:$PLUGIN_LIST"
|
|
38
|
+
|
|
39
|
+
# Collect apt packages
|
|
40
|
+
PKGS=""
|
|
41
|
+
for name in $PLUGIN_LIST; do
|
|
42
|
+
f="$PLUGINS_DIR/$name/apt.txt"
|
|
43
|
+
[ -f "$f" ] || continue
|
|
44
|
+
while IFS= read -r line; do
|
|
45
|
+
case "$line" in \#*|"") continue ;; esac
|
|
46
|
+
PKGS="$PKGS $line"
|
|
47
|
+
done < "$f"
|
|
48
|
+
done
|
|
49
|
+
|
|
50
|
+
if [ -n "$PKGS" ]; then
|
|
51
|
+
echo "[install-plugin-deps] Installing:$PKGS"
|
|
52
|
+
apt-get update && apt-get install -y $PKGS && rm -rf /var/lib/apt/lists/*
|
|
53
|
+
else
|
|
54
|
+
echo "[install-plugin-deps] No apt dependencies"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Run post-install hooks
|
|
58
|
+
for name in $PLUGIN_LIST; do
|
|
59
|
+
hook="$PLUGINS_DIR/$name/post-install.sh"
|
|
60
|
+
[ -x "$hook" ] || continue
|
|
61
|
+
echo "[install-plugin-deps] Running post-install for $name"
|
|
62
|
+
"$hook"
|
|
63
|
+
done
|
|
@@ -0,0 +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 'child_process';
|
|
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(`⚠ ${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(`⚠ ${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(`✓ 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 ? '✓' : '○';
|
|
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✓ 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
|
+
}
|