UNPKG

@catladder/cli

Version:

Panter cli tool for cloud CI/CD and DevOps

245 lines (212 loc) • 7.27 kB
import type { ComponentContext } from "@catladder/pipeline"; import { Command } from "commander"; import { createTerminalContext } from "./adapters/terminal"; import { generateZshCompletionScript, getCompletions, installCompletions, uninstallCompletions, } from "./completion"; import { getAllPipelineContexts } from "./config/getProjectConfig"; import type { CommandDef } from "./core/types"; import packageInfos from "./packageInfos"; import { stopAllPortForwards } from "./utils/portForwards"; // Import all commands import * as commands from "./commands"; const program = new Command(); program .name("catladder") .description("Panter CLI tool for cloud CI/CD and DevOps") .version(packageInfos.version) .option("-y, --yes", "skip all confirmation prompts"); // Collect all command defs for completion const allCommandDefs: CommandDef[] = []; // Cache for intermediate subcommand groups (e.g., "project", "project cloudsql") const subcommandCache = new Map<string, Command>(); /** * Get or create a chain of nested subcommands. * E.g., for ["project", "cloudsql"], ensures program.command("project").command("cloudsql") exists. */ function getOrCreateParent(parts: string[]): Command { if (parts.length === 0) return program; const key = parts.join(" "); if (subcommandCache.has(key)) return subcommandCache.get(key)!; const parent = getOrCreateParent(parts.slice(0, -1)); const cmd = parent.command(parts[parts.length - 1]); subcommandCache.set(key, cmd); return cmd; } /** * Register a CommandDef with Commander.js. * - Command name can contain spaces for nesting (e.g., "project cloudsql proxy") * - Inputs with `positional: true` become Commander positional args * - Other inputs become optional --flags * - --inputs accepts JSON for programmatic use */ function registerCommand(def: CommandDef): void { allCommandDefs.push(def); // Separate positional inputs from flag inputs const positionalInputs: [string, (typeof def.inputs)[string]][] = []; const flagInputs: [string, (typeof def.inputs)[string]][] = []; for (const [name, inputDef] of Object.entries(def.inputs)) { if (inputDef.positional) { positionalInputs.push([name, inputDef]); } else { flagInputs.push([name, inputDef]); } } // Split name into parent groups + leaf command const nameParts = def.name.split(" "); const parentParts = nameParts.slice(0, -1); const leafName = nameParts[nameParts.length - 1]; // Build leaf command string with positional args let cmdStr = leafName; for (const [name] of positionalInputs) { cmdStr += ` [${name}]`; } const parent = getOrCreateParent(parentParts); const cmd = parent.command(cmdStr).description(def.description); // Add --flag for each non-positional input for (const [name, inputDef] of flagInputs) { const flagName = name.replace(/([A-Z])/g, "-$1").toLowerCase(); const desc = inputDef.message.replace(/\s*🤔\s*$/, "").trim(); if (inputDef.type === "boolean") { cmd.option(`--${flagName}`, desc); cmd.option(`--no-${flagName}`, `negate: ${desc}`); } else { cmd.option(`--${flagName} <value>`, desc); } } // Add --inputs for JSON input cmd.option("--inputs <json>", "JSON object with input values"); cmd.action(async (...rawArgs: any[]) => { // Commander passes positional args first, then options object, then the Command const opts = rawArgs[rawArgs.length - 2]; // Parse positional args into cliOptions const cliOptions: Record<string, unknown> = {}; positionalInputs.forEach(([name], i) => { if (rawArgs[i] !== undefined) { cliOptions[name] = rawArgs[i]; } }); // Parse CLI flag overrides for (const [name, inputDef] of flagInputs) { const flagName = name.replace(/([A-Z])/g, "-$1").toLowerCase(); const camelName = flagName.replace(/-([a-z])/g, (_, c) => c.toUpperCase(), ); if (opts[camelName] !== undefined) { let value = opts[camelName]; if (inputDef.type === "number" && typeof value === "string") { value = Number(value); } cliOptions[name] = value; } } // Parse --inputs JSON let jsonInputs: Record<string, unknown> = {}; if (opts.inputs) { try { jsonInputs = JSON.parse(opts.inputs); } catch { console.error("Error: --inputs must be valid JSON"); process.exit(1); } } const ctx = createTerminalContext(def, { cliOptions, jsonInputs, yes: program.opts().yes, }); try { await def.execute(ctx); } catch (err: any) { if (err.name === "MissingInputError") { console.error(`\n${err.message}\n`); process.exit(1); } throw err; } }); } // Register all commands (async to support isAvailable checks) async function registerAllCommands() { // Fetch contexts once for all isAvailable checks let contexts: ComponentContext[] = []; try { contexts = await getAllPipelineContexts(); } catch { // No config available (e.g. outside a project) — contexts stays empty } for (const cmd of Object.values(commands)) { if (cmd && typeof cmd === "object" && "name" in cmd && "execute" in cmd) { const def = cmd as CommandDef; if (def.isAvailable) { if (!def.isAvailable(contexts)) continue; } registerCommand(def); } } } // Hidden __complete command for shell completion program .command("__complete", { hidden: true }) .argument("[words]", "words typed so far") .argument("[cursor]", "word at cursor") .action(async (wordsStr: string | undefined, cursor: string | undefined) => { const words = wordsStr ? wordsStr.split(" ").filter(Boolean) : []; const cursorWord = cursor ?? ""; try { const completions = await getCompletions( allCommandDefs, words, cursorWord, ); for (const c of completions) { console.log(c); } } catch { // Silently fail -- completion should never break the shell } process.exit(0); }); // completion commands const completionCmd = program .command("completion") .description("shell completion setup"); completionCmd .command("zsh") .description("output zsh completion script") .action(() => { console.log(generateZshCompletionScript()); }); completionCmd .command("install") .description("install shell completions into your .zshrc") .action(async () => { await installCompletions(); }); completionCmd .command("uninstall") .description("remove shell completions from your .zshrc") .action(async () => { await uninstallCompletions(); }); // Cleanup on exit process.on("exit", () => { stopAllPortForwards(); }); // Prevent Node.js from printing the (minified) source line on uncaught errors process.on("uncaughtException", (err) => { console.error(err.stack ?? `${err.name}: ${err.message}`); process.exit(1); }); process.on("unhandledRejection", (reason) => { if (reason instanceof Error) { console.error(reason.stack ?? `${reason.name}: ${reason.message}`); } else { console.error(reason); } process.exit(1); }); registerAllCommands().then(() => program.parse());