optc
Version:
An easy way to write TypeScript cli script application.
539 lines (525 loc) • 17.6 kB
JavaScript
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import fs from 'fs-extra';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import BabelTsPlugin from '@babel/plugin-transform-typescript';
import { breadc } from 'breadc';
import { lightYellow } from 'kolorist';
import path$1 from 'path';
import axios from 'axios';
import { globby } from 'globby';
import os from 'node:os';
import { findUpSync } from 'find-up';
import createDebug from 'debug';
import { kebabCase } from 'scule';
const version = "0.6.4";
var __defProp$1 = Object.defineProperty;
var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField$1 = (obj, key, value) => {
__defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
function Process(pieces, args, { cwd = process.cwd(), verbose = true, shell = true } = {}) {
const parseCmd = () => {
const escape = (arg) => {
if (typeof arg === "number") {
return "" + arg;
} else if (typeof arg === "boolean") {
return arg ? "true" : "false";
} else if (typeof arg === "string") {
if (arg === "" || /^[a-z0-9/_.-]+$/i.test(arg)) {
return arg;
} else {
return `"${arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\f/g, "\\f").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/\v/g, "\\v").replace(/\0/g, "\\0")}"`;
}
} else if (arg instanceof ProcessResult) {
throw new Error("Optc: Unimplement branch");
} else {
throw new Error("Optc: Unreachable branch");
}
};
const cmd = [pieces[0]];
let i = 0;
while (i < args.length) {
if (Array.isArray(args[i])) {
cmd.push(args[i].map(escape).join(" "));
} else {
cmd.push(escape(args[i]));
}
cmd.push(pieces[++i]);
}
return cmd.join("");
};
return new Promise((res) => {
const cmd = parseCmd();
setTimeout(() => {
if (verbose) {
console.log(`$ ${cmd}`);
}
const child = spawn(cmd, {
cwd,
shell,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true
});
let stdout = "", stderr = "", combined = "";
const onStdout = (data) => {
if (verbose) {
process.stdout.write(data);
}
stdout += data;
combined += data;
};
const onStderr = (data) => {
if (verbose) {
process.stderr.write(data);
}
stderr += data;
combined += data;
};
child.stdout.on("data", onStdout);
child.stderr.on("data", onStderr);
child.on("close", (code, signal) => {
const result = new ProcessResult({ code, signal, stdout, stderr, combined });
res(result);
});
}, 0);
});
}
class ProcessResult {
constructor({
code,
signal,
stdout,
stderr,
combined
}) {
__publicField$1(this, "code");
__publicField$1(this, "signal");
__publicField$1(this, "stdout");
__publicField$1(this, "stderr");
__publicField$1(this, "combined");
this.code = code;
this.signal = signal;
this.stdout = stdout;
this.stderr = stderr;
this.combined = combined;
}
}
function logWarn(msg) {
console.warn(`${lightYellow("Warn")} ${msg}`);
}
async function importJiti() {
const jiti = await import('jiti');
return jiti.default ? jiti.default : jiti;
}
const OPTC_CACHE = process.env.OPTC_CACHE === "false" ? false : true;
const OPTC_ROOT = process.env.OPTC_ROOT ? path.resolve(process.env.OPTC_ROOT) : path.join(os.homedir(), ".optc");
const NODE_MODULES = findUpSync("node_modules", { type: "directory" });
const CACHE_ROOT = NODE_MODULES ? path.join(NODE_MODULES, ".cache/optc") : path.join(OPTC_ROOT, ".cache");
async function ensureSpace() {
await fs.ensureDir(OPTC_ROOT);
const dep = path.join(OPTC_ROOT, "dep.ts");
const pkg = path.join(OPTC_ROOT, "package.json");
const optcDts = path.join(OPTC_ROOT, "optc.d.ts");
const globalDts = path.join(OPTC_ROOT, "globals.d.ts");
if (!fs.existsSync(pkg)) {
await fs.writeFile(
pkg,
JSON.stringify(
{
name: "optc-workspace",
private: true,
dependencies: {},
optc: {
version
}
},
null,
2
),
"utf-8"
);
}
if (!fs.existsSync(dep)) {
const body = `export default function(global: any) {
}`;
await fs.writeFile(dep, body, "utf-8");
}
if (!fs.existsSync(globalDts)) {
const dts = path.join(fileURLToPath(import.meta.url), "../../../globals.d.ts");
const body = `/// <reference path="${dts}" />`;
await fs.writeFile(globalDts, body, "utf-8");
}
if (!fs.existsSync(optcDts)) {
const body = `/// <reference path="./optc.d.ts" />`;
await fs.writeFile(optcDts, body, "utf-8");
}
}
async function loadDep(name = "dep.ts") {
const filepath = path.join(OPTC_ROOT, name);
if (!fs.existsSync(filepath))
return;
const jiti = (await importJiti())(filepath, { cache: true, sourceMaps: false });
const module = await jiti(filepath);
if (module.default && typeof module.default === "function") {
await module.default(global);
}
}
$.prompt = "$";
$.shell = true;
$.verbose = true;
function $(pieces, ...args) {
return Process(pieces, args, { cwd: process.cwd(), verbose: $.verbose, shell: $.shell });
}
function cd(dir) {
print(`cd ${dir}`, { prompt: true });
process.chdir(dir);
}
function pwd() {
print(`pwd`, { prompt: true });
print(process.cwd());
return process.cwd();
}
function ls(dir) {
return readdirSync(dir ?? process.cwd());
}
function readTextFile(filename, encode = "utf-8") {
return readFileSync(filename, encode);
}
function writeTextFile(filename, content, encode = "utf-8") {
return writeFileSync(filename, content, encode);
}
function sleep(ms) {
return new Promise((res) => setTimeout(() => res(), ms));
}
function print(msg, option) {
if ($.verbose) {
if (!!option?.prompt) {
const prompt = typeof option?.prompt === "string" ? option.prompt : $.prompt;
console.log(`${prompt} ${msg}`);
} else {
console.log(msg);
}
}
}
async function registerGlobal(preset) {
global.$ = $;
global.cd = cd;
global.pwd = pwd;
global.ls = ls;
global.path = path$1;
global.fs = fs;
global.readTextFile = readTextFile;
global.writeTextFile = writeTextFile;
global.glob = globby;
global.globby = globby;
global.sleep = sleep;
global.http = axios;
global.axios = axios;
for (const key of [
"copy",
"mkdirp",
"move",
"remove",
"outputFile",
"readJson",
"writeJson",
"outputJson",
"emptyDir",
"ensureFile",
"ensureDir"
]) {
global[key] = fs[key];
}
await loadDep(preset);
}
var ValueType = /* @__PURE__ */ ((ValueType2) => {
ValueType2["String"] = "string";
ValueType2["Number"] = "number";
ValueType2["Boolean"] = "boolean";
ValueType2["Array"] = "string[]";
return ValueType2;
})(ValueType || {});
const debug = createDebug("optc:reflection");
function ReflectionPlugin(_ctx, option) {
debug("Create Reflection Plugin");
const optionMap = /* @__PURE__ */ new Map();
const mainCode = option.code.toString();
const isSkip = (code) => code !== mainCode;
return {
name: "optc-reflection",
pre(file) {
optionMap.clear();
},
visitor: {
ExportDeclaration(exportPath, state) {
if (isSkip(state.file.code))
return;
const isDefault = exportPath.node.type === "ExportDefaultDeclaration";
exportPath.traverse({
FunctionDeclaration(path) {
debug(isDefault ? "Export a default function" : "Export a function");
const options = [];
const parameters = path.node.params.map((param, pidx) => {
if (param.type === "Identifier") {
let type = param.typeAnnotation?.type === "TSTypeAnnotation" ? parseType(param.typeAnnotation) : void 0;
if (type && type === ValueType.Boolean) {
type = ValueType.String;
logWarn(
`Unsupport parameter type annotation at function ${path.node.id?.name ?? "default"}`
);
}
if (!type && pidx + 1 === path.node.params.length) {
if (param.typeAnnotation?.type === "TSTypeAnnotation") {
if (param.typeAnnotation.typeAnnotation.type === "TSTypeReference") {
if (param.typeAnnotation.typeAnnotation.typeName.type === "Identifier") {
const name = param.typeAnnotation.typeAnnotation.typeName.name;
if (optionMap.has(name)) {
options.push(...optionMap.get(name));
} else {
optionMap.set(name, options);
}
}
} else if (param.typeAnnotation.typeAnnotation.type === "TSTypeLiteral") {
options.push(...parseOptions(param.typeAnnotation.typeAnnotation.members));
}
}
return void 0;
} else if (!type) {
logWarn(
`Unsupport parameter type annotation at function ${path.node.id?.name ?? "default"}`
);
}
return {
name: param.name,
type: type ?? ValueType.String,
required: !param.optional
};
} else if (param.type === "ObjectPattern") ; else {
return void 0;
}
});
const command = {
name: path.node.id?.name ?? "",
default: isDefault,
options,
parameters: parameters.filter(Boolean),
description: parseComment(exportPath.node.leadingComments)
};
option.commands.push(command);
path.stop();
}
});
},
TSInterfaceDeclaration(path, state) {
if (isSkip(state.file.code))
return;
debug("Declare Interface");
const options = parseOptions(path.node.body.body);
if (optionMap.has(path.node.id.name)) {
optionMap.get(path.node.id.name).push(...options);
} else {
optionMap.set(path.node.id.name, options);
}
}
}
};
}
function parseComment(comments) {
if (!Array.isArray(comments))
return "";
comments = comments.filter((c) => !(c.type === "CommentLine") || !c.value.startsWith("/"));
if (comments.length === 0)
return "";
if (comments[comments.length - 1].type === "CommentLine") {
return comments[comments.length - 1].value.trim();
} else {
const text = comments[comments.length - 1].value;
if (text.startsWith("*\n")) {
const lines = text.split("\n").map((t) => t.trim()).map((t) => t.startsWith("*") ? t.slice(1) : t).map((t) => t.trim()).filter(Boolean);
return lines.length > 0 ? lines[0] : "";
} else {
return text.trim();
}
}
}
function parseType(typeAnnotation) {
if (!typeAnnotation)
return ValueType.String;
const type = typeAnnotation.typeAnnotation.type;
if (type === "TSStringKeyword") {
return ValueType.String;
} else if (type === "TSNumberKeyword") {
return ValueType.Number;
} else if (type === "TSBooleanKeyword") {
return ValueType.Boolean;
} else if (type === "TSArrayType") {
const elType = typeAnnotation.typeAnnotation.elementType.type;
if (elType === "TSStringKeyword") {
return ValueType.Array;
} else {
return void 0;
}
} else {
return void 0;
}
}
function parseOptions(body) {
const sigs = body.filter((t) => t.type === "TSPropertySignature");
return sigs.map((sig) => {
if (sig.key.type === "Identifier" || sig.key.type === "StringLiteral") {
const name = sig.key.type === "Identifier" ? sig.key.name : sig.key.value;
if (name === "--") {
if (parseType(sig.typeAnnotation) !== ValueType.Array) ;
return void 0;
}
let type = parseType(sig.typeAnnotation);
return {
name: kebabCase(name),
type: type ?? ValueType.String,
required: !sig.optional,
description: parseComment(sig.leadingComments)
};
} else {
return void 0;
}
}).filter(Boolean);
}
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
class Optc {
constructor(scriptPath, option) {
__publicField(this, "scriptPath");
__publicField(this, "breadc");
__publicField(this, "commands", []);
this.scriptPath = scriptPath;
this.breadc = breadc(option.name, { version: option.version, description: option.description });
}
getRawCommands() {
return this.commands;
}
setupCommands(module, commands) {
this.commands.push(...commands);
for (const command of commands) {
const name = [
command.name,
...command.parameters.map(
(arg) => arg.type === ValueType.Array ? `[...${arg.name}]` : arg.required ? `<${arg.name}>` : `[${arg.name}]`
)
];
if (command.default) {
name.splice(0, 1);
}
const fn = command.default ? module.default : module[command.name];
if (!fn || typeof fn !== "function") {
if (command.default) {
logWarn(`Can not find default function`);
} else {
logWarn(`Can not find function ${command.name}`);
}
}
const cmd = command.options.reduce(
(cmd2, option) => {
let text = `--${option.name}`;
if (option.type === ValueType.String || option.type === ValueType.Number) {
if (option.required) {
text += " <text>";
} else {
text += " <text>";
}
} else if (option.type === ValueType.Array) {
text += " [...text]";
}
return cmd2.option(text, option.description);
},
this.breadc.command(name.join(" "), command.description)
);
if (command.default && command.name) {
cmd.alias(command.name);
}
cmd.action(fn);
}
}
async run(args) {
await registerGlobal();
return this.breadc.run(args);
}
}
async function makeOptc(script) {
await fs.ensureDir(CACHE_ROOT);
const scriptName = path.parse(path.basename(script)).name;
const content = await fs.readFile(script);
const hash = crypto.createHash("sha256").update(content).digest("hex");
const cachedScriptPath = path.join(CACHE_ROOT, scriptName + "_" + hash + ".js");
const cachedReflPath = path.join(CACHE_ROOT, scriptName + "_" + hash + ".json");
const initOptc = async () => {
const commands = [];
const jiti = (await importJiti())(pathToFileURL(script).href, {
cache: OPTC_CACHE,
sourceMaps: false,
transformOptions: {
babel: {
plugins: [
[ReflectionPlugin, { code: content, commands }],
[BabelTsPlugin, {}]
]
}
}
});
if (!OPTC_CACHE || !fs.existsSync(cachedScriptPath) || !fs.existsSync(cachedReflPath)) {
await fs.writeFile(cachedScriptPath, content);
const module = await jiti(cachedScriptPath);
const loadField = (field, defaultValue) => {
const value = module[field];
if (value === void 0 || value === null)
return defaultValue;
if (typeof value === "string")
return value;
if (typeof value === "function")
return value();
return defaultValue;
};
const cliName = loadField("name", scriptName);
const cliVersion = loadField("version", "unknown");
const cliDescription = loadField("description", "");
const refl = {
name: cliName,
description: cliDescription,
version: cliVersion,
commands,
optc: {
version: version
}
};
await fs.writeFile(cachedReflPath, JSON.stringify(refl, null, 2), "utf-8");
const cli = new Optc(cachedScriptPath, {
name: cliName,
version: cliVersion
});
cli.setupCommands(module, commands);
return cli;
} else {
const refl = JSON.parse(await fs.readFile(cachedReflPath, "utf-8"));
if (refl?.optc?.version !== version) {
await fs.unlink(cachedReflPath);
return initOptc();
}
const cli = new Optc(cachedScriptPath, refl);
const module = await jiti(cachedScriptPath);
cli.setupCommands(module, refl.commands);
return cli;
}
};
return await initOptc();
}
async function bootstrap(script, ...args) {
return await (await makeOptc(script)).run(args);
}
export { $, OPTC_ROOT as O, Process as P, ProcessResult as a, bootstrap as b, cd as c, ensureSpace as e, ls as l, makeOptc as m, pwd as p, readTextFile as r, sleep as s, version as v, writeTextFile as w };