Files
CHOMPStation2/tools/build/build.ts
2025-07-11 15:35:20 +02:00

479 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* Build script for /tg/station 13 codebase.
*
* This script uses Juke Build, read the docs here:
* https://github.com/stylemistake/juke-build
*/
import Bun from "bun";
import fs from "node:fs";
import Juke from "./juke/index.js";
import { bun } from "./lib/bun";
import { DreamDaemon, DreamMaker, NamedVersionFile } from "./lib/byond";
import { downloadFile } from "./lib/download";
import { formatDeps } from "./lib/helpers";
import { prependDefines } from "./lib/tgs";
export const TGS_MODE = process.env.CBT_BUILD_MODE === "TGS";
export const DME_NAME = "vorestation";
Juke.chdir("../..", import.meta.url);
const dependencies: Record<string, any> = await Bun.file("dependencies.sh")
.text()
.then(formatDeps)
.catch((err) => {
Juke.logger.error(
"Failed to read dependencies.sh, please ensure it exists and is formatted correctly.",
);
Juke.logger.error(err);
throw new Juke.ExitCode(1);
});
// Canonical path for the cutter exe at this moment
function getCutterPath() {
const ver = dependencies.CUTTER_VERSION;
const suffix = process.platform === "win32" ? ".exe" : "";
const file_ver = ver.split(".").join("-");
return `tools/icon_cutter/cache/hypnagogic${file_ver}${suffix}`;
}
const cutter_path = getCutterPath();
export const DefineParameter = new Juke.Parameter({
type: "string[]",
alias: "D",
});
export const PortParameter = new Juke.Parameter({
type: "string",
alias: "p",
});
export const DmVersionParameter = new Juke.Parameter({
type: "string",
});
export const CiParameter = new Juke.Parameter({ type: "boolean" });
export const ForceRecutParameter = new Juke.Parameter({
type: "boolean",
name: "force-recut",
});
export const SkipIconCutter = new Juke.Parameter({
type: "boolean",
name: "skip-icon-cutter",
});
export const WarningParameter = new Juke.Parameter({
type: "string[]",
alias: "W",
});
export const NoWarningParameter = new Juke.Parameter({
type: "string[]",
alias: "I",
});
export const CutterTarget = new Juke.Target({
onlyWhen: () => {
const files = Juke.glob(cutter_path);
return files.length === 0;
},
executes: async () => {
const repo = dependencies.CUTTER_REPO;
const ver = dependencies.CUTTER_VERSION;
const suffix = process.platform === "win32" ? ".exe" : "";
const download_from = `https://github.com/${repo}/releases/download/${ver}/hypnagogic${suffix}`;
await downloadFile(download_from, cutter_path);
if (process.platform !== "win32") {
await Juke.exec("chmod", ["+x", cutter_path]);
}
},
});
export const IconCutterTarget = new Juke.Target({
parameters: [ForceRecutParameter],
dependsOn: () => [CutterTarget],
inputs: () => {
const standard_inputs = [
`icons/**/*.png.toml`,
`icons/**/*.dmi.toml`,
`cutter_templates/**/*.toml`,
cutter_path,
];
// Alright we're gonna search out any existing toml files and convert
// them to their matching .dmi or .png file
const existing_configs = [
...Juke.glob(`icons/**/*.png.toml`),
...Juke.glob(`icons/**/*.dmi.toml`),
];
return [
...standard_inputs,
...existing_configs.map((file) => file.replace(".toml", "")),
];
},
outputs: ({ get }) => {
if (get(ForceRecutParameter)) return [];
const folders = [
...Juke.glob(`icons/**/*.png.toml`),
...Juke.glob(`icons/**/*.dmi.toml`),
];
return folders
.map((file) => file.replace(`.png.toml`, ".dmi"))
.map((file) => file.replace(`.dmi.toml`, ".png"));
},
executes: async () => {
await Juke.exec(cutter_path, [
"--dont-wait",
"--templates",
"cutter_templates",
"icons",
]);
},
});
export const DmMapsIncludeTarget = new Juke.Target({
executes: async () => {
const folders = [
...Juke.glob("_maps/map_files/**/modular_pieces/*.dmm"),
...Juke.glob("_maps/RandomRuins/**/*.dmm"),
...Juke.glob("_maps/RandomZLevels/**/*.dmm"),
...Juke.glob("_maps/shuttles/**/*.dmm"),
...Juke.glob("_maps/templates/**/*.dmm"),
];
const content =
folders
.map((file) => file.replace("_maps/", ""))
.map((file) => `#include "${file}"`)
.join("\n") + "\n";
fs.writeFileSync("_maps/templates.dm", content);
},
});
export const DmTarget = new Juke.Target({
parameters: [
DefineParameter,
DmVersionParameter,
WarningParameter,
NoWarningParameter,
SkipIconCutter,
],
dependsOn: ({ get }) => [
get(DefineParameter).includes("ALL_MAPS") && DmMapsIncludeTarget,
!get(SkipIconCutter) && IconCutterTarget,
],
inputs: [
"_maps/map_files/generic/**",
"maps/**/*.dm",
"code/**",
"html/**",
"icons/**",
"interface/**",
"sound/**",
"tgui/public/tgui.html",
`${DME_NAME}.dme`,
NamedVersionFile,
],
outputs: ({ get }) => {
if (get(DmVersionParameter)) {
return []; // Always rebuild when dm version is provided
}
return [`${DME_NAME}.dmb`, `${DME_NAME}.rsc`];
},
executes: async ({ get }) => {
await DreamMaker(`${DME_NAME}.dme`, {
defines: ["CBT", ...get(DefineParameter)],
warningsAsErrors: get(WarningParameter).includes("error"),
ignoreWarningCodes: get(NoWarningParameter),
namedDmVersion: get(DmVersionParameter),
});
},
});
export const DmTestTarget = new Juke.Target({
parameters: [
DefineParameter,
DmVersionParameter,
WarningParameter,
NoWarningParameter,
],
dependsOn: ({ get }) => [
get(DefineParameter).includes("ALL_MAPS") && DmMapsIncludeTarget,
IconCutterTarget,
],
executes: async ({ get }) => {
fs.copyFileSync(`${DME_NAME}.dme`, `${DME_NAME}.test.dme`);
await DreamMaker(`${DME_NAME}.test.dme`, {
defines: ["CBT", "CIBUILDING", ...get(DefineParameter)],
warningsAsErrors: get(WarningParameter).includes("error"),
ignoreWarningCodes: get(NoWarningParameter),
namedDmVersion: get(DmVersionParameter),
});
Juke.rm("data/logs/ci", { recursive: true });
const options = {
dmbFile: `${DME_NAME}.test.dmb`,
namedDmVersion: get(DmVersionParameter),
};
await DreamDaemon(
options,
"-close",
"-trusted",
"-verbose",
"-params",
"log-directory=ci",
);
Juke.rm("*.test.*");
try {
const cleanRun = fs.readFileSync("data/logs/ci/clean_run.lk", "utf-8");
console.log(cleanRun);
} catch (err) {
Juke.logger.error("Test run was not clean, exiting");
throw new Juke.ExitCode(1);
}
},
});
export const AutowikiTarget = new Juke.Target({
parameters: [
DefineParameter,
DmVersionParameter,
WarningParameter,
NoWarningParameter,
],
dependsOn: ({ get }) => [
get(DefineParameter).includes("ALL_MAPS") && DmMapsIncludeTarget,
IconCutterTarget,
],
outputs: ["data/autowiki_edits.txt"],
executes: async ({ get }) => {
fs.copyFileSync(`${DME_NAME}.dme`, `${DME_NAME}.test.dme`);
await DreamMaker(`${DME_NAME}.test.dme`, {
defines: ["CBT", "AUTOWIKI", ...get(DefineParameter)],
warningsAsErrors: get(WarningParameter).includes("error"),
ignoreWarningCodes: get(NoWarningParameter),
namedDmVersion: get(DmVersionParameter),
});
Juke.rm("data/autowiki_edits.txt");
Juke.rm("data/autowiki_files", { recursive: true });
Juke.rm("data/logs/ci", { recursive: true });
const options = {
dmbFile: `${DME_NAME}.test.dmb`,
namedDmVersion: get(DmVersionParameter),
};
await DreamDaemon(
options,
"-close",
"-trusted",
"-verbose",
"-params",
"log-directory=ci",
);
Juke.rm("*.test.*");
if (!fs.existsSync("data/autowiki_edits.txt")) {
Juke.logger.error("Autowiki did not generate an output, exiting");
throw new Juke.ExitCode(1);
}
},
});
export const BunTarget = new Juke.Target({
parameters: [CiParameter],
inputs: ["tgui/**/package.json"],
executes: () => {
return bun("install", "--frozen-lockfile", "--ignore-scripts");
},
});
export const TgFontTarget = new Juke.Target({
dependsOn: [BunTarget],
inputs: [
"tgui/packages/tgfont/**/*.+(js|mjs|svg)",
"tgui/packages/tgfont/package.json",
],
outputs: [
"tgui/packages/tgfont/dist/tgfont.css",
"tgui/packages/tgfont/dist/tgfont.woff2",
],
executes: async () => {
await bun("tgfont:build");
fs.mkdirSync("tgui/packages/tgfont/static", { recursive: true });
fs.copyFileSync(
"tgui/packages/tgfont/dist/tgfont.css",
"tgui/packages/tgfont/static/tgfont.css",
);
fs.copyFileSync(
"tgui/packages/tgfont/dist/tgfont.woff2",
"tgui/packages/tgfont/static/tgfont.woff2",
);
},
});
export const TguiTarget = new Juke.Target({
dependsOn: [BunTarget],
inputs: [
"tgui/rspack.config.ts",
"tgui/**/package.json",
"tgui/packages/**/*.+(js|cjs|ts|tsx|jsx|scss)",
],
outputs: [
"tgui/public/tgui.bundle.css",
"tgui/public/tgui.bundle.js",
"tgui/public/tgui-panel.bundle.css",
"tgui/public/tgui-panel.bundle.js",
"tgui/public/tgui-say.bundle.css",
"tgui/public/tgui-say.bundle.js",
],
executes: () => bun("tgui:build"),
});
export const TguiEslintTarget = new Juke.Target({
parameters: [CiParameter],
dependsOn: [BunTarget],
executes: ({ get }) => bun("tgui:lint", !get(CiParameter) && "--fix"),
});
export const TguiPrettierTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:prettier"),
});
export const TguiSonarTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:sonar"),
});
export const TguiTscTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:tsc"),
});
export const TguiTestTarget = new Juke.Target({
parameters: [CiParameter],
dependsOn: [BunTarget],
executes: () => bun("tgui:test"),
});
export const TguiLintTarget = new Juke.Target({
dependsOn: [BunTarget, TguiPrettierTarget, TguiEslintTarget, TguiTscTarget],
});
export const TguiDevTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: ({ args }) => bun("tgui:dev", ...args),
});
export const TguiAnalyzeTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:analyze"),
});
export const TguiBenchTarget = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:bench"),
});
export const TguiPrettierFix = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:prettier-fix"),
});
export const TguiEslintFix = new Juke.Target({
dependsOn: [BunTarget],
executes: () => bun("tgui:eslint-fix"),
});
export const TguiFix = new Juke.Target({
dependsOn: [TguiPrettierFix, TguiEslintFix],
});
export const TestTarget = new Juke.Target({
dependsOn: [DmTestTarget, TguiTestTarget],
});
export const LintTarget = new Juke.Target({
dependsOn: [TguiLintTarget],
});
export const BuildTarget = new Juke.Target({
dependsOn: [TguiTarget, DmTarget],
});
export const ServerTarget = new Juke.Target({
parameters: [DmVersionParameter, PortParameter],
dependsOn: [BuildTarget],
executes: async ({ get }) => {
const port = get(PortParameter) || "1337";
const options = {
dmbFile: `${DME_NAME}.dmb`,
namedDmVersion: get(DmVersionParameter),
};
await DreamDaemon(options, port, "-trusted -invisible");
},
});
export const AllTarget = new Juke.Target({
dependsOn: [TestTarget, LintTarget, BuildTarget],
});
export const TguiCleanTarget = new Juke.Target({
executes: async () => {
Juke.rm("tgui/public/.tmp", { recursive: true });
Juke.rm("tgui/public/*.map");
Juke.rm("tgui/public/*.{chunk,bundle,hot-update}.*");
Juke.rm("tgui/packages/tgfont/dist", { recursive: true });
Juke.rm("tgui/node_modules", { recursive: true });
},
});
export const CleanTarget = new Juke.Target({
dependsOn: [TguiCleanTarget],
executes: async () => {
Juke.rm("*.{dmb,rsc}");
Juke.rm("_maps/templates.dm");
},
});
/**
* Removes more junk at the expense of much slower initial builds.
*/
export const CleanAllTarget = new Juke.Target({
dependsOn: [CleanTarget],
executes: async () => {
Juke.logger.info("Cleaning up data/logs");
Juke.rm("data/logs", { recursive: true });
},
});
export const TgsTarget = new Juke.Target({
dependsOn: [TguiTarget],
executes: async () => {
Juke.logger.info("Prepending TGS define");
prependDefines("TGS");
},
});
Juke.setup({ file: import.meta.url }).then((code) => {
// We're using the currently available quirk in Juke Build, which
// prevents it from exiting on Windows, to wait on errors.
if (code !== 0 && process.argv.includes("--wait-on-error")) {
Juke.logger.error("Please inspect the error and close the window.");
return;
}
if (TGS_MODE) {
// workaround for ESBuild process lingering
// Once https://github.com/privatenumber/esbuild-loader/pull/354 is merged and updated to, this can be removed
setTimeout(() => process.exit(code), 10000);
} else {
process.exit(code);
}
});
export default TGS_MODE ? TgsTarget : BuildTarget;