342 lines
8.2 KiB
TypeScript
342 lines
8.2 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import path from "node:path";
|
|
import { parseArgs } from "node:util";
|
|
import { $, Glob } from "bun";
|
|
import chalk from "chalk";
|
|
import pkg from "../package.json" assert { type: "json" };
|
|
|
|
interface ScriptData {
|
|
name: string;
|
|
description: string;
|
|
absolutePath: string;
|
|
}
|
|
|
|
class CLIError extends Error {
|
|
name = "CLIError";
|
|
|
|
constructor(message: string) {
|
|
super(`rundir error: ${message}`);
|
|
}
|
|
}
|
|
|
|
const TEMPLATE_FILE_PATH = process.env["RUNDIR_TEMPLATE"] ?? null;
|
|
const DEFAULT_TEMPLATE = `
|
|
#!/usr/bin/env bash
|
|
# Description...
|
|
`.trim();
|
|
const VERSION = pkg.version;
|
|
const RUNDIR_PATH = path.join(process.cwd(), ".run");
|
|
const COMMENT_STARTERS = ["#", "//", "--"];
|
|
const COMMANDS = ["ls", "help", "new", "create", "which", "x", "run"] as const;
|
|
type Command = (typeof COMMANDS)[number];
|
|
|
|
/**
|
|
* Given an unknown string, parse a valid command or throw
|
|
*
|
|
* @param command The command to parse
|
|
* @returns The parsed command
|
|
*/
|
|
function parseCommand(command: string): Command {
|
|
if (COMMANDS.includes(command as Command)) {
|
|
return command as Command;
|
|
}
|
|
|
|
throw new CLIError(`Unknown command: ${command}`);
|
|
}
|
|
|
|
/**
|
|
* Fund a rundir, checking the current or provided directory
|
|
* path and walking up the tree if no dir is found
|
|
*
|
|
* @param fromPath The path to start searching from
|
|
* @returns The path to the rundir, or null if not found
|
|
*/
|
|
function findNearestRundir(fromPath?: string): string | null {
|
|
const currentDir = fromPath ?? process.cwd();
|
|
const potentialPath = path.join(currentDir, ".run");
|
|
|
|
if (currentDir === path.dirname(currentDir)) {
|
|
return null;
|
|
}
|
|
|
|
if (existsSync(potentialPath)) {
|
|
return potentialPath;
|
|
}
|
|
|
|
return findNearestRundir(path.dirname(currentDir));
|
|
}
|
|
|
|
/**
|
|
* Given the name of a script, return the absolute path to the script
|
|
*
|
|
* @param name The name of the script
|
|
* @returns The absolute path to the script
|
|
*/
|
|
function makeScriptPath(name: string): string {
|
|
const rundir = findNearestRundir();
|
|
|
|
if (!rundir) {
|
|
throw new CLIError("No rundir found in the current directory tree.");
|
|
}
|
|
|
|
return path.join(rundir, name);
|
|
}
|
|
|
|
/**
|
|
* Given the name of a script, search for the script in the rundir and
|
|
* return the absolute path to the script.
|
|
* Searches the current directory's `.run` directory first, then works
|
|
* up the directory tree until it reaches the home directory.
|
|
*
|
|
* If the script is not found, returns `null`.
|
|
*
|
|
* @param name The name of the script to find the path of
|
|
*/
|
|
async function findScriptPath(name: string): Promise<string | null> {
|
|
let currentDir = process.cwd();
|
|
|
|
while (currentDir !== path.dirname(currentDir)) {
|
|
const potentialPath = path.join(currentDir, ".run", name);
|
|
if (await Bun.file(potentialPath).exists()) {
|
|
return potentialPath;
|
|
}
|
|
currentDir = path.dirname(currentDir);
|
|
}
|
|
|
|
const homeDirPath = path.join(homedir(), ".run", name);
|
|
if (await Bun.file(homeDirPath).exists()) {
|
|
return homeDirPath;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parses a line of text and returns the comment line or null
|
|
* if the line does not start with a comment starter
|
|
*
|
|
* @param line The line of text to parse
|
|
* @returns The comment line or null
|
|
*/
|
|
function getCommentLine(line: string): string | null {
|
|
for (const starter of COMMENT_STARTERS) {
|
|
if (line.startsWith(starter)) {
|
|
return line.replace(starter, "").trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Given a script name, read the script file and return
|
|
*
|
|
* @param scriptName The name of the script to read
|
|
* @returns The script data
|
|
*/
|
|
async function readScriptData(scriptName: string): Promise<ScriptData> {
|
|
const scriptPath = await findScriptPath(scriptName);
|
|
|
|
if (!scriptPath) {
|
|
throw new CLIError(
|
|
`Script "${scriptName}" not found in any rundir in the directory tree.`,
|
|
);
|
|
}
|
|
|
|
const scriptFile = Bun.file(scriptPath);
|
|
const scriptContent = await scriptFile.text();
|
|
const [_, ...lines] = scriptContent.split("\n");
|
|
|
|
const descriptionLines: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
const commentLine = getCommentLine(line);
|
|
if (!commentLine) {
|
|
break;
|
|
}
|
|
|
|
descriptionLines.push(commentLine);
|
|
}
|
|
|
|
const description =
|
|
descriptionLines.length > 0
|
|
? descriptionLines.join(" ")
|
|
: "No description provided";
|
|
|
|
return {
|
|
absolutePath: scriptPath,
|
|
name: path.basename(scriptPath),
|
|
description: description.trim(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List the scripts in the rundir or the path to a script if provided
|
|
* @command
|
|
*/
|
|
async function ls(scriptName?: string) {
|
|
if (scriptName) {
|
|
const pathname = await findScriptPath(scriptName);
|
|
if (pathname === null) {
|
|
throw new CLIError(`Script "${scriptName}" not found.`);
|
|
}
|
|
|
|
console.log(pathname);
|
|
return;
|
|
}
|
|
|
|
const currentRundir = findNearestRundir();
|
|
const filesInDir = new Glob(`${currentRundir}/*`).scan();
|
|
const scriptLines: string[] = [];
|
|
|
|
for await (const file of filesInDir) {
|
|
const scriptName = path.basename(file);
|
|
const scriptData = await readScriptData(scriptName);
|
|
scriptLines.push(
|
|
`${chalk.dim("$")} ${scriptName} ${chalk.dim(`- ${scriptData.description}`)}`,
|
|
);
|
|
}
|
|
|
|
const sorted = scriptLines.sort((a, b) => a.localeCompare(b));
|
|
|
|
console.log(chalk.dim(currentRundir));
|
|
console.log(sorted.join("\n"));
|
|
}
|
|
|
|
/**
|
|
* Prints the absolute path of the rundir
|
|
* @command
|
|
*/
|
|
async function which() {
|
|
const nearest = findNearestRundir();
|
|
|
|
if (!nearest) {
|
|
console.error("No rundir found in the current directory tree.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(path.resolve(nearest));
|
|
}
|
|
|
|
/**
|
|
* Create a new script in the rundir
|
|
* @command
|
|
*/
|
|
async function newScript(name: string) {
|
|
const scriptPath = makeScriptPath(name);
|
|
const scriptFile = Bun.file(scriptPath);
|
|
let template = DEFAULT_TEMPLATE;
|
|
|
|
if (await scriptFile.exists()) {
|
|
throw new CLIError(`Script "${name}" already exists.`);
|
|
}
|
|
|
|
if (TEMPLATE_FILE_PATH) {
|
|
const templateFile = Bun.file(TEMPLATE_FILE_PATH);
|
|
if (await templateFile.exists()) {
|
|
template = await templateFile.text();
|
|
} else {
|
|
console.warn(
|
|
chalk.yellow(`Warn: Template "${TEMPLATE_FILE_PATH}" not found`),
|
|
);
|
|
}
|
|
}
|
|
|
|
await Bun.write(scriptFile, template);
|
|
await $`chmod +x ${scriptPath}`;
|
|
console.log(name);
|
|
}
|
|
|
|
/**
|
|
* Executes a script from the rundir
|
|
* @command
|
|
*/
|
|
async function runScript(name: string) {
|
|
const scriptPath = await findScriptPath(name);
|
|
|
|
if (!scriptPath) {
|
|
console.error(`Script "${name}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
await $`${scriptPath}`;
|
|
}
|
|
|
|
/**
|
|
* Prints the help message
|
|
* @command
|
|
*/
|
|
async function help() {
|
|
console.log(
|
|
`
|
|
rundir ${VERSION}
|
|
Usage: rundir [command]
|
|
|
|
Commands:
|
|
x <name> Run a script from the nearest rundir (alias: run)
|
|
new <name> Create a new script in the rundir (alias: create)
|
|
ls List all scripts in the rundir
|
|
ls <name> Print the absolute path to a specific script
|
|
which Print the absolute path of the rundir
|
|
help Show this help message
|
|
`.trim(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parses the command line arguments and returns the positionals
|
|
* @returns The positional arguments for the command
|
|
*/
|
|
function readArgs(): string[] {
|
|
const { positionals } = parseArgs({
|
|
args: Bun.argv,
|
|
allowPositionals: true,
|
|
strict: true,
|
|
});
|
|
|
|
return positionals.slice(2);
|
|
}
|
|
|
|
type Handler = (...args: string[]) => void | Promise<void>;
|
|
type HandlerDescriptor = [number, Handler];
|
|
const COMMAND_HANDLERS: Record<Command, HandlerDescriptor> = {
|
|
ls: [0, ls],
|
|
help: [0, help],
|
|
which: [0, which],
|
|
new: [1, newScript],
|
|
create: [1, newScript],
|
|
x: [1, runScript],
|
|
run: [1, runScript],
|
|
};
|
|
|
|
async function main() {
|
|
const [rawCommand, ...params] = readArgs();
|
|
|
|
// No arguments provided - list scripts
|
|
if (rawCommand === undefined) {
|
|
return ls();
|
|
}
|
|
|
|
const command = parseCommand(rawCommand);
|
|
const [requiredArgs, handler] = COMMAND_HANDLERS[command];
|
|
|
|
if (params.length < requiredArgs) {
|
|
throw new CLIError(`Missing required arguments for command "${command}"`);
|
|
}
|
|
|
|
return handler(...params);
|
|
}
|
|
|
|
try {
|
|
await main();
|
|
} catch (e) {
|
|
if (e instanceof CLIError) {
|
|
console.error(chalk.red(e.message));
|
|
process.exit(0);
|
|
}
|
|
|
|
console.error(chalk.red("An unexpected error occurred:"));
|
|
console.error(e);
|
|
process.exit(0);
|
|
}
|