import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import archiver from "archiver"; import { format } from "date-fns"; import { createLogger } from "../logger/logger.controller.js"; import { emitBuildLog } from "./build.utils.js"; import { updateAppStats } from "./updateAppStats.utils.js"; const log = createLogger({ module: "utils", subModule: "zip" }); const exists = async (target: string) => { try { await fsp.access(target); return true; } catch { return false; } }; const getNextBuildNumber = async (versionPath: string) => { const raw = await fsp.readFile(versionPath, "utf8"); const config = JSON.parse(raw); const current = Number.parseInt(config.build.trim(), 10); let nextBuild: string; if (Number.isNaN(current) || current < 1) { nextBuild = "1"; } else { nextBuild = String(current + 1); // Incrementing the build number } const updatedConfig = { ...config, build: nextBuild, lastBuildDate: format(new Date(Date.now()), "M/d/yyyy HH:mm"), }; await fsp.writeFile( versionPath, JSON.stringify(updatedConfig, null, 4) + "\n", "utf8", ); return { version: config.version, build: config.build }; }; const cleanupOldBuilds = async (buildFolder: string, maxBuilds: number) => { const entries = await fsp.readdir(buildFolder, { withFileTypes: true }); const zipFiles: { fullPath: string; name: string; mtimeMs: number }[] = []; for (const entry of entries) { if (!entry.isFile()) continue; //if (!/^LSTV3-\d+\.zip$/i.test(entry.name)) continue; if (!entry.name.includes("LSTV3")) continue; const fullPath = path.join(buildFolder, entry.name); const stat = await fsp.stat(fullPath); zipFiles.push({ fullPath, name: entry.name, mtimeMs: stat.mtimeMs, }); } zipFiles.sort((a, b) => b.mtimeMs - a.mtimeMs); const toRemove = zipFiles.slice(maxBuilds); for (const file of toRemove) { await fsp.rm(file.fullPath, { force: true }); emitBuildLog(`Removed old build: ${file.name}`); } }; export const zipBuild = async () => { const appDir = process.env.DEV_DIR ?? ""; const maxBuilds = Number(process.env.MAX_BUILDS ?? 5); if (!appDir) { log.error({ notify: true }, "Forgot to add in the dev dir into the env"); return; } const includesFile = path.join(appDir, ".includes"); const version = path.join(appDir, "package.json"); const buildFolder = path.join(appDir, "builds"); const tempFolder = path.join(appDir, "temp", "zip-temp"); if (!(await exists(includesFile))) { log.error({ notify: true }, "Missing .includes file common"); return; } await fsp.mkdir(buildFolder, { recursive: true }); const lstVersion = await getNextBuildNumber(version); const zipFileName = `LSTV3-${lstVersion.version}.${lstVersion.build}.zip`; const zipFile = path.join(buildFolder, zipFileName); // make the folders in case they are not created already emitBuildLog( `Using version, build number: ${lstVersion.version}.${lstVersion.build}`, ); if (await exists(tempFolder)) { await fsp.rm(tempFolder, { recursive: true, force: true }); } await fsp.mkdir(tempFolder, { recursive: true }); const includes = (await fsp.readFile(includesFile, "utf8")) .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); emitBuildLog(`Preparing zip from ${includes.length} include entries`); for (const relPath of includes) { const source = path.join(appDir, relPath); const dest = path.join(tempFolder, relPath); if (!(await exists(source))) { emitBuildLog(`Skipping missing path: ${relPath}`, "error"); continue; } const stat = await fsp.stat(source); await fsp.mkdir(path.dirname(dest), { recursive: true }); if (stat.isDirectory()) { emitBuildLog(`Copying folder: ${relPath}`); await fsp.cp(source, dest, { recursive: true }); } else { emitBuildLog(`Copying file: ${relPath}`); await fsp.copyFile(source, dest); } } // if something crazy happens and we get the same build lets just reuse it // if (await exists(zipFile)) { // await fsp.rm(zipFile, { force: true }); // } emitBuildLog(`Creating zip: ${zipFile}`); await new Promise((resolve, reject) => { const output = fs.createWriteStream(zipFile); const archive = archiver("zip", { zlib: { level: 9 } }); output.on("close", () => resolve()); output.on("error", reject); archive.on("error", reject); archive.pipe(output); // zip contents of temp folder, not temp folder itself archive.directory(tempFolder, false); archive.finalize(); }); await fsp.rm(tempFolder, { recursive: true, force: true }); emitBuildLog(`Zip completed successfully: ${zipFile}`); await cleanupOldBuilds(buildFolder, maxBuilds); await updateAppStats({ lastUpdated: new Date(), building: false, currentBuild: lstVersion.build, }); return { success: true, build: lstVersion.build, zipFile, zipFileName, }; };