diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..87fe6af --- /dev/null +++ b/.editorconfig @@ -0,0 +1,2 @@ +[*.coffee] +tab_width = 4 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e395dc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# deno build +bin +tmp + +# bootstrap cache +tools/tmp* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..88b36cc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{"deno.enable":true,"deno.lint":true,"deno.unstable":true} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..19338ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +COPYRIGHT 2022 HEIN THANT MAUNG MAUNG + +REDISTRIBUTION AND USE IN SOURCE AND BINARY FORMS, WITH OR WITHOUT MODIFICATION, ARE PERMITTED PROVIDED THAT THE FOLLOWING CONDITIONS ARE MET: + +1. REDISTRIBUTIONS OF SOURCE CODE MUST RETAIN THE ABOVE COPYRIGHT NOTICE, THIS LIST OF CONDITIONS AND THE FOLLOWING DISCLAIMER. + +2. REDISTRIBUTIONS IN BINARY FORM MUST REPRODUCE THE ABOVE COPYRIGHT NOTICE, THIS LIST OF CONDITIONS AND THE FOLLOWING DISCLAIMER IN THE DOCUMENTATION AND/OR OTHER MATERIALS PROVIDED WITH THE DISTRIBUTION. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..634f5ce --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# XueRun ( 雪 run ) + +Just a make-like task runner but with more power! It's written in CoffeeScript + Deno. + +## How to Build + +First, clone this repo. Then, run "tools/bootstrap.ts" + +```shell +deno run tools/bootstrap.ts +``` + +## License + +XueRun is licensed under BSD-2-Clause. For more, see [LICENSE](LICENSE). diff --git a/src/core.coffee b/src/core.coffee new file mode 100644 index 0000000..5e6d68a --- /dev/null +++ b/src/core.coffee @@ -0,0 +1,104 @@ +import * as eta from "https://deno.land/x/eta@v1.12.3/mod.ts" + +eta.configure + tags: ["{{", "}}"] + useWith: true + parse: { exec: "#", raw: "%", interpolate: "" } + +# magic happens here +export runRecipe = (rc, recipe, options, recon, asIngredient) -> + if not (rc.hasOwnProperty(recipe)) + console.error("\nxuerun: oops, recipe '#{recipe}' is not in .xuerun tasks!\n") + Deno.exit(1) + # resolve dependencies + currentRecipe = rc[recipe] + + currentOption = {} + if typeof currentRecipe.passEnv == "boolean" and currentRecipe.passEnv + currentOption = { ...currentOption, ...Deno.env.toObject() } + else if Array.isArray(currentRecipe.passEnv) + currentOption = { ...currentOption } + currentRecipe.passEnv.forEach (env) -> currentOption[env] = Deno.env.get(env) + else ### do pass env ### + + if asIngredient then currentOption = { ...currentOption, ...options } + else if typeof currentRecipe.passCLI == "boolean" and currentRecipe.passCLI + currentOption = { ...currentOption, ...options } + else if Array.isArray(currentRecipe.passCLI) + # since current task is main task ( not ingredient ), options point to CLI option + currentOption = { ...currentOption } + currentRecipe.passCLI.forEach (opt) -> currentOption[opt] = options[opt] || "" + else ### don't pass options ### + + dependencies = if typeof currentRecipe.dependencies == + "string" then currentRecipe.dependencies.split(" ") else currentRecipe.dependencies + + dependencies.forEach (dep) -> + # won't pass options + if typeof dep == "string" then return runRecipe(rc, dep, {}) + + depOption = { ...dep.options } + if typeof dep.passParentOptions == "boolean" and dep.passParentOptions + depOption = { ...currentOption, ...depOption } + else if Array.isArray(dep.passParentOptions) + dep.passParentOptions.forEach (opt) -> depOption[opt] = currentOption[opt] + else ### do pass parent option ### + + # resolve ( compile ) option value with current option + depOptionToBePassed = {} + Object.entries(depOption).forEach ([option, value]) -> + unless typeof value == "string" then return depOptionToBePassed[option] = value + try depOptionToBePassed[option] = eta.render(value, currentOption) + catch err + console.error("\nxuerun: oops, something went wrong while reading options.\nError:", + err.message, "\n") + Deno.exit(1) + runRecipe(rc, dep.name, depOptionToBePassed, recon, true) + + # make main recipe + commands = currentRecipe.command + (if typeof commands == "string" then [commands] else commands) + .map (cmdOption) -> + try + if typeof cmdOption == "string" then return eta.render(cmdOption, currentOption) + else return {...cmdOption, cmd: eta.render(cmdOption.cmd, currentOption)} + catch err + console.error( + "\nxuerun: oops, something went wrong while reading command.\nError:", + err.message, "\n") + Deno.exit(1) + .forEach (cmdOption) -> + commandToRun = [ + (if typeof cmdOption == "object" and + cmdOption.shell then cmdOption.shell else currentRecipe.shell), "-c", + if typeof cmdOption == "string" then cmdOption else cmdOption.cmd ] + + # if recon, just show command + if recon then return console.info(commandToRun) + + # run command + preparedEnv = {} + Object.entries(currentOption).forEach ([k, v]) -> + preparedEnv[k] = switch + when v == null then "" + when typeof v == "undefined" then "" + else v.toString() + + commandProcess = null + try + commandProcess = Deno.run + cmd: commandToRun + stdin: "inherit" + stdout: "inherit" + stderr: "inherit" + clearEnv: true + env: preparedEnv + catch err + console.error("\nxuerun: Something went wrong while running command", commandToRun) + console.error("Error:", err.message, "\n") + + if commandProcess == null then Deno.exit(1) + status = await commandProcess.status() + if status.code != 0 + console.error("\nxuerun: command exit with exit code:", status.code, "\n") + Deno.exit(status.code) diff --git a/src/schema.coffee b/src/schema.coffee new file mode 100644 index 0000000..f8f79d2 --- /dev/null +++ b/src/schema.coffee @@ -0,0 +1,30 @@ +import { create, defaulted, optional, union, object, record, + array, string, number, boolean, validate } from "https://esm.sh/superstruct"; + +XueRunUserCmd = union([string(), object({ shell: optional(string()), cmd: string() })]); + +XueRunIngredient$option = union([string(), number(), boolean()]) +export XueRunIngredient = object + name: string(), + options: defaulted(optional(record(string(), XueRunIngredient$option)), () => ({})), + passParentOptions: defaulted(optional(union([boolean(), array(string())])), () => !1), + +getCurrentSH = -> if Deno.build.os == "windows" then "cmd" else "sh" + +XueRunRecipe$dependencies = union([string(), array(union([string(), XueRunIngredient]))]) +export XueRunRecipe = object + description: defaulted(optional(string()), () => ""), + shell: defaulted(optional(string()), () => Deno.env.get("SHELL") || getCurrentSH()), + command: defaulted(optional(union([string(), array(XueRunUserCmd)])), () => ""), + passEnv: defaulted(optional(union([boolean(), array(string())])), () => !1), + passCLI: defaulted(optional(union([boolean(), array(string())])), () => !1), + dependencies: defaulted(optional(XueRunRecipe$dependencies), () => []), + +export XueRunConfiguration = record(string(), XueRunRecipe) + +createConfiguration = (userConfig) -> + [err, data] = validate userConfig, XueRunConfiguration + if err then throw err + return create data, XueRunConfiguration + +export default createConfiguration diff --git a/src/xuerun.coffee b/src/xuerun.coffee new file mode 100644 index 0000000..31d83f2 --- /dev/null +++ b/src/xuerun.coffee @@ -0,0 +1,95 @@ +import { parse } from "https://deno.land/std@0.132.0/flags/mod.ts"; +import { parse as parseYAML } from "https://deno.land/std@0.132.0/encoding/yaml.ts" +import { YAMLError } from "https://deno.land/std@0.132.0/encoding/_yaml/error.ts" +import { printf } from "https://deno.land/std@0.132.0/fmt/printf.ts" +import { StructError } from "https://esm.sh/superstruct" +import { runRecipe } from "./core.coffee" +import createConfiguration from "./schema.coffee" + +loadXueRunTasks = (path) -> + try + content = Deno.readTextFileSync(path) + return createConfiguration(parseYAML(content)) + catch error + if error instanceof YAMLError or error instanceof StructError + console.error( + "\nxuerun: oops, invalid .xuerun tasks.\nError:", error.message, "\n") + else console.error( + "\nxuerun: oops, can't read .xuerun tasks.\nError:", error.message, "\n") + Deno.exit(1) + +printVersion = () -> + console.log() # print padding + ascii = [ + 'y88b / 888~-_ ' + ' y88b / 888 888 e88~~8e 888 \\ 888 888 888-~88e' + ' y88b/ 888 888 d888 88b 888 | 888 888 888 888' + ' /y88b 888 888 8888__888 888 / 888 888 888 888' + ' / y88b 888 888 y888 , 888_-~ 888 888 888 888' + '/ y88b "88_-888 "88___/ 888 ~-_ "88_-888 888 888'].join("\n") + console.info("%s\n", ascii) + printf("XueRun v%s ( %s / %s )\n", "0.1.0", Deno.build.os, Deno.build.arch) + currentYear = new Date().getFullYear() + printf("(c) 2022%s Hein Thant Maung Maung. Licensed under BSD-2-CLAUSE.\n\n", + if currentYear == 2022 then "" else " - #{currentYear}") + +printHelp = (shouldPrintVersion = true) -> + if shouldPrintVersion then printVersion() + helpStrings = [ + "General:", + " xuerun [tasks]... [options]...", + "", + "Options:", + " -t, --tasks path to xuerun tasks ( default: tasks.xuerun ).", + " -n, --recon do nothing, print commands." + "", + " -v, --version print xuerun version and others.", + " -h, --help print this help message.", + "", + "For docs, usage, etc., visit https://github.com/heinthanth/xuerun."].join("\n") + console.info(helpStrings, "\n") + +actionKind = + RUN_RECIPE: "RUN_RECIPE" + PRINT_HELP: "PRINT_HELP" + PRINT_VERSION: "PRINT_VERSION" + +parseCmdOption = () -> + {'_': positional, '--': cliRest, ...options} = parse Deno.args, { "--": true } + userOption = + action: actionKind.RUN_RECIPE, + recon: !1, + recipes: [] + options: {} + tasksPath: "tasks.xuerun" + + # parse options + Object.entries(options).forEach ([k, v]) -> switch k + when "h", "help" then userOption.action = actionKind.PRINT_HELP + when "v", "version" then userOption.action = actionKind.PRINT_VERSION + when "t", "tasks" then userOption.tasksPath = + if typeof v == "string" or typeof v == "number" then v.toString() else "tasks.xuerun" + when "n", "recon" then userOption.recon = true + + # remove default CLI arguments + {h, v, t, n, help, version, tasks, recon, ...restCLIargs} = options + # parse options passed with -- --something + {'_': _restPositional, ...optionsFromCLIrestOptions} = parse(cliRest) + # combine options + userOption.options = { ...restCLIargs, ...optionsFromCLIrestOptions } + return { ...userOption, recipes: positional } + +programMain = () -> + {recipes, tasksPath, action, options, recon} = parseCmdOption() + if action == actionKind.PRINT_HELP then return printHelp() + if action == actionKind.PRINT_VERSION then return printVersion() + + # load and run + xueRunRc = loadXueRunTasks(tasksPath) + if recipes.length == 0 + if xueRunRc.hasOwnProperty("all") then return runRecipe(xueRunRc, recipes, userOption) + else console.error("\nxuerun: oops, no recipe given, nothing to do!\n"); Deno.exit(1) + recipes.forEach (recipe) -> runRecipe(xueRunRc, recipe, options, recon, !1) + +# call main function +if import.meta.main then programMain() diff --git a/tools/bootstrap.ts b/tools/bootstrap.ts new file mode 100755 index 0000000..d1409c6 --- /dev/null +++ b/tools/bootstrap.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env + +import { compile } from "https://esm.sh/coffeescript"; +import { join, parse, format } from "https://deno.land/std@0.132.0/path/mod.ts"; + +const __dirname = new URL(".", import.meta.url).pathname; +const compiler = join(__dirname, "build.coffee"); +const { base: _base, ext: _ext, ...rest } = parse(compiler); +const compilerOut = format({ ...rest, name: "tmp_", ext: ".js" }); + +await Deno.writeTextFile(compilerOut, compile(await Deno.readTextFile(compiler))); + +const cmd = ["deno", "run", "--allow-read", + "--allow-write", "--allow-env", "--allow-run", compilerOut]; +const p = Deno.run({ cmd, stderr: "inherit" }); + +const processStatus = await p.status(); +await Deno.remove(compilerOut); +(processStatus.code != 0) && Deno.exit(processStatus.code); diff --git a/tools/build.coffee b/tools/build.coffee new file mode 100644 index 0000000..3f612ef --- /dev/null +++ b/tools/build.coffee @@ -0,0 +1,27 @@ +import { compile } from "https://esm.sh/coffeescript" +import { walkSync, ensureDirSync, emptyDir } from "https://deno.land/std@0.132.0/fs/mod.ts" +import { join, parse, format } from "https://deno.land/std@0.132.0/path/mod.ts" + +__dirname = new URL(".", import.meta.url).pathname +srcPath = join(__dirname, "..", "src") +distPath = join(__dirname, "..", "tmp") +binPath = join(__dirname, "..", "bin", "xuerun") +mainMod = join(distPath, "xuerun.js") + +await emptyDir(distPath) + +Array(...walkSync(srcPath)).forEach (p) -> + dist = p.path.replace(srcPath, distPath) + if (p.isDirectory) then return ensureDirSync(p.path) + content = Deno.readTextFileSync(p.path) + { base: _base, ext: _ext, ...d } = parse(dist) + compiledCode = compile(content, { bare: true }).replace(/\.coffee/g, ".js") + Deno.writeTextFileSync(format({ ...d, ext: ".js" }), compiledCode) + +cmd = ["deno", "compile", "--allow-read", "--allow-write", + "--allow-env", "--allow-run", "--unstable", "--output", binPath, mainMod] +p = Deno.run({ cmd }) + +processStatus = await p.status() +await Deno.remove(distPath, { recursive: true }); +(processStatus.code != 0) and Deno.exit(processStatus.code);