import { existsSync } from "node:fs"; 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}`); } } class NoRundirError extends CLIError { name = "NoRundirError"; constructor() { super("No rundir found in current directory tree"); } } const TEMPLATE_FILE_PATH = process.env["RUNDIR_TEMPLATE"] ?? null; const DEFAULT_TEMPLATE = ` #!/usr/bin/env bash # Description... `.trim(); const VERSION = pkg.version; 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 getRundirPath(fromPath?: string): string { const currentDir = fromPath ?? process.cwd(); const potentialPath = path.resolve(currentDir, ".run"); if (currentDir === path.dirname(currentDir)) { throw new NoRundirError(); } if (existsSync(potentialPath)) { return potentialPath; } return getRundirPath(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 getScriptPath(name: string): string { return path.resolve(getRundirPath(), name); } async function scriptExists(name: string): Promise { return Bun.file(getScriptPath(name)).exists(); } /** * 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 { const currentRundir = getRundirPath(); if (!currentRundir) { throw new NoRundirError(); } const expectedPath = path.resolve(currentRundir, name); const checkFile = Bun.file(expectedPath); const exists = await checkFile.exists(); if (!exists) { return null; } return expectedPath; } /** * 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 { 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 = getScriptPath(scriptName); const exists = await scriptExists(pathname); if (!exists) { throw new CLIError(`Script "${scriptName}" not found.`); } console.log(pathname); return; } const currentRundir = getRundirPath(); 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() { console.log(getRundirPath()); } /** * Create a new script in the rundir * @command */ async function newScript(name: string) { const scriptPath = getScriptPath(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(scriptFile); } /** * 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); } const rundir = path.dirname(scriptPath); const cwd = path.resolve(rundir, ".."); const proc = Bun.spawn([scriptPath], { cwd, env: { ...process.env, RUNDIR_PATH: rundir, RUNDIR_CWD: cwd, RUNDIR_SCRIPT_PATH: scriptPath, }, stdout: Bun.stdout, stderr: Bun.stderr, stdin: Bun.stdin, }); await proc.exited; } /** * Prints the help message * @command */ async function help() { console.log( ` rundir ${VERSION} Usage: rundir [command] Commands: x Run a script from the nearest rundir (alias: run) new Create a new script in the rundir (alias: create) ls List all scripts in the rundir ls 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; type HandlerDescriptor = [number, Handler]; const COMMAND_HANDLERS: Record = { 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); }