/[sudobot]/trunk/scripts/extensions.js
ViewVC logotype

Contents of /trunk/scripts/extensions.js

Parent Directory Parent Directory | Revision Log Revision Log


Revision 624 - (show annotations)
Fri Aug 30 10:10:33 2024 UTC (7 months ago) by rakinar2
File MIME type: text/javascript
File size: 17109 byte(s)
chore(extensions): fixup build process
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 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26