building pipeline
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = 80
|
||||
@@ -0,0 +1,21 @@
|
||||
# /tg/station build script
|
||||
|
||||
This build script is the recommended way to compile the game, including not only the DM code but also the JavaScript and any other dependencies.
|
||||
|
||||
- VSCode: use `Ctrl+Shift+B` to build or `F5` to build and run.
|
||||
|
||||
- Windows: double-click `Build.bat` in the repository root to build.
|
||||
|
||||
- Linux: run `tools/build/build` from the repository root.
|
||||
|
||||
The script will skip build steps whose inputs have not changed since the last run.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- On Windows, `Build.bat` will automatically install a private copy of Node.
|
||||
|
||||
- On Linux, install Node using your package manager or from <https://nodejs.org/en/download/>.
|
||||
|
||||
## Why?
|
||||
|
||||
We used to include compiled versions of the tgui JavaScript code in the Git repository so that the project could be compiled using BYOND only. These pre-compiled files tended to have merge conflicts for no good reason. Using a build script lets us avoid this problem, while keeping builds convenient for people who are not modifying tgui.
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
exec ../bootstrap/node build.js "$@"
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
@"%~dp0\..\bootstrap\node" "%~dp0\build.js"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { resolve: resolvePath } = require('path');
|
||||
const { resolveGlob } = require('./cbt/fs');
|
||||
const { exec } = require('./cbt/process');
|
||||
const { Task, runTasks } = require('./cbt/task');
|
||||
const { regQuery } = require('./cbt/winreg');
|
||||
|
||||
// Change working directory to project root
|
||||
process.chdir(resolvePath(__dirname, '../../'));
|
||||
|
||||
const taskTgui = new Task('tgui')
|
||||
.depends('tgui/.yarn/releases/*')
|
||||
.depends('tgui/yarn.lock')
|
||||
.depends('tgui/webpack.config.js')
|
||||
.depends('tgui/**/package.json')
|
||||
.depends('tgui/packages/**/*.js')
|
||||
.depends('tgui/packages/**/*.jsx')
|
||||
.provides('tgui/public/tgui.bundle.css')
|
||||
.provides('tgui/public/tgui.bundle.js')
|
||||
.provides('tgui/public/tgui-common.chunk.js')
|
||||
.provides('tgui/public/tgui-panel.bundle.css')
|
||||
.provides('tgui/public/tgui-panel.bundle.js')
|
||||
.build(async () => {
|
||||
// Instead of calling `tgui/bin/tgui`, we reproduce the whole pipeline
|
||||
// here for maximum compilation speed.
|
||||
const yarnRelease = resolveGlob('./tgui/.yarn/releases/yarn-*.cjs')[0]
|
||||
.replace('/tgui/', '/');
|
||||
const yarn = args => exec('node', [yarnRelease, ...args], {
|
||||
cwd: './tgui',
|
||||
});
|
||||
await yarn(['install']);
|
||||
await yarn(['run', 'webpack-cli', '--mode=production']);
|
||||
});
|
||||
|
||||
const taskDm = new Task('dm')
|
||||
.depends('_maps/map_files/generic/**')
|
||||
.depends('code/**')
|
||||
.depends('goon/**')
|
||||
.depends('html/**')
|
||||
.depends('icons/**')
|
||||
.depends('interface/**')
|
||||
.depends('tgui/public/tgui.html')
|
||||
.depends('tgui/public/*.bundle.*')
|
||||
.depends('tgui/public/*.chunk.*')
|
||||
.depends('tgstation.dme')
|
||||
.provides('tgstation.dmb')
|
||||
.provides('tgstation.rsc')
|
||||
.build(async () => {
|
||||
let compiler = 'dm';
|
||||
// Let's do some registry queries on Windows, because dm is not in PATH.
|
||||
if (process.platform === 'win32') {
|
||||
const installPath = (
|
||||
await regQuery(
|
||||
'HKLM\\Software\\Dantom\\BYOND',
|
||||
'installpath')
|
||||
|| await regQuery(
|
||||
'HKLM\\SOFTWARE\\WOW6432Node\\Dantom\\BYOND',
|
||||
'installpath')
|
||||
);
|
||||
if (installPath) {
|
||||
compiler = resolvePath(installPath, 'bin/dm.exe');
|
||||
}
|
||||
} else {
|
||||
compiler = 'DreamMaker';
|
||||
}
|
||||
await exec(compiler, ['tgstation.dme']);
|
||||
});
|
||||
|
||||
// Frontend
|
||||
const tasksToRun = [
|
||||
taskTgui,
|
||||
taskDm,
|
||||
];
|
||||
|
||||
if (process.env['TG_BUILD_TGS_MODE']) {
|
||||
tasksToRun.pop();
|
||||
}
|
||||
|
||||
runTasks(tasksToRun);
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const glob = require('./glob');
|
||||
|
||||
class File {
|
||||
constructor(path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
get stat() {
|
||||
if (this._stat === undefined) {
|
||||
this._stat = stat(this.path);
|
||||
}
|
||||
return this._stat;
|
||||
}
|
||||
|
||||
exists() {
|
||||
return this.stat !== null;
|
||||
}
|
||||
|
||||
get mtime() {
|
||||
return this.stat && this.stat.mtime;
|
||||
}
|
||||
|
||||
touch() {
|
||||
const time = new Date();
|
||||
try {
|
||||
fs.utimesSync(this.path, time, time);
|
||||
}
|
||||
catch (err) {
|
||||
fs.closeSync(fs.openSync(this.path, 'w'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Glob {
|
||||
constructor(path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
toFiles() {
|
||||
const paths = glob.sync(this.path, {
|
||||
strict: false,
|
||||
silent: true,
|
||||
});
|
||||
return paths
|
||||
.map(path => new File(path))
|
||||
.filter(file => file.exists());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, source is newer than target.
|
||||
* @param {File[]} sources
|
||||
* @param {File[]} targets
|
||||
*/
|
||||
const compareFiles = (sources, targets) => {
|
||||
let bestSource = null;
|
||||
let bestTarget = null;
|
||||
for (const file of sources) {
|
||||
if (!bestSource || file.mtime > bestSource.mtime) {
|
||||
bestSource = file;
|
||||
}
|
||||
}
|
||||
for (const file of targets) {
|
||||
if (!file.exists()) {
|
||||
return `target '${file.path}' is missing`;
|
||||
}
|
||||
if (!bestTarget || file.mtime < bestTarget.mtime) {
|
||||
bestTarget = file;
|
||||
}
|
||||
}
|
||||
// Doesn't need rebuild if there is no source, but target exists.
|
||||
if (!bestSource) {
|
||||
if (bestTarget) {
|
||||
return false;
|
||||
}
|
||||
return 'no known sources or targets';
|
||||
}
|
||||
// Always needs a rebuild if no targets were specified (e.g. due to GLOB).
|
||||
if (!bestTarget) {
|
||||
return 'no targets were specified';
|
||||
}
|
||||
// Needs rebuild if source is newer than target
|
||||
if (bestSource.mtime > bestTarget.mtime) {
|
||||
return `source '${bestSource.path}' is newer than target '${bestTarget.path}'`;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns file stats for the provided path, or null if file is
|
||||
* not accessible.
|
||||
*/
|
||||
const stat = path => {
|
||||
try {
|
||||
return fs.statSync(path);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a glob pattern and returns files that are safe
|
||||
* to call `stat` on.
|
||||
*/
|
||||
const resolveGlob = globPath => {
|
||||
const unsafePaths = glob.sync(globPath, {
|
||||
strict: false,
|
||||
silent: true,
|
||||
});
|
||||
const safePaths = [];
|
||||
for (let path of unsafePaths) {
|
||||
try {
|
||||
fs.statSync(path);
|
||||
safePaths.push(path);
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
return safePaths;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
File,
|
||||
Glob,
|
||||
compareFiles,
|
||||
stat,
|
||||
resolveGlob,
|
||||
compareFiles,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { resolve: resolvePath } = require('path');
|
||||
const { stat } = require('./fs');
|
||||
|
||||
/**
|
||||
* @type {import('child_process').ChildProcessWithoutNullStreams}
|
||||
*/
|
||||
const children = new Set();
|
||||
|
||||
const killChildren = () => {
|
||||
for (const child of children) {
|
||||
child.kill('SIGTERM');
|
||||
children.delete(child);
|
||||
console.log('killed child process');
|
||||
}
|
||||
};
|
||||
|
||||
const trap = (signals, handler) => {
|
||||
let readline;
|
||||
if (process.platform === 'win32') {
|
||||
readline = require('readline').createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
}
|
||||
for (const signal of signals) {
|
||||
const handleSignal = () => handler(signal);
|
||||
if (signal === 'EXIT') {
|
||||
process.on('exit', handleSignal);
|
||||
continue;
|
||||
}
|
||||
if (readline) {
|
||||
readline.on('SIG' + signal, handleSignal);
|
||||
}
|
||||
process.on('SIG' + signal, handleSignal);
|
||||
}
|
||||
};
|
||||
|
||||
trap(['EXIT', 'BREAK', 'HUP', 'INT', 'TERM'], signal => {
|
||||
if (signal !== 'EXIT') {
|
||||
console.log('Received', signal);
|
||||
}
|
||||
killChildren();
|
||||
if (signal !== 'EXIT') {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const exceptionHandler = err => {
|
||||
console.log(err);
|
||||
killChildren();
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', exceptionHandler);
|
||||
process.on('uncaughtException', exceptionHandler);
|
||||
|
||||
class ExitError extends Error {}
|
||||
|
||||
/**
|
||||
* @param {string} executable
|
||||
* @param {string[]} args
|
||||
* @param {import('child_process').SpawnOptionsWithoutStdio} options
|
||||
*/
|
||||
const exec = (executable, args, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If executable exists relative to the current directory,
|
||||
// use that executable, otherwise spawn should fall back to
|
||||
// running it from PATH.
|
||||
if (stat(executable)) {
|
||||
executable = resolvePath(executable);
|
||||
}
|
||||
const child = spawn(executable, args, options);
|
||||
children.add(child);
|
||||
child.stdout.on('data', data => {
|
||||
process.stdout.write(data);
|
||||
});
|
||||
child.stderr.on('data', data => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
child.stdin.end();
|
||||
child.on('exit', code => {
|
||||
children.delete(child);
|
||||
if (code !== 0) {
|
||||
const error = new ExitError('Process exited with code: ' + code);
|
||||
error.code = code;
|
||||
reject(error);
|
||||
}
|
||||
else {
|
||||
resolve(code);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
exec,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { compareFiles, Glob, File } = require('./fs');
|
||||
|
||||
class Task {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.sources = [];
|
||||
this.targets = [];
|
||||
this.script = null;
|
||||
}
|
||||
|
||||
depends(path) {
|
||||
if (path.includes('*')) {
|
||||
this.sources.push(new Glob(path));
|
||||
}
|
||||
else {
|
||||
this.sources.push(new File(path));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
provides(path) {
|
||||
if (path.includes('*')) {
|
||||
this.targets.push(new Glob(path));
|
||||
}
|
||||
else {
|
||||
this.targets.push(new File(path));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
build(script) {
|
||||
this.script = script;
|
||||
return this;
|
||||
}
|
||||
|
||||
async run() {
|
||||
/**
|
||||
* @returns {File[]}
|
||||
*/
|
||||
const getFiles = files => files
|
||||
.flatMap(file => {
|
||||
if (file instanceof Glob) {
|
||||
return file.toFiles();
|
||||
}
|
||||
if (file instanceof File) {
|
||||
return file;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
// Normalize all our dependencies by converting globs to files
|
||||
const fileSources = getFiles(this.sources);
|
||||
const fileTargets = getFiles(this.targets);
|
||||
// Consider dependencies first, and skip the task if it
|
||||
// doesn't need a rebuild.
|
||||
let needsRebuild = 'no targets';
|
||||
if (fileTargets.length > 0) {
|
||||
needsRebuild = compareFiles(fileSources, fileTargets);
|
||||
if (!needsRebuild) {
|
||||
console.warn(` => Skipping '${this.name}' (up to date)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this.script) {
|
||||
return;
|
||||
}
|
||||
console.warn(` => Starting '${this.name}'`);
|
||||
const startedAt = Date.now();
|
||||
// Run the script
|
||||
await this.script();
|
||||
// Touch all targets so that they don't rebuild again
|
||||
if (fileTargets.length > 0) {
|
||||
for (const file of fileTargets) {
|
||||
file.touch();
|
||||
}
|
||||
}
|
||||
const time = ((Date.now() - startedAt) / 1000) + 's';
|
||||
console.warn(` => Finished '${this.name}' in ${time}`);
|
||||
}
|
||||
}
|
||||
|
||||
const runTasks = async tasks => {
|
||||
const startedAt = Date.now();
|
||||
// Run all if none of the tasks were specified in command line
|
||||
const runAll = !tasks.some(task => process.argv.includes(task.name));
|
||||
for (const task of tasks) {
|
||||
if (runAll || process.argv.includes(task.name)) {
|
||||
await task.run();
|
||||
}
|
||||
}
|
||||
const time = ((Date.now() - startedAt) / 1000) + 's';
|
||||
console.log(` => Done in ${time}`);
|
||||
process.exit();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Task,
|
||||
runTasks,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Tools for dealing with Windows Registry bullshit.
|
||||
*
|
||||
* Adapted from `tgui/packages/tgui-dev-server/winreg.js`.
|
||||
*
|
||||
* @file
|
||||
* @copyright 2020 Aleksej Komarov
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const regQuery = async (path, key) => {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const command = `reg query "${path}" /v ${key}`;
|
||||
const { stdout } = await promisify(exec)(command);
|
||||
const keyPattern = ` ${key} `;
|
||||
const indexOfKey = stdout.indexOf(keyPattern);
|
||||
if (indexOfKey === -1) {
|
||||
return null;
|
||||
}
|
||||
const indexOfEol = stdout.indexOf('\r\n', indexOfKey);
|
||||
if (indexOfEol === -1) {
|
||||
return null;
|
||||
}
|
||||
const indexOfValue = stdout.indexOf(
|
||||
' ',
|
||||
indexOfKey + keyPattern.length);
|
||||
if (indexOfValue === -1) {
|
||||
return null;
|
||||
}
|
||||
const value = stdout.substring(indexOfValue + 4, indexOfEol);
|
||||
return value;
|
||||
}
|
||||
catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
regQuery,
|
||||
};
|
||||
Reference in New Issue
Block a user