@magicdawn/x-args
Version:
play with cli commands like a composer
264 lines (263 loc) • 9.8 kB
JavaScript
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 };