@penadidik/meo-agent 1.2.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/CHANGELOG.md +84 -0
- package/LICENSE +24 -0
- package/README.md +483 -0
- package/bin/meo-agent.js +158 -0
- package/developer-kit/README.md +99 -0
- package/developer-kit/developer-kit.sh +108 -0
- package/developer-kit/templates/requirements.md +95 -0
- package/developer-kit/templates/tasks.md +85 -0
- package/developer-kit/templates/tdd.md +128 -0
- package/examples/plugins/meo-agent-logger.js +21 -0
- package/index.js +2 -0
- package/lib/args.js +134 -0
- package/lib/checksum.js +29 -0
- package/lib/config.js +52 -0
- package/lib/doctor.js +84 -0
- package/lib/downloader.js +115 -0
- package/lib/mirror.js +137 -0
- package/lib/plugins.js +93 -0
- package/lib/reporter.js +108 -0
- package/package.json +87 -0
- package/pull_request/README.md +104 -0
- package/pull_request/generate-pr.sh +190 -0
- package/pull_request/templates/basic.md +41 -0
- package/pull_request/templates/hod-review.md +101 -0
- package/pull_request/templates/lead-review.md +83 -0
package/lib/args.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function parseArgs(argv) {
|
|
4
|
+
const opts = {
|
|
5
|
+
url: null,
|
|
6
|
+
output: null,
|
|
7
|
+
continue: false,
|
|
8
|
+
json: false,
|
|
9
|
+
quiet: false,
|
|
10
|
+
timeout: null,
|
|
11
|
+
help: false,
|
|
12
|
+
version: false,
|
|
13
|
+
sha256: null,
|
|
14
|
+
mirror: false,
|
|
15
|
+
mirrorDepth: null,
|
|
16
|
+
mirrorLimit: null,
|
|
17
|
+
config: null,
|
|
18
|
+
pluginList: false
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const positional = [];
|
|
22
|
+
for (let i = 2; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
switch (a) {
|
|
25
|
+
case '-h':
|
|
26
|
+
case '--help':
|
|
27
|
+
opts.help = true;
|
|
28
|
+
break;
|
|
29
|
+
case '-V':
|
|
30
|
+
case '--version':
|
|
31
|
+
opts.version = true;
|
|
32
|
+
break;
|
|
33
|
+
case '-o':
|
|
34
|
+
case '--output':
|
|
35
|
+
opts.output = argv[++i];
|
|
36
|
+
break;
|
|
37
|
+
case '-c':
|
|
38
|
+
case '--continue':
|
|
39
|
+
opts.continue = true;
|
|
40
|
+
break;
|
|
41
|
+
case '-j':
|
|
42
|
+
case '--json':
|
|
43
|
+
opts.json = true;
|
|
44
|
+
break;
|
|
45
|
+
case '-q':
|
|
46
|
+
case '--quiet':
|
|
47
|
+
opts.quiet = true;
|
|
48
|
+
break;
|
|
49
|
+
case '-m':
|
|
50
|
+
case '--mirror':
|
|
51
|
+
opts.mirror = true;
|
|
52
|
+
break;
|
|
53
|
+
case '--sha256':
|
|
54
|
+
opts.sha256 = argv[++i];
|
|
55
|
+
break;
|
|
56
|
+
case '--mirror-depth':
|
|
57
|
+
opts.mirrorDepth = parseInt(argv[++i], 10);
|
|
58
|
+
break;
|
|
59
|
+
case '--mirror-limit':
|
|
60
|
+
opts.mirrorLimit = parseInt(argv[++i], 10);
|
|
61
|
+
break;
|
|
62
|
+
case '--config':
|
|
63
|
+
opts.config = argv[++i];
|
|
64
|
+
break;
|
|
65
|
+
case '--list-plugins':
|
|
66
|
+
opts.pluginList = true;
|
|
67
|
+
break;
|
|
68
|
+
case '--timeout':
|
|
69
|
+
opts.timeout = parseInt(argv[++i], 10);
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
positional.push(a);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (positional.length > 0 && !opts.url) {
|
|
77
|
+
opts.url = positional[0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printHelp() {
|
|
84
|
+
const help = `
|
|
85
|
+
meo-agent — wget-like CLI for downloading files (Meo Code Labs)
|
|
86
|
+
|
|
87
|
+
Usage:
|
|
88
|
+
meo-agent [options] <URL>
|
|
89
|
+
meo-agent [options] doctor
|
|
90
|
+
meo-agent [options] mirror <URL> -o <dir>
|
|
91
|
+
meo-agent [options] plugins
|
|
92
|
+
|
|
93
|
+
Options:
|
|
94
|
+
-o, --output <name> Save to a custom filename or directory (for mirror)
|
|
95
|
+
-c, --continue Resume a partial download via HTTP Range
|
|
96
|
+
-m, --mirror Recursive mirror mode (downloads HTML pages + assets)
|
|
97
|
+
--mirror-depth <N> Max recursion depth (default: 2)
|
|
98
|
+
--mirror-limit <N> Max total pages to download (default: 50)
|
|
99
|
+
--sha256 <hash> Verify downloaded file SHA256
|
|
100
|
+
-j, --json Emit machine-readable JSON events to stdout
|
|
101
|
+
-q, --quiet Suppress progress output (errors only)
|
|
102
|
+
--timeout <sec> Network timeout in seconds (default: 30)
|
|
103
|
+
--config <path> Load config from custom path
|
|
104
|
+
--list-plugins List loaded plugins and exit
|
|
105
|
+
-V, --version Print version and exit
|
|
106
|
+
-h, --help Print this help and exit
|
|
107
|
+
|
|
108
|
+
Config file lookup order:
|
|
109
|
+
1. --config <path> (explicit override)
|
|
110
|
+
2. ./.meo-agent.json (current directory)
|
|
111
|
+
3. ~/.meo-agent.json (home directory)
|
|
112
|
+
4. ~/.config/meo-agent/config.json (XDG)
|
|
113
|
+
|
|
114
|
+
Plugin directories (auto-loaded):
|
|
115
|
+
- ./.meo-agent/plugins/
|
|
116
|
+
- ~/.meo-agent/plugins/
|
|
117
|
+
- ~/.config/meo-agent/plugins/
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
meo-agent https://example.com/file.zip
|
|
121
|
+
meo-agent -o backup.zip https://example.com/file.zip
|
|
122
|
+
meo-agent --sha256=abc123... https://example.com/file.zip
|
|
123
|
+
meo-agent --continue https://example.com/large.iso
|
|
124
|
+
meo-agent --mirror https://docs.example.com/ -o ./docs
|
|
125
|
+
meo-agent --json https://example.com/file.zip | jq
|
|
126
|
+
meo-agent doctor
|
|
127
|
+
meo-agent plugins
|
|
128
|
+
|
|
129
|
+
Repository: https://github.com/meocode-labs/meo-agent
|
|
130
|
+
`.trim();
|
|
131
|
+
console.log(help);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { parseArgs, printHelp };
|
package/lib/checksum.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
function verifyFile(filePath, expectedSha256) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const hash = crypto.createHash('sha256');
|
|
9
|
+
const stream = fs.createReadStream(filePath);
|
|
10
|
+
|
|
11
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
12
|
+
stream.on('end', () => {
|
|
13
|
+
const actual = hash.digest('hex');
|
|
14
|
+
const ok = actual.toLowerCase() === expectedSha256.toLowerCase();
|
|
15
|
+
resolve({ ok, actual, expected: expectedSha256 });
|
|
16
|
+
});
|
|
17
|
+
stream.on('error', reject);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseChecksumArg(arg) {
|
|
22
|
+
if (arg.includes('=')) {
|
|
23
|
+
const [_, hash] = arg.split('=');
|
|
24
|
+
return hash;
|
|
25
|
+
}
|
|
26
|
+
return arg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { verifyFile, parseChecksumArg };
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const CONFIG_PATHS = [
|
|
8
|
+
path.join(process.cwd(), '.meo-agent.json'),
|
|
9
|
+
path.join(os.homedir(), '.meo-agent.json'),
|
|
10
|
+
path.join(os.homedir(), '.config', 'meo-agent', 'config.json')
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
outputDir: null,
|
|
15
|
+
timeout: 30,
|
|
16
|
+
retries: 0,
|
|
17
|
+
headers: {},
|
|
18
|
+
plugins: [],
|
|
19
|
+
defaultChecksum: null,
|
|
20
|
+
mirrorMaxDepth: 2,
|
|
21
|
+
mirrorLimit: 50
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function loadConfig(customPath) {
|
|
25
|
+
let config = { ...DEFAULT_CONFIG };
|
|
26
|
+
|
|
27
|
+
const pathsToCheck = customPath ? [customPath] : CONFIG_PATHS;
|
|
28
|
+
|
|
29
|
+
for (const p of pathsToCheck) {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(p)) {
|
|
32
|
+
const content = fs.readFileSync(p, 'utf8');
|
|
33
|
+
const parsed = JSON.parse(content);
|
|
34
|
+
config = { ...config, ...parsed };
|
|
35
|
+
config._loadedFrom = p;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// ignore malformed config
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function saveConfig(configPath, config) {
|
|
47
|
+
const dir = path.dirname(configPath);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { loadConfig, saveConfig, DEFAULT_CONFIG, CONFIG_PATHS };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
async function check(label, fn) {
|
|
10
|
+
try {
|
|
11
|
+
const result = await fn();
|
|
12
|
+
return { label, status: 'ok', detail: result };
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return { label, status: 'fail', detail: err.message };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function doctor() {
|
|
19
|
+
return new Promise(async (resolve) => {
|
|
20
|
+
const checks = [];
|
|
21
|
+
|
|
22
|
+
checks.push(await check('Node.js version', async () => {
|
|
23
|
+
const v = process.version;
|
|
24
|
+
const major = parseInt(v.slice(1), 10);
|
|
25
|
+
if (major < 18) throw new Error(`${v} — requires Node 18+`);
|
|
26
|
+
return v;
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
checks.push(await check('Platform', async () => `${os.platform()} ${os.arch()} (${os.release()})`));
|
|
30
|
+
|
|
31
|
+
checks.push(await check('Current directory writable', async () => {
|
|
32
|
+
const testFile = path.join(process.cwd(), '.meo-agent-doctor.tmp');
|
|
33
|
+
fs.writeFileSync(testFile, 'ok');
|
|
34
|
+
fs.unlinkSync(testFile);
|
|
35
|
+
return process.cwd();
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
checks.push(await check('Temp directory writable', async () => {
|
|
39
|
+
const testFile = path.join(os.tmpdir(), '.meo-agent-doctor.tmp');
|
|
40
|
+
fs.writeFileSync(testFile, 'ok');
|
|
41
|
+
fs.unlinkSync(testFile);
|
|
42
|
+
return os.tmpdir();
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
checks.push(await check('HTTPS connectivity (github.com)', async () => {
|
|
46
|
+
return new Promise((res, rej) => {
|
|
47
|
+
const req = https.get('https://github.com', { timeout: 10000 }, (r) => {
|
|
48
|
+
r.resume();
|
|
49
|
+
if (r.statusCode >= 200 && r.statusCode < 500) res(`HTTP ${r.statusCode}`);
|
|
50
|
+
else rej(new Error(`HTTP ${r.statusCode}`));
|
|
51
|
+
});
|
|
52
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
53
|
+
req.on('error', rej);
|
|
54
|
+
});
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
checks.push(await check('HTTP connectivity (example.com)', async () => {
|
|
58
|
+
return new Promise((res, rej) => {
|
|
59
|
+
const req = http.get('http://example.com', { timeout: 10000 }, (r) => {
|
|
60
|
+
r.resume();
|
|
61
|
+
if (r.statusCode >= 200 && r.statusCode < 500) res(`HTTP ${r.statusCode}`);
|
|
62
|
+
else rej(new Error(`HTTP ${r.statusCode}`));
|
|
63
|
+
});
|
|
64
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
65
|
+
req.on('error', rej);
|
|
66
|
+
});
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
checks.push(await check('Disk space', async () => {
|
|
70
|
+
const df = require('child_process').execSync('df -k . 2>/dev/null || echo ""').toString();
|
|
71
|
+
const lines = df.split('\n').filter(Boolean);
|
|
72
|
+
if (lines.length < 2) return 'unknown';
|
|
73
|
+
const parts = lines[1].split(/\s+/);
|
|
74
|
+
const available = parseInt(parts[3] || '0', 10);
|
|
75
|
+
const availableMB = (available / 1024).toFixed(0);
|
|
76
|
+
if (available < 100 * 1024) throw new Error(`Only ${availableMB} MB available`);
|
|
77
|
+
return `${availableMB} MB available`;
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
resolve(checks);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { doctor };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
function pickClient(parsedUrl) {
|
|
10
|
+
return parsedUrl.protocol === 'https:' ? https : http;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sanitizeFilename(name) {
|
|
14
|
+
return name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 255) || 'index.html';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deriveFilename(urlString) {
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(urlString);
|
|
20
|
+
const last = u.pathname.split('/').pop();
|
|
21
|
+
return sanitizeFilename(last || 'index.html');
|
|
22
|
+
} catch (e) {
|
|
23
|
+
return 'index.html';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkExisting(output, continueFlag) {
|
|
28
|
+
if (!fs.existsSync(output)) {
|
|
29
|
+
return { exists: false, offset: 0 };
|
|
30
|
+
}
|
|
31
|
+
if (!continueFlag) {
|
|
32
|
+
return { exists: true, offset: 0 };
|
|
33
|
+
}
|
|
34
|
+
const stat = fs.statSync(output);
|
|
35
|
+
return { exists: true, offset: stat.size };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function download(opts, reporter) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let parsedUrl;
|
|
41
|
+
try {
|
|
42
|
+
parsedUrl = new URL(opts.url);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return reject(new Error(`Invalid URL: ${opts.url}`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const client = pickClient(parsedUrl);
|
|
48
|
+
const headers = { 'User-Agent': `meo-agent/1.0 (+https://github.com/meocode-labs/meo-agent)` };
|
|
49
|
+
|
|
50
|
+
if (opts.continue && fs.existsSync(opts.output)) {
|
|
51
|
+
const offset = fs.statSync(opts.output).size;
|
|
52
|
+
headers['Range'] = `bytes=${offset}-`;
|
|
53
|
+
reporter.info(`Resuming from byte ${offset}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const req = client.get(parsedUrl, { headers, timeout: opts.timeout }, (res) => {
|
|
57
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
58
|
+
const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
|
|
59
|
+
reporter.info(`Redirect → ${redirectUrl}`);
|
|
60
|
+
res.resume();
|
|
61
|
+
return resolve(download({ ...opts, url: redirectUrl }, reporter));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (res.statusCode >= 400) {
|
|
65
|
+
return reject(new Error(`HTTP ${res.statusCode} ${res.statusMessage || ''}`.trim()));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const totalBytes = parseInt(res.headers['content-length'] || '0', 10) + (opts.continue ? checkExisting(opts.output, true).offset : 0);
|
|
69
|
+
reporter.setTotal(totalBytes);
|
|
70
|
+
|
|
71
|
+
const flags = (res.statusCode === 206 || (opts.continue && fs.existsSync(opts.output))) ? 'a' : 'w';
|
|
72
|
+
const fileStream = fs.createWriteStream(opts.output, { flags });
|
|
73
|
+
|
|
74
|
+
let received = (flags === 'a' && fs.existsSync(opts.output)) ? fs.statSync(opts.output).size : 0;
|
|
75
|
+
reporter.progress(received);
|
|
76
|
+
|
|
77
|
+
res.on('data', (chunk) => {
|
|
78
|
+
received += chunk.length;
|
|
79
|
+
reporter.progress(received);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
res.pipe(fileStream);
|
|
83
|
+
|
|
84
|
+
fileStream.on('finish', () => {
|
|
85
|
+
fileStream.close();
|
|
86
|
+
reporter.finish({
|
|
87
|
+
statusCode: res.statusCode,
|
|
88
|
+
resumed: flags === 'a'
|
|
89
|
+
});
|
|
90
|
+
resolve({
|
|
91
|
+
output: opts.output,
|
|
92
|
+
url: opts.url,
|
|
93
|
+
bytes: received,
|
|
94
|
+
statusCode: res.statusCode,
|
|
95
|
+
resumed: flags === 'a'
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
fileStream.on('error', (err) => {
|
|
100
|
+
fs.unlink(opts.output, () => {});
|
|
101
|
+
reject(err);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
req.on('timeout', () => {
|
|
106
|
+
req.destroy(new Error(`Network timeout after ${opts.timeout / 1000}s`));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
req.on('error', (err) => {
|
|
110
|
+
reject(err);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { download, deriveFilename, sanitizeFilename };
|
package/lib/mirror.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
function pickClient(u) {
|
|
10
|
+
return u.protocol === 'https:' ? https : http;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SKIP_PATTERNS = [
|
|
14
|
+
/\?/, // query strings
|
|
15
|
+
/#/, // fragments
|
|
16
|
+
/\.(zip|tar|gz|tgz|7z|rar|exe|dmg|pkg|deb|rpm|iso)$/i
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function shouldSkip(href) {
|
|
20
|
+
return SKIP_PATTERNS.some((re) => re.test(href));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isSameOrigin(a, b) {
|
|
24
|
+
return a.host === b.host;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fetchHtml(urlString, redirects = 0) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
30
|
+
let u;
|
|
31
|
+
try { u = new URL(urlString); } catch (e) { return reject(e); }
|
|
32
|
+
|
|
33
|
+
const client = pickClient(u);
|
|
34
|
+
const req = client.get(u, { timeout: 15000 }, (res) => {
|
|
35
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
36
|
+
res.resume();
|
|
37
|
+
const next = new URL(res.headers.location, u).toString();
|
|
38
|
+
return resolve(fetchHtml(next, redirects + 1));
|
|
39
|
+
}
|
|
40
|
+
if (res.statusCode !== 200) {
|
|
41
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
42
|
+
}
|
|
43
|
+
let body = '';
|
|
44
|
+
res.setEncoding('utf8');
|
|
45
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
46
|
+
res.on('end', () => resolve({ html: body, base: u }));
|
|
47
|
+
res.on('error', reject);
|
|
48
|
+
});
|
|
49
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractLinks(html, base) {
|
|
55
|
+
const links = new Set();
|
|
56
|
+
const hrefRe = /href\s*=\s*["']([^"']+)["']/gi;
|
|
57
|
+
const srcRe = /src\s*=\s*["']([^"']+)["']/gi;
|
|
58
|
+
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const re of [hrefRe, srcRe]) {
|
|
61
|
+
let m;
|
|
62
|
+
while ((m = re.exec(html)) !== null) {
|
|
63
|
+
seen.add(m[1]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const raw of seen) {
|
|
68
|
+
if (raw.startsWith('mailto:') || raw.startsWith('javascript:') || raw.startsWith('#')) continue;
|
|
69
|
+
try {
|
|
70
|
+
const u = new URL(raw, base);
|
|
71
|
+
u.hash = '';
|
|
72
|
+
links.add(u.toString());
|
|
73
|
+
} catch (e) {
|
|
74
|
+
// ignore invalid URLs
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return Array.from(links);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function mirror(baseUrlString, outputDir, opts = {}) {
|
|
81
|
+
const visited = new Set();
|
|
82
|
+
const queue = [baseUrlString];
|
|
83
|
+
const downloaded = [];
|
|
84
|
+
const maxDepth = opts.maxDepth || 2;
|
|
85
|
+
const limit = opts.limit || 50;
|
|
86
|
+
|
|
87
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
while (queue.length > 0 && downloaded.length < limit) {
|
|
90
|
+
const url = queue.shift();
|
|
91
|
+
if (visited.has(url)) continue;
|
|
92
|
+
visited.add(url);
|
|
93
|
+
|
|
94
|
+
if (shouldSkip(url)) continue;
|
|
95
|
+
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
result = await fetchHtml(url);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const baseU = result.base;
|
|
104
|
+
const parsed = new URL(url);
|
|
105
|
+
|
|
106
|
+
let relPath;
|
|
107
|
+
if (url === baseUrlString) {
|
|
108
|
+
relPath = 'index.html';
|
|
109
|
+
} else {
|
|
110
|
+
relPath = parsed.pathname.replace(/^\//, '') || 'index.html';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const outPath = path.join(outputDir, relPath);
|
|
114
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
115
|
+
fs.writeFileSync(outPath, result.html);
|
|
116
|
+
|
|
117
|
+
downloaded.push({ url, output: outPath });
|
|
118
|
+
|
|
119
|
+
if (downloaded.length - 1 < maxDepth) {
|
|
120
|
+
const links = extractLinks(result.html, baseU);
|
|
121
|
+
for (const link of links) {
|
|
122
|
+
try {
|
|
123
|
+
const lu = new URL(link);
|
|
124
|
+
if (isSameOrigin(lu, baseU) && !visited.has(link)) {
|
|
125
|
+
queue.push(link);
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return downloaded;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { mirror, fetchHtml, extractLinks };
|
package/lib/plugins.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const PLUGIN_DIRS = [
|
|
8
|
+
path.join(process.cwd(), '.meo-agent', 'plugins'),
|
|
9
|
+
path.join(os.homedir(), '.meo-agent', 'plugins'),
|
|
10
|
+
path.join(os.homedir(), '.config', 'meo-agent', 'plugins')
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
class PluginManager {
|
|
14
|
+
constructor(reporter) {
|
|
15
|
+
this.reporter = reporter;
|
|
16
|
+
this.plugins = [];
|
|
17
|
+
this.hooks = {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
register(plugin) {
|
|
21
|
+
if (!plugin || typeof plugin.name !== 'string') {
|
|
22
|
+
throw new Error('Plugin must have a name');
|
|
23
|
+
}
|
|
24
|
+
this.plugins.push(plugin);
|
|
25
|
+
for (const hook of plugin.hooks || []) {
|
|
26
|
+
if (!this.hooks[hook.name]) this.hooks[hook.name] = [];
|
|
27
|
+
this.hooks[hook.name].push(hook.handler);
|
|
28
|
+
}
|
|
29
|
+
if (this.reporter && this.reporter.info) {
|
|
30
|
+
this.reporter.info(`Plugin loaded: ${plugin.name} v${plugin.version || '0.0.0'}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async trigger(hookName, context = {}) {
|
|
35
|
+
const handlers = this.hooks[hookName] || [];
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
try {
|
|
38
|
+
await handler(context);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (this.reporter) {
|
|
41
|
+
this.reporter.error(`Plugin hook '${hookName}' failed: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
loadFromDir(dir) {
|
|
48
|
+
if (!fs.existsSync(dir)) return 0;
|
|
49
|
+
let loaded = 0;
|
|
50
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
51
|
+
const full = path.join(dir, entry);
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(full);
|
|
54
|
+
let pluginModule;
|
|
55
|
+
if (stat.isDirectory()) {
|
|
56
|
+
const pkg = path.join(full, 'package.json');
|
|
57
|
+
const main = path.join(full, fs.existsSync(pkg) ? require(pkg).main || 'index.js' : 'index.js');
|
|
58
|
+
pluginModule = require(path.resolve(main));
|
|
59
|
+
} else if (entry.endsWith('.js')) {
|
|
60
|
+
pluginModule = require(path.resolve(full));
|
|
61
|
+
} else {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
this.register(pluginModule);
|
|
65
|
+
loaded++;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (this.reporter) {
|
|
68
|
+
this.reporter.error(`Failed to load plugin from ${full}: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return loaded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
loadAll() {
|
|
76
|
+
let total = 0;
|
|
77
|
+
for (const dir of PLUGIN_DIRS) {
|
|
78
|
+
total += this.loadFromDir(dir);
|
|
79
|
+
}
|
|
80
|
+
return total;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
list() {
|
|
84
|
+
return this.plugins.map((p) => ({
|
|
85
|
+
name: p.name,
|
|
86
|
+
version: p.version || '0.0.0',
|
|
87
|
+
description: p.description || '',
|
|
88
|
+
hooks: (p.hooks || []).map((h) => h.name)
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { PluginManager, PLUGIN_DIRS };
|