code:
// cSpell:ignore fchost, megajs

// @ts-ignore
import jetpack from "fs-jetpack";
import {execSync} from "child_process";
import * as path from "path";
import inquirer from 'inquirer';
import {File} from 'megajs';
import decompress from "decompress";

// link to pre-compiled fchost
let windowsLink = "https://mega.nz/file/kY5GmLRa#jwwgshNuKz0yAQDDeSs-jbPOOGX4Ap1ha_EKlWp-9-M";

// location to save the zip file to
const zipLocation = path.join("FCHost", "fchost", "PreCompiledFCHost.zip");

const currentDate = new Date();
const month   = currentDate.getUTCMonth() + 1; // months from 1-12
const day     = currentDate.getUTCDate();
const year    = currentDate.getUTCFullYear();
const epoch   = Math.round(currentDate.getTime() / 1000);
const backupLocation = path.join("FCHost", "fchost", `Release_PreUpdate_${month}-${day}-${year}_${epoch}`);

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./setup.js").Settings} */
const settings = jetpack.read("settings.json", "json");

let wineExists = false;
try {
	if (process.platform !== "win32" && execSync("command -v wine").toString().trim() !== "") { wineExists = true; }
} catch (e) {
	// fail silently
	// console.log(e);
}

/**
 * Asks the user if they want to download FCHost from the given mega link
 * @param {string} link if this is an empty string then this function is a no-op
 */
async function download(link) {
	if (link === "") { return; }

	// download FCHost
	const file = File.fromURL(link);

	// @ts-ignore
	await file.loadAttributes();
	console.log(`Downloading ${file.name} with a size of ${file.size} bytes`);

	// if zipLocation exists then remove it
	jetpack.remove(zipLocation);

	// @ts-ignore
	const data = await file.downloadBuffer();
	// check that buffer size is the same as file.size
	if (data.byteLength !== file.size) {
		console.log(`File did not download correctly! There is a ${file.size - data.byteLength} byte size difference.}`);
		console.log("Rerun this script to try again.");
		process.exit();
	}
	jetpack.write(zipLocation, data, {atomic: true});

	// I don't know how we got both a folder and a file with the same name, but that is invalid on Windows
	if (jetpack.exists(settings.FCHostPath) === "file") {
		// Renaming it to `${settings.FCHostPath}.file` should be okay since anyone manually compiling should be recreating the file anyway
		jetpack.move(settings.FCHostPath, settings.FCHostPath + ".file");
	}

	// move old FCHost install to the backup path
	if (jetpack.exists(settings.FCHostPath) !== false) {
		console.log(`Moving existing install to ${backupLocation}`);
		jetpack.move(settings.FCHostPath, backupLocation);
	}

	// make sure the path exists and is a directory
	jetpack.dir(settings.FCHostPath);

	// extract FCHost
	// @ts-ignore
	await decompress(zipLocation, settings.FCHostPath).then(files => {
		// find the FCHost folder in releases
		/** @type {string[]|string} */
		let folder = jetpack.find(settings.FCHostPath, {
			matching: "FCHost*", files: false, directories: true, recursive: false
		});
		if (folder.length === 0) {
			console.log("Zip file extraction failed!");
			jetpack.remove(settings.FCHostPath);
			process.exit();
		} else {
			// move contents of folder[0] to settings.FCHostPath
			jetpack.find(folder[0]).forEach(element => {
				jetpack.move(element, path.join(settings.FCHostPath, path.relative(folder[0], element)));
			});
			// remove folder[0]
			jetpack.remove(folder[0]);
			console.log('Zip file extracted successfully!');
		}
	});
	// delete zip file
	jetpack.remove(zipLocation);
	// set FCHostDownloadURL
	settings.FCHostDownloadURL = link;
	// save settings
	jetpack.write("settings.json", settings, {atomic: true});
}

// get the correct link for the given platform
let link = "";
if (process.platform === "win32") {
	link = windowsLink;
} else {
	if (wineExists === false) {
		// there are no pre-compiled versions of FCHost for this os at this time and wine isn't installed.
		console.log("There is not a pre-compiled version of FCHost available for your system at this time.");
		console.log("You can still use FCHost, but you will have to manually build it yourself.");
		console.log("Instructions for doing so are here: https://gitgud.io/pregmodfan/fc-pregmod/-/blob/pregmod-master/FCHost/HowToBuild.md");
		console.log("Alternatively you can install Wine (https://www.winehq.org/) and rerun this script to attempt to run the Windows version of FCHost.");
	} else {
		// use windows FCHost link
		link = windowsLink;
		console.log("A native pre-compiled version of FCHost is not available for your os, but Wine is installed.");
		console.log("Selected the pre-compiled Windows version of FCHost for download.");
	}
}

// if FCHost doesn't exist
if (
	link !== "" &&
	jetpack.exists(path.join(settings.FCHostPath, "fchost.exe")) === false &&
	jetpack.exists(path.join(settings.FCHostPath, "fchost")) === false
) {
	// ask user if they want to download FCHost
	console.log("This script will download, extract, and run FCHost.");
	// ask the users permission to download FCHost
	let choice;
	// @ts-ignore
	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: `Do you want to download and extract a pre-compiled version of FCHost from '${link}'?`,
			choices: ["Yes", "No"],
			default: "Yes",
			loop: false
		}])
		.then((answers) => {
			choice = answers.choice;
		});
	if (choice === "Yes") {
		// @ts-ignore
		await download(link);
	} else {
		console.log("You can run this script at any time if you change your mind.");
		console.log("You can also manually compile FCHost if you wish.");
		console.log("Instructions for doing so are here: https://gitgud.io/pregmodfan/fc-pregmod/-/blob/pregmod-master/FCHost/HowToBuild.md");
		process.exit();
	}
// FCHost exists but FCHostDownloadURL is not as expected
} else if (
	link !== "" &&
	settings.FCHostDownloadURL !== link
) {
	if (link !== "") {
		let choice;
		// @ts-ignore
		await inquirer
			.prompt([{
				type: "rawlist",
				name: "choice",
				message: `FCHost has been updated since you last build/downloaded it. Do you want to download and extract an updated pre-compiled version of FCHost from '${link}'?`,
				choices: ["Yes", "No"],
				default: "Yes",
				loop: false
			}])
			.then((answers) => {
				choice = answers.choice;
			});
		if (choice === "Yes") {
			// @ts-ignore
			await download(link);
		} else {
			// @ts-ignore
			await inquirer
				.prompt([{
					type: "rawlist",
					name: "choice",
					message: `Are you going to manually build FCHost and/or skip this version?`,
					choices: ["Yes", "No"],
					default: "Yes",
					loop: false
				}])
				.then((answers) => {
					choice = answers.choice;
				});
			if (choice === "Yes") {
				// set FCHostDownloadURL
				settings.FCHostDownloadURL = link;
				// save settings
				jetpack.write("settings.json", settings, {atomic: true});
				console.log("Instructions for manually compiling FCHost are at: https://gitgud.io/pregmodfan/fc-pregmod/-/blob/pregmod-master/FCHost/HowToBuild.md");
				process.exit();
			} else {
				console.log("You can run this script at any time if you change your mind.");
				console.log("You can also manually compile FCHost if you wish.");
				console.log("Instructions for doing so are here: https://gitgud.io/pregmodfan/fc-pregmod/-/blob/pregmod-master/FCHost/HowToBuild.md");
				process.exit();
			}
		}
	}
}

// If FCHost still doesn't exist
if (
	jetpack.exists(path.join(settings.FCHostPath, "fchost.exe")) === false &&
	jetpack.exists(path.join(settings.FCHostPath, "fchost")) === false
) {
	console.log(`FCHost doesn't exist at "${settings.FCHostPath}"`);
	console.log("You can manually compile FCHost if you wish.");
	console.log("Instructions for doing so are here: https://gitgud.io/pregmodfan/fc-pregmod/-/blob/pregmod-master/FCHost/HowToBuild.md");
	process.exit();
}

// if FC_pregmod.html is not in settings.FCHostPath
if (jetpack.exists(path.join(settings.FCHostPath, "FC_pregmod.html")) !== "file") {
	console.log(`There doesn't seem to be a FC_pregmod.html file at ${settings.FCHostPath}`);
	console.log("Running the compiler with it's default settings should fix this problem.");
	let choice;
	// @ts-ignore
	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: `Do want to run the compiler now?`,
			choices: ["Yes", "No"],
			default: "Yes",
			loop: false
		}])
		.then((answers) => {
			choice = answers.choice;
		});
	if (choice === "Yes") {
		try {
			execSync("node devTools/scripts/advancedCompiler.js --skip-sanity-checks --no-interaction", {stdio: "inherit"});
		} catch (e) {
			console.log(e);
		}
	}
}

// launch FCHost
// cd into settings.FCHostPath
process.chdir(settings.FCHostPath);
console.log("Launching FCHost");
// TODO:@franklygeorge spawn FCHost as a separate process to allow this script to close
if (process.platform === "win32") {
	// run fchost
	execSync("fchost.exe", {stdio: "inherit"});
} else if (wineExists === true) {
	console.log("Using Wine to run the Windows version of FCHost");
	execSync("wine fchost.exe", {stdio: "inherit"});
} else {
	// run fchost
	execSync("./fchost", {stdio: "inherit"});
}


code:
/**
 * @file This creates a Gulp compiler command out of settings.json and executes it
 */

// cSpell:ignore pmodversion

import {execSync} from "child_process";
// @ts-ignore
import jetpack from "fs-jetpack";
import * as path from "path";
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import notifier from "node-notifier";

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('interaction', {
		type: 'boolean',
		description: 'Used by scripts to let the compiler know that user interaction is not possible.',
		default: true,
	})
	.option('filename', {
		type: 'string',
		description: 'The filename to save the compiled HTML file as',
		default: "FC_pregmod.html"
	})
	.option('skip-sanity-checks', {
		type: 'boolean',
		description: 'If true sanity checks will be skipped regardless of settings',
		default: false,
	})
	.option('called-by-watcher', {
		type: 'boolean',
		description: 'Should not be used directly, if true configure the compiler for the watcher',
		default: false,
	})
	.parse();

const batSh = (process.platform === "win32") ? "bat" : "sh";

if (args.interaction === true) {
	console.log(`Using the advanced compiler, run 'simple-compiler.${batSh}' instead to use the simple compiler.`);
}

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./setup.js").Settings} */
const settings = jetpack.read("settings.json", "json");

let command = "";

if (settings.compilerMode === "simple") {
	if (process.platform === "win32") {
		command = "simple-compiler.bat --no-wait";
	} else {
		command = "./simple-compiler.sh";
	}
	if (settings.compileThemes === true) {
		command += ` --themes`;
	}
} else {
	if (settings.compilerRunSanityChecks === 1 && args.skipSanityChecks === false) {
		try {
			execSync(
				`node devTools/scripts/sanityCheck.js${args.interaction ? "" : " --no-interaction"}`,
				{stdio: "inherit"}
			);
		} catch {
			console.log("Sanity checks failed! See above for details");
		}
	}
	if (settings.compileThemes === true) {
		command = `npx gulp all`;
	} else {
		command = `npx gulp html mods`;
	}
	command += ` --verbosity ${settings.compilerVerbosity}`;
	if (settings.compilerSourcemaps === false) {
		command += ` -r`; // release
	}
	if (settings.compilerMinify === true) {
		command += ` -m`; // minify
	}
	if (settings.compilerAddDebugFiles === true) {
		command += ` --debug`;
	}
	if (settings.compilerFilenameHash === true) {
		command += ` --hash`;
	}
	if (settings.compilerFilenameEpoch === true) {
		command += ` --epoch`;
	}
	if (settings.compilerFilenamePmodVersion === true) {
		command += ` --pmodversion`;
	}
	if (settings.WatcherLiveReload === true && args.calledByWatcher) {
		command += ` --inject-live-reload`;
	}
	command += ` --filename=${args.filename}`;
}

console.log(`Executing "${command}"`);

execSync(command, {stdio: "inherit"});

if (settings.compilerCopyToFCHost === true) {
	if (jetpack.exists(settings.FCHostPath) === "dir") {
		console.log(`Copying files from "bin" to "${settings.FCHostPath}"`);
		// for each file/folder in bin
		jetpack.list("bin").forEach(fPath => {
			// if path doesn't end with .html
			if (!fPath.endsWith(".html")) {
				// copy it to FCHostPath, overwritting the existing file
				jetpack.copy(
					path.join("bin", fPath),
					path.join(settings.FCHostPath, fPath),
					{overwrite: true}
				);
			}
		});
		// copy output html file to FCHostPath, renaming it to FC_pregmod.html
		const htmlPath = path.join("bin", args.filename);
		if (jetpack.exists(htmlPath) === "file") {
			jetpack.copy(
				htmlPath,
				path.join(settings.FCHostPath, "FC_pregmod.html"),
				{overwrite: true}
			);
		} else {
			console.log(`Couldn't find "${htmlPath}"!`);
		}
	} else {
		console.log(`Skipping: 'Copy "bin" to "${settings.FCHostPath}"' because the path doesn't exist`);
	}
}

if (settings.compilerMode === "advanced" && settings.compilerRunSanityChecks === 2 && args.skipSanityChecks === false) {
	try {
		execSync(
			`node devTools/scripts/sanityCheck.js${args.interaction ? "" : " --no-interaction"}`,
			{stdio: "inherit"}
		);
	} catch {
		console.log("Sanity checks failed! See above for details");
	}
}

if (settings.compilerMode === "advanced" && settings.compilerFinishedNotification) {
	// push a notification
	notifier.notify({
		title: 'FC Dev',
		message: 'Compiler finished',
		icon: 'resources/raster/favicon/arcologyVector-144.png',
	});
}

console.log(`Run 'setup.${batSh}' to change compiler settings`);
if (settings.compilerWaitOnWindows && process.platform === "win32" && args.interaction === true) {
	console.log('Press enter to exit.');
	process.stdin.once('data', function() {
		process.exit();
	});
}

repo_name: Nithhogg/fc

license: mit


code:
/**
 * @file This ports the non twine sanity checks from sanityCheck.sh into JavaScript.
 * Only checks .js and .md files
 *
 * Can be called from the command line
 *
 * node devTools/scripts/customChecks.js
 * node devTools/scripts/customChecks.js --changed
 *
 * Or imported as shown below
 *
 * import customChecks from './customChecks.js';
 * const changed = false;
 * /** @type{Array<string>} *\/
 * const report = customChecks(changed);
 */

// This file uses Regex for a lot of its checks.
// A great resource for Regex is here: https://regex101.com/

import detectChanges from './detectChanges.js';

// @ts-ignore
import jetpack from 'fs-jetpack';
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {resolve} from 'path';
import {fileURLToPath} from 'url';
import indefinite from "indefinite";
// @ts-ignore
import path from "path";

const ignoredPaths = [
	"js/001-lib/",
	"src/001-lib/"
]
	.map((entry) => entry.replace(/\//g, path.sep));

const customArticles = [
	// list of words with their articles. If the article used doesn't match this list, return an error
	// unlisted words are sent to indefinite (https://www.npmjs.com/package/indefinite) for checking
	// a word can be listed with both articles
	// all entries will be matched by exact case (except for the first letter)
	// "A house" and "a house" are the same; but "a House" and "a house" are different
	"a FCTV",
	"a MILF",
	"a SHIT",
	"a MUCH",
	"a span",
	"a html",
	"a HTML",
]
	.map((entry) => entry.slice(0, 1).toLowerCase() + entry.slice(1));

/**
 * @typedef {object} LineObject
 * @property {string} line
 * @property {number} lineNumber
 */

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('changed', {
		type: 'boolean',
		description: 'Only check changed files',
		default: false,
	})
	.parse();

/**
 * Runs custom sanity checks of files
 * @param {boolean} changed If true, only check changed files. If false, check all files.
 * @param {string[]} files If provided we will sent this list of files to the custom checks instead
 * @param {detectChanges} parser If provided it will be used instead of the default parser
 * @returns {Array<string>}
 */
function customChecks(changed = false, files = undefined, parser = detectChanges) {
	/** @type {Array<string>} */
	let errors = [];

	/** @type {Array<string>} */
	let filesToCheck = [];

	if (files === undefined && changed === true) {
		// get list of changed files
		let changedFiles = parser.changedFiles();
		if (changedFiles !== null) {
			filesToCheck = changedFiles.filter(file => (
				file.endsWith(".js") ||
				file.endsWith(".md")
			));
		} else {
			// There are no changed files. Return an empty array
			return [];
		}
	} else if (files !== undefined) {
		// make sure files is an array of strings
		if (Object.prototype.toString.call(files) !== '[object Array]' || files.every(i => typeof i !== "string")) {
			throw new Error("files must be an array of strings or undefined");
		}
		filesToCheck = files;
	} else {
		// get list of all JavaScript and markdown files
		filesToCheck = jetpack.find(".", {matching: "*.{js,md}"});
	}

	// filter out ignored paths
	filesToCheck = filesToCheck.filter(file => {
		let ignored = false;
		// for path in ignoredPaths
		ignoredPaths.forEach(ignoredPath => {
			if (file.startsWith(ignoredPath)) {
				ignored = true;
			}
		});
		return !ignored;
	});

	if (files === undefined && changed === true) {
		console.log("Running custom sanity checks on changed files compared to pregmodfan/fc-pregmod");
	} else if (files === undefined) {
		console.log("Running custom sanity checks on all files");
	} else {
		console.log(`Running custom sanity checks on ${files.length} file(s)`);
	}

	// filter out any files not in js/ or src/
	filesToCheck = filesToCheck.filter(file => file.startsWith("js") || file.startsWith("src"));

	filesToCheck.forEach(file => {
		/** @type {string} */
		const content = jetpack.read(file, "utf8");

		if (content === undefined) {
			// skip file
			return;
		}

		content.split("\n").forEach((line, index) => {
			const lineNumber = index +1;
			// TODO:@franklygeorge ignoring lines based off comments
			// TODO:@franklygeorge prettier/more readable error messages

			// Raises an error if @@color: is found in the line
			if (line.includes("@@color:") && !line.includes("@@color:rgb(")) {
				errors.push(
					`"Invalid color code. Should be @@.[color], @@.red for example." at .${path.sep}${file}:${lineNumber}`
				);
			}

			// Raises an error if there is punctuation following a </span>
			if (line.match(/<\/span>(\.|,|;|:)/) !== null) {
				errors.push(
					`"</span> should have punctuation in front of it not behind it" at .${path.sep}${file}:${lineNumber}`
				);
			}

			// Raises an error if there is a class="something" without any quotation marks
			// invalid: class=something || class=something"
			// valid: class="something"
			if (line.match(/<.+class=[^\\"']/) !== null) {
				errors.push(
					`"Missing quotation marks around class selector" at .${path.sep}${file}:${lineNumber}`
				);
			}

			// Raises an error if a word exists twice in a row
			// invalid: word word || a a || you you || on on
			// valid: word words || let t || you you're || strap-on on
			let duplicateWordList = line.match(/(^|\s)([a-zA-Z]+)\s\2(\s|$)/g);

			// remove duplicates from list
			// @ts-expect-error invalid typescript error message
			duplicateWordList = [...new Set(duplicateWordList)];

			if (duplicateWordList !== null) {
				duplicateWordList.forEach((group) => {
					if (group === undefined || group === null || group === "") { return; }

					const groupLower = group.trim().toLowerCase();

					if (groupLower !== "in in") {
						errors.push(`"Duplicate words" at .${path.sep}${file}:${lineNumber}`);
					}
				});
			}

			// Checks for a letter that repeats twice, with some exceptions
			// invalid: aa || ee || ll
			// valid: ann || cc || mm || xx || II
			// skips any line that starts with "for ", "if ", "else ", and "let "
			const doubleLetterList = line.match(/(\s|^)([a-zA-Z])\2\b(\s|\n|$)/g);

			if (doubleLetterList !== null) {
				doubleLetterList.forEach((group) => {
					const groupLower = group.trim().toLowerCase();

					if (
						groupLower.length === 2 &&
						groupLower !== "cc" && groupLower !== "mm" && groupLower !== "xx" &&
						group.trim() !== "II" &&
						!line.startsWith("for ") && !line.startsWith("if ") &&
						!line.startsWith("else ") && !line.startsWith("let ")
					) {
						errors.push(`"Double letters" at .${path.sep}${file}:${lineNumber}`);
					}
				});
			}

			// Raises an error if "a an" or "an a" exists in the line
			if (line.toLowerCase().match(/(^|\s|\n)(a an|an a)($|\s|\n)/g) !== null) {
				errors.push(`"Double articles" at .${path.sep}${file}:${lineNumber}`);
			}

			// Raises an error if the article is potentially incorrect for the word following it
			// Uses customArticles and indefinite (https://www.npmjs.com/package/indefinite) for checking
			const articleWords = line.match(/(^|\s)(a|an) \w+/g);

			if (articleWords!== null) {
				articleWords.forEach((wordWithAAn) => {
					wordWithAAn = wordWithAAn.trim();

					const wordWithAAnLowerFirst = wordWithAAn.slice(0, 1).toLowerCase() + wordWithAAn.slice(1);
					const word = wordWithAAn.replace(/(^|\s)(a|an) /, "").trim();
					const aAn = wordWithAAn.replace(word, "").trim();

					if (
						aAn === "" || word === null || word === undefined || word.length < 2 ||
						customArticles.indexOf(wordWithAAnLowerFirst) !== -1
					) {
						// do nothing
					} else if (wordWithAAnLowerFirst !== indefinite(word)) {
						errors.push(`"Potentially incorrect article" at .${path.sep}${file}:${lineNumber}`);
					}
				});
			}

			// Raises an error if the line has a word that has a dollar sign in it
			// invalid: b$dding
			// valid: $bidding || bidding$
			if (line.match(/\w\$\w/) !== null) {
				errors.push(`"Mid-word $" at .${path.sep}${file}:${lineNumber}`);
			}
		});
	});

	// remove duplicates from errors
	errors = [...new Set(errors)];

	if (changed === true) {
		const changedLines = parser.changedLines();

		if (changedLines === null) {
			return errors;
		}

		errors = errors.filter(error => {
			// filter out errors for unchanged lines
			// file path is between ` at .${path.sep}` and ":"
			let file = error.match(/\.(\\|\/)\S+?:/)[0].replace("." + path.sep, "").replace(":", "");
			if (file in changedLines) {
				// line is from ":" to the end of the line and is only digits
				let lineNumber = error.match(/\d+?$/)[0];
				if (lineNumber in changedLines[file]) {
					return true;
				}
			}
			return false;
		});
	}

	return errors;
}

// @ts-ignore
const pathToThisFile = resolve(fileURLToPath(import.meta.url));
const pathPassedToNode = resolve(process.argv[1]);

if (pathToThisFile.includes(pathPassedToNode)) {
	const initializedParser = detectChanges;
	const report = customChecks(args.changed, undefined, initializedParser);
	if (report.length > 0) {
		console.log("");
		console.log(report.join("\n"));
		console.log("");
		console.log(`Custom sanity checks found ${report.length} issues.`);
	} else {
		console.log("");
		console.log("No custom sanity check issues found.");
	}
}

export default customChecks;


code:
@ECHO off

:: Checks dependencies and prompts the user to install them (using winget) if missing.
:: If winget is not installed falls back to printing info
:: Returns exit code 69 :) if dependencies still don't exist at end of execution
:: checks for git and node

:: If you make a change to the functionality of this script you should also make sure to add the same functionality to 'dependencyCheck.sh'

SETLOCAL EnableDelayedExpansion

:: Set working directory
SET BASEDIR=%~dp0

:: check for dependencies
where /q git
IF %ERRORLEVEL% EQU 0 SET git="true"

where /q node
IF %ERRORLEVEL% EQU 0 SET node="true"

IF DEFINED git (
	:: And this is why I hate batch, nested if statements just to do an and operation
	IF DEFINED node (
		CALL :modulesCheck
		EXIT /b !ERRORLEVEL!
	)
)

:: Otherwise prompt the user
ECHO This project has some optional dependencies that enable extra features that we believe will be helpful to our users and developers.
ECHO You are seeing this because the feature you are trying to use requires dependencies that are missing.
ECHO.
ECHO Here is a list of the dependencies that are missing:

IF NOT DEFINED git CALL :gitMessage

IF NOT DEFINED node CALL :nodeMessage

:: check for winget
where /q winget
IF ERRORLEVEL 1 (
	CALL :manualInstall
	EXIT /b !ERRORLEVEL!
)

ECHO.
ECHO Should the packages listed above be installed automatically using the Windows package manager?
ECHO Administrator access may be required to install these packages.
ECHO If you do not want us to install these dependencies automatically, decline and the required commands will be printed out to do so manually.

:: ask for confirmation
CHOICE /C YN /N /M "Continue with the automatic installation? [Y/N]?"
IF %ERRORLEVEL% EQU 1 (
	CALL :automaticInstall
	EXIT /b !ERRORLEVEL!
) ELSE (
	CALL :manualInstall
	EXIT /b !ERRORLEVEL!
)

:: we should never hit this exit code
ECHO Code should have never gotten here!!!
EXIT /B 2

:gitMessage
ECHO   git, https://git-scm.com/, needed to interact with the .git folder in this project.
ECHO     Allows for things like:
ECHO       Keeping multiple compiled versions of FC based of the commit they were compiled with.
ECHO       The sanity checks have this as a hard dependency.
GOTO :eof

:nodeMessage
ECHO   Node.js, https://nodejs.org/, enables all of the new sanity checks and the advanced compiler.
ECHO     Allows for things like:
ECHO       Serving FC using 'serve.bat'
ECHO       Source maps for easier debugging: https://dzone.com/articles/what-are-source-maps-and-how-to-properly-use-them
ECHO       JavaScript linting to catch bugs early using ESLint: https://eslint.org/
ECHO       JavaScript type checking to catch bugs early using the Typescript compiler: https://www.typescriptlang.org/
ECHO       Custom sanity checks to catch common errors.
ECHO       Spell checking using the cSpell project: https://cspell.org/
ECHO       Tweaking of compiler and sanity check settings using 'setup.bat'
ECHO       Manage and run FCHost using 'FCHost.bat'
ECHO       Copy FC to FCHost (if installed) after each successful compile
ECHO       Automatic rebuilding and hot reloading of FC when file changes occur using 'watcher.bat'
:: TODO: @franklygeorge: update as we add the rest of the features
GOTO :eof

:modulesCheck
:: if node_modules doesn't exist
IF NOT EXIST node_modules (
	ECHO.
	ECHO All optional dependencies are installed, but the Node modules are not installed.
	ECHO.
	ECHO These packages should take up less than 500MB of disk space. ~150 MB at time of writing.
	ECHO They will be stored in the 'node_modules' directory inside of the project directory.
	ECHO.
	CHOICE /C YN /N /M "Would you like us to run 'npm install' to install them for you? [Y/N]?"
	IF !ERRORLEVEL! EQU 1 (
		CALL npm install
		:: Check for missing node dependencies
		CALL node .\devTools\scripts\dependencyCheck.js
		ECHO.
	) ELSE (
		ECHO.
		EXIT /B 69
	)
) ELSE (
	:: Check for missing node dependencies
	CALL node .\devTools\scripts\dependencyCheck.js
)
GOTO :eof

:ensureDependenciesAndExit
	:: wait for user input
	<nul set /p "=Press any key to continue..."
		pause >nul
	ECHO.

	:: check for dependencies
	where /q git
	IF %ERRORLEVEL% EQU 0 SET git="true"

	where /q node
	IF %ERRORLEVEL% EQU 0 SET node="true"

	:: if no missing dependencies, exit with code 0, else exit with code 69
	IF DEFINED git (
		:: And this is why I hate batch, nested if statements and redundant code just to do an and operation
		IF DEFINED node (
			CALL :modulesCheck
            EXIT /b !ERRORLEVEL!
        ) ELSE (
            ECHO.
            EXIT /b 69
		)
	) ELSE (
		ECHO.
		EXIT /b 69
	)
GOTO :eof

:manualInstall
	:: prints out the info for manual installation
    :: waits for user input
    :: checks for dependencies
    :: and then exits
	ECHO.
	ECHO To install these packages run the commands below.
	ECHO.
	ECHO "winget install Git.Git OpenJS.NodeJS -e --no-upgrade"
	ECHO.
	ECHO or download git from https://git-scm.com/download/win
	ECHO and download Node.js from https://nodejs.org
	ECHO.
	ECHO. After installing you will need to restart your command prompt and/or editor and run this again.
	ECHO.
	ECHO You should install these packages now if you wish to do so.
	CALL :ensureDependenciesAndExit
	EXIT /b %ERRORLEVEL%
GOTO :eof

:automaticInstall
	:: Install using winget
	ECHO.
	ECHO Executing "winget install Git.Git OpenJS.NodeJS -e --no-upgrade"
	CALL winget install Git.Git OpenJS.NodeJS -e --no-upgrade
	:: Windows has no official or reliable way to update the shell's system environments
	:: Technical there is a way but it's hacky and fails on several edge cases. One of the edge cases being PowerShell
	:: You would have thought that they would have build this into Winget so that they would have feature parity with the Linux package managers they were mimicking, but no they couldn't possible do that...
	:: As such we need the user to manually restart their console :(
	ECHO To continue please restart your command prompt and/or editor and run this again.
	:: Wait basically forever
	TIMEOUT /T 99999 /NOBREAK > NUL
GOTO :eof


code:
// cSpell:ignore yesno

/**
 * @file Makes sure that node dependencies are installed
 */

// @ts-ignore
import jetpack from "fs-jetpack";
import {ask} from "./yesno.js";
import {execSync} from "child_process";

// We need to load package.json to detect kinds of dependencies
// Don't want to install a dev dependency as a normal dependency and vise versa
/** @type {object} */
const packageContents = jetpack.read("package.json", "json");
/** @type {object} */
const devDependencies = packageContents.devDependencies;
/** @type {object} */
const dependencies = packageContents.dependencies;

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./setup.js").Settings} */
const settings = jetpack.read("settings.json", "json");

function installGitHooks() {
	execSync("node devTools/scripts/gitHooks/addHooks.js");
}

/**
 * Adds any packages with the wrong version to problems
 * @param {string} npmList results of `npm ls` command
 * @param {string[]} problems
 */
async function parseWrongVersion(npmList, problems) {
	npmList.split("\n").forEach(line => {
		// npm ERR! invalid: eslint@7.0.0 C:\Users\dev\Documents\Projects\fc pregmod\node_modules\eslint
		if (line.trim().startsWith("npm ERR! invalid: ")) {
			line = line.replace("npm ERR! invalid: ", "");
			let nodePackage = line.match(/^[^ ]*/)[0];
			problems.push(nodePackage.split("@")[0]);
		}
	});
	return problems;
}

/**
 * Adds any missing packages to problems
 * @param {string} npmList results of `npm ls` command
 * @param {string[]} problems
 */
async function parseMissing(npmList, problems) {
	npmList.split("\n").forEach(line => {
		// npm ERR! missing: eslint@^8.0.0, required by free-cities@1.0.0
		if (line.trim().startsWith("npm ERR! missing: ")) {
			line = line.replace("npm ERR! missing: ", "");
			let nodePackage = line.match(/^[^,]*/)[0];
			problems.push(nodePackage.split("@")[0]);
		}
	});
	return problems;
}

/**
 * Checks for missing and outdated node packages.
 */
async function main() {
	let npmList = "";
	try {
		npmList = execSync('npm ls', {maxBuffer: 1024 * 1024 * 1024, stdio: ['pipe']}, ).toString();
	} catch (e) {
		e.output.forEach(out => {
			if (out === null) { return; }
			npmList += "\n" + out.toString();
		});
	}

	/** @type {string[]} */
	let problems = [];

	if (npmList.includes("npm ERR! invalid: ")) {
		problems = await parseWrongVersion(npmList, problems);
	}

	if (npmList.includes("npm ERR! missing: ")) {
		problems = await parseMissing(npmList, problems);
	}

	// remove empty strings from problems
	problems = problems.filter(problem => problem.trim() !== "");

	let devDependencyCommand = "npm install --save-dev";
	let dependencyCommand = "npm install --save";

	problems.forEach(problem => {
		if (problem.trim().length === 0) { return; }
		let matched = false;
		for (let [key, value] of Object.entries(devDependencies)) {
			if (matched === true) { continue; }
			if (key === problem) {
				matched = true;
				devDependencyCommand += ` ${key}@${value}`;
			}
		}
		if (matched === true) { return; }
		for (let [key, value] of Object.entries(dependencies)) {
			if (matched === true) { continue; }
			if (key === problem) {
				matched = true;
				dependencyCommand += ` ${key}@${value}`;
			}
		}
		if (matched === false) {
			console.log(`Unknown dependency type for ${problem}`);
		}
	});

	if (problems.length === 0) {
		installGitHooks();
		return;
	}

	console.log("The Node packages below are missing or are the wrong version.");
	console.log("");
	problems.forEach(problem => console.log(problem));
	console.log("");
	console.log("The command(s) that need to be run to fix this problem are:");
	console.log("");
	if (devDependencyCommand !== "npm install --save-dev") {
		console.log(devDependencyCommand);
	}
	if (dependencyCommand !== "npm install --save") {
		console.log(dependencyCommand);
	}
	console.log("");
	if (settings.manageNodePackages === 0) {
		// @ts-ignore
		const answer = await ask({
			question: "Would you like us to run the above command(s) for you? [Y/N]",
		});
		if (answer === true) {
			if (devDependencyCommand !== "npm install --save-dev") {
				execSync(devDependencyCommand, {stdio:[0, 1, 2]});
			}
			if (dependencyCommand !== "npm install --save") {
				execSync(dependencyCommand, {stdio:[0, 1, 2]});
			}
			const settingsScript = (process.platform === "win32") ? "setup.bat" : "setup.sh";
			console.log(`If you wish not to be asked about Node packages again, run '${settingsScript}' and change option #1 in "Edit Miscellaneous Settings"`);
			installGitHooks();
		} else {
			console.log("If you wish not to be asked about Node packages again, change 'manageNodePackages' in settings.json to -1 (no) or 1 (yes).");
		}
	} else if (settings.manageNodePackages === 1) {
		if (devDependencyCommand !== "npm install --save-dev") {
			execSync(devDependencyCommand, {stdio:[0, 1, 2]});
		}
		if (dependencyCommand !== "npm install --save") {
			execSync(dependencyCommand, {stdio:[0, 1, 2]});
		}
		installGitHooks();
	}
}

// @ts-ignore
await main();


code:
#!/bin/bash

# Checks dependencies and prompts the user to install them if missing.
# Returns exit code 69 :) if dependencies still don't exist at end of execution
# checks for git and node

# If you make a change to the functionality of this script you should also make sure to add the same functionality to 'dependencyCheck.bat'

aptInstall="sudo apt-get install git nodejs"
pacmanInstall="sudo pacman -S git nodejs"
dnfInstall="sudo dnf install git nodejs"
yumInstall="sudo yum install git nodejs"
brewInstall="brew install git nodejs"

function modulesCheck() {
    # if node_modules doesn't exist
    if [[ ! -d node_modules ]]; then
        # ask user if we can install modules
        echo ""
        echo "All optional dependencies are installed, but the Node modules are not installed."
        echo ""
        echo "These packages should take up less than 500MB of disk space. (~150 MB at time of writing)."
        echo "They will be stored in the 'node_modules' directory inside of the project directory."
        echo ""
        read -n 1 -p "Would you like us to run 'npm install' to install them for you? [Y/N]?" choice
        case "$choice" in
        y|Y )
            echo ""
            npm install
            # Check for missing node dependencies
            node ./devTools/scripts/dependencyCheck.js
            echo ""
            echo ""
            ;;
        * )
            echo ""
            echo ""
            exit 69
            ;;
        esac
    else
    # Check for missing node dependencies
	node ./devTools/scripts/dependencyCheck.js
    fi
}

function ensureDependenciesAndExit() {
    # wait for user input
    read -n 1 -s -r -p "Press any key to continue..."
    echo ""

    # check for dependencies
    if command -v git &> /dev/null; then
        git="true"
    fi

    if command -v node &> /dev/null; then
        node="true"
    fi

    # If no missing dependencies, exit with code 0, else exit with code 69
    if [[ "$git" && "$node" ]]; then
        modulesCheck
        echo ""
        echo ""
        exit 0
    else
        echo ""
        echo ""
        exit 69
    fi
}

function manualInstall() {
    # prints out the info for manual installation
    # waits for user input
    # checks for dependencies
    # and then exits
    echo ""
    echo ""
    echo "To install these packages run the command below that applies to your system."
    echo ""

    echo "Ubuntu/Debian and derivatives:"
    echo "  $aptInstall"

    echo "Arch and derivatives:"
    echo "  $pacmanInstall"

    echo "Fedora/RHEL/CentOS and derivatives:"
    # dnf
    echo "  $dnfInstall"
    # yum
    echo "  or"
    echo "  $yumInstall"

    echo "Mac OS X:"
    echo "  $brewInstall"

    echo ""
    echo "You should install these packages now if you wish to do so."
    ensureDependenciesAndExit
}

function automaticInstall() {
    # Install using $packageManager
    echo ""
    echo ""
    if [[ "$packageManager" == "apt" ]]; then
        echo "Executing '$aptInstall'"
        $aptInstall
    elif [[ "$packageManager" == "pacman" ]]; then
        echo "Executing '$pacmanInstall'"
        $pacmanInstall
    elif [[ "$packageManager" == "dnf" ]]; then
        echo "Executing '$dnfInstall'"
        $dnfInstall
    elif [[ "$packageManager" == "yum" ]]; then
        echo "Executing '$yumInstall'"
        $yumInstall
    elif [[ "$packageManager" == "brew" ]]; then
        echo "Executing '$brewInstall'"
        $brewInstall
    fi
    ensureDependenciesAndExit
}

# Check for dependencies
if command -v git &> /dev/null; then
    git="true"
fi

if command -v node &> /dev/null; then
    node="true"
fi

# If no missing dependencies, exit
if [[ "$git" && "$node" ]]; then
    modulesCheck
    exit 0
fi

# Otherwise prompt the user
echo "This project has some optional dependencies that enable extra features that we believe will be helpful to our users and developers."
echo "You are seeing this because the feature you are trying to use requires dependencies that are missing."
echo ""
echo "Here is a list of the dependencies that are missing:"

if [[ ! "$git" ]]; then
    echo "  git, https://git-scm.com/, needed to interact with the .git folder in this project."
    echo "    Allows for things like:"
    echo "      Keeping multiple compiled versions of FC based of the commit they were compiled with."
    echo "      The sanity checks have this as a hard dependency."
fi

if [[ ! "$node" ]]; then
    echo "  Node.js, https://nodejs.org/, enables all of the new sanity checks and the advanced compiler."
    echo "    Allows for things like:"
    echo "      Serving FC using 'serve.sh'"
    echo "      Source maps for easier debugging: https://dzone.com/articles/what-are-source-maps-and-how-to-properly-use-them"
    echo "      JavaScript linting to catch bugs early using ESLint: https://eslint.org/"
    echo "      JavaScript type checking to catch bugs early using the Typescript compiler: https://www.typescriptlang.org/"
    echo "      Custom sanity checks to catch common errors."
    echo "      Spell checking using the cSpell project: https://cspell.org/"
    echo "      Tweaking of compiler and sanity check settings using 'setup.sh'"
    echo "      Manage and run FCHost using 'FCHost.sh'"
    echo "      Copy FC to FCHost (if installed) after each successful compile"
    echo "      Automatic rebuilding and hot reloading of FC when file changes occur using 'watcher.sh'"
    # TODO: @franklygeorge: update as we add the rest of the features
fi

# checks for apt, brew, dnf, yum, and pacman in that order
# meaning that we need to check for them in reverse order
if command -v pacman &> /dev/null; then
    packageManager="pacman"
fi

if command -v yum &> /dev/null; then
    packageManager="yum"
fi

if command -v dnf &> /dev/null; then
    packageManager="dnf"
fi

if command -v brew &> /dev/null; then
    packageManager="brew"
fi

if command -v apt-get &> /dev/null; then
    packageManager="apt"
fi

# if no package manager
if [[ ! "$packageManager" ]]; then
    manualInstall
fi

echo ""
echo "Should the packages listed above be installed automatically using the ${packageManager} package manager?"
echo "You will likely be asked for your root/sudo password as it is often required to install new packages."
echo "If you do not want us to install these dependencies automatically, decline and the required commands will be printed out to do so manually."

# ask for confirmation
read -n 1 -p "Continue with the automatic installation? [Y/N]?" choice
case "$choice" in
  y|Y ) automaticInstall;;
  n|N ) manualInstall;;
  * ) manualInstall;;
esac


code:
/**
 * @file returns changes to the project's files. can return files changed or which lines have been changed.
 *
 * Can be called from the command line
 *
 * node devTools/scripts/detectChanges.js
 * node devTools/scripts/detectChanges.js --lines
 *
 * Or imported as shown below
 *
 * import parser from './detectChanges.js';
 * const changedFiles = parser.changedFiles();
 * const changedLines = parser.changedLines();
 */

// cSpell:ignore ACMR

import {execSync} from 'child_process';
// @ts-ignore
import jetpack from 'fs-jetpack';
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {resolve} from 'path';
import {fileURLToPath} from 'url';

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('lines', {
		type: 'boolean',
		description: 'Returns list of lines changed',
		default: false,
	})
	.parse();

/**
 * An object that contains file paths as keys and a list of numbers as values
 * @typedef {{[key: string]: Array<number>}} ChangedLines
 */

/**
 * Detects file changes
 * @class
 */
class ChangeParser {
	/** @type {string} */
	branchName;
	/** @type {string} */
	mergeCommit;

	/**
	 * Gets the current branch name and the merge commit that the branch was created with and set this.branchName and this.mergeCommit to them
	 * @returns {boolean} false if we are on the "pregmod-master" branch, true otherwise
	 */
	setMergeCommitAndBranchName() {
		if (typeof this.branchName === 'string' && typeof this.mergeCommit === 'string') {
			return true;
		}

		const branchNameCommand = "git rev-parse --abbrev-ref HEAD";
		const branchName = execSync(branchNameCommand).toString().trim();

		const commitLogCommand = `git reflog show --no-abbrev ${branchName}`;
		const commitLog = execSync(commitLogCommand).toString().trim().split("\n");

		const mergeCommit = commitLog[commitLog.length - 1].trim().split(" ")[0].trim();

		if (branchName === "pregmod-master") {
			// we cannot detect changes, fall back to checking all files
			console.log(`Cannot check for changed files on the "pregmod-master" branch, please use a different branch`);
			this.branchName = null;
			this.mergeCommit = null;
			return false;
		} else {
			console.log(`Comparing changes on branch "${branchName}" since commit "${mergeCommit}"`);
			this.branchName = branchName;
			this.mergeCommit = mergeCommit;
			return true;
		}
	}
	/**
	 * Filters paths and removes anything that is not a file. Fixes a really obscure bug where git returns a directory
	 * and jetpack chokes on it when it tries to read it as a file.
	 * @param {string[]} paths list of paths to filter
	 * @returns {string[]} list of filtered paths
	 */
	filterFiles(paths) {
		return paths.filter(path => {
			return path.trim() !== "" && jetpack.exists(path) === "file";
		});
	}

	/**
	 * Returns the number of lines in the file.
	 * @param {string} filePath path to the file to count lines
	 * @returns {number} number of lines in the file
	 */
	countFileLines(filePath){
		return jetpack.read(filePath, "utf8").split('\n').length;
	}

	/**
	 * Returns a list of files that are not tracked by git and are not in .gitignore
	 * @returns {Array<string>}
	 */
	getUntrackedFiles() {
		const command = "git ls-files -o --exclude-standard .";
		let result = execSync(command).toString().trim().split('\n');

		if (result !== undefined) {
			return this.filterFiles(result.filter(file => file!== ""));
		} else {
			return [];
		}
	}

	/**
	 * Returns a list of files that are staged for a git commit
	 * @returns {string[]}
	 */
	stagedFiles() {
		let command = "git diff --name-only --cached --diff-filter=ACMR";
		return this.filterFiles(
			execSync(command).toString().trim().split('\n')
		);
	}

	/**
	 * Returns a list of files that have been changed.
	 * If the current branch is "pregmod-master", returns null, otherwise returns changed files
	 * @returns {string[]|null}
	 */
	changedFiles() {
		if (!this.setMergeCommitAndBranchName()) {
			return null;
		}

		const command = `git diff --name-only --diff-filter=d ${this.mergeCommit}`;
		let result = execSync(command).toString().trim().split('\n');

		// add result and this.getUntrackedFiles() together and return
		return this.filterFiles(result.concat(this.getUntrackedFiles()));
	}

	/**
	 * Returns a json object of files with their changed lines.
	 * If the current branch is "pregmod-master", returns null, otherwise returns changed lines
	 * @returns {ChangedLines|null}
	 */
	changedLines() {
		/** @type {ChangedLines} */
		let changed = {};

		const untracked = this.getUntrackedFiles();

		let changedFiles = this.changedFiles();

		if (changedFiles === null) {
			return null;
		}

		for (const file of changedFiles) {
			changed[file] = [];
			if (untracked.includes(file)) {
				// add all lines to changed[file]
				for (let i = 1; i <= this.countFileLines(file); i++) {
					changed[file].push(i);
				}
			} else {
				const command = `git diff -U${this.countFileLines(file)} ${this.mergeCommit} -- ${file}`;
				/** @type {Array<string>} */
				try {
					let result = execSync(command).toString().trim().split('\n');

					// remove first two lines
					result = result.slice(2);
					// remove all lines starting with ---, +++, or @@
					result = result.filter(line => !line.startsWith("---") && !line.startsWith("+++") && !line.startsWith("@@"));
					// remove all lines starting with -
					result = result.filter(line => !line.startsWith("-"));
					let lineNo = 0;
					// for each line
					result.forEach(line => {
						lineNo += 1;
						// if line starts with + add line number to changed lines
						if (line.startsWith("+")) {
							changed[file].push(lineNo);
						}
					});
				} catch {
					// git fails with a `Circular *1` error sometimes on merge commits
				}
			}
		}

		return changed;
	}
}

const parser = new ChangeParser();

// @ts-ignore
const pathToThisFile = resolve(fileURLToPath(import.meta.url));
const pathPassedToNode = resolve(process.argv[1]);

if (pathToThisFile.includes(pathPassedToNode)) {
	// called via console
	if (args.lines === true) {
		// get changed lines
		const changed = parser.changedLines();
		// for each file in changed
		for (const file in changed) {
			// for each line in the file
			for (const line of changed[file]) {
				console.log(`${file}, line ${line}`);
			}
		}
	} else {
		// get changed files and print them
		console.log(parser.changedFiles().join("\n"));
	}
}

export default parser;


code:
/**
 * @file Lints files using ESLint
 *
 * Can be called from the command line
 *
 * node devTools/scripts/eslintChecks.js
 * node devTools/scripts/eslintChecks.js --changed
 *
 * Or imported as shown below
 *
 * import eslintChecks from './eslintChecks.js';
 * const changed = false;
 * /** @type{object[]} *\/
 * const report = await eslintChecks(changed);
 */

import detectChanges from './detectChanges.js';

import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {resolve} from 'path';
import {fileURLToPath} from 'url';
import {ESLint} from "eslint";
// @ts-ignore
import path from "path";

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('changed', {
		type: 'boolean',
		description: 'Only check changed files',
		default: false,
	})
	.parse();

const eslint = new ESLint({
	cache: true,
	cacheStrategy: "content"
});

/**
 * Runs ESLint on files
 * @param {boolean} changed If true, only check changed files. If false, check all files.
 * @param {string[]} files If provided we will sent this list of files to ESLint instead
 * @param {detectChanges} parser If provided it will be used instead of the default parser
 * @returns {Promise<object[]>}
 */
async function eslintChecks(changed = false, files = undefined, parser = detectChanges) {
	let results = [];

	try {
		let filesToLint = ["**/*.js"];

		if (files === undefined && changed === true) {
			let changedFiles = parser.changedFiles();
			if (changedFiles !== null) {
				filesToLint = changedFiles.filter(file => file.endsWith(".js"));
			} else {
				// There are no changed files. Return an empty array
				return [];
			}
		} else if (files !== undefined) {
			// make sure files is an array of strings
			if (Object.prototype.toString.call(files) !== '[object Array]' || files.every(i => typeof i !== "string")) {
				throw new Error("files must be an array of strings or undefined");
			}
			filesToLint = files.filter(file => file.endsWith(".js"));
		}
		if (files === undefined && changed === false) {
			console.log("If this is the first time you have ran ESLint on all files, then this will take a while...");
		} else if (files === undefined) {
			console.log("Running ESLint on changed files compared to pregmodfan/fc-pregmod");
		} else {
			console.log(`Running ESLint on ${files.length} file(s)`);
		}
		const ESLintResults = await eslint.lintFiles(filesToLint);

		const formatter = await eslint.loadFormatter("json");
		results = JSON.parse(await formatter.format(ESLintResults));
	} catch (e) {
		console.error(e);
		process.exit(1);
	}

	// If changed filter out line numbers that were not edited
	if (changed === true) {
		const changedLines = parser.changedLines();

		if (changedLines === null) {
			// remove any result that has an empty messages array and return
			return results.filter(result => result.messages.length > 0);
		}

		const originalResults = JSON.parse(JSON.stringify(results));

		results = [];

		Object.keys(originalResults).forEach(resultKey => {
			/** @type {object} */
			let result = originalResults[resultKey];
			const filePath = path.relative(process.cwd(), result.filePath).replace(/\\/g, "/");
			if (Object.keys(changedLines).includes(filePath)) {
				const changedLineNumbers = changedLines[filePath];
				const originalMessages = JSON.parse(JSON.stringify(result.messages));
				result.messages = [];
				originalMessages.forEach(message => {
					// if changedLineNumbers is not between message.line and message.endLine
					changedLineNumbers.forEach(lineNo => {
						if (
							lineNo >= message.line &&
							lineNo <= message.endLine
						) {
							// Keep this one
							message.path = filePath;
							result.messages.push(message);
						}
					});
				});
				results.push(result);
			}
		});
	}

	// remove any result that has an empty messages array and return
	return results.filter(result => result.messages.length > 0);
}

// @ts-ignore
const pathToThisFile = resolve(fileURLToPath(import.meta.url));
const pathPassedToNode = resolve(process.argv[1]);

if (pathToThisFile.includes(pathPassedToNode)) {
	const initializedParser = detectChanges;
	// @ts-ignore
	const report = await eslintChecks(args.changed, undefined, initializedParser);
	// parse report using eslint
	// @ts-ignore
	const formatter = await eslint.loadFormatter("stylish");
	const reportText = formatter.format(report);
	console.log(reportText);
}

export default eslintChecks;


code:
/**
 * @file Moves all the git hooks to .git/hooks
 */

// @ts-ignore
import jetpack from "fs-jetpack";
import * as path from "path";

// copy contents of devTools/scripts/gitHooks to .git/hooks
// for file in devTools/scripts/gitHooks
jetpack.list("devTools/scripts/gitHooks").forEach((/** @type {string} */ file) => {
	// skip if path ends with `addHooks.js`
	if (file.endsWith("addHooks.js")) { return; }
	const newPath = path.join(".git/hooks", file);
	// copy file if it doesn't already exist
	if (jetpack.exists(newPath) === false) {
		jetpack.copy(
			path.join("devTools/scripts/gitHooks", file),
			newPath
		);
	}
});


code:
#!/bin/bash
#
# An hook script to verify what is about to be committed.
# Called by "git commit" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.

# We do a simple check to make sure node exists
if command -v node &> /dev/null; then
  # Very rudimentary way to check if we have the node packages we need
  # This could potentially fail since we don't actually check what packages are installed
  if [ -d "node_modules" ]; then
    SOURCE=${BASH_SOURCE[0]}
    while [ -L "$SOURCE" ]; do # resolve $SOURCE until the file is no longer a symlink
      DIR=$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )
      SOURCE=$(readlink "$SOURCE")
      [[ $SOURCE != /* ]] && SOURCE=$DIR/$SOURCE # if $SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
    done
    DIR=$( cd -P "$( dirname "$SOURCE" )" >/dev/null 2>&1 && pwd )

    node "$DIR/pre-commit.js"
  else
    echo "Pre-commit hook cannot run because the Node modules have not been installed."
  fi
else
  echo "Pre-commit hook cannot run because Node is not installed"
fi

path: devTools/scripts/gitHooks/pre-commit


code:
/**
 * @file An hook script to verify what is about to be committed.
 *Called by "git commit" with no arguments.  The hook should
 * exit with non-zero status after issuing an appropriate message if
 * it wants to stop the commit.
 */

// @ts-ignore
import jetpack from "fs-jetpack";
import {execSync} from 'child_process';

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./../setup.js").Settings} */
const settings = jetpack.read("settings.json", "json");

if (settings.precommitHookEnabled === 1) {
	try {
		execSync("node devTools/scripts/sanityCheck.js --staged", {stdio: "inherit"});
	} catch (e) {
		console.log("Sanity checks failed! See above for details");
		process.exit(1);
	}
} else if (settings.precommitHookEnabled === -1) {
	console.log("Precommit hook temporarily disabled");
	settings.precommitHookEnabled = 1;
	// save settings to settings.json
	jetpack.write("settings.json", settings, {atomic: true});
} else {
	console.log("pre-commit sanity checks are currently disabled");
}


code:
// @ts-ignore
import jetpack from 'fs-jetpack';
import {ESLint} from "eslint";
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {execSync} from 'child_process';
import stripAnsi from "strip-ansi";
// @ts-ignore
import c from "ansi-colors";

import customChecks from "./customChecks.js";
import spellingChecks from "./spellingChecks.js";
import eslintChecks from "./eslintChecks.js";
import typescriptChecks from "./typescriptChecks.js";
import parser from './detectChanges.js';

// TODO:@franklygeorge flags to selectively run parts of the sanity check. After you add those change the commands in package.json to use them

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('staged', {
		type: 'boolean',
		description: 'Only check staged files',
		default: false,
	})
	.option('interaction', {
		type: 'boolean',
		description: 'Used by scripts to let the sanity checker know that user interaction is not possible.',
		default: true,
	})
	.parse();

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./setup.js").Settings} */
const settings = jetpack.read("settings.json", "json");

// move old log to sanityCheck.log.bak
if (jetpack.exists("sanityCheck.log") === "file") {
	jetpack.move("sanityCheck.log", "sanityCheck.log.bak", {overwrite: true});
}

// write new log starting with the current date and time
jetpack.write("sanityCheck.log", new Date().toLocaleString() + "\n\n", {atomic: true});

/**
 * Adds a message to the log file, also prints the message to the console by default
 * @param {string} message message to log
 * @param {boolean} [print] if true (default) then print message to console
 */
function log(message, print = true) {
	if (print === true) {
		console.log(message);
	}
	// if message doesn't end with `\n` then add it
	if (message.trimEnd() === message) {
		message += "\n";
	}
	// strip color codes and save to log file
	jetpack.append("sanityCheck.log", stripAnsi(message));
}

const eslint = new ESLint({
	cache: true,
	cacheStrategy: "content"
});

/** @type {Array<string>} */
let customProblems = [];
/** @type {Array<string>} */
let spellingProblems = [];
/** @type {Array<object>} */
let eslintProblems = [];
let eslintProblemCount = 0;
/** @type {Array<string>} */
let typescriptProblems = [];

let stagedFiles = undefined;

// add state of staged flag to log
log(`args.staged: ${args.staged}`, false);

if (args.staged === true) {
	// get staged files
	stagedFiles = parser.stagedFiles();
	if (stagedFiles === null) {
		stagedFiles = undefined;
	}
}

if (stagedFiles !== undefined) {
	// if no files in stagedFiles
	if (stagedFiles.length === 0) {
		log("No staged files found for processing!");
		process.exit(2);
	}
	// add list of staged files to log
	log(`staged files: ${JSON.stringify(stagedFiles, undefined, "\t")}`, false);
}

if (settings.checksEnableCustom === true) {
	customProblems = customChecks(settings.checksOnlyChangedCustom, stagedFiles, parser);
}
if (settings.checksEnableSpelling === true) {
	spellingProblems = spellingChecks(settings.checksOnlyChangedSpelling, stagedFiles, parser);
}
if (settings.checksEnableESLint === true) {
	// @ts-ignore
	eslintProblems = await eslintChecks(settings.checksOnlyChangedESLint, stagedFiles, parser);
	eslintProblems.forEach(problem => {
		eslintProblemCount += problem.messages.length;
	});
}
if (settings.checksEnableTypescript === true) {
	// @ts-ignore
	typescriptProblems = typescriptChecks(settings.checksOnlyChangedTypescript, stagedFiles, parser);
}

if (customProblems.length > 0) {
	log("=".repeat(20) + "Custom problems" + "=".repeat(20));
	log("");
	log(customProblems.join("\n"));
}
if (spellingProblems.length > 0) {
	log("=".repeat(20) + "Spelling problems" + "=".repeat(20));
	log("");
	log(spellingProblems.join("\n"));
}
if (eslintProblems.length > 0) {
	log("=".repeat(20) + "JavaScript linting problems" + "=".repeat(20));
	log("");
	// @ts-ignore
	const formatter = await eslint.loadFormatter("stylish");
	// @ts-ignore
	const reportText = await formatter.format(eslintProblems);
	log(reportText);
}
if (typescriptProblems.length > 0) {
	log("=".repeat(20) + "JavaScript type problems" + "=".repeat(20));
	log("");
	log(typescriptProblems.join("\n"));
}

log("=".repeat(60));

let skippedChecks = 0;

if (customProblems.length > 0) {
	log(c.bold.red(`Custom sanity checks found ${customProblems.length} issues.`));
} else if (settings.checksEnableCustom === true) {
	log(c.green("Custom sanity checks found no issues."));
} else {
	log(c.yellow("Custom sanity checks are currently disabled."));
	skippedChecks += 1;
}

if (spellingProblems.length > 0) {
	log(c.bold.red(`cSpell found ${spellingProblems.length} spelling issues.`));
} else if (settings.checksEnableSpelling === true) {
	log(c.green("cSpell found no spelling issues."));
} else {
	log(c.yellow("Spelling checks using cSpell are currently disabled."));
	skippedChecks += 1;
}

if (eslintProblemCount > 0) {
	log(c.bold.red(`ESLint found ${eslintProblemCount} linting issues.`));
} else if (settings.checksEnableESLint === true) {
	log(c.green(`ESLint found no linting issues.`));
} else {
	log(c.yellow("Linting using ESLint is currently disabled."));
	skippedChecks += 1;
}

if (typescriptProblems.length > 0) {
	log(c.bold.red(`The TypeScript compiler found ${typescriptProblems.length} type issues.`));
} else if (settings.checksEnableTypescript === true) {
	log(c.green(`The Typescript compiler found no type issues.`));
} else {
	log(c.yellow("Type checking using the Typescript compiler is currently disabled."));
	skippedChecks += 1;
}

const issueCount = customProblems.length + spellingProblems.length + eslintProblemCount + typescriptProblems.length;

log("");
let finalMessage = "";
if (issueCount > 0) {
	finalMessage = c.bold.red(`${issueCount}`) + " issues found.";
} else {
	finalMessage = c.green("No issues found.");
}
if (skippedChecks > 0) {
	finalMessage += ` ${c.yellow(skippedChecks)} sanity checker${(skippedChecks > 1) ? "s" : ""} disabled.`;
} else {
	finalMessage += c.green(" No sanity checkers disabled.");
}
log(finalMessage);

log("=".repeat(60));

// exclude eslint and typescript problems from making git pre-commit hook fail
// we may change this in the future
if ((issueCount - (eslintProblemCount + typescriptProblems.length)) > 0) {
	if (args.staged === true) {
		log(`You can temporarily disable the pre-commit hook by changing 'Edit Sanity Check Settings' -> 'Running sanity checks before commiting' in "setup.${process.platform === "win32" ? "bat": "sh"}" to 'Sanity checks are temporarily disabled...'`);
	}
	process.exit(1);
}


code:
/**
 * @file Creates a menu to manage settings for the compiler and sanity checks
 */

// cSpell:words list
// cSpell:ignore fchost

import yargs from "yargs";
import {hideBin} from "yargs/helpers";
// @ts-ignore
import jetpack from "fs-jetpack";
import inquirer from 'inquirer';

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('settings', {
		type: 'boolean',
		description: 'Creates/Updates settings.json and then exits',
		default: false,
	})
	.parse();

const separatorString = "-".repeat(78);

// default settings
/**
 * @typedef {object} Settings
 * @property {-1|0|1} manageNodePackages -1 = do not ask, 0 = ask, 1 = auto install
 * @property {"simple"|"advanced"} compilerMode
 * @property {boolean} compilerWaitOnWindows Wait for user input before closing bat files on Windows.
 * @property {1|2|3|4|5|6} compilerVerbosity
 * @property {boolean} compileThemes If true then tell the advanced compiler to compile themes
 * @property {boolean} compilerSourcemaps If true then tell the advanced compiler to add sourcemaps (don't add the release flag)
 * @property {boolean} compilerMinify If true then tell the advanced compiler to minify the build
 * @property {boolean} compilerAddDebugFiles If true then tell the advanced compiler to add files matching *.debug.* to the build
 * @property {boolean} compilerFilenameHash If true then tell the advanced compiler to add the git commit hash to the filename
 * @property {boolean} compilerFilenameEpoch If true then tell the advanced compiler to add the current epoch to the filename
 * @property {boolean} compilerFilenamePmodVersion If true then tell the advanced compiler to add the current pmod version to the filename
 * @property {0|1|2} compilerRunSanityChecks 0 = Do not run sanity checks during compiling, 1 = Run before compiling, 2 = Run after compiling
 * @property {boolean} compilerCopyToFCHost If true copy files from "bin" to settings.FCHostPath
 * @property {boolean} compilerFinishedNotification if true then we send a notification before exiting the compiler
 * @property {boolean} checksEnableCustom If true then we will run Extra sanity checks
 * @property {boolean} checksOnlyChangedCustom If true then we will only check changed lines
 * @property {boolean} checksEnableSpelling If true then we will run Spelling checks
 * @property {boolean} checksOnlyChangedSpelling If true then we will only check changed lines
 * @property {boolean} checksEnableESLint If true then we will run ESLint checks
 * @property {boolean} checksOnlyChangedESLint If true then we will only check changed lines
 * @property {boolean} checksEnableTypescript If true then we will run Typescript checks
 * @property {boolean} checksOnlyChangedTypescript If true then we will only check changed lines
 * @property {-1|0|1} precommitHookEnabled 0 = Disabled, 1 = Enabled, -1 = temporarily disabled
 * @property {string} FCHostPath Path to FCHost's directory
 * @property {string} FCHostDownloadURL the url last used to download FCHost. This is used to suggest updating to the user
 * @property {boolean} WatcherLiveReload If true then the watcher will trigger a live reload of FC each time it gets re-compiled
 */

// TODO:@franklygeorge Figure out why setup.[bat,sh] is slow to start (~5 seconds). Probably affects compile.[bat,sh] and sanityCheck.[bat,sh] as well

/** @type {Settings} */
const settings = {
	manageNodePackages: 0,
	compilerMode: "advanced",
	compilerWaitOnWindows: true,
	compilerVerbosity: 6,
	compileThemes: true,
	compilerSourcemaps: true,
	compilerMinify: false,
	compilerAddDebugFiles: true,
	compilerFilenameHash: false,
	compilerFilenameEpoch: false,
	compilerFilenamePmodVersion: false,
	compilerRunSanityChecks: 0,
	compilerCopyToFCHost: true,
	compilerFinishedNotification: true,
	checksEnableCustom: true,
	checksOnlyChangedCustom: true,
	checksEnableSpelling: true,
	checksOnlyChangedSpelling: true,
	checksEnableESLint: true,
	checksOnlyChangedESLint: true,
	checksEnableTypescript: false,
	checksOnlyChangedTypescript: true,
	precommitHookEnabled: 1,
	FCHostPath: "FCHost/fchost/Release",
	FCHostDownloadURL: "",
	WatcherLiveReload: false,
};

// create settings.json if it doesn't exist
if (jetpack.exists("settings.json") !== "file") {
	jetpack.write("settings.json", settings, {atomic: true});
}

// load settings from settings.json
let settingsChanged = false;
const settingsJson = jetpack.read("settings.json", "json");
for (let [key, value] of Object.entries(settings)) {
	if (!(key in settingsJson)) {
		settingsChanged = true;
	} else if (settingsJson[key] !== value) {
		if (typeof settingsJson[key] !== typeof value) {
			throw new Error(`Invalid type "${typeof settingsJson[key]}" for setting "${key}", expected a "${typeof value}"`);
		}
		// @ts-ignore
		settings[key] = settingsJson[key];
	}
}

// setting migrations
if ("checksEnableExtras" in settings) {
	// @ts-ignore
	// eslint-disable-next-line dot-notation
	settings.checksEnableCustom = settings["checksEnableExtras"];
	// eslint-disable-next-line dot-notation
	delete settings["checksEnableExtras"];
}

if ("checksOnlyChangedExtras" in settings) {
	// @ts-ignore
	// eslint-disable-next-line dot-notation
	settings.checksOnlyChangedCustom = settings["checksOnlyChangedExtras"];
	// eslint-disable-next-line dot-notation
	delete settings["checksOnlyChangedExtras"];
}

if (settingsChanged === true) {
	jetpack.write("settings.json", settings, {atomic: true});
}

if (args.settings === true) {
	process.exit(0);
}

/** @type {Settings} */
let originalSettings = JSON.parse(JSON.stringify(settings));

const strings = {
	compilerMode: () => {
		if (settings.compilerMode === "advanced") {
			return "Using the advanced compiler";
		} else {
			return "Using the simple compiler, change to the advanced compiler for more options";
		}
	},
	compileThemes: () => {
		return (settings.compileThemes
			? "Themes are compiled"
			: "Themes are not compiled"
		);
	},
	compilerSourcemaps: () => {
		return (settings.compilerSourcemaps
			? "Source maps are added, minification is disabled"
			: "Source maps are not added"
		);
	},
	compilerMinify: () => {
		return (settings.compilerMinify
			? "Build is minified"
			: "Build is not minified"
		);
	},
	compilerAddDebugFiles: () => {
		return (settings.compilerAddDebugFiles
			? "Adding *.debug.* files to the build"
			: "Ignoring *.debug.* files"
		);
	},
	compilerFilenameHash: () => {
		return (settings.compilerFilenameHash
			? "Adding the current Git commit hash to the final filename"
			: "Not adding the current Git commit hash to the final filename"
		);
	},
	compilerFilenameEpoch: () => {
		return (settings.compilerFilenameEpoch
			? "Adding the current time to the final filename"
			: "Not adding the current time to the final filename"
		);
	},
	compilerFilenamePmodVersion: () => {
		return (settings.compilerFilenamePmodVersion
			? "Adding the current Pmod version to the final filename"
			: "Not adding the current Pmod version to the final filename"
		);
	},
	compilerVerbosity: () => {
		return `Verbosity level: ${settings.compilerVerbosity}`;
	},
	compilerRunSanityChecks: () => {
		if (settings.compilerRunSanityChecks === 0) {
			return "Not running sanity checks when compiling";
		} else if (settings.compilerRunSanityChecks === 1) {
			return "Running sanity checks before compiling";
		} else {
			return "Running sanity checks after compiling";
		}
	},
	compilerWaitOnWindows: () => {
		return (settings.compilerWaitOnWindows
			? "Waiting for user input before exiting compiler"
			: "Exiting compiler without user input"
		);
	},
	precommitHookEnabled: () => {
		if (settings.precommitHookEnabled === 0) {
			return "Not running sanity checks before commiting";
		} else if (settings.precommitHookEnabled === 1) {
			return "Running sanity checks before commiting";
		} else {
			return "Sanity checks are temporarily disabled and will be re-enabled after the next commit";
		}
	},
	checksEnableCustom: () => {
		return (settings.checksEnableCustom
			? "Custom sanity checks are enabled"
			: "Custom sanity checks are disabled"
		);
	},
	checksEnableSpelling: () => {
		return (settings.checksEnableSpelling
			? "Spelling checks are enabled"
			: "Spelling checks are disabled"
		);
	},
	checksEnableESLint: () => {
		return (settings.checksEnableESLint
			? "JavaScript linting is enabled"
			: "JavaScript linting is disabled"
		);
	},
	checksEnableTypescript: () => {
		return (settings.checksEnableTypescript
			? "JavaScript type checking is enabled"
			: "JavaScript type checking is disabled"
		);
	},
	checksOnlyChangedCustom: () => {
		return (settings.checksOnlyChangedCustom
			? "Custom sanity checks are only reporting problems on changed lines"
			: "Custom sanity checks are reporting all problems"
		);
	},
	checksOnlyChangedSpelling: () => {
		return (settings.checksOnlyChangedSpelling
			? "Spelling checks are only reporting problems on changed lines"
			: "Spelling checks are reporting all problems"
		);
	},
	checksOnlyChangedESLint: () => {
		return (settings.checksOnlyChangedESLint
			? "JavaScript linting is only reporting problems on changed lines"
			: "JavaScript linting is reporting all problems"
		);
	},
	checksOnlyChangedTypescript: () => {
		return (settings.checksOnlyChangedTypescript
			? "JavaScript type checking is only reporting problems on changed lines"
			: "JavaScript type checking is reporting all problems"
		);
	},
	manageNodePackages: () => {
		if (settings.manageNodePackages === -1) {
			return "Ignoring incorrect Node packages";
		} else if (settings.manageNodePackages === 0) {
			return "Asking about incorrect Node packages";
		} else {
			return "Automatically fixing incorrect Node packages";
		}
	},
	WatcherLiveReload: () => {
		return (settings.WatcherLiveReload
			? "Watcher is triggering a live reload on each successful build"
			: "Watcher is not triggering a live reload on each successful build"
		);
	},
	compilerCopyToFCHost: () => {
		return (settings.compilerCopyToFCHost
			? "Copying FC to FCHost's directory after a successful build"
			: "Not copying FC to FCHost's directory"
		);
	},
	compilerFinishedNotification: () => {
		return (settings.compilerFinishedNotification
			? "Sending a notification when the compiler finishes"
			: "Not sending a notification when the compiler finishes"
		);
	},
};

let compilerMenuChoice;

async function compilerSettings() {
	let choices = [];
	choices.push(strings.compilerMode());
	choices.push(new inquirer.Separator(separatorString));
	if (settings.compilerMode === "advanced") {
		choices.push(strings.compileThemes());
		choices.push(strings.compilerSourcemaps());
		if (settings.compilerSourcemaps === false) {
			choices.push(strings.compilerMinify());
		}
		choices.push(strings.compilerAddDebugFiles());
		choices.push(strings.compilerFilenameHash());
		choices.push(strings.compilerFilenameEpoch());
		choices.push(strings.compilerFilenamePmodVersion());
		choices.push(strings.compilerVerbosity());
		choices.push(new inquirer.Separator(separatorString));
		choices.push(strings.compilerFinishedNotification());
		choices.push(strings.compilerRunSanityChecks());
	} else {
		choices.push(strings.compileThemes());
	}
	if (process.platform === "win32") {
		choices.push(strings.compilerWaitOnWindows());
	}

	choices.push("Back");

	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: "Compiler Settings",
			choices: choices,
			default: compilerMenuChoice,
			loop: false,
			pageSize: 11
		}])
		.then((answers) => {
			compilerMenuChoice = answers.choice;
		});

	if (compilerMenuChoice === strings.compilerMode()) {
		if (settings.compilerMode === "simple") {
			settings.compilerMode = "advanced";
		} else {
			settings.compilerMode = "simple";
		}
		compilerMenuChoice = strings.compilerMode();
	} else if (compilerMenuChoice === strings.compileThemes()) {
		settings.compileThemes = !settings.compileThemes;
		compilerMenuChoice = strings.compileThemes();
	} else if (compilerMenuChoice === strings.compilerSourcemaps()) {
		settings.compilerSourcemaps = !settings.compilerSourcemaps;
		if (settings.compilerSourcemaps === true) {
			settings.compilerMinify = false;
		}
		compilerMenuChoice = strings.compilerSourcemaps();
	} else if (compilerMenuChoice === strings.compilerMinify()) {
		settings.compilerMinify = !settings.compilerMinify;
		compilerMenuChoice = strings.compilerMinify();
	} else if (compilerMenuChoice === strings.compilerAddDebugFiles()) {
		settings.compilerAddDebugFiles = !settings.compilerAddDebugFiles;
		compilerMenuChoice = strings.compilerAddDebugFiles();
	} else if (compilerMenuChoice === strings.compilerFilenameHash()) {
		settings.compilerFilenameHash = !settings.compilerFilenameHash;
		compilerMenuChoice = strings.compilerFilenameHash();
	} else if (compilerMenuChoice === strings.compilerFilenameEpoch()) {
		settings.compilerFilenameEpoch = !settings.compilerFilenameEpoch;
		compilerMenuChoice = strings.compilerFilenameEpoch();
	} else if (compilerMenuChoice === strings.compilerFilenamePmodVersion()) {
		settings.compilerFilenamePmodVersion = !settings.compilerFilenamePmodVersion;
		compilerMenuChoice = strings.compilerFilenamePmodVersion();
	} else if (compilerMenuChoice === strings.compilerVerbosity()) {
		if (settings.compilerVerbosity === 6) {
			settings.compilerVerbosity = 1;
		} else {
			settings.compilerVerbosity += 1;
		}
		compilerMenuChoice = strings.compilerVerbosity();
	} else if (compilerMenuChoice === strings.compilerRunSanityChecks()) {
		if (settings.compilerRunSanityChecks === 2) {
			settings.compilerRunSanityChecks = 0;
		} else {
			settings.compilerRunSanityChecks += 1;
		}
		compilerMenuChoice = strings.compilerRunSanityChecks();
	} else if (compilerMenuChoice === strings.compilerFinishedNotification()) {
		settings.compilerFinishedNotification = !settings.compilerFinishedNotification;
		compilerMenuChoice = strings.compilerFinishedNotification();
	} else if (compilerMenuChoice === strings.compilerWaitOnWindows()) {
		settings.compilerWaitOnWindows = !settings.compilerWaitOnWindows;
		compilerMenuChoice = strings.compilerWaitOnWindows();
	} else if (compilerMenuChoice === "Back") {
		compilerMenuChoice = 0;
		return;
	} else {
		throw new Error("Invalid compiler menu choice: " + compilerMenuChoice);
	}

	await compilerSettings();
}

let sanityCheckMenuChoice;

async function sanityCheckSettings() {
	let choices = [];
	choices.push(strings.precommitHookEnabled());
	choices.push(strings.compilerRunSanityChecks());
	choices.push(new inquirer.Separator(separatorString));
	choices.push(strings.checksEnableCustom());
	choices.push(strings.checksEnableSpelling());
	choices.push(strings.checksEnableESLint());
	choices.push(strings.checksEnableTypescript());
	choices.push(new inquirer.Separator(separatorString));
	if (settings.checksEnableCustom === true) {
		choices.push(strings.checksOnlyChangedCustom());
	}
	if (settings.checksEnableSpelling === true) {
		choices.push(strings.checksOnlyChangedSpelling());
	}
	if (settings.checksEnableESLint === true) {
		choices.push(strings.checksOnlyChangedESLint());
	}
	if (settings.checksEnableTypescript === true) {
		choices.push(strings.checksOnlyChangedTypescript());
	}

	choices.push("Back");

	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: "Sanity Check Settings",
			choices: choices,
			default: sanityCheckMenuChoice,
			loop: false,
			pageSize: 11
		}])
		.then((answers) => {
			sanityCheckMenuChoice = answers.choice;
		});
	if (sanityCheckMenuChoice === strings.precommitHookEnabled()) {
		if (settings.precommitHookEnabled === -1) {
			settings.precommitHookEnabled = 0;
		} else if (settings.precommitHookEnabled === 0) {
			settings.precommitHookEnabled = 1;
		} else {
			settings.precommitHookEnabled = -1;
		}
		sanityCheckMenuChoice = strings.precommitHookEnabled();
	} else if (sanityCheckMenuChoice === strings.compilerRunSanityChecks()) {
		if (settings.compilerRunSanityChecks === 2) {
			settings.compilerRunSanityChecks = 0;
		} else {
			settings.compilerRunSanityChecks += 1;
		}
		sanityCheckMenuChoice = strings.compilerRunSanityChecks();
	} else if (sanityCheckMenuChoice === strings.checksEnableCustom()) {
		settings.checksEnableCustom = !settings.checksEnableCustom;
		sanityCheckMenuChoice = strings.checksEnableCustom();
	} else if (sanityCheckMenuChoice === strings.checksEnableSpelling()) {
		settings.checksEnableSpelling = !settings.checksEnableSpelling;
		sanityCheckMenuChoice = strings.checksEnableSpelling();
	} else if (sanityCheckMenuChoice === strings.checksEnableESLint()) {
		settings.checksEnableESLint = !settings.checksEnableESLint;
		sanityCheckMenuChoice = strings.checksEnableESLint();
	} else if (sanityCheckMenuChoice === strings.checksEnableTypescript()) {
		settings.checksEnableTypescript = !settings.checksEnableTypescript;
		sanityCheckMenuChoice = strings.checksEnableTypescript();
	} else if (sanityCheckMenuChoice === strings.checksOnlyChangedCustom()) {
		settings.checksOnlyChangedCustom = !settings.checksOnlyChangedCustom;
		sanityCheckMenuChoice = strings.checksOnlyChangedCustom();
	} else if (sanityCheckMenuChoice === strings.checksOnlyChangedSpelling()) {
		settings.checksOnlyChangedSpelling = !settings.checksOnlyChangedSpelling;
		sanityCheckMenuChoice = strings.checksOnlyChangedSpelling();
	} else if (sanityCheckMenuChoice === strings.checksOnlyChangedESLint()) {
		settings.checksOnlyChangedESLint = !settings.checksOnlyChangedESLint;
		sanityCheckMenuChoice = strings.checksOnlyChangedESLint();
	} else if (sanityCheckMenuChoice === strings.checksOnlyChangedTypescript()) {
		settings.checksOnlyChangedTypescript = !settings.checksOnlyChangedTypescript;
		sanityCheckMenuChoice = strings.checksOnlyChangedTypescript();
	} else if (
		sanityCheckMenuChoice === "Back"
	) {
		sanityCheckMenuChoice = 0;
		return;
	} else {
		throw new Error("Invalid sanity check menu choice: " + sanityCheckMenuChoice);
	}

	await sanityCheckSettings();
}

let miscMenuChoice;

async function MiscSettings() {
	let choices = [];
	choices.push(strings.manageNodePackages());
	choices.push(new inquirer.Separator(separatorString));
	choices.push(strings.WatcherLiveReload());
	choices.push(strings.compilerCopyToFCHost());
	choices.push("Back");

	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: "Miscellaneous Settings",
			choices: choices,
			default: miscMenuChoice,
			loop: false,
			pageSize: 11
		}])
		.then((answers) => {
			miscMenuChoice = answers.choice;
		});

	if (miscMenuChoice === strings.manageNodePackages()) {
		if (settings.manageNodePackages === 1) {
			settings.manageNodePackages = -1;
		} else {
			settings.manageNodePackages += 1;
		}
		miscMenuChoice = strings.manageNodePackages();
	} else if (miscMenuChoice === strings.WatcherLiveReload()) {
		settings.WatcherLiveReload = !settings.WatcherLiveReload;
		miscMenuChoice = strings.WatcherLiveReload();
	} else if (miscMenuChoice === strings.compilerCopyToFCHost()) {
		settings.compilerCopyToFCHost = !settings.compilerCopyToFCHost;
		miscMenuChoice = strings.compilerCopyToFCHost();
	} else if (miscMenuChoice === "Back") {
		miscMenuChoice = 0;
		return;
	} else {
		throw new Error("Invalid misc menu choice: " + miscMenuChoice);
	}

	await MiscSettings();
}

let mainMenuChoice;

async function mainMenu() {
	let choices = [
		"Edit Compiler Settings",
		"Edit Sanity Check Settings",
		"Edit Miscellaneous Settings",
	];
	if (JSON.stringify(settings) !== JSON.stringify(originalSettings)) {
		choices.push("Save Settings");
		choices.push("Exit without saving settings");
	} else {
		choices.push("Exit");
	}

	await inquirer
		.prompt([{
			type: "rawlist",
			name: "choice",
			message: "Welcome to FC development! Here you can change the settings that are used for the compiler and sanity checks.",
			choices: choices,
			default: mainMenuChoice,
			loop: false,
			pageSize: 11
		}])
		.then((answers) => {
			mainMenuChoice = answers.choice;
		});

	if (mainMenuChoice === "Edit Compiler Settings") {
		await compilerSettings();
	} else if (mainMenuChoice === "Edit Sanity Check Settings") {
		await sanityCheckSettings();
	} else if (mainMenuChoice === "Edit Miscellaneous Settings") {
		await MiscSettings();
	} else if (mainMenuChoice === "Save Settings") {
		jetpack.write("settings.json", settings, {atomic: true});
		originalSettings = JSON.parse(JSON.stringify(settings));
	} else if (
		mainMenuChoice === "Exit without saving settings" ||
        mainMenuChoice === "Exit"
	) {
		process.exit(0);
	} else {
		throw new Error("Invalid main menu choice: " + mainMenuChoice);
	}

	mainMenuChoice = choices.indexOf(mainMenuChoice);

	await mainMenu();
}

// @ts-ignore
await mainMenu();


code:
/**
 * @file Spell checks files using cspell
 * Only checks .js and .md files
 *
 * Can be called from the command line
 *
 * node devTools/scripts/spellingChecks.js
 * node devTools/scripts/spellingChecks.js --changed
 *
 * Or imported as shown below
 *
 * import spellingChecks from './spellingChecks.js';
 * const changed = false;
 * /** @type{Array<string>} *\/
 * const report = spellingChecks(changed);
 */

import detectChanges from './detectChanges.js';

import {execSync} from 'child_process';
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {resolve} from 'path';
import {fileURLToPath} from 'url';
// @ts-ignore
import jetpack from 'fs-jetpack';
import stripAnsi from "strip-ansi";

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('changed', {
		type: 'boolean',
		description: 'Only check changed files',
		default: false,
	})
	.parse();

/**
 * Runs spelling checks of files
 * @param {boolean} changed If true, only check changed files. If false, check all files.
 * @param {string[]} files If provided we will sent this list of files to cSpell instead
 * @param {detectChanges} parser If provided it will be used instead of the default parser
 * @returns {Array<string>}
 */
function spellingChecks(changed = false, files = undefined, parser = detectChanges) {
	const cspellPath = "cspell.json";

	// sort words in cspell.json by alphabetical order
	// also makes them lowercase
	if (jetpack.exists(cspellPath) === "file") {
		const cspell = jetpack.read(cspellPath, "json");
		let changed = false;

		// if cspell doesn't have a words property create it
		if (!cspell.hasOwnProperty("words")) {
			cspell.words = [];
		}

		// if cspell doesn't have a ignoreWords property create it
		if (!cspell.hasOwnProperty("ignoreWords")) {
			cspell.ignoreWords = [];
		}

		// if cspell doesn't have a flagWords property create it
		if (!cspell.hasOwnProperty("flagWords")) {
			cspell.flagWords = [];
		}

		// make a copy of words, ignoredWords, and flagWords for reference
		const words = JSON.parse(JSON.stringify(cspell.words));
		const ignoreWords = JSON.parse(JSON.stringify(cspell.ignoreWords));
		const flagWords = JSON.parse(JSON.stringify(cspell.flagWords));

		// cspell.json to lower case
		cspell.words = cspell.words.map(word => word.toLowerCase());
		cspell.ignoreWords = cspell.ignoreWords.map(word => word.toLowerCase());

		// sort cspell.json by alphabetically order
		cspell.words.sort();
		cspell.ignoreWords.sort();
		cspell.flagWords.sort();

		if (
			!words.every((value, index) => value === cspell.words[index]) ||
			!ignoreWords.every((value, index) => value === cspell.ignoreWords[index]) ||
			!flagWords.every((value, index) => value === cspell.flagWords[index])
		) {
			changed = true;
		}

		if (changed === true) {
			// save cspell.json
			jetpack.write(cspellPath, cspell, {jsonIndent: 4, atomic: true});
		}
	}

	/** @type {Array<string>} */
	let errors = [];

	let command = `npx cspell --show-context --show-suggestions --gitignore --color "**/*.{js,md}"`;

	if (files === undefined && changed === true) {
		let changedFiles = parser.changedFiles();
		if (changedFiles !== null) {
			// only change files
			let filesToCheck = changedFiles;
			// filter out any files that are not JavaScript or markdown files
			filesToCheck = filesToCheck.filter(file => file.endsWith(".js") || file.endsWith(".md"));
			// if no files return errors
			if (filesToCheck.length === 0) {
				console.log("Only checking changed files compared to pregmodfan/fc-pregmod");
				return errors;
			}
			command = `npx cspell --show-context --show-suggestions --gitignore --color "${filesToCheck.join('" "')}"`;
		} else {
			// There are no changed files. Return an empty array
			return [];
		}
	} else if (files !== undefined) {
		// make sure files is an array of strings
		if (Object.prototype.toString.call(files) !== '[object Array]' || files.every(i => typeof i !== "string")) {
			throw new Error("files must be an array of strings or undefined");
		}
		command = `npx cspell --show-context --show-suggestions --gitignore --color "${files.join('" "')}"`;
	}

	if (files === undefined && changed === false) {
		console.log("Running cSpell on all files, this may take a while...");
	} else if (files === undefined) {
		console.log("Running cSpell on changed files compared to pregmodfan/fc-pregmod");
	} else {
		console.log(`Running cSpell on ${files.length} file(s)`);
	}

	// cSpell will error if there are any misspelled words
	let result = "";
	try {
		result = execSync(command, {maxBuffer: 1024 * 1024 * 1024}).toString();
	} catch (e) {
		e.output.forEach(out => {
			if (out === null) { return; }
			result += "\n" + out.toString();
		});
	}

	errors = result.split("\n").filter(
		line => stripAnsi(line.trim()).startsWith(".")
	);

	// remove duplicates from errors
	errors = [...new Set(errors)];

	if (changed === true) {
		const changedLines = parser.changedLines();

		if (changedLines === null) {
			return errors;
		}

		errors = errors.filter(error => {
			let cleanError = stripAnsi(error);
			// filter out errors for unchanged lines
			// file path is from the start of the line to :
			let file = cleanError.match(/^[^:]+/)[0].slice(2);
			if (file in changedLines) {
				// line is from first : to second :
				let lineNumber = cleanError.split(":")[1];
				if (lineNumber in changedLines[file]) {
					return true;
				}
			}
			return false;
		});
	}

	return errors;
}

// @ts-ignore
const pathToThisFile = resolve(fileURLToPath(import.meta.url));
const pathPassedToNode = resolve(process.argv[1]);

if (pathToThisFile.includes(pathPassedToNode)) {
	const initializedParser = detectChanges;
	const report = spellingChecks(args.changed, undefined, initializedParser);
	if (report.length > 0) {
		console.log("");
		console.log(report.join("\n"));
		console.log("");
		console.log(`cSpell found ${report.length} issues.`);
	} else {
		console.log("");
		console.log("cSpell found no issues.");
	}
}

export default spellingChecks;


code:
/**
 * @file Checks JavaScript files for type errors
 * Only checks .js files
 *
 * Can be called from the command line
 *
 * node devTools/scripts/typescriptChecks.js
 * node devTools/scripts/typescriptChecks.js --changed
 *
 * Or imported as shown below
 *
 * import typescriptChecks from './typescriptChecks.js';
 * const changed = false;
 * /** @type{Array<string>} *\/
 * const report = typescriptChecks(changed);
 */

import detectChanges from './detectChanges.js';

import {execSync} from 'child_process';
import yargs from "yargs";
import {hideBin} from "yargs/helpers";
import {resolve} from 'path';
import {fileURLToPath} from 'url';
import stripAnsi from "strip-ansi";

const args = yargs(hideBin(process.argv))
	.showHelpOnFail(true)
	.option('changed', {
		type: 'boolean',
		description: 'Only check changed files',
		default: false,
	})
	.parse();

/**
 * Runs the typescript compiler on files
 * @param {boolean} changed If true, only check changed files. If false, check all files.
 * @param {string[]} files If provided we will sent this list of files to the extra checks instead
 * @param {detectChanges} parser If provided it will be used instead of the default parser
 * @returns {Array<string>}
 */
function typescriptChecks(changed = false, files = undefined, parser = detectChanges) {
	if (files !== undefined) {
		// make sure files is an array of strings
		if (Object.prototype.toString.call(files) !== '[object Array]' || files.every(i => typeof i !== "string")) {
			throw new Error("files must be an array of strings or undefined");
		}
	}

	/** @type {Array<string>} */
	let errors = [];

	let command = `npx tsc`;

	console.log("If this is the first time you have ran the TypeScript compiler, then this will take a while...");

	// the TypeScript compiler will error if there are any issues
	let result = "";
	try {
		result = execSync(command, {maxBuffer: 1024 * 1024 * 1024}).toString();
	} catch (e) {
		e.output.forEach(out => {
			if (out === null) { return; }
			result += "\n" + out.toString();
		});
	}

	// for each line
	// if line contains 2 or more ": " then it is the start of an issue
	// it and all of the lines up to the next 2x ": " line should be part of the same error message
	// issue example: src/player/js/enslavePlayer.js(129,15): error TS2339: Property 'rumor' does not exist on type 'SlaveState'.
	let lastError = "";

	result.split("\n").forEach(line => {
		line = stripAnsi(line).trimEnd();
		if (line !== "") {
			let count = line.match(/: /g);
			if (count !== null && count.length >= 2) {
				if (lastError !== "") {
					errors.push(lastError);
				}
				lastError = line;
			} else {
				lastError += "\n" + line;
			}
		}
	});

	// remove duplicates from errors
	errors = [...new Set(errors)];

	if (changed === true) {
		if (files === undefined) {
			files = [];
		}
		const changedLines = parser.changedLines();

		if (changedLines === null) {
			return errors;
		}

		// issue example: src/player/js/enslavePlayer.js(129,15): error TS2339: Property 'rumor' does not exist on type 'SlaveState'.
		errors = errors.filter(error => {
			// filter out errors for unchanged lines
			// file path is from the start of the line to (
			let file = error.match(/^[^\\(]+/)[0];
			if (file in changedLines) {
				// line number and column is from first ( to first ,
				let lineNumber = error.split("(")[1].split(",")[0];
				if (lineNumber in changedLines[file]) {
					return true;
				}
			} else if (file in files) {
				// I don't think it is actually possible to get here because of how git handles staged and changed files
				// but better safe then sorry
				return true;
			}
			return false;
		});
	}

	return errors;
}

// @ts-ignore
const pathToThisFile = resolve(fileURLToPath(import.meta.url));
const pathPassedToNode = resolve(process.argv[1]);

if (pathToThisFile.includes(pathPassedToNode)) {
	const initializedParser = detectChanges;
	const report = typescriptChecks(args.changed, undefined, initializedParser);
	if (report.length > 0) {
		console.log("");
		console.log(report.join("\n"));
		console.log("");
		console.log(`The TypeScript compiler found ${report.length} issues.`);
	} else {
		console.log("");
		console.log("The TypeScript compiler found no issues.");
	}
}

export default typescriptChecks;


code:
/**
 * @file Watches for changes in the project, when changes occur we run the advanced compiler
 */

// @ts-ignore
import jetpack from "fs-jetpack";
import watch from "node-watch";
import {execSync, spawn} from "child_process";
import * as path from "path";
import * as http from "http";
import {createHash} from "crypto";
// @ts-ignore
import colors from "ansi-colors";

const batSh = (process.platform === "win32") ? "bat" : "sh";

/** @type {import("child_process").ChildProcessWithoutNullStreams} */
let buildProcess;
/** @type {import("child_process").ChildProcessWithoutNullStreams} */
let sanityCheckProcess;

// make sure settings.json exists and has all the required properties
execSync("node devTools/scripts/setup.js --settings");

// load settings.json
/** @type {import("./setup.js").Settings} */
let settings = jetpack.read("settings.json", "json");

/**
 * @param {string} fPath path to check
 * @returns {boolean} true if a path is allowed, false otherwise
 */
function allowed(fPath) {
	if (fPath.endsWith("fc-version.js.commitHash.js")) {
		return false;
	} else if (
		fPath.startsWith("src") ||
		fPath.startsWith("js") ||
		fPath.startsWith("css") ||
		fPath.startsWith("mods") ||
		fPath.startsWith("themes") ||
		fPath.startsWith("tests") ||
		fPath.startsWith("resources") ||
		fPath.startsWith("settings.json") ||
		fPath.startsWith(`devTools${path.sep}scripts`) ||
		fPath.startsWith("gulpfile.js")
	) {
		return true;
	}
	return false;
}

let hashes = {};

let needsReloaded = false;

if (settings.WatcherLiveReload === true) {
	// start http server for live reloading
	let app = http.createServer((req, res) => {
		res.setHeader("Content-Type", "application/json");
		res.setHeader("Access-Control-Allow-Origin", "*");
		res.end(JSON.stringify({needsReloaded: needsReloaded}, null, 2));
		needsReloaded = false;
	});
	app.listen(16969);
	console.log("Live reload server listening on port 16969");
} else {
	console.log("Live reloading is disabled");
}

/**
 * runs the compiler
 * @param {boolean} andSanity if true then we run sanity checks after compiling
 */
function runCompiler(andSanity = false) {
	console.log("Running the compiler...");
	buildProcess = spawn(
		"node",
		[
			"devTools/scripts/advancedCompiler.js", "--filename=FC_pregmod.watcher.html",
			"--no-interaction", "--skip-sanity-checks", "--called-by-watcher"
		],
		{
			stdio: ['inherit', 'inherit', 'inherit'],
		}
	);
	buildProcess.on('exit', function(code) {
		if (code === null) {
			return;
		} else if (code === 0) {
			console.log(colors.blue(`Saving changes in "setup.${batSh}" will toggle a rebuild using the new settings`));
			if (settings.WatcherLiveReload === true) {
				// trigger a reload
				needsReloaded = true;
			}
			if (andSanity === true) {
				runSanity();
			}
		} else {
			console.log(colors.red(`Compiler exited with code: ${code.toString()}`));
		}
	});
}

/**
 * runs the sanity checker
 * @param {boolean} andCompiler if true then we run compiler after doing sanity checks
 */
function runSanity(andCompiler = false) {
	console.log("Running sanity checks...");
	sanityCheckProcess = spawn(
		"node",
		[ "devTools/scripts/sanityCheck.js", "--no-interaction"],
		{
			stdio: ['inherit', 'inherit', 'inherit'],
		}
	);
	sanityCheckProcess.on('exit', function(code) {
		if (code === null) {
			return;
		} else {
			if (code !== 0) {
				console.log(colors.red(`Sanity checker exited with code: ${code.toString()}`));
			}
			if (andCompiler === true) {
				runCompiler();
			}
		}
	});
}

/**
 * Builds FC using the advanced compiler
 */
function build() {
	console.log("");

	if (buildProcess !== undefined) {
		buildProcess.kill();
	}
	if (sanityCheckProcess !== undefined) {
		sanityCheckProcess.kill();
	}

	// set needsReloaded to false to stop unnecessary reloads
	needsReloaded = false;

	if (settings.compilerRunSanityChecks === 1) {
		// sanity checks first then compile
		runSanity(true);
	} else if (settings.compilerRunSanityChecks === 2) {
		// compile first then sanity checks
		runCompiler(true);
	} else {
		// compile without sanity checks
		runCompiler(false);
	}
}

console.log(colors.blue(`The watcher will save its output to "bin/FC_pregmod.watcher.html"`));
if (settings.compilerCopyToFCHost === true && jetpack.exists(settings.FCHostPath) === "dir") {
	console.log(colors.blue(`The watcher's output will be copied to "${settings.FCHostPath}"`));
}
console.log("Watcher is starting, please wait...");

// hash all files
jetpack.find(".").forEach(file => {
	if (!allowed(file)) { return; }
	// console.log(`Hashing "${file}"`);
	try {
		hashes[file] = createHash("sha256").update(jetpack.read(file, "utf8")).digest("base64");
	} catch (e) {
		// fail silently
	}
});

const watcher = watch(".", {
	recursive: true,
	// eslint-disable-next-line jsdoc/require-jsdoc
	filter(f, skip) {
		if (
			allowed(f)
		) {
			return true;
		} else if (jetpack.exists(f) === "dir") {
			return skip;
		} else {
			return false;
		}
	}
});

watcher.on("change", function(event, filename) {
	filename = filename.toString();

	if (event === "update" && filename && jetpack.exists(filename) === "file") {
		try {
			let hash = createHash("sha256").update(jetpack.read(filename, "utf8")).digest("base64");
			if (!(filename in hashes) || hash !== hashes[filename]) {
				console.log("");
				console.log(filename + " changed");
				if (filename === "settings.json") {
					// reload settings.json
					console.log("Reloading settings");
					/** @type {import("./setup.js").Settings} */
					settings = jetpack.read("settings.json", "json");
				}
				hashes[filename] = hash;
				build();
			}
		} catch (e) {
			console.log(e);
		}
	}
});

console.log("Watching for changes");
console.log("");

// run build when we first startup
build();


code:
/** @file This is injected into FC to allow the watchers live reload functionality */

const liveReloadUrl = "http://localhost:16969";

function checkForLiveReload() {
	try {
		$.getJSON(liveReloadUrl, function(data) {
			if (data !== undefined && "needsReloaded" in data && data["needsReloaded"] === true) {
				// reload
				location.reload();
			}
		});
	} catch (e) {
		// fail silently
		// this can happen for many reasons. The main one being that the server has been closed while FC has been left open
	}
}

// query server every 2 seconds and see if we need to reload
window.setInterval(checkForLiveReload, 2000);