This commit is contained in:
Endeavorance 2025-05-10 10:46:40 -04:00
parent cc37b06d8b
commit cc19888102
14 changed files with 529 additions and 322 deletions

View file

@ -1,102 +1,340 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { parseArgs } from "node:util";
import { edit, ls, newScript, runScript, which } from "./scriptFile";
import { $, Glob } from "bun";
import chalk from "chalk";
import pkg from "../package.json" assert { type: "json" };
const KNOWN_COMMANDS = [
"ls",
"help",
"new",
"create",
"which",
"x",
"run",
"edit",
] as const;
type Command = (typeof KNOWN_COMMANDS)[number];
function isCommand(command: string): command is Command {
return KNOWN_COMMANDS.includes(command as Command);
interface ScriptData {
name: string;
description: string;
absolutePath: string;
}
function help() {
console.log(
`
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 filesInDir = new Glob(`${RUNDIR_PATH}/*`).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(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:
new <name> - Create a new script in the rundir (alias: create)
edit <name> - Open a script in the editor
x <name> - Run a script from the nearest rundir (alias: run)
which - Print the absolute path of the rundir
ls - List all scripts in the rundir
help - Show this help message
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,
});
const { positionals } = parseArgs({
args: Bun.argv,
allowPositionals: true,
strict: true,
});
return positionals.slice(2);
return positionals.slice(2);
}
const args = readArgs();
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],
};
// Default functionality is to list
if (args.length === 0) {
await ls();
process.exit(0);
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);
}
const command = args[0];
try {
await main();
} catch (e) {
if (e instanceof CLIError) {
console.error(chalk.red(e.message));
process.exit(0);
}
if (!isCommand(command)) {
console.error(`Unknown command: ${args[0]}`);
process.exit(1);
console.error(chalk.red("An unexpected error occurred:"));
console.error(e);
process.exit(0);
}
switch (command) {
case "ls":
await ls();
break;
case "help":
help();
break;
case "which":
which();
break;
case "new":
case "create":
if (args.length < 2) {
console.error("Missing script name");
process.exit(1);
}
await newScript(args[1]);
break;
case "x":
case "run":
if (args.length < 2) {
console.error("Missing script name");
process.exit(1);
}
await runScript(args[1]);
break;
case "edit":
if (args.length < 2) {
console.error("Missing script name");
process.exit(1);
}
edit(args[1]);
break;
default:
console.error(`Unknown command "${command}"`);
break;
}
process.exit(0);