import { createHash } from "crypto"; import fs from "fs"; import { Plugins as PluginsType } from "#type/db"; // Renamed to avoid conflict import { compareSemanticVersions } from "#Modules/semantic"; import store from "../types/store"; // Assuming this type is correctly defined import dotenv from "dotenv"; import { finished } from "stream/promises"; import { Readable } from "stream"; import { ReadableStream } from "stream/web"; import { createConfig, migrateToLatest } from "#scripts/migrate"; import db from "#database"; // Kysely instance import { Selectable } from "kysely"; // Load environment variables if (process.env.APP_ENV !== "test") { dotenv.config({ path: ".env.local" }); } else { dotenv.config({ path: ".env.test.local" }); } // Downloads and generates all the plugin code async function compilePlugins() { console.log("Compiling plugins"); let mainFileTextStart = `// Note that this file is autogenerated by startup.ts DO NOT EDIT\nimport dataGetter from "#type/data";\n`; let mainFileText = "export const plugins: { [key: string]: dataGetter } = {\n"; const request = process.env.APP_ENV !== "test" ? await fetch( "https://git.lschaefer.xyz/lukasdotcom/fantasy-manager/raw/branch/main/store/default_store.json", ).catch((err) => { console.error("Could not get the default store:", err); return "error" as const; // Ensure type is 'error' literal }) : ("error" as const); const defaultStore: string[] = request instanceof Response ? await request.json().catch((err) => { console.error("Could not parse the default store JSON:", err); return []; }) : // Uses a fallback store if the request fails(this is also the testing store) [ "https://git.lschaefer.xyz/lukasdotcom/fantasy-manager/raw/branch/main/store/Bundesliga_Testing/Bundesliga_Testing.json", ]; // Installs all plugins that should be installed by default if (defaultStore.length > 0) { await db .insertInto("plugins") .onConflict((eb) => eb.doNothing()) .values( defaultStore.map((pluginUrl) => ({ name: "", settings: "{}", enabled: 0, installed: 0, url: pluginUrl, })), ) .execute(); } // Makes sure that bundesliga is enabled when testing if (process.env.APP_ENV === "test") { await db.updateTable("plugins").set({ enabled: 1 }).execute(); // This will enable ALL plugins } const currentVersion = (await import("../package.json")).default.version; // Makes sure that the store is correct await db .insertInto("data") .values({ value1: "defaultStore", value2: JSON.stringify(defaultStore), }) .onConflict((oc) => oc.column("value1").doUpdateSet({ value2: JSON.stringify(defaultStore) }), ) .execute(); const pluginRecords: Selectable[] = await db .selectFrom("plugins") .selectAll() .execute(); await Promise.all( pluginRecords.map(async (pluginEntry) => { const dataResponse = await fetch(pluginEntry.url).catch( () => "error" as const, ); if (dataResponse === "error" || !(dataResponse instanceof Response)) { console.log(`Failed to fetch plugin data from ${pluginEntry.url}`); return; } const jsonStore: store | "error" = await dataResponse .json() .catch(() => "error" as const); if (jsonStore === "error") { console.log(`Failed to parse JSON for plugin at ${pluginEntry.url}`); return; } // Update plugin name in DB await db .updateTable("plugins") .set({ name: jsonStore.id }) .where("url", "=", pluginEntry.url) .execute(); pluginEntry.name = jsonStore.id; // Update local copy // Makes sure the plugin is compatible with the current version if ( compareSemanticVersions( jsonStore.min_version || "0.0.1", currentVersion, ) === -1 ) { console.error( `Plugin ${pluginEntry.name} (min_version: ${ jsonStore.min_version || "0.0.1" }) is not compatible with the current program version ${currentVersion}. Disabling.`, ); await db .updateTable("plugins") .set({ version: "", enabled: 0, installed: 0 }) .where("url", "=", pluginEntry.url) .execute(); return; } const hash = createHash("sha256").update(pluginEntry.url).digest("hex"); const pluginDir = `scripts/data/${hash}`; if ( pluginEntry.version !== jsonStore.version || !fs.existsSync(pluginDir) || // Check directory existence !pluginEntry.installed ) { if (pluginEntry.version === jsonStore.version) { console.log( `Updating plugin ${pluginEntry.name} (version ${jsonStore.version}) files.`, ); } else { console.log( `Installing plugin ${pluginEntry.name} (version ${jsonStore.version}). Old version: ${pluginEntry.version}`, ); } if (!fs.existsSync("scripts/data")) { fs.mkdirSync("scripts/data"); } if (fs.existsSync(pluginDir)) { fs.rmSync(pluginDir, { recursive: true, force: true }); } fs.mkdirSync(pluginDir); try { await Promise.all( jsonStore.files.map(async (fileUrl) => { const fileName = fileUrl.split("/").pop(); if (!fileName) { console.error( `Could not determine filename from ${fileUrl} for plugin ${pluginEntry.name}`, ); throw new Error(`Invalid file URL in plugin store: ${fileUrl}`); } // __dirname points to the directory of the current module (startup.ts) // If scripts/data is relative to project root, adjust path. // Assuming scripts/data is at project root: const filePath = `scripts/data/${hash}/${fileName}`; const stream = fs.createWriteStream(filePath); const { body, status } = await fetch(fileUrl); if (!body || status !== 200) { throw new Error( `Failed to download ${fileUrl} (status: ${status})`, ); } await finished( Readable.fromWeb(body as ReadableStream).pipe( stream, ), ); }), ); console.log(`Finished downloading plugin ${pluginEntry.name}`); mainFileTextStart += `import plugin${hash} from "./data/${hash}";\n`; // Assuming default export from plugin's index mainFileText += ` "${pluginEntry.url}":\n plugin${hash},\n`; await db .updateTable("plugins") .set({ version: jsonStore.version, installed: 1, enabled: pluginEntry.enabled ?? 0, }) // Preserve enabled state .where("url", "=", pluginEntry.url) .execute(); } catch (downloadError) { console.error( `Failed to download files for plugin ${pluginEntry.name}. Error: ${downloadError}. Restart server to try again.`, ); await db .updateTable("plugins") .set({ version: "", enabled: 0, installed: 0 }) .where("url", "=", pluginEntry.url) .execute(); } } else { // Already installed and up-to-date mainFileTextStart += `import plugin${hash} from "./data/${hash}";\n`; mainFileText += ` "${pluginEntry.url}":\n plugin${hash},\n`; } }), ); mainFileText += "};\nexport default plugins;\n"; // Ensure scripts directory exists if (!fs.existsSync("scripts")) { fs.mkdirSync("scripts"); } fs.writeFileSync("scripts/data.ts", mainFileTextStart + mainFileText); console.log("Done compiling plugins"); } async function startUp() { const currentProgVersion = (await import("../package.json")).default.version; // Ensures that invalid database migration does not happen const dbVersionEntry = await db .selectFrom("data") .select("value2") .where("value1", "=", "version") .executeTakeFirst() .catch(() => undefined); // Handle case where 'data' table or 'version' row might not exist yet if (dbVersionEntry) { const storedDbVersion = dbVersionEntry.value2; if (compareSemanticVersions(storedDbVersion, "1.20.3") === 1) { console.error( `Cannot upgrade from database version ${storedDbVersion} (produced before program version 1.20.3). Please upgrade to 1.20.3 first.`, ); process.exit(1); } if (compareSemanticVersions(storedDbVersion, currentProgVersion) === -1) { console.error( `Database version ${storedDbVersion} is newer than the current program version ${currentProgVersion}. Cannot downgrade. Please use a compatible program version or database.`, ); process.exit(1); } } await migrateToLatest(); // Assumes this handles DB schema creation/migration // Checks if the server hash has been created and if not makes one const serverId = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2); await db .insertInto("data") .onConflict((eb) => eb.doNothing()) .values({ value1: "serverID", value2: serverId }) .execute(); // Unlocks the database by removing 'locked' entries for plugins const allPlugins: Selectable[] = await db .selectFrom("plugins") .selectAll() .execute(); for (const plugin of allPlugins) { if (plugin.name) { // Ensure plugin.name is not null or empty await db .deleteFrom("data") .where("value1", "=", `locked${plugin.name}`) .execute(); } } await createConfig(); // Assumes this sets up necessary config in 'data' table // Makes sure that the admin user is the correct user await db.updateTable("users").set({ admin: 0 }).execute(); // Reset all admins if (process.env.ADMIN !== undefined) { const adminUserId = parseInt(process.env.ADMIN); if (!isNaN(adminUserId)) { console.log(`Setting user ${adminUserId} as the admin user.`); const result = await db .updateTable("users") .set({ admin: 1 }) .where("id", "=", adminUserId) .executeTakeFirst(); // Kysely returns UpdateResult, check affected rows if ( result && result.numUpdatedRows === BigInt(0) && result.numChangedRows === BigInt(0) ) { console.warn(`Admin user ID ${adminUserId} not found. No admin set.`); } } else { console.warn( `Invalid ADMIN user ID in environment: ${process.env.ADMIN}. No admin set.`, ); } } else { console.log("ADMIN environment variable not set. Admin user is disabled."); } // Updated version of database in table await db .insertInto("data") .values({ value1: "version", value2: currentProgVersion }) .onConflict((oc) => oc.column("value1").doUpdateSet({ value2: currentProgVersion }), ) .execute(); await compilePlugins(); // Compile plugins after DB setup and migration } startUp();