feat(admin): moved server build/update to full app
All checks were successful
Build and Push LST Docker Image / docker (push) Successful in 2m27s

This commit is contained in:
2026-04-21 07:36:04 -05:00
parent b832d7aa1e
commit cb00addee9
49 changed files with 15551 additions and 36 deletions

View File

@@ -0,0 +1,38 @@
/**
* To be able to run this we need to set our dev pc in the .env.
* if its empty just ignore it. this will just be the double catch
*/
import { Router } from "express";
import { build, building } from "../utils/build.utils.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const router = Router();
router.post("/release", async (_, res) => {
if (!building) {
build();
return apiReturn(res, {
success: true,
level: "info",
module: "admin",
subModule: "build",
message: `The build has been triggered see logs for progress of the current build.`,
data: [],
status: 200,
});
} else {
return apiReturn(res, {
success: false,
level: "error",
module: "admin",
subModule: "build",
message: `There is a build in progress already please check the logs for on going progress.`,
data: [],
status: 200,
});
}
});
export default router;

View File

@@ -0,0 +1,12 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import build from "./admin.build.js";
import update from "./admin.updateServer.js";
export const setupAdminRoutes = (baseUrl: string, app: Express) => {
//stats will be like this as we dont need to change this
app.use(`${baseUrl}/api/admin/build`, requireAuth, build);
app.use(`${baseUrl}/api/admin/build`, requireAuth, update);
// all other system should be under /api/system/*
};

View File

@@ -0,0 +1,86 @@
/**
* To be able to run this we need to set our dev pc in the .env.
* if its empty just ignore it. this will just be the double catch
*/
import { Router } from "express";
import z from "zod";
import { building } from "../utils/build.utils.js";
import { runUpdate, updating } from "../utils/deployApp.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
const updateServer = z.object({
server: z.string(),
destination: z.string(),
token: z.string().min(5, "Plant tokens should be at least 5 characters long"),
});
const router = Router();
type Update = {
success: boolean;
message: string;
};
router.post("/updateServer", async (req, res) => {
try {
const validated = updateServer.parse(req.body);
if (!updating && !building) {
const update = (await runUpdate({
server: validated.server,
destination: validated.destination,
token: validated.token,
})) as Update;
return apiReturn(res, {
success: update.success,
level: update.success ? "info" : "error",
module: "admin",
subModule: "update",
message: update.message,
data: [],
status: 200,
});
} else {
return apiReturn(res, {
success: false,
level: "error",
module: "admin",
subModule: "update",
message: `${validated.server}: ${validated.token} is already being updated, or is currently building the app.`,
data: [],
status: 200,
});
}
} catch (err) {
if (err instanceof z.ZodError) {
const flattened = z.flattenError(err);
// return res.status(400).json({
// error: "Validation failed",
// details: flattened,
// });
return apiReturn(res, {
success: false,
level: "error", //connect.success ? "info" : "error",
module: "routes",
subModule: "auth",
message: "Validation failed",
data: [flattened.fieldErrors],
status: 400, //connect.success ? 200 : 400,
});
}
return apiReturn(res, {
success: false,
level: "error", //connect.success ? "info" : "error",
module: "routes",
subModule: "auth",
message: "Internal Server Error creating user",
data: [err],
status: 400, //connect.success ? 200 : 400,
});
}
});
export default router;

View File

@@ -179,6 +179,12 @@ export const runDatamartQuery = async (data: Data) => {
data.options.articles
? `and r.ArticleHumanReadableId in (${data.options.articles})`
: "--and r.ArticleHumanReadableId in ([articles]) ",
)
.replace(
"and DeliveredQuantity > 0",
data.options.all
? "--and DeliveredQuantity > 0"
: "and DeliveredQuantity > 0",
);
break;

View File

@@ -0,0 +1,10 @@
import { integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const deploymentHistory = pgTable("deployment_history", {
id: uuid("id").defaultRandom().primaryKey(),
serverId: uuid("server_id"),
buildNumber: integer("build_number").notNull(),
status: text("status").notNull(), // started, success, failed
message: text("message"),
createdAt: timestamp("created_at").defaultNow(),
});

View File

@@ -0,0 +1,40 @@
import {
boolean,
integer,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type z from "zod";
export const serverData = pgTable(
"server_data",
{
server_id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
server: text("server"),
plantToken: text("plant_token").notNull().unique(),
idAddress: text("id_address"),
greatPlainsPlantCode: text("great_plains_plant_code"),
contactEmail: text("contact_email"),
contactPhone: text("contact_phone"),
active: boolean("active").default(true),
serverLoc: text("server_loc"),
lastUpdated: timestamp("last_updated").defaultNow(),
buildNumber: integer("build_number"),
isUpgrading: boolean("is_upgrading").default(false),
},
// (table) => [
// // uniqueIndex('emailUniqueIndex').on(sql`lower(${table.email})`),
// uniqueIndex("plant_token").on(table.plantToken),
// ],
);
export const serverDataSchema = createSelectSchema(serverData);
export const newServerDataSchema = createInsertSchema(serverData);
export type ServerDataSchema = z.infer<typeof serverDataSchema>;
export type NewServerData = z.infer<typeof newServerDataSchema>;

View File

@@ -1,10 +1,27 @@
import type { InferSelectModel } from "drizzle-orm";
import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type z from "zod";
export const serverStats = pgTable("stats", {
id: text("id").primaryKey().default("serverStats"),
build: integer("build").notNull().default(1),
lastUpdate: timestamp("last_update").defaultNow(),
export const appStats = pgTable("app_stats", {
id: text("id").primaryKey().default("primary"),
currentBuild: integer("current_build").notNull().default(1),
lastBuildAt: timestamp("last_build_at"),
lastDeployAt: timestamp("last_deploy_at"),
building: boolean("building").notNull().default(false),
updating: boolean("updating").notNull().default(false),
lastUpdated: timestamp("last_updated").defaultNow(),
meta: jsonb("meta").$type<Record<string, unknown>>().default({}),
});
export type ServerStats = InferSelectModel<typeof serverStats>;
export const appStatsSchema = createSelectSchema(appStats);
export const newAppStatsSchema = createInsertSchema(appStats, {});
export type AppStats = z.infer<typeof appStatsSchema>;
export type NewAppStats = z.infer<typeof newAppStatsSchema>;

View File

@@ -1,5 +1,5 @@
import type { Express } from "express";
import { setupAdminRoutes } from "./admin/admin.routes.js";
import { setupAuthRoutes } from "./auth/auth.routes.js";
// import the routes and route setups
import { setupApiDocsRoutes } from "./configs/scaler.config.js";
@@ -16,6 +16,7 @@ import { setupUtilsRoutes } from "./utils/utils.routes.js";
export const setupRoutes = (baseUrl: string, app: Express) => {
//routes that are on by default
setupSystemRoutes(baseUrl, app);
setupAdminRoutes(baseUrl, app);
setupApiDocsRoutes(baseUrl, app);
setupProdSqlRoutes(baseUrl, app);
setupGPSqlRoutes(baseUrl, app);

View File

@@ -15,6 +15,7 @@ import { opendockSocketMonitor } from "./opendock/opendockSocketMonitor.utils.js
import { connectProdSql } from "./prodSql/prodSqlConnection.controller.js";
import { monitorAlplaPurchase } from "./purchase/purchase.controller.js";
import { setupSocketIORoutes } from "./socket.io/serverSetup.js";
import { serversChecks } from "./system/serverData.controller.js";
import { baseSettingValidationCheck } from "./system/settingsBase.controller.js";
import { startTCPServer } from "./tcpServer/tcp.server.js";
import { createCronJob } from "./utils/croner.utils.js";
@@ -70,6 +71,7 @@ const start = async () => {
// one shots only needed to run on server startups
createNotifications();
startNotifications();
serversChecks();
}, 5 * 1000);
process.on("uncaughtException", async (err) => {

View File

@@ -9,7 +9,7 @@ type RoomDefinition<T = unknown> = {
export const protectedRooms: any = {
logs: { requiresAuth: true, role: ["admin", "systemAdmin"] },
admin: { requiresAuth: true, role: ["admin", "systemAdmin"] },
//admin: { requiresAuth: false, role: ["admin", "systemAdmin"] },
};
export const roomDefinition: Record<RoomId, RoomDefinition> = {
@@ -36,4 +36,16 @@ export const roomDefinition: Record<RoomId, RoomDefinition> = {
return [];
},
},
admin: {
seed: async (limit) => {
console.info(limit);
return [];
},
},
"admin:build": {
seed: async (limit) => {
console.info(limit);
return [];
},
},
};

View File

@@ -88,14 +88,12 @@ export const setupSocketIORoutes = (baseUrl: string, server: HttpServer) => {
});
}
const roles = Array.isArray(config.role) ? config.role : [config.role];
console.log(roles, s.user.role);
const roles = Array.isArray(config?.role) ? config?.role : [config?.role];
//if (config?.role && s.user?.role !== config.role) {
if (config?.role && !roles.includes(s.user?.role)) {
return s.emit("room-error", {
room: rn,
roomId: rn,
message: `Not authorized to be in room: ${rn}`,
});
}

View File

@@ -1 +1 @@
export type RoomId = "logs" | "labels"; //| "alerts" | "metrics";
export type RoomId = "logs" | "labels" | "admin" | "admin:build"; //| "alerts" | "metrics";

View File

@@ -0,0 +1,132 @@
import { sql } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import {
type NewServerData,
serverData,
} from "../db/schema/serverData.schema.js";
import { createLogger } from "../logger/logger.controller.js";
import { tryCatch } from "../utils/trycatch.utils.js";
const servers: NewServerData[] = [
{
name: "Test server 1",
server: "USMCD1VMS036",
plantToken: "test3",
idAddress: "10.193.0.56",
greatPlainsPlantCode: "00",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Test server 2",
server: "USIOW1VMS036",
plantToken: "test2",
idAddress: "10.75.0.56",
greatPlainsPlantCode: "00",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Lima",
server: "USLIM1VMS006",
plantToken: "uslim1",
idAddress: "10.53.0.26",
greatPlainsPlantCode: "50",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Houston",
server: "ushou1VMS006",
plantToken: "ushou1",
idAddress: "10.195.0.26",
greatPlainsPlantCode: "20",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Dayton",
server: "usday1VMS006",
plantToken: "usday1",
idAddress: "10.44.0.56",
greatPlainsPlantCode: "80",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "West Bend",
server: "usweb1VMS006",
plantToken: "usweb1",
idAddress: "10.80.0.26",
greatPlainsPlantCode: "65",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Jeff City",
server: "usjci1VMS006",
plantToken: "usjci",
idAddress: "10.167.0.26",
greatPlainsPlantCode: "40",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
{
name: "Sherman",
server: "usshe1vms006",
plantToken: "usshe1",
idAddress: "10.205.0.26",
greatPlainsPlantCode: "21",
contactEmail: "",
contactPhone: "",
serverLoc: "D$\\LST_V3",
buildNumber: 1,
},
];
export const serversChecks = async () => {
const log = createLogger({ module: "system", subModule: "serverData" });
const { data, error } = await tryCatch(
db
.insert(serverData)
.values(servers)
.onConflictDoUpdate({
target: serverData.plantToken,
set: {
server: sql`excluded.server`,
name: sql`excluded.name`,
idAddress: sql`excluded."id_address"`,
greatPlainsPlantCode: sql`excluded.great_plains_plant_code`,
contactEmail: sql`excluded."contact_email"`,
contactPhone: sql`excluded.contact_phone`,
serverLoc: sql`excluded.server_loc`,
},
})
.returning(),
);
if (error) {
log.error(
{ error: error },
"There was an error when adding or updating the servers.",
);
}
if (data) {
log.info({}, "All Servers were added/updated");
}
};

View File

@@ -0,0 +1,43 @@
import { type Response, Router } from "express";
import { db } from "../db/db.controller.js";
import { serverData } from "../db/schema/serverData.schema.js";
import { apiReturn } from "../utils/returnHelper.utils.js";
import { tryCatch } from "../utils/trycatch.utils.js";
// export const updateSetting = async (setting: Setting) => {
// // TODO: when the setting is a feature setting we will need to have it run each kill switch on the crons well just stop them and during a reset it just wont start them
// // TODO: when the setting is a system we will need to force an app restart
// // TODO: when the setting is standard we don't do anything.
// };
const r = Router();
r.get("/", async (_, res: Response) => {
const { data: sName, error: sError } = await tryCatch(
db.select().from(serverData).orderBy(serverData.name),
);
if (sError) {
return apiReturn(res, {
success: false,
level: "error",
module: "system",
subModule: "serverData",
message: `There was an error getting the servers `,
data: [sError],
status: 400,
});
}
return apiReturn(res, {
success: true,
level: "info",
module: "system",
subModule: "serverData",
message: `All current servers`,
data: sName ?? [],
status: 200,
});
});
export default r;

View File

@@ -1,5 +1,6 @@
import type { Express } from "express";
import { requireAuth } from "../middleware/auth.middleware.js";
import getServers from "./serverData.route.js";
import getSettings from "./settings.route.js";
import updSetting from "./settingsUpdate.route.js";
import stats from "./stats.route.js";
@@ -10,6 +11,7 @@ export const setupSystemRoutes = (baseUrl: string, app: Express) => {
app.use(`${baseUrl}/api/stats`, stats);
app.use(`${baseUrl}/api/mobile`, mobile);
app.use(`${baseUrl}/api/settings`, getSettings);
app.use(`${baseUrl}/api/servers`, getServers);
app.use(`${baseUrl}/api/settings`, requireAuth, updSetting);
// all other system should be under /api/system/*

View File

@@ -0,0 +1,91 @@
import { spawn } from "node:child_process";
import { createLogger } from "../logger/logger.controller.js";
import { emitToRoom } from "../socket.io/roomEmitter.socket.js";
import { updateAppStats } from "./updateAppStats.utils.js";
import { zipBuild } from "./zipper.utils.js";
export const emitBuildLog = (message: string, level = "info") => {
const payload = {
type: "build",
level,
message,
timestamp: new Date().toISOString(),
};
//console.log(`[BUILD][${level.toUpperCase()}] ${message}`);
emitToRoom("admin:build", payload as any);
if (payload.level === "info") {
log.info({ stack: payload }, payload.message);
}
// if (log) {
// log(payload);
// }
};
export let building = false;
const log = createLogger({ module: "utils", subModule: "builds" });
export const build = async () => {
const appDir = process.env.DEV_DIR ?? "";
return new Promise((resolve) => {
building = true;
updateAppStats({
lastUpdated: new Date(),
building: true,
});
emitBuildLog(`Starting build in: ${appDir}`);
const child = spawn("npm", ["run", "build"], {
cwd: appDir,
shell: true,
});
child.stdout.on("data", (data) => {
const lines = data.toString().split(/\r?\n/);
for (const line of lines) {
if (line.trim() !== "") {
emitBuildLog(line, "info");
}
}
});
child.stderr.on("data", (data) => {
const lines = data.toString().split(/\r?\n/);
for (const line of lines) {
if (line.trim() !== "") {
emitBuildLog(line, "error");
}
}
});
child.on("close", (code) => {
if (code === 0) {
emitBuildLog("Build completed successfully.", "info");
building = false;
zipBuild();
resolve(true);
} else {
building = false;
updateAppStats({
lastUpdated: new Date(),
building: false,
});
emitBuildLog(`Build failed with code ${code}`, "error");
//reject(new Error(`Build failed with code ${code}`));
}
});
child.on("error", (err) => {
building = false;
updateAppStats({
lastUpdated: new Date(),
building: false,
});
emitBuildLog(`Process error: ${err.message}`, "error");
// reject(err);
});
});
};

123
backend/utils/deployApp.ts Normal file
View File

@@ -0,0 +1,123 @@
import { spawn } from "node:child_process";
import { eq, sql } from "drizzle-orm";
import { db } from "../db/db.controller.js";
import { serverData } from "../db/schema/serverData.schema.js";
import { appStats } from "../db/schema/stats.schema.js";
//import { createLogger } from "../logger/logger.controller.js";
import { emitBuildLog } from "./build.utils.js";
import { returnFunc } from "./returnHelper.utils.js";
// const log = createLogger({ module: "utils", subModule: "deploy" });
export let updating = false;
const updateServerBuildNumber = async (token: string) => {
// get the current build
const buildNum = await db.select().from(appStats);
// update the build now
await db
.update(serverData)
.set({ buildNumber: buildNum[0]?.currentBuild, lastUpdated: sql`NOW()` })
.where(eq(serverData.plantToken, token));
};
export const runUpdate = ({
server,
destination,
token,
}: {
server: string;
destination: string;
token: string;
}) => {
return new Promise((resolve, reject) => {
updating = true;
const scriptPath = process.env.UPDATE_SCRIPT_PATH;
if (!scriptPath) {
return returnFunc({
success: true,
level: "error",
module: "utils",
subModule: "deploy",
message: "UPDATE_SCRIPT_PATH please make sure you have this set.",
data: [],
notify: true,
room: "admin",
});
}
const args = [
"-ExecutionPolicy",
"Bypass",
"-File",
scriptPath,
"-Server",
server,
"-Destination",
destination,
"-Token",
token,
"-ADM_USER",
process.env.DEV_USER ?? "",
"-ADM_PASSWORD",
process.env.DEV_PASSWORD ?? "",
"-AppDir",
process.env.DEV_DIR ?? "",
];
emitBuildLog(`Starting update for ${server}`);
const child = spawn("powershell.exe", args, {
shell: false,
});
child.stdout.on("data", (data) => {
const lines = data.toString().split(/\r?\n/);
for (const line of lines) {
if (line.trim()) {
emitBuildLog(line);
}
}
});
child.stderr.on("data", (data) => {
const lines = data.toString().split(/\r?\n/);
for (const line of lines) {
if (line.trim()) {
emitBuildLog(line, "error");
}
}
});
child.on("close", (code) => {
if (code === 0) {
emitBuildLog(`Update completed for ${server}`);
updating = false;
updateServerBuildNumber(token);
resolve({
success: true,
message: `Update completed for ${server}`,
data: [],
});
} else {
emitBuildLog(`Update failed for ${server} (code ${code})`, "error");
updating = false;
reject({
success: false,
message: `Update failed for ${server} (code ${code})`,
data: [],
});
}
});
child.on("error", (err) => {
emitBuildLog(`Process error: ${err.message}`, "error");
updating = false;
reject({
success: false,
message: `${server}: Encountered an error while processing: ${err.message} `,
data: err,
});
});
});
};

View File

@@ -14,7 +14,8 @@ export interface ReturnHelper<T = unknown[]> {
| "email"
| "purchase"
| "tcp"
| "logistics";
| "logistics"
| "admin";
subModule: string;
level: "info" | "error" | "debug" | "fatal" | "warn";

View File

@@ -0,0 +1,17 @@
import { db } from "../db/db.controller.js";
import { appStats } from "../db/schema/stats.schema.js";
export const updateAppStats = async (
data: Partial<typeof appStats.$inferInsert>,
) => {
await db
.insert(appStats)
.values({
id: "primary",
...data,
})
.onConflictDoUpdate({
target: appStats.id,
set: data,
});
};

View File

@@ -0,0 +1,177 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import archiver from "archiver";
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 (buildNumberFile: string) => {
if (!(await exists(buildNumberFile))) {
await fsp.writeFile(buildNumberFile, "1", "utf8");
return 1;
}
const raw = await fsp.readFile(buildNumberFile, "utf8");
const current = Number.parseInt(raw.trim(), 10);
if (Number.isNaN(current) || current < 1) {
await fsp.writeFile(buildNumberFile, "1", "utf8");
return 1;
}
const next = current + 1;
await fsp.writeFile(buildNumberFile, String(next), "utf8");
// update the server with the next build number
await updateAppStats({
currentBuild: next,
lastBuildAt: new Date(),
building: true,
});
return next;
};
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;
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 buildNumberFile = path.join(appDir, ".buildNumber");
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 buildNumber = await getNextBuildNumber(buildNumberFile);
const zipFileName = `LSTV3-${buildNumber}.zip`;
const zipFile = path.join(buildFolder, zipFileName);
// make the folders in case they are not created already
emitBuildLog(`Using build number: ${buildNumber}`);
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<void>((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,
});
return {
success: true,
buildNumber,
zipFile,
zipFileName,
};
};