UNPKG

@magicdawn/x-args

Version:

play with cli commands like a composer

264 lines (263 loc) 9.8 kB
import { Command, Option } from "clipanion"; import { z } from "zod"; import assert from "node:assert"; import { execSync } from "node:child_process"; import path from "node:path"; import { Result } from "better-result"; import chalk from "chalk"; import { watch } from "chokidar"; import { debounce, once, uniq } from "es-toolkit"; import logSymbols from "log-symbols"; import ms from "ms"; import PQueue from "p-queue"; import { quote } from "shlex"; import superjson from "superjson"; import boxen$1 from "boxen"; import fse from "fs-extra"; //#region src/util/parse-line.ts function parseLineToArgs(line) { const result = []; const regex = /'([^']*)'|"([^"]*)"|\S+/g; let match; while ((match = regex.exec(line)) !== null) if (match[1] !== void 0) result.push(match[1]); else if (match[2] === void 0) result.push(match[0]); else result.push(match[2]); return result; } //#endregion //#region src/commands/txt-command/api.ts let SessionControl = /* @__PURE__ */ function(SessionControl) { SessionControl["Start"] = "start"; SessionControl["ReStart"] = "restart"; SessionControl["Continue"] = "continue"; return SessionControl; }({}); function applyCommandTemplate(command, { line, args }) { return command.replaceAll(/:rawLine/gi, line).replaceAll(/:line/gi, quote(line)).replaceAll(/:rawArgs?(\d)/gi, (_, index) => args[index]).replaceAll(/:args?(\d)/gi, (_, index) => quote(args[index])); } const defaultTxtCommandContext = { session: "continue" }; const lognsp = "x-args:txt-command"; function assertCommandOrRun({ command, run }) { assert(command || run, "command and run cannot both be undefined"); } async function startTxtCommand(opts) { assertCommandOrRun(opts); const { wait, waitTimeout } = opts; const waitTimeoutMs = waitTimeout ? ms(waitTimeout) : Infinity; if (Number.isNaN(waitTimeoutMs)) throw new TypeError("unrecognized --wait-timeout format, pls check https://npm.im/ms"); const txtFiles = uniq([opts.txtFiles].flat().map((x) => path.resolve(x))); const queue = new PQueue({ concurrency: 1 }); const queueTxtFile = (txtFile) => queue.add(() => startTxtCommandSingleTxtFile(txtFile, opts)); queue.addAll(txtFiles.map((x) => () => startTxtCommandSingleTxtFile(x, opts))); if (!wait) await queue.onIdle(); if (wait) { const watcher = watch(txtFiles).on("change", (changedFile) => { queueTxtFile(changedFile); }); const unwatch = once(() => watcher.close()); process.on("exit", unwatch); const { promise, resolve } = Promise.withResolvers(); const printIdleInfo = debounce(() => { if (queue.pending === 0 && queue.size === 0) { console.log(`\n${logSymbols.info}: Queue ${chalk.green("Idle")}, Waiting for changes to:`); txtFiles.forEach((x) => { console.log(` 📝 %s`, x); }); } }, 2e3); const scheduleExit = debounce(() => { printIdleInfo.cancel(); scheduleExit.cancel(); unwatch(); resolve(); }, waitTimeoutMs); queue.on("idle", () => { printIdleInfo(); if (Number.isFinite(waitTimeoutMs)) scheduleExit(); }).on("active", () => { printIdleInfo.cancel(); scheduleExit.cancel(); }); return promise; } } async function startTxtCommandSingleTxtFile(txtFile, { session, yes, command, run, execOptions }) { console.log(""); console.log(`${chalk.green("[x-args]")}: received`); console.log(` ${chalk.cyan("txt file")}: ${chalk.yellow(txtFile)}`); if (command && typeof command === "string") console.log(` ${chalk.cyan("command")}: ${chalk.yellow(command)}`); console.log(""); const sessionControl = session; const sessionFile = path.join(path.dirname(txtFile), `.x-args-session.${path.basename(txtFile)}`); let processedLines = /* @__PURE__ */ new Set(); if (sessionControl === "start") { if (fse.existsSync(sessionFile) && fse.readFileSync(sessionFile, "utf8").length) { console.error(`session already exists, use \`continue\` or \`restart\``); process.exit(1); } } else if (sessionControl === "restart") { if (fse.existsSync(sessionFile)) fse.removeSync(sessionFile); } else if (sessionControl === "continue" && fse.existsSync(sessionFile)) { const content = fse.readFileSync(sessionFile, "utf8"); if (content) { const result = Result.try(() => superjson.parse(content)); if (result.isErr()) console.error(`${logSymbols.error}: failed to parse broken session file: %s`, sessionFile); else { processedLines = new Set(result.value.processed); console.info(`${chalk.green(`[${lognsp}:session]`)} loaded from file %s`, sessionFile); } } } function saveProcessed() { fse.outputFileSync(sessionFile, superjson.stringify({ processedLines })); } let line; while (line = getTxtFileNextLine(txtFile, processedLines)) { await runSingleLine(txtFile, line, { yes, command, run, execOptions }); processedLines.add(line); if (yes) saveProcessed(); } } function getTxtFileNextLine(txtFile, processedLines) { const lines = fse.readFileSync(txtFile, "utf8").split("\n").map((line) => line.trim()).filter(Boolean).filter((line) => !(line.startsWith("//") || line.startsWith("#"))).filter((line) => !processedLines.has(line)); if (lines.length) return lines[0]; } async function runSingleLine(txtFile, line, { yes, command, run, execOptions }) { assertCommandOrRun({ command, run }); const commandBuilderContext = { applyCommandTemplate, quote, line, args: parseLineToArgs(line) }; const runCommandSync = (cmd) => execSync(cmd, { stdio: "inherit", cwd: path.dirname(txtFile), ...execOptions }); const headerBox = (cmdOrRun) => { return boxen$1([ `${chalk.green(" file =>")} ${chalk.yellow(txtFile.padEnd(70, " "))}`, `${chalk.green(" line =>")} ${chalk.yellow(line.padEnd(70, " "))}`, cmdOrRun ].join("\n"), { borderColor: "green", title: chalk.green(`${lognsp} txt-file:${txtFile}`) }); }; if (run) { const runContext = { ...commandBuilderContext, runCommandSync, txtFile }; console.log(""); console.log(headerBox(`${chalk.green(" run =>")} funciton-name:${chalk.yellow(run.name)}`)); if (yes) await run(runContext); } else if (command) { const cmd = typeof command === "string" ? applyCommandTemplate(command, commandBuilderContext) : command(commandBuilderContext); console.log(""); console.log(headerBox(`${chalk.green(" cmd =>")} ${chalk.yellow(cmd)}`)); if (yes) runCommandSync(cmd); } else throw new Error("unexpected logic branch"); } //#endregion //#region src/commands/txt-command/index.ts function inspectArray(arr) { return arr.map((x) => `\`${x.toString()}\``).join(" | "); } var TxtCommand = class extends Command { static paths = [["txt"]]; static usage = { description: "xargs txt <txt-file>, use `:line` as placeholder of a line of txt file, use (`:arg0` or `:args0`) ... to replace a single value" }; txtFiles = Option.Rest({ name: "txt-files", required: 1 }); command = Option.String("-c,--command", { required: true, description: "the command to execute" }); yes = Option.Boolean("-y,--yes", false, { description: "exec commands, default false(only preview commands, aka dry run)" }); wait = Option.Boolean("-w,--wait", false, { description: "wait new items when queue empty" }); waitTimeout = Option.String("--wait-timeout,--WT", { description: "wait timeout, will pass to ms()" }); session = Option.String("--session", "continue", { description: `session handling: default \`continue\`; allowed values: ${inspectArray(Object.values(SessionControl))};` }); execute() { return startTxtCommand({ ...this, session: z.enum(SessionControl).parse(this.session) }); } }; //#endregion //#region src/util/BaseCommand.ts var BaseCommand = class extends Command { /** * glob */ files = Option.String("-f,--files", { required: true, description: "files as input" }); ignoreCase = Option.Boolean("--ignore-case", true, { description: "ignore case for -f,--files, default true" }); globCwd = Option.String("--glob-cwd", { description: "cwd used in glob" }); yes = Option.Boolean("-y,--yes", false, { description: "exec commands, default false(only preview commands, aka dry run)" }); showTokens = Option.Boolean("-t,--tokens,--show-tokens", false, { description: "show available tokens" }); }; //#endregion //#region src/util/file.ts function getFilenameTokens(item) { const fullpath = path.resolve(item); const dir = path.dirname(fullpath); const file = path.basename(fullpath); let ext = path.extname(fullpath); const name = path.basename(fullpath, ext); ext = ext.slice(1); const pdir = path.basename(dir); return { fullpath, dir, file, name, ext, pdir, rname: item }; } function renderFilenameTokens(template, options) { const tokens = [ ":pdir", ":rname", ":fullpath", ":dir", ":file", ":name", ":ext" ]; let result = template; for (const t of tokens) { const val = options[t.slice(1)]; if (!val) continue; result = result.replaceAll(new RegExp(t, "g"), val); } return result; } function printFilenameTokens(tokens) { const { fullpath, dir, file, name, ext, pdir, rname } = tokens; console.log(` token ${chalk.green(":fullpath")} ${chalk.cyan(fullpath)}`); console.log(` token ${chalk.green(":dir")} ${chalk.cyan(dir)}`); console.log(` token ${chalk.green(":file")} ${chalk.cyan(file)}`); console.log(` token ${chalk.green(":name")} ${chalk.cyan(name)}`); console.log(` token ${chalk.green(":ext")} ${chalk.cyan(ext)}`); console.log(` token ${chalk.green(":pdir")} ${chalk.cyan(pdir)}`); console.log(` token ${chalk.green(":rname")} ${chalk.cyan(rname)}`); } //#endregion export { TxtCommand as a, defaultTxtCommandContext as c, BaseCommand as i, startTxtCommand as l, printFilenameTokens as n, SessionControl as o, renderFilenameTokens as r, applyCommandTemplate as s, getFilenameTokens as t };