UNPKG

@glyphtek/scriptit

Version:

A cross-runtime CLI and library for running scripts with environment management, TUI, and support for lambda functions. Optimized for Bun with compatibility for Node.js and Deno.

447 lines (435 loc) 20.9 kB
import { existsSync as pathExistsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import chalk from "chalk"; // src/cli.ts import { Command, Option } from "commander"; // Use a simpler approach with regular imports import { logger } from "./common/logger/index.js"; import { DEFAULT_CONFIG_FILE, DEFAULT_SCRIPTS_DIR, DEFAULT_TMP_DIR, getScriptFiles, interpolateEnvVars, loadConfig, loadEnvironmentVariables, parseEnvPromptsList, promptForEnvironmentVariables, } from "./common/utils/index.js"; import { executeScriptWithEnvironment, } from "./core/script-executor.js"; import { runBlessedTUI as runTUI } from "./ui/index.js"; // Reusable CLI Options const CLI_OPTIONS = { // Configuration options config: new Option("-c, --config <path>", "Path to runner configuration file"), // Directory options scriptsDir: new Option("-s, --scripts-dir <dir>", "Directory for scripts").default(DEFAULT_SCRIPTS_DIR), scriptsDirOverride: new Option("-s, --scripts-dir <dir>", "Override scripts directory from config"), tmpDir: new Option("-t, --tmp-dir <dir>", "Directory for temporary files").default(DEFAULT_TMP_DIR), tmpDirOverride: new Option("-t, --tmp-dir <dir>", "Override temporary directory from config"), // Environment options env: new Option("-e, --env <vars...>", "Set environment variables (e.g., NAME=Bun X=Y)"), envPrompts: new Option("--env-prompts <vars...>", "Prompt for environment variables before execution (e.g., API_KEY,SECRET)"), // UI options noTui: new Option("--no-tui", "Run without Terminal UI, just list available scripts"), forceTui: new Option("--force-tui", "Force TUI mode even when debug is enabled"), // Init options force: new Option("-f, --force", "Overwrite existing files and directories if they exist"), // Global options debug: new Option("-d, --debug", "Run in debug mode with verbose logging"), pwd: new Option("--pwd <dir>", "Set working directory for script execution (all relative paths resolve from here)"), }; // Continue with CLI implementation const program = new Command(); program .name("scriptit") .description("ScriptIt - A powerful CLI and library for running scripts with environment management, TUI, and support for lambda functions") .version("0.7.1") .addOption(CLI_OPTIONS.debug) .addOption(CLI_OPTIONS.pwd) .hook("preAction", (thisCommand, actionCommand) => { const opts = thisCommand.opts(); if (opts.debug) { process.env.SCRIPTIT_DEBUG = "true"; console.log(chalk.blue("Debug mode enabled")); } if (opts.pwd) { const targetDir = path.resolve(opts.pwd); if (!pathExistsSync(targetDir)) { console.error(chalk.red(`Error: Directory does not exist: ${targetDir}`)); process.exit(1); } console.log(chalk.gray(`Changing working directory to: ${targetDir}`)); process.chdir(targetDir); } }); program .command("init") .description("Initialize a new script runner project structure.") .addOption(CLI_OPTIONS.scriptsDir) .addOption(CLI_OPTIONS.tmpDir) .addOption(CLI_OPTIONS.force) .action(async (options) => { const scriptsDir = path.resolve(options.scriptsDir); const tmpDir = path.resolve(options.tmpDir); const configFilePath = path.resolve(DEFAULT_CONFIG_FILE); const gitignorePath = path.resolve(".gitignore"); console.log(chalk.blue("Initializing script runner project...")); const dirsToCreate = [scriptsDir, tmpDir]; for (const dir of dirsToCreate) { if (pathExistsSync(dir) && !options.force) { console.warn(chalk.yellow(`Directory ${dir} already exists. Use --force to overwrite.`)); } else { await fs.mkdir(dir, { recursive: true }); console.log(chalk.green(`Created directory: ${dir}`)); } } // Create example runner.config.js if (!pathExistsSync(configFilePath) || options.force) { const runnerConfigContent = ` // runner.config.js // You can use CommonJS (module.exports) or ES Modules (export default) export default { scriptsDir: '${path.relative(process.cwd(), scriptsDir) || "."}', // relative path from project root tmpDir: '${path.relative(process.cwd(), tmpDir) || "."}', // relative path from project root envFiles: [ '.env', // General environment variables '.env.local', // Local overrides (gitignored) // '.env.production' // Example for specific environments ], defaultParams: { // These will be available in the script context // myGlobalParam: "hello world", // apiToken: "\${API_TOKEN_FROM_ENV}" // Example of env var interpolation } }; `; await fs.writeFile(configFilePath, runnerConfigContent.trim()); console.log(chalk.green(`Created config file: ${configFilePath}`)); } else { console.warn(chalk.yellow(`Config file ${configFilePath} already exists. Use --force to overwrite.`)); } // Create example .env file const exampleEnvPath = path.resolve(".env.example"); if (!pathExistsSync(exampleEnvPath) || options.force) { await fs.writeFile(exampleEnvPath, 'MY_VARIABLE="Hello from .env"\nAPI_TOKEN_FROM_ENV="your_secret_token_here"\n'); console.log(chalk.green(`Created example env file: ${exampleEnvPath} (copy to .env and customize)`)); } if (!pathExistsSync(path.resolve(".env"))) { await fs.copyFile(exampleEnvPath, path.resolve(".env")); console.log(chalk.green("Copied .env.example to .env")); } // Create example script const exampleScriptPath = path.join(scriptsDir, "example.ts"); if (!pathExistsSync(exampleScriptPath) || options.force) { const exampleScriptContent = ` // ${path.join(options.scriptsDir, "example.ts")} // This is an example script for ScriptIt /** * @description An example script that demonstrates the tearUp, execute, and tearDown pipeline. */ export const description = "Logs messages and environment variables."; /** * @param {import('../../src/common/types').ScriptContext} context */ export async function tearUp(context) { context.log('TearUp: Preparing something...'); await new Promise(resolve => setTimeout(resolve, 500)); // Simulate async work const data = { prepared: true, timestamp: Date.now() }; context.log(\`TearUp: Done. Temp dir: \${context.tmpDir}\`); return data; } /** * @param {import('../../src/common/types').ScriptContext} context * @param {any} tearUpResult The result from tearUp */ export async function execute(context, tearUpResult) { context.log('Execute: Running main logic...'); context.log(\` Received from tearUp: \${JSON.stringify(tearUpResult)}\`); context.log(\` MY_VARIABLE from env: \${context.env.MY_VARIABLE}\`); context.log(\` Config path: \${context.configPath}\`); // Example: writing to tmpDir const tempFilePath = context.tmpDir + '/example_output.txt'; await fs.writeFile(tempFilePath, \`Output from example script at \${new Date().toISOString()}\\n\`); context.log(\` Wrote to \${tempFilePath}\`); if (context.env.FAIL_EXAMPLE === 'true') { throw new Error("Intentional failure triggered by FAIL_EXAMPLE env var."); } await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate async work context.log('Execute: Main logic complete.'); return { success: true, data: "Execution finished" }; } /** * @param {import('../../src/common/types').ScriptContext} context * @param {any} executeResult The result from execute * @param {any} tearUpResult The result from tearUp */ export async function tearDown(context, executeResult, tearUpResult) { context.log('TearDown: Cleaning up...'); await new Promise(resolve => setTimeout(resolve, 300)); // Simulate async work context.log(\` Received from execute: \${JSON.stringify(executeResult)}\`); context.log(\` TearUp data during teardown: \${JSON.stringify(tearUpResult)}\`); context.log('TearDown: Cleanup complete.'); } `; await fs.writeFile(exampleScriptPath, exampleScriptContent.trim()); console.log(chalk.green(`Created example script: ${exampleScriptPath}`)); // Create a lambda-style example const lambdaExamplePath = path.join(options.scriptsDir, "lambda-example.ts"); const lambdaExampleContent = ` // ${path.join(options.scriptsDir, "lambda-example.ts")} // This demonstrates using a default export (lambda-style function) export const description = "Lambda-style script using default export"; /** * Default export function - ScriptIt will use this automatically * Perfect for existing lambda functions or simple scripts */ export default async function(context) { context.log('Lambda-style execution starting...'); context.log(\`Environment: \${context.env.NODE_ENV || 'development'}\`); context.log(\`Working directory: \${process.cwd()}\`); // Simulate some work await new Promise(resolve => setTimeout(resolve, 800)); const result = { timestamp: new Date().toISOString(), success: true, message: "Lambda execution completed" }; context.log(\`Result: \${JSON.stringify(result)}\`); return result; } `; await fs.writeFile(lambdaExamplePath, lambdaExampleContent.trim()); console.log(chalk.green(`Created lambda example: ${lambdaExamplePath}`)); } else { console.warn(chalk.yellow(`Example script ${exampleScriptPath} already exists. Use --force to overwrite.`)); } // Add tmpDir to .gitignore let gitignoreContent = ""; if (pathExistsSync(gitignorePath)) { gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); } const tmpDirRelative = path.relative(process.cwd(), tmpDir) || "."; const tmpDirGitIgnoreEntry = `${tmpDirRelative.replace(/\\/g, "/")}/`; // Ensure forward slashes and trailing slash if (!gitignoreContent.includes(tmpDirGitIgnoreEntry)) { gitignoreContent += `\n# Temporary files for ScriptIt\n${tmpDirGitIgnoreEntry}\n`; await fs.writeFile(gitignorePath, gitignoreContent); console.log(chalk.green(`Added '${tmpDirGitIgnoreEntry}' to .gitignore`)); } else { console.log(chalk.blue(`'${tmpDirGitIgnoreEntry}' already in .gitignore`)); } // Add .env.local to .gitignore const envLocalGitIgnoreEntry = ".env.local"; if (!gitignoreContent.includes(envLocalGitIgnoreEntry)) { gitignoreContent += `\n# Local environment variables (gitignored)\n${envLocalGitIgnoreEntry}\n`; await fs.writeFile(gitignorePath, gitignoreContent.trim()); // trim to avoid multiple newlines if added multiple times console.log(chalk.green(`Added '${envLocalGitIgnoreEntry}' to .gitignore`)); } else { console.log(chalk.blue(`'${envLocalGitIgnoreEntry}' already in .gitignore`)); } console.log(chalk.bgGreen.black("\nInitialization complete!")); console.log(chalk.yellow("To get started:")); console.log(chalk.yellow(`1. (Optional) Customize '${DEFAULT_CONFIG_FILE}'`)); console.log(chalk.yellow(`2. (Optional) Create/edit '.env' file with your environment variables.`)); console.log(chalk.yellow(`3. Add your scripts to the '${options.scriptsDir}' directory.`)); console.log(chalk.yellow(`4. Run '${program.name()} run' or just '${program.name()}' to start.`)); }); program .command("exec <scriptPath>") .description("Execute a single script file directly.") .addOption(CLI_OPTIONS.config) .addOption(CLI_OPTIONS.env) .addOption(CLI_OPTIONS.envPrompts) .action(async (scriptPath, options) => { // Load configuration (tmpDir, envFiles, defaultParams will be used from here) const config = await loadConfig(options.config); // Store where the config was loaded from for context config.loadedConfigPath = options.config || (pathExistsSync(DEFAULT_CONFIG_FILE) ? DEFAULT_CONFIG_FILE : undefined); // Process --env options const cliEnvVars = {}; if (options.env && Array.isArray(options.env)) { for (const envVar of options.env) { const [key, ...valueParts] = envVar.split("="); if (key && valueParts.length > 0) { cliEnvVars[key.trim()] = valueParts.join("=").trim(); } else { console.warn(chalk.yellow(`Skipping malformed --env argument: ${envVar}`)); } } } // Process --env-prompts options let envPromptsToPass = []; if (options.envPrompts && Array.isArray(options.envPrompts)) { envPromptsToPass = parseEnvPromptsList(options.envPrompts); } await executeScriptFile(scriptPath, config, cliEnvVars, envPromptsToPass); }); program .command("run", { isDefault: true }) .description("Run scripts using the interactive UI.") .addOption(CLI_OPTIONS.config) .addOption(CLI_OPTIONS.scriptsDirOverride) .addOption(CLI_OPTIONS.tmpDirOverride) .addOption(CLI_OPTIONS.noTui) .addOption(CLI_OPTIONS.forceTui) .addOption(CLI_OPTIONS.envPrompts) .action(async (options) => { const config = await loadConfig(options.config); config.loadedConfigPath = options.config || (pathExistsSync(DEFAULT_CONFIG_FILE) ? DEFAULT_CONFIG_FILE : undefined); // Override config with CLI options if provided if (options.scriptsDir) { config.scriptsDir = path.resolve(options.scriptsDir); } if (options.tmpDir) { config.tmpDir = path.resolve(options.tmpDir); } // Ensure tmpDir exists if (!pathExistsSync(config.tmpDir)) { await fs.mkdir(config.tmpDir, { recursive: true }); } const baseEnv = loadEnvironmentVariables(config.envFiles.map((f) => path.resolve(f))); const interpolatedDefaultParams = config.defaultParams ? interpolateEnvVars(config.defaultParams, baseEnv) : {}; const fullEnv = { ...baseEnv, ...(typeof interpolatedDefaultParams === "object" && interpolatedDefaultParams !== null ? interpolatedDefaultParams : {}), }; // Populate configDisplayValues for the TUI const configDisplayValues = { scriptsDir: path.relative(process.cwd(), config.scriptsDir), tmpDir: path.relative(process.cwd(), config.tmpDir), envFiles: config.envFiles, excludePatterns: config.excludePatterns || [], defaultParamsCount: Object.keys(config.defaultParams || {}).length, loadedConfigPath: config.loadedConfigPath ? path.basename(config.loadedConfigPath) : "Defaults / Not found", }; // If --no-tui is specified or in debug mode (unless --force-tui is specified), just list scripts without TUI if (options.noTui === true || (process.env.SCRIPTIT_DEBUG === "true" && !options.forceTui)) { console.log(chalk.blue("Running in non-TUI mode. Available scripts:")); console.log(chalk.gray(`Scripts directory: ${config.scriptsDir}`)); // Show exclude patterns if defined if (config.excludePatterns && config.excludePatterns.length > 0) { console.log(chalk.gray(`Exclude patterns: ${config.excludePatterns.join(", ")}`)); } try { const scripts = await getScriptFiles(config.scriptsDir, config.excludePatterns); if (scripts.length === 0) { console.log(chalk.yellow("No scripts found in the scripts directory.")); } else { console.log(chalk.green("Available scripts:")); for (const [index, scriptPath] of scripts.entries()) { const relativePath = path.relative(config.scriptsDir, scriptPath); console.log(chalk.cyan(`${index + 1}. ${relativePath}`)); try { const scriptModule = await import(`file://${scriptPath}?v=${Date.now()}`); // Show function type information const hasDefault = typeof scriptModule.default === "function"; const hasExecute = typeof scriptModule.execute === "function"; const hasTearUp = typeof scriptModule.tearUp === "function"; const hasTearDown = typeof scriptModule.tearDown === "function"; let functionInfo = ""; if (hasDefault && hasExecute) { functionInfo = " [default + execute]"; } else if (hasDefault) { functionInfo = " [default]"; } else if (hasExecute) { functionInfo = " [execute]"; } else { functionInfo = " [no valid function]"; } if (hasTearUp || hasTearDown) { const lifecycle = []; if (hasTearUp) lifecycle.push("tearUp"); if (hasTearDown) lifecycle.push("tearDown"); functionInfo += ` + ${lifecycle.join("+")}`; } console.log(chalk.gray(` Function type:${functionInfo}`)); if (scriptModule.description) { console.log(chalk.gray(` Description: ${scriptModule.description}`)); } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); console.log(chalk.yellow(` Error loading script: ${errorMessage}`)); } } console.log(chalk.blue("\nTo run a script, use:")); console.log("scriptit exec <script-path>"); } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); console.error(chalk.red(`Error listing scripts: ${errorMessage}`)); } return; } try { await runTUI(config.loadedConfigPath, // Pass the actual path used config.scriptsDir, config.tmpDir, fullEnv, configDisplayValues); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); console.error(chalk.red("Error initializing TUI:")); console.error(chalk.red(errorMessage)); console.log(chalk.yellow("\nTry running with --no-tui or --debug option to see available scripts.")); } }); // Parse command line arguments and execute the appropriate command program.parse(); // Function to execute a single script (will be used by 'exec' command and potentially by TUI later if refactored) async function executeScriptFile(scriptPath, config, cliProvidedEnv = {}, // For env vars passed directly via CLI cliEnvPrompts = []) { const prompter = new CLIEnvironmentPrompter(); const cliLogger = { info: (msg) => logger.cliOutput(chalk.cyan(msg)), warn: (msg) => logger.cliOutput(chalk.yellow(msg)), error: (msg) => logger.cliOutput(chalk.red(msg)), }; try { logger.cliOutput(chalk.cyan(`--- Running script: ${path.basename(scriptPath)} ---`)); const result = await executeScriptWithEnvironment({ scriptPath, config, cliProvidedEnv, cliEnvPrompts, prompter, logger: { info: (msg) => logger.cliOutput(chalk.gray(` ${msg}`)), warn: (msg) => console.warn(chalk.yellow(` ${msg}`)), error: (msg) => console.error(chalk.red(` ${msg}`)), }, }); console.log(chalk.green(`--- Script ${path.basename(scriptPath)} finished successfully ---\n`)); if (result !== undefined) { console.log(chalk.gray(` Script result: ${typeof result === "object" ? JSON.stringify(result) : result}`)); } process.exit(0); // Explicit success exit } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(chalk.red(`--- Error running script ${path.basename(scriptPath)} ---`)); console.error(chalk.red(errorMessage)); console.error(chalk.red(`--- Script ${path.basename(scriptPath)} failed ---\n`)); process.exit(1); // Explicit failure exit } } // CLI-specific environment prompter using readline class CLIEnvironmentPrompter { async promptForVariables(variables, existingEnv) { return promptForEnvironmentVariables(variables, existingEnv); } } //# sourceMappingURL=cli.js.map