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 { 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 { 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 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); }