UNPKG

hackmud-script-manager

Version:

Script manager for game hackmud, with minification, TypeScript support, and player script type definition generation.

487 lines (486 loc) 21.6 kB
#!/usr/bin/env node import { AutoMap } from "@samual/lib/AutoMap" import { assert } from "@samual/lib/assert" import { countHackmudCharacters } from "@samual/lib/countHackmudCharacters" import { writeFilePersistent } from "@samual/lib/writeFilePersistent" import { writeFile, readFile } from "fs/promises" import { homedir } from "os" import { resolve, extname, basename, dirname, relative } from "path" import { supportedExtensions } from "../constants.js" import { generateTypeDeclaration } from "../generateTypeDeclaration.js" import { pull } from "../pull.js" import { syncMacros } from "../syncMacros.js" import "@samual/lib/readDirectoryWithStats" import "@samual/lib/copyFilePersistent" const formatOption = name => colourN(`-${1 == name.length ? "" : "-"}${name}`), options = new Map(), commands = [], userColours = new AutoMap(user => { let hash = 0 for (const char of user) hash += (hash >> 1) + hash + "xi1_8ratvsw9hlbgm02y5zpdcn7uekof463qj".indexOf(char) + 1 return [colourJ, colourK, colourM, colourW, colourL, colourB][hash % 6](user) }), log = message => console.log(colourS(message)) for (const argument of process.argv.slice(2)) if ("-" == argument[0]) { const argumentEqualsIndex = argument.indexOf("=") let key, value if (-1 == argumentEqualsIndex) { key = argument value = !0 } else { key = argument.slice(0, argumentEqualsIndex) value = argument.slice(argumentEqualsIndex + 1) "true" == value ? (value = !0) : "false" == value && (value = !1) } if ("-" == argument[1]) options.set(key.slice(2), value) else for (const option of key.slice(1)) options.set(option, value) } else commands.push(argument) const pushModule = import("../push.js"), processScriptModule = import("../processScript/index.js"), watchModule = import("../watch.js"), chokidarModule = import("chokidar"), { default: chalk } = await import("chalk"), colourA = chalk.rgb(255, 255, 255), colourB = chalk.rgb(202, 202, 202), colourC = chalk.rgb(155, 155, 155), colourD = chalk.rgb(255, 0, 0), colourF = chalk.rgb(255, 128, 0), colourJ = chalk.rgb(255, 244, 4), colourK = chalk.rgb(243, 249, 152), colourL = chalk.rgb(30, 255, 0), colourM = chalk.rgb(179, 255, 155), colourN = chalk.rgb(0, 255, 255), colourS = chalk.rgb(122, 178, 244), colourV = chalk.rgb(255, 0, 236), colourW = chalk.rgb(255, 150, 224) if (process.version.startsWith("v21.")) { process.exitCode = 1 console.warn( colourF( `${chalk.bold("Warning:")} Support for Node.js 21 will be dropped in the next minor version of HSM\n Your current version of Node.js is ${chalk.bold(process.version)}\n You should update your version of Node.js\n https://nodejs.org/en/download/package-manager\n` ) ) } if ("v" == commands[0] || "version" == commands[0] || popOption("version", "v")?.value) { console.log("0.21.2") process.exit() } let warnedDeprecatedEmitDtsAlias = !1 if (popOption("help", "h")?.value) { logHelp() process.exit() } let autoExit = !0 switch (commands[0]) { case "push": case "dev": case "watch": case "golf": case "minify": { const noMinifyOption = popOption("no-minify", "skip-minify") if (noMinifyOption && "no-minify" != noMinifyOption.name) { process.exitCode = 1 console.warn( colourF( `${chalk.bold("Warning:")} ${formatOption(noMinifyOption.name)} is deprecated and will be removed in the next minor\n release of HSM\n You should switch to using its alias ${colourN("--no-minify")}\n` ) ) } const mangleNamesOption = popOption("mangle-names"), forceQuineCheatsOption = popOption("force-quine-cheats"), noQuineCheatsOptions = popOption("no-quine-cheats"), noMinifyIncompatibleOption = mangleNamesOption || forceQuineCheatsOption || noQuineCheatsOptions if (noMinifyOption && noMinifyIncompatibleOption) { logError( `Options ${formatOption(noMinifyOption.name)} and ${formatOption(noMinifyIncompatibleOption.name)} are incompatible\n` ) logHelp() process.exit(1) } if (forceQuineCheatsOption && noQuineCheatsOptions) { logError( `Options ${formatOption(forceQuineCheatsOption.name)} and ${formatOption(noQuineCheatsOptions.name)} are incompatible\n` ) logHelp() process.exit(1) } noMinifyOption && assertOptionIsBoolean(noMinifyOption) mangleNamesOption && assertOptionIsBoolean(mangleNamesOption) forceQuineCheatsOption && assertOptionIsBoolean(forceQuineCheatsOption) noQuineCheatsOptions && assertOptionIsBoolean(noQuineCheatsOptions) const rootFolderPathOption = popOption("root-folder-path"), rootFolderPath = rootFolderPathOption && resolve(rootFolderPathOption.value + "") if ("golf" == commands[0] || "minify" == commands[0]) { const watchOption = popOption("watch"), target = commands[1] if (!target) { logError("Must provide target\n") logHelp() process.exit(1) } const fileExtension = extname(target) if (!supportedExtensions.includes(fileExtension)) { logError( `Unsupported file extension "${chalk.bold(fileExtension)}"\nSupported extensions are "${supportedExtensions.map(extension => chalk.bold(extension)).join('", "')}"` ) process.exit(1) } complainAboutUnrecognisedOptions() const { processScript } = await processScriptModule, fileBaseName = basename(target, fileExtension), fileBaseNameEndsWithDotSrc = fileBaseName.endsWith(".src"), scriptName = fileBaseNameEndsWithDotSrc ? fileBaseName.slice(0, -4) : fileBaseName, scriptUser = "scripts" == basename(resolve(target, "..")) && "hackmud" == basename(resolve(target, "../../..")) ? basename(resolve(target, "../..")) : void 0 let outputPath = commands[2] || resolve( dirname(target), fileBaseNameEndsWithDotSrc ? scriptName + ".js" : ".js" == fileExtension ? fileBaseName + ".min.js" : fileBaseName + ".js" ) const golfFile = () => readFile(target, { encoding: "utf8" }).then(async source => { const timeStart = performance.now(), { script, warnings } = await processScript(source, { minify: noMinifyOption && !noMinifyOption.value, scriptUser, scriptName, filePath: target, mangleNames: mangleNamesOption?.value, forceQuineCheats: forceQuineCheatsOption?.value ?? !noQuineCheatsOptions?.value, rootFolderPath }), timeTook = performance.now() - timeStart warnings.length && (process.exitCode = 1) for (const { message } of warnings) console.warn(colourF(`${chalk.bold("Warning:")} ${message}`)) await writeFilePersistent(outputPath, script) .catch(error => { if (!commands[2] || "EISDIR" != error.code) throw error outputPath = resolve(outputPath, basename(target, fileExtension) + ".js") return writeFilePersistent(outputPath, script) }) .then(() => log( `Wrote ${chalk.bold(countHackmudCharacters(script))} chars to ${chalk.bold(relative(".", outputPath))} | took ${Math.round(100 * timeTook) / 100}ms` ) ) }) if (watchOption) { const { watch: watchFile } = await chokidarModule watchFile(target, { awaitWriteFinish: { stabilityThreshold: 100 } }) .on("ready", () => log("Watching " + target)) .on("change", golfFile) autoExit = !1 } else await golfFile() } else { const hackmudPath = getHackmudPath(), sourcePath = commands[1] if (!sourcePath) { logError(`Must provide the directory to ${"push" == commands[0] ? "push from" : "watch"}\n`) logHelp() process.exit(1) } const scripts = commands.slice(2) if (scripts.length) { const invalidScript = scripts.find( script => !/^(?:[a-z_][a-z\d_]{0,24}|\*)\.(?:[a-z_][a-z\d_]{0,24}|\*)$/.test(script) ) if (invalidScript) { logError(`Invalid script name: ${JSON.stringify(invalidScript)}\n`) logHelp() process.exit(1) } } else scripts.push("*.*") const watchOption = popOption("watch") if ("push" != commands[0] || watchOption?.value) { const dtsPathOption = popOption( "dts-path", "type-declaration-path", "type-declaration", "dts", "gen-types" ) if ( dtsPathOption && "dts-path" != dtsPathOption.name && "type-declaration-path" != dtsPathOption.name ) { process.exitCode = 1 console.warn( colourF( `${chalk.bold("Warning:")} ${formatOption(dtsPathOption.name)} is deprecated and will be removed in the\n next minor release of HSM\n You should switch to using its alias ${colourN("--dts-path")}\n` ) ) } complainAboutUnrecognisedOptions() const { watch } = await watchModule watch(sourcePath, hackmudPath, { scripts, onPush: info => logInfo(info, hackmudPath), typeDeclarationPath: dtsPathOption?.value.toString(), minify: noMinifyOption && !noMinifyOption.value, mangleNames: mangleNamesOption?.value, onReady: () => log("Watching"), forceQuineCheats: forceQuineCheatsOption?.value ?? !noQuineCheatsOptions?.value, rootFolderPath }) autoExit = !1 } else { const dtsPathOption = popOption("dts-path") complainAboutUnrecognisedOptions() let declarationPathPromise if (dtsPathOption) { if ("string" != typeof dtsPathOption.value) { logError( `Option ${formatOption(dtsPathOption.name)} must be a string, got ${colourV(dtsPathOption.value)}\n` ) logHelp() process.exit(1) } let typeDeclarationPath = resolve(dtsPathOption.value) const typeDeclaration = await generateTypeDeclaration(sourcePath, hackmudPath) declarationPathPromise = writeFile(typeDeclarationPath, typeDeclaration) .catch(error => { assert(error instanceof Error, "src/bin/hsm.ts:299:38") if ("EISDIR" != error.code) throw error typeDeclarationPath = resolve(typeDeclarationPath, "player.d.ts") return writeFile(typeDeclarationPath, typeDeclaration) }) .then(() => typeDeclarationPath) } const { push, MissingSourceFolderError, MissingHackmudFolderError, NoUsersError } = await pushModule, infos = await push(sourcePath, hackmudPath, { scripts, onPush: info => logInfo(info, hackmudPath), minify: noMinifyOption && !noMinifyOption.value, mangleNames: mangleNamesOption?.value, forceQuineCheats: forceQuineCheatsOption?.value ?? !noQuineCheatsOptions?.value, rootFolderPath }) if (infos instanceof Error) { logError(infos.message) if (infos instanceof MissingSourceFolderError || infos instanceof NoUsersError) { console.log() logHelp() } else infos instanceof MissingHackmudFolderError && log( `If this is not where your hackmud folder is, you can specify it with the\n${colourN("--hackmud-path")}=${colourB("<path>")} option or ${colourN("HSM_HACKMUD_PATH")} environment variable` ) } else infos.length || logError("Could not find any scripts to push") declarationPathPromise && log("Wrote type declaration to " + chalk.bold(await declarationPathPromise)) } } } break case "pull": { const hackmudPath = getHackmudPath(), script = commands[1] if (!script) { logError("Must provide the script to pull\n") logHelp() process.exit(1) } complainAboutUnrecognisedOptions() const sourcePath = commands[2] || "." await pull(sourcePath, hackmudPath, script).catch(error => { console.error(error) logError(`Something went wrong, did you forget to ${colourC("#down")} the script?`) }) } break case "sync-macros": { const hackmudPath = getHackmudPath() complainAboutUnrecognisedOptions() const { macrosSynced, usersSynced } = await syncMacros(hackmudPath) log(`Synced ${macrosSynced} macros to ${usersSynced} users`) } break case "generate-type-declaration": case "gen-type-declaration": case "gen-dts": case "gen-types": case "emit-dts": { if ("emit-dts" != commands[0] && "gen-dts" != commands[0]) { warnedDeprecatedEmitDtsAlias = !0 process.exitCode = 1 console.warn( colourF( `${chalk.bold("Warning:")} ${colourC("hsm")} ${colourL(commands[0])} is deprecated and will be removed\n in the next minor release of HSM\n You should switch to using its alias ${colourC("hsm")} ${colourL("emit-dts")}\n` ) ) } const hackmudPath = getHackmudPath(), target = commands[1] if (!target) { logError("Must provide target directory\n") logHelp() process.exit(1) } complainAboutUnrecognisedOptions() const sourcePath = resolve(target), outputPath = commands[2] || "./player.d.ts", typeDeclaration = await generateTypeDeclaration(sourcePath, hackmudPath) let typeDeclarationPath = resolve(outputPath) await writeFile(typeDeclarationPath, typeDeclaration).catch(error => { assert(error instanceof Error, "src/bin/hsm.ts:438:35") if ("EISDIR" != error.code) throw error typeDeclarationPath = resolve(typeDeclarationPath, "player.d.ts") return writeFile(typeDeclarationPath, typeDeclaration) }) log("Wrote type declaration to " + chalk.bold(typeDeclarationPath)) } break case "help": logHelp() break default: commands[0] && logError(`Unknown command: ${colourL(commands[0])}\n`) logHelp() } autoExit && process.exit() function logHelp() { const pushCommandDescription = "Push scripts from a directory to hackmud user's scripts directories", hackmudPathOption = `${colourN("--hackmud-path")}=${colourB("<path>")}\n Override hackmud path` switch (commands[0]) { case "dev": case "watch": case "push": console.log( colourS( `${colourJ("push" == commands[0] ? pushCommandDescription : "Watch a directory and push a script when modified")}\n\n${colourA("Usage:")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourB('<directory> ["<script user>.<script name>"...]')}\n\n${colourA("Arguments:")}\n${colourB("<directory>")}\n The source directory containing your scripts\n${colourB("<script user>")}\n A user to push script(s) to. Can be set to wild card (${colourV("*")}) which will try\n and discover users to push to\n${colourB("<script name>")}\n Name of a script to push. Can be set to wild card (${colourV("*")}) to find all scripts\n\n${colourA("Options:")}\n${colourN("--no-minify")}\n Skip minification to produce a "readable" script\n${colourN("--mangle-names")}\n Reduce character count further but lose function names in error call stacks\n${colourN("--force-quine-cheats")}, ${colourN("--no-quine-cheats")}\n Force quine cheats on or off\n${hackmudPathOption}\n${colourN("--dts-path")}=${colourB("<path>")}\n Path to generate a type declaration (.d.ts) file for the scripts\n${colourN("--watch")}\n Watch for changes\n${colourN("--root-folder-path")}\n The folder that root will be aliased to in import statements\n\n${colourA("Examples:")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")}\n Pushes all scripts found in ${colourV("src")} folder to all users\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")} ${colourC("foo")}${colourV(".")}${colourL("bar")}\n Pushes a script named ${colourL("bar")} found in ${colourV("src")} folder to user ${userColours.get("foo")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")} ${colourC("foo")}${colourV(".")}${colourL("bar")} ${colourC("baz")}${colourV(".")}${colourL("qux")}\n Multiple can be specified\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")} ${colourC("foo")}${colourV(".")}${colourL("*")}\n Pushes all scripts found in ${colourV("src")} folder to user ${userColours.get("foo")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")} ${colourC("*")}${colourV(".")}${colourL("foo")}\n Pushes all scripts named ${colourL("foo")} found in ${colourV("src")} folder to all user\n${colourC("hsm")} ${colourL(commands[0])} ${colourV("src")} ${colourC("*")}${colourV(".")}${colourL("*")}\n Pushes all scripts found in ${colourV("src")} folder to all users` ) ) break case "pull": console.log( colourS( `${colourJ("Pull a script a from a hackmud user's script directory")}\n\n${colourA("Usage:")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourB("<script user>")}${colourV(".")}${colourB("<script name>")}\n\n${colourA("Options:")}\n${hackmudPathOption}` ) ) break case "minify": case "golf": console.log( colourS( `${colourJ("Minify a script file on the spot")}\n\n${colourA("Usage:")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourB("<target> [output path]")}\n\n${colourA("Options:")}\n${colourN("--no-minify")}\n Skip minification to produce a "readable" script\n${colourN("--mangle-names")}\n Reduce character count further but lose function names in error call stacks\n${colourN("--force-quine-cheats")}, ${colourN("--no-quine-cheats")}\n Force quine cheats on or off\n${colourN("--watch")}\n Watch for changes\n${colourN("--root-folder-path")}\n The folder that root will be aliased to in import statements` ) ) break case "generate-type-declaration": case "gen-type-declaration": case "gen-dts": case "gen-types": case "emit-dts": if (!warnedDeprecatedEmitDtsAlias && "emit-dts" != commands[0] && "gen-dts" != commands[0]) { process.exitCode = 1 console.warn( colourF( `${chalk.bold("Warning:")} ${colourC("hsm")} ${colourL(commands[0])} is deprecated and will be removed\n in the next minor release of HSM\n You should switch to using its alias ${colourC("hsm")} ${colourL("emit-dts")}\n` ) ) } console.log( colourS( `${colourJ("Generate a type declaration file for a directory of scripts")}\n\n${colourA("Usage:")}\n${colourC("hsm")} ${colourL(commands[0])} ${colourB("<directory> [output path]")}\n\n${colourA("Options:")}\n${hackmudPathOption}` ) ) break case "sync-macros": console.log( colourS( `${colourJ("Sync macros across all hackmud users")}\n\n${colourA("Options:")}\n${hackmudPathOption}` ) ) break default: console.log( colourS( `${colourJ("Hackmud Script Manager")}\n${colourN("Version") + colourS(": ") + colourV("0.21.2")}\n\n${colourA("Commands:")}\n${colourL("push")}\n ${pushCommandDescription}\n${colourL("minify")}\n Minify a script file on the spot\n${colourL("emit-dts")}\n Generate a type declaration file for a directory of scripts\n${colourL("sync-macros")}\n Sync macros across all hackmud users\n${colourL("pull")}\n Pull a script a from a hackmud user's script directory\n\n${colourA("Options:")}\n${colourN("--help")}\n Can be used on any command e.g. ${colourC("hsm")} ${colourL("push")} ${colourN("--help")} to show helpful information` ) ) } } function logInfo({ path, users, characterCount, error, warnings }, hackmudPath) { path = relative(".", path) if (error) logError(`Error "${chalk.bold(error.message)}" in ${chalk.bold(path)}`) else { warnings.length && (process.exitCode = 1) for (const warning of warnings) console.warn(colourF(`${chalk.bold("Warning:")} ${warning.message}`)) log( `Pushed ${chalk.bold(path)} to ${users.map(user => chalk.bold(userColours.get(user))).join(", ")} | ${chalk.bold(characterCount + "")} chars | ${chalk.bold(resolve(hackmudPath, users[0], "scripts", basename(path, extname(path))) + ".js")}` ) } } function logError(message) { console.error(colourD(message)) process.exitCode = 1 } function getHackmudPath() { const hackmudPathOption = popOption("hackmud-path") if (hackmudPathOption) { if ("string" != typeof hackmudPathOption.value) { logError(`Option ${colourN("--hackmud-path")} must be a string, got ${colourV(hackmudPathOption.value)}\n`) logHelp() process.exit(1) } if (!hackmudPathOption.value) { logError(`Option ${colourN("--hackmud-path")} was specified but empty\n`) logHelp() process.exit(1) } return hackmudPathOption.value } if (null != process.env.HSM_HACKMUD_PATH) { if (!process.env.HSM_HACKMUD_PATH) { logError(`Environment variable ${colourN("HSM_HACKMUD_PATH")} was specified but empty\n`) logHelp() process.exit(1) } return process.env.HSM_HACKMUD_PATH } return "win32" == process.platform ? resolve(process.env.APPDATA, "hackmud") : resolve(homedir(), ".config/hackmud") } function assertOptionIsBoolean(option) { if ("boolean" != typeof option.value) { logError(`The value for ${formatOption(option.name)} must be ${colourV("true")} or ${colourV("false")}\n`) logHelp() process.exit(1) } } function popOption(...names) { const presentOptionNames = names.filter(name => options.has(name)) if (!presentOptionNames.length) return if (presentOptionNames.length > 1) { logError( `The options ${presentOptionNames.map(formatOption).join(", ")} are aliases for each other. Please only specify one` ) process.exit(1) } const value = options.get(presentOptionNames[0]) options.delete(presentOptionNames[0]) return { name: presentOptionNames[0], value } } function complainAboutUnrecognisedOptions() { if (options.size) { logError( `Unrecognised option${options.size > 1 ? "s" : ""}: ${[...options.keys()].map(formatOption).join(", ")}` ) process.exit(1) } }