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
JavaScript
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)
}
}