From cc19888102be9f4e5cbedad3948799d292d356d8 Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Sat, 10 May 2025 10:46:40 -0400 Subject: [PATCH] 0.1.0 --- .gitignore | 5 +- .run/build | 28 +++- .run/clean | 3 + .run/format | 5 +- .run/test | 2 +- LICENSE | 24 +++ README.md | 42 ++--- biome.json | 37 +++++ bun.lock | 49 ++++++ bun.lockb | Bin 6965 -> 0 bytes package.json | 34 ++-- src/cli.ts | 404 ++++++++++++++++++++++++++++++++++++---------- src/scriptFile.ts | 167 ------------------- tsconfig.json | 51 +++--- 14 files changed, 529 insertions(+), 322 deletions(-) create mode 100755 .run/clean create mode 100644 LICENSE create mode 100644 biome.json create mode 100644 bun.lock delete mode 100755 bun.lockb delete mode 100644 src/scriptFile.ts diff --git a/.gitignore b/.gitignore index ce007ac..505b3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,7 @@ dist .idea # Finder (MacOS) folder config -.DS_Store \ No newline at end of file +.DS_Store + +bin +build diff --git a/.run/build b/.run/build index 919efcd..79ed72f 100755 --- a/.run/build +++ b/.run/build @@ -1,3 +1,27 @@ #!/usr/bin/env bash -#DESCRIPTION# Compile the binary cli -bun build --target bun --compile ./src/cli.ts --outfile rundir +# Cross build rundir for multiple platforms + +echo "Clean" +rm -rf bin build + +mkdir bin + +echo "linux-x64" +bun build --compile --minify --target=bun-linux-x64 ./src/cli.ts --outfile bin/rundir-linux-x64 + +echo "linux-arm64" +bun build --compile --minify --target=bun-linux-arm64 ./src/cli.ts --outfile bin/rundir-linux-arm64 + +echo "windows-x64" +bun build --compile --minify --target=bun-windows-x64 ./src/cli.ts --outfile bin/rundir-windows-x64 + +echo "macos-arm" +bun build --compile --minify --target=bun-darwin-arm64 ./src/cli.ts --outfile bin/rundir-macos-apple-silicon + +echo "macos-x64" +bun build --compile --minify --target=bun-darwin-x64 ./src/cli.ts --outfile bin/rundir-macos-x64 + +mkdir build + +echo "package" +bun build --target bun --outdir build ./src/cli.ts diff --git a/.run/clean b/.run/clean new file mode 100755 index 0000000..5c5e0ae --- /dev/null +++ b/.run/clean @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Clean build artifacts +rm -rf build diff --git a/.run/format b/.run/format index fbe8e31..4570fa7 100755 --- a/.run/format +++ b/.run/format @@ -1,3 +1,4 @@ #!/usr/bin/env bash -#DESCRIPTION# Format the repo, writing the formatted files to disk -bunx biome format . --write \ No newline at end of file +# Format the repo, writing the formatted files to disk +bunx biome format . --write + diff --git a/.run/test b/.run/test index 2c1e99d..5a3f48e 100755 --- a/.run/test +++ b/.run/test @@ -1,3 +1,3 @@ #!/usr/bin/env bash -#DESCRIPTION# A rundir script +# A rundir script echo 'Hello, world!' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md index a396e1c..e98b9b2 100644 --- a/README.md +++ b/README.md @@ -26,40 +26,28 @@ $ rundir x build # runs the `build` script in the original `.run` directory ## CLI -### List scripts +``` +Usage: rundir [command] -```shell -$ rundir +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 ``` -### Create script +## `rdx` alias + +Consider setting an alias for `rundir x` to `rdx` for convenience: ```shell -$ rundir create myscript +alias rdx="rundir x" ``` -### Run a script +Then you can run scripts with `rdx` instead of `rundir x`: ```shell -$ rundir x myscript - -# ... or if you're in the root directory just... -$ .run/myscript +rdx build ``` - -### Print the path of the current rundir - -```shell -$ rundir which -``` - -### Open a script in your editor - -```shell -$ rundir edit myscript -``` - -### Print help message -```shell -$ rundir help -``` \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b6909ea --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "ignore": ["dist", "*.d.ts", "*.json", "build"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noArrayIndexKey": "off" + }, + "complexity": { + "useLiteralKeys": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..eeafa95 --- /dev/null +++ b/bun.lock @@ -0,0 +1,49 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "rundir", + "dependencies": { + "chalk": "^5.4.1", + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + + "@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="], + + "@types/node": ["@types/node@22.15.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw=="], + + "bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="], + + "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 94404a3de3a767d460ef84f3f49f47c30988eda8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6965 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 diff --git a/package.json b/package.json index d61d3db..744506c 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,22 @@ { - "name": "rundir", - "module": "src/cli.ts", - "version": "0.0.1", - "type": "module", - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@types/bun": "latest" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "chalk": "^5.3.0" - } + "name": "@endeavorance/rundir", + "type": "module", + "module": "src/cli.ts", + "version": "0.1.0", + "bin": { + "rundir": "build/cli.js" + }, + "files": [ + "build" + ], + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.8.3" + }, + "dependencies": { + "chalk": "^5.4.1" + } } diff --git a/src/cli.ts b/src/cli.ts index 40c4507..bf79e02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { + 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 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 - Create a new script in the rundir (alias: create) - edit - Open a script in the editor - x - 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 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, - }); + 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; +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], +}; -// 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); diff --git a/src/scriptFile.ts b/src/scriptFile.ts deleted file mode 100644 index 8d323ed..0000000 --- a/src/scriptFile.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { $, Glob } from "bun"; -import chalk from "chalk"; -import path from "node:path"; -import { homedir } from "node:os"; -import { existsSync } from "node:fs"; - -const RUNDIR_PATH = path.join(process.cwd(), ".run"); - -interface ScriptData { - name: string; - description: string; - absolutePath: string; -} - -function findNearestRundir(): string | null { - let currentDir = process.cwd(); - - while (currentDir !== path.dirname(currentDir)) { - const potentialPath = path.join(currentDir, ".run"); - if (existsSync(potentialPath)) { - return potentialPath; - } - currentDir = path.dirname(currentDir); - } - - const homeDirPath = path.join(homedir(), ".run"); - if (existsSync(homeDirPath)) { - return homeDirPath; - } - - return null; -} - -function makeScriptPath(name: string): string { - const rundir = findNearestRundir(); - - if (!rundir) { - throw new Error("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; -} - -export async function readScriptData(scriptName: string): Promise { - const scriptPath = await findScriptPath(scriptName); - - if (!scriptPath) { - throw new Error( - `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 description = - lines.find((line) => line.startsWith("#DESCRIPTION#")) || - "No description provided."; - - return { - absolutePath: scriptPath, - name: path.basename(scriptPath), - description: description.replace("#DESCRIPTION#", "").trim(), - }; -} - -export async function ls() { - 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")); -} - -export async function edit(scriptName: string) { - const scriptPath = await findScriptPath(scriptName); - - if (!scriptPath) { - console.error(`Script "${scriptName}" not found.`); - process.exit(1); - } - - Bun.openInEditor(scriptPath); -} - -/** - * Prints the absolute path of the rundir - */ -export 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)); -} - -export async function newScript(name: string) { - const scriptPath = makeScriptPath(name); - const scriptFile = Bun.file(scriptPath); - - if (await scriptFile.exists()) { - console.error(`Script "${name}" already exists.`); - process.exit(1); - } - - await Bun.write( - scriptFile, - "#!/usr/bin/env bash\n#DESCRIPTION# A rundir script\necho 'Hello, world!'\n", - ); - await $`chmod +x ${scriptPath}`; - - console.log(`Created script "${name}"`); -} - -export async function runScript(name: string) { - const scriptPath = await findScriptPath(name); - - if (!scriptPath) { - console.error(`Script "${name}" not found.`); - process.exit(1); - } - - await $`${scriptPath}`; -} diff --git a/tsconfig.json b/tsconfig.json index ffc08ab..70725ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,28 @@ { - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "compilerOptions": { + // Enable latest features + "lib": [ + "ESNext", + "DOM" + ], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + // Some stricter flags (disabled by default) + "noUnusedLocals": true, + "noUnusedParameters": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + } }