1 |
#!/usr/bin/env node |
2 |
|
3 |
/* |
4 |
* This file is part of SudoBot. |
5 |
* |
6 |
* Copyright (C) 2021-2023 OSN Developers. |
7 |
* |
8 |
* SudoBot is free software; you can redistribute it and/or modify it |
9 |
* under the terms of the GNU Affero General Public License as published by |
10 |
* the Free Software Foundation, either version 3 of the License, or |
11 |
* (at your option) any later version. |
12 |
* |
13 |
* SudoBot is distributed in the hope that it will be useful, but |
14 |
* WITHOUT ANY WARRANTY; without even the implied warranty of |
15 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
16 |
* GNU Affero General Public License for more details. |
17 |
* |
18 |
* You should have received a copy of the GNU Affero General Public License |
19 |
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>. |
20 |
*/ |
21 |
|
22 |
require("module-alias/register"); |
23 |
require("dotenv/config"); |
24 |
|
25 |
const chalk = require("chalk"); |
26 |
const { spawnSync } = require("child_process"); |
27 |
const { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync, rmSync } = require("fs"); |
28 |
const { readFile, symlink, unlink } = require("fs/promises"); |
29 |
const path = require("path"); |
30 |
const { chdir, cwd } = require("process"); |
31 |
const { z } = require("zod"); |
32 |
const semver = require("semver"); |
33 |
|
34 |
const extensionsPath = process.env.EXTENSIONS_DIRECTORY ?? path.resolve(__dirname, "../extensions"); |
35 |
|
36 |
function error(...args) { |
37 |
console.error(...args); |
38 |
process.exit(-1); |
39 |
} |
40 |
|
41 |
if (!existsSync(extensionsPath)) { |
42 |
error( |
43 |
"You're not using any extension! To get started, create an `extensions` folder in the project root." |
44 |
); |
45 |
} |
46 |
|
47 |
function getRecuriveJavaScriptFiles(dir) { |
48 |
if (!existsSync(dir)) { |
49 |
return []; |
50 |
} |
51 |
|
52 |
const files = readdirSync(dir); |
53 |
const flat = []; |
54 |
|
55 |
for (const fileName of files) { |
56 |
const file = path.join(dir, fileName); |
57 |
const isDirectory = lstatSync(file).isDirectory(); |
58 |
|
59 |
if (isDirectory) { |
60 |
flat.push(...getRecuriveJavaScriptFiles(file)); |
61 |
continue; |
62 |
} |
63 |
|
64 |
if (!file.endsWith(".js")) { |
65 |
continue; |
66 |
} |
67 |
|
68 |
flat.push(file); |
69 |
} |
70 |
|
71 |
return flat; |
72 |
} |
73 |
|
74 |
const extensionMetadataSchema = z.object({ |
75 |
main: z.string().optional(), |
76 |
commands: z.string().optional(), |
77 |
services: z.string().optional(), |
78 |
events: z.string().optional(), |
79 |
language: z.enum(["typescript", "javascript"]).optional(), |
80 |
main_directory: z.string().optional(), |
81 |
build_command: z.string().optional(), |
82 |
name: z.string().optional(), |
83 |
description: z.string().optional(), |
84 |
resources: z.string().optional(), |
85 |
id: z.string(), |
86 |
icon: z.string().optional(), |
87 |
readmeFileName: z.string().default("README.md") |
88 |
}); |
89 |
|
90 |
function readMeta(extensionName, extensionDirectory) { |
91 |
const metadataFile = path.join(extensionDirectory, "extension.json"); |
92 |
|
93 |
if (!existsSync(metadataFile)) { |
94 |
error(`Extension ${extensionName} does not have a "extension.json" file!`); |
95 |
} |
96 |
|
97 |
const metadata = JSON.parse(readFileSync(metadataFile, { encoding: "utf-8" })); |
98 |
|
99 |
try { |
100 |
return extensionMetadataSchema.parse(metadata); |
101 |
} catch (e) { |
102 |
error(`Extension ${extensionName} has an invalid "extension.json" file!`, e); |
103 |
} |
104 |
} |
105 |
|
106 |
async function writeCacheIndex() { |
107 |
const extensionsOutputArray = []; |
108 |
const meta = []; |
109 |
const extensions = readdirSync(extensionsPath); |
110 |
|
111 |
for await (const extensionName of extensions) { |
112 |
const extensionDirectory = path.resolve(extensionsPath, extensionName); |
113 |
const isDirectory = lstatSync(extensionDirectory).isDirectory(); |
114 |
|
115 |
if (!isDirectory || extensionName === ".extbuilds") { |
116 |
continue; |
117 |
} |
118 |
|
119 |
console.log("Found extension: ", extensionName); |
120 |
|
121 |
const { |
122 |
main_directory = "./build", |
123 |
commands = `./${main_directory}/commands`, |
124 |
events = `./${main_directory}/events`, |
125 |
services = `./${main_directory}/services`, |
126 |
main = `./${main_directory}/index.js`, |
127 |
language = "typescript", |
128 |
id |
129 |
} = readMeta(extensionName, extensionDirectory); |
130 |
|
131 |
const extensionPath = path.join(extensionDirectory, main); |
132 |
|
133 |
const imported = await require(extensionPath); |
134 |
const { default: ExtensionClass } = imported.__esModule ? imported : { default: imported }; |
135 |
const extension = new ExtensionClass(this.client); |
136 |
let commandPaths = await extension.commands(); |
137 |
let eventPaths = await extension.events(); |
138 |
let servicePaths = await extension.services(); |
139 |
|
140 |
if (commandPaths === null) { |
141 |
const directory = path.join( |
142 |
...(process.env.EXTENSIONS_DIRECTORY |
143 |
? [process.env.EXTENSIONS_DIRECTORY] |
144 |
: [__dirname, "../extensions"]), |
145 |
extensionName, |
146 |
commands |
147 |
); |
148 |
|
149 |
if (existsSync(directory)) { |
150 |
commandPaths = getRecuriveJavaScriptFiles(directory); |
151 |
} |
152 |
} |
153 |
|
154 |
if (eventPaths === null) { |
155 |
const directory = path.join( |
156 |
...(process.env.EXTENSIONS_DIRECTORY |
157 |
? [process.env.EXTENSIONS_DIRECTORY] |
158 |
: [__dirname, "../extensions"]), |
159 |
extensionName, |
160 |
events |
161 |
); |
162 |
|
163 |
if (existsSync(directory)) { |
164 |
eventPaths = getRecuriveJavaScriptFiles(directory); |
165 |
} |
166 |
} |
167 |
|
168 |
if (servicePaths === null) { |
169 |
const directory = path.join( |
170 |
...(process.env.EXTENSIONS_DIRECTORY |
171 |
? [process.env.EXTENSIONS_DIRECTORY] |
172 |
: [__dirname, "../extensions"]), |
173 |
extensionName, |
174 |
services |
175 |
); |
176 |
|
177 |
if (existsSync(directory)) { |
178 |
servicePaths = getRecuriveJavaScriptFiles(directory); |
179 |
} |
180 |
} |
181 |
|
182 |
extensionsOutputArray.push({ |
183 |
name: extensionName, |
184 |
entry: extensionPath, |
185 |
commands: commandPaths ?? [], |
186 |
events: eventPaths ?? [], |
187 |
services: servicePaths ?? [], |
188 |
id |
189 |
}); |
190 |
|
191 |
meta.push({ |
192 |
language |
193 |
}); |
194 |
} |
195 |
|
196 |
console.log("Overview of the extensions: "); |
197 |
console.table( |
198 |
extensionsOutputArray.map((e, i) => ({ |
199 |
name: e.name, |
200 |
entry: e.entry.replace(extensionsPath, "{ROOT}"), |
201 |
commands: e.commands.length, |
202 |
events: e.events.length, |
203 |
services: e.services.length, |
204 |
language: meta[i].language |
205 |
})) |
206 |
); |
207 |
|
208 |
const indexFile = path.join(extensionsPath, "index.json"); |
209 |
|
210 |
writeFileSync( |
211 |
indexFile, |
212 |
JSON.stringify( |
213 |
{ |
214 |
extensions: extensionsOutputArray |
215 |
}, |
216 |
null, |
217 |
4 |
218 |
) |
219 |
); |
220 |
|
221 |
console.log("Wrote cache index file: ", indexFile); |
222 |
console.warn( |
223 |
"Note: If you're getting import errors after caching extensions, please try first by removing or rebuilding them." |
224 |
); |
225 |
} |
226 |
|
227 |
const MAX_CHARS = 7; |
228 |
|
229 |
function actionLog(action, description) { |
230 |
console.log( |
231 |
chalk.green.bold( |
232 |
`${action}${action.length >= MAX_CHARS ? "" : " ".repeat(MAX_CHARS - action.length)} ` |
233 |
), |
234 |
description |
235 |
); |
236 |
} |
237 |
|
238 |
function spawnSyncCatchExit(...args) { |
239 |
actionLog("RUN", args[0]); |
240 |
const { status } = spawnSync(...args); |
241 |
if (status !== 0) process.exit(status); |
242 |
} |
243 |
|
244 |
async function buildExtensions() { |
245 |
const startTime = Date.now(); |
246 |
const extensions = readdirSync(extensionsPath); |
247 |
const workingDirectory = cwd(); |
248 |
let count = 0; |
249 |
|
250 |
for await (const extensionName of extensions) { |
251 |
const extensionDirectory = path.resolve(extensionsPath, extensionName); |
252 |
const isDirectory = lstatSync(extensionDirectory).isDirectory(); |
253 |
|
254 |
if (!isDirectory || extensionName === ".extbuilds") { |
255 |
continue; |
256 |
} |
257 |
|
258 |
chdir(path.join(extensionsPath, extensionName)); |
259 |
|
260 |
if (!process.argv.includes("--tsc")) { |
261 |
actionLog("DEPS", extensionName); |
262 |
spawnSyncCatchExit("npm install -D", { |
263 |
encoding: "utf-8", |
264 |
shell: true, |
265 |
stdio: "inherit" |
266 |
}); |
267 |
|
268 |
actionLog("RELINK", extensionName); |
269 |
spawnSyncCatchExit( |
270 |
`npm install --save ${path.relative(cwd(), path.resolve(__dirname, ".."))}`, |
271 |
{ |
272 |
encoding: "utf-8", |
273 |
shell: true, |
274 |
stdio: "inherit" |
275 |
} |
276 |
); |
277 |
} |
278 |
|
279 |
actionLog("PREPARE", extensionName); |
280 |
|
281 |
if (existsSync("tsconfig.json")) { |
282 |
await unlink("tsconfig.json").catch(() => {}); |
283 |
} |
284 |
|
285 |
await symlink( |
286 |
`tsconfig.${process.env.BUN === "1" ? "bun" : "node"}.json`, |
287 |
"tsconfig.json" |
288 |
).catch(() => {}); |
289 |
|
290 |
actionLog("BUILD", extensionName); |
291 |
const { build_command } = readMeta(extensionName, extensionDirectory); |
292 |
|
293 |
if (!build_command) { |
294 |
console.log(chalk.cyan.bold("INFO "), "This extension doesn't require building."); |
295 |
continue; |
296 |
} |
297 |
|
298 |
spawnSyncCatchExit(build_command, { |
299 |
encoding: "utf-8", |
300 |
shell: true, |
301 |
stdio: "inherit" |
302 |
}); |
303 |
|
304 |
count++; |
305 |
} |
306 |
|
307 |
actionLog( |
308 |
"SUCCESS", |
309 |
`in ${((Date.now() - startTime) / 1000).toFixed(2)}s, built ${count} extensions` |
310 |
); |
311 |
chdir(workingDirectory); |
312 |
} |
313 |
|
314 |
async function writeExtensionIndex() { |
315 |
const extensionsPath = path.resolve(__dirname, "../extensions"); |
316 |
const extensionsOutputRecord = {}; |
317 |
const extensions = readdirSync(extensionsPath); |
318 |
|
319 |
for await (const extensionName of extensions) { |
320 |
const extensionDirectory = path.resolve(extensionsPath, extensionName); |
321 |
const isDirectory = lstatSync(extensionDirectory).isDirectory(); |
322 |
|
323 |
if (!isDirectory || extensionName === ".extbuilds") { |
324 |
continue; |
325 |
} |
326 |
|
327 |
console.log("Found extension: ", extensionName); |
328 |
|
329 |
const packageJsonPath = path.join(extensionDirectory, "package.json"); |
330 |
const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: "utf-8" })); |
331 |
|
332 |
const { |
333 |
main_directory = "./build", |
334 |
commands = `./${main_directory}/commands`, |
335 |
events = `./${main_directory}/events`, |
336 |
build_command = null, |
337 |
name = packageJson.name ?? extensionName, |
338 |
description = packageJson.description, |
339 |
language = "javascript", |
340 |
main = `./${main_directory}/index.js`, |
341 |
services = `./${main_directory}/services`, |
342 |
id, |
343 |
icon, |
344 |
readmeFileName |
345 |
} = readMeta(extensionName, extensionDirectory) ?? {}; |
346 |
|
347 |
const commandPaths = getRecuriveJavaScriptFiles(path.join(extensionDirectory, commands)); |
348 |
const eventPaths = getRecuriveJavaScriptFiles(path.join(extensionDirectory, events)); |
349 |
const servicePaths = getRecuriveJavaScriptFiles(path.join(extensionDirectory, services)); |
350 |
|
351 |
const tarballs = readdirSync(path.resolve(extensionsPath, ".extbuilds", extensionName)); |
352 |
|
353 |
tarballs.sort((a, b) => { |
354 |
const vA = path |
355 |
.basename(a) |
356 |
.replace(`${extensionName}-`, "") |
357 |
.replace(/\.tar\.gz$/gi, ""); |
358 |
const vB = path |
359 |
.basename(b) |
360 |
.replace(`${extensionName}-`, "") |
361 |
.replace(/\.tar\.gz$/gi, ""); |
362 |
const splitA = vA.split("-"); |
363 |
const splitB = vB.split("-"); |
364 |
const dashVA = splitA[1]; |
365 |
const dashVB = splitB[1]; |
366 |
const revA = isNaN(dashVA) ? 0 : parseInt(dashVA); |
367 |
const revB = isNaN(dashVB) ? 0 : parseInt(dashVB); |
368 |
const result = semver.rcompare(vA, vB); |
369 |
|
370 |
if ( |
371 |
splitA[0] === splitB[0] && |
372 |
vA.includes("-") && |
373 |
!isNaN(dashVA) && |
374 |
(!vB.includes("-") || isNaN(dashVB)) |
375 |
) { |
376 |
return -1; |
377 |
} |
378 |
|
379 |
if ( |
380 |
splitA[0] === splitB[0] && |
381 |
vB.includes("-") && |
382 |
!isNaN(dashVB) && |
383 |
(!vA.includes("-") || isNaN(dashVA)) |
384 |
) { |
385 |
return 1; |
386 |
} |
387 |
|
388 |
return result === 0 ? revB - revA : result; |
389 |
}); |
390 |
|
391 |
const tarballList = tarballs.map(file => { |
392 |
const basename = path.basename(file); |
393 |
const filePath = path.join(extensionsPath, ".extbuilds", extensionName, basename); |
394 |
const { stdout } = spawnSync(`sha512sum`, [filePath], { |
395 |
encoding: "utf-8" |
396 |
}); |
397 |
const { size, birthtime } = lstatSync(filePath); |
398 |
const checksum = stdout.split(" ")[0]; |
399 |
|
400 |
return { |
401 |
url: |
402 |
"https://raw.githubusercontent.com/onesoft-sudo/sudobot/main/extensions" + |
403 |
path.join("/.extbuilds/", extensionName, basename), |
404 |
basename, |
405 |
version: basename.replace(`${extensionName}-`, "").replace(/\.tar\.gz$/gi, ""), |
406 |
checksum, |
407 |
size, |
408 |
createdAt: birthtime |
409 |
}; |
410 |
}); |
411 |
|
412 |
extensionsOutputRecord[id] = { |
413 |
name, |
414 |
id, |
415 |
shortName: packageJson.name ?? extensionName, |
416 |
description, |
417 |
version: packageJson.version, |
418 |
commands: commandPaths.length, |
419 |
events: eventPaths.length, |
420 |
buildCommand: build_command, |
421 |
language, |
422 |
services: servicePaths.length, |
423 |
main, |
424 |
iconURL: icon |
425 |
? "https://raw.githubusercontent.com/onesoft-sudo/sudobot/main/extensions" + |
426 |
path.join("/", extensionName, icon) |
427 |
: null, |
428 |
author: |
429 |
typeof packageJson.author === "string" |
430 |
? { |
431 |
name: packageJson.author |
432 |
} |
433 |
: packageJson.author, |
434 |
license: packageJson.license, |
435 |
licenseURL: `https://spdx.org/licenses/${packageJson.license}.html`, |
436 |
homepage: packageJson.homepage, |
437 |
repository: |
438 |
typeof packageJson.repository === "string" |
439 |
? packageJson.repository |
440 |
: packageJson.repository?.url, |
441 |
issues: typeof packageJson.bugs === "string" ? packageJson.bugs : packageJson.bugs?.url, |
442 |
lastUpdated: new Date(), |
443 |
readmeFileName: readmeFileName, |
444 |
readmeFileURL: `https://raw.githubusercontent.com/onesoft-sudo/sudobot/main/extensions/${extensionName}/${readmeFileName}`, |
445 |
tarballs: tarballList |
446 |
}; |
447 |
} |
448 |
|
449 |
console.log("Overview of the extensions: "); |
450 |
console.table( |
451 |
Object.values(extensionsOutputRecord).map(e => ({ |
452 |
name: e.name, |
453 |
commands: e.commands.length, |
454 |
events: e.events.length |
455 |
})) |
456 |
); |
457 |
|
458 |
const indexFile = path.join(extensionsPath, ".extbuilds/index.json"); |
459 |
|
460 |
writeFileSync(indexFile, JSON.stringify(extensionsOutputRecord, null, 4)); |
461 |
|
462 |
console.log("Wrote index file: ", indexFile); |
463 |
} |
464 |
|
465 |
if ( |
466 |
process.argv.includes("--clear-cache") || |
467 |
process.argv.includes("--clean") || |
468 |
process.argv.includes("--delcache") |
469 |
) { |
470 |
const indexFile = path.join(extensionsPath, "index.json"); |
471 |
|
472 |
if (!existsSync(indexFile)) { |
473 |
error("No cached index file found!"); |
474 |
} |
475 |
|
476 |
rmSync(indexFile); |
477 |
console.log("Removed cached index file: ", indexFile); |
478 |
} else if (process.argv.includes("--cache") || process.argv.includes("--index")) { |
479 |
console.time("Finished in"); |
480 |
console.log("Creating cache index for all the installed extensions"); |
481 |
writeCacheIndex() |
482 |
.then(() => console.timeEnd("Finished in")) |
483 |
.catch(e => { |
484 |
console.log(e); |
485 |
console.timeEnd("Finished in"); |
486 |
}); |
487 |
} else if (process.argv.includes("--build")) { |
488 |
console.log("Building installed extensions"); |
489 |
buildExtensions().catch(e => { |
490 |
console.log(e); |
491 |
process.exit(-1); |
492 |
}); |
493 |
} else if (process.argv.includes("--mkindex")) { |
494 |
console.log("Creating index for all the available extensions in the extensions/ directory"); |
495 |
|
496 |
writeExtensionIndex().catch(e => { |
497 |
console.log(e); |
498 |
process.exit(-1); |
499 |
}); |
500 |
} else { |
501 |
console.log("Usage:"); |
502 |
console.log(" node extensions.js <options>\n"); |
503 |
console.log("Options:"); |
504 |
console.log(" --build [--tsc] | Builds all the installed extensions, if needed."); |
505 |
console.log(" | The `--tsc` flag will only run the typescript compiler."); |
506 |
console.log( |
507 |
" --cache | Creates cache indexes for installed extensions, to improve the startup time." |
508 |
); |
509 |
console.log(" --clean | Clears all installed extension cache."); |
510 |
console.log( |
511 |
" --mkindex | Creates indexes for all the available extensions, in the extensions/ top level directory." |
512 |
); |
513 |
process.exit(-1); |
514 |
} |