UNPKG

bktide

Version:

Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users

570 lines 25.1 kB
#!/usr/bin/env node import { Command } from 'commander'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { BaseCommand, ShowViewer, ListOrganizations, ListBuilds, ListPipelines, ManageToken, ListAnnotations, GenerateCompletions, ShowBuild, Snapshot, ShowPipeline, ShowLogs, SmartShow, Prime, ArtifactsList, ArtifactsDownload, } from './commands/index.js'; import { initializeErrorHandling } from './utils/errorUtils.js'; import { displayCLIError, setErrorFormat } from './utils/cli-error-handler.js'; import { logger, setLogLevel } from './services/logger.js'; import { WidthAwareHelp } from './ui/help.js'; import { enhanceCommanderError } from './utils/commander-error-handler.js'; import { parseBuildkiteReference } from './utils/parseBuildkiteReference.js'; // Set a global error handler for uncaught exceptions const uncaughtExceptionHandler = (err) => { // Remove any existing handlers to avoid duplicates const handlers = process.listeners('uncaughtException'); handlers.forEach(listener => { if (listener !== uncaughtExceptionHandler) { process.removeListener('uncaughtException', listener); } }); displayCLIError(err, process.argv.includes('--debug')); }; process.on('uncaughtException', uncaughtExceptionHandler); // Set a global error handler for unhandled promise rejections const unhandledRejectionHandler = (reason) => { // Remove any existing handlers to avoid duplicates const handlers = process.listeners('unhandledRejection'); handlers.forEach(listener => { if (listener !== unhandledRejectionHandler) { process.removeListener('unhandledRejection', listener); } }); displayCLIError(reason, process.argv.includes('--debug')); }; process.on('unhandledRejection', unhandledRejectionHandler); // Initialize error handling after our handlers are registered initializeErrorHandling(); const program = new Command(); program.allowUnknownOption(); // Configure custom error output to enhance Commander.js errors program.configureOutput({ writeErr: (str) => { // Try to extract command name from different error formats // "too many arguments for 'builds'" or "missing required argument 'build'" let commandName = ''; const tooManyMatch = str.match(/for '(\w+)'/); if (tooManyMatch) { commandName = tooManyMatch[1]; } else { // For missing argument, get the command from process.argv // The command is typically the first non-option argument after 'bktide' const args = process.argv.slice(2); const cmdArg = args.find(a => !a.startsWith('-')); if (cmdArg) { commandName = cmdArg; } } // Get the extra args from process.argv const commandIndex = process.argv.findIndex(arg => arg === commandName); const extraArgs = commandIndex >= 0 ? process.argv.slice(commandIndex + 1).filter(a => !a.startsWith('-')) : []; const enhanced = enhanceCommanderError(str, commandName, extraArgs); process.stderr.write(enhanced + '\n'); } }); // Handler for executing commands with proper option handling const createCommandHandler = (CommandClass) => { return async function () { try { const options = this.mergedOptions || this.opts(); const cacheOptions = { enabled: options.cache !== false, ttl: options.cacheTtl, clear: options.clearCache }; if (CommandClass.requiresToken) { const token = await BaseCommand.getToken(options); options.token = token; } const handler = new CommandClass({ ...cacheOptions, token: options.token, debug: options.debug, format: options.format, quiet: options.quiet, tips: options.tips, }); // Pass command-specific options if available const commandName = this.name(); if (commandName === 'pipelines' && this.pipelineOptions) { logger.debug('Using pipeline options:', this.pipelineOptions); } else if (commandName === 'builds' && this.buildOptions) { logger.debug('Using build options:', this.buildOptions); } const exitCode = await handler.execute(options); // Set process.exitCode to propagate the exit code process.exitCode = exitCode; } catch (error) { const debug = this.mergedOptions?.debug || this.opts().debug || false; // No need to pass format - will use global format set in preAction hook displayCLIError(error, debug); process.exitCode = 1; // Set error exit code } }; }; function resolveAppVersion() { // Prefer environment-provided version (set in CI before publish) if (process.env.BKTIDE_VERSION && process.env.BKTIDE_VERSION.trim().length > 0) { return process.env.BKTIDE_VERSION.trim(); } try { // Attempt to read package.json near compiled dist/index.js const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const candidatePaths = [ path.resolve(__dirname, '..', 'package.json'), // when running from dist/ path.resolve(__dirname, '..', '..', 'package.json'), // fallback ]; for (const pkgPath of candidatePaths) { if (fs.existsSync(pkgPath)) { const raw = fs.readFileSync(pkgPath, 'utf-8'); const pkg = JSON.parse(raw); if (pkg.version) return pkg.version; } } } catch { // ignore } // Last resort return '0.0.0'; } // Create custom help instance const customHelp = new WidthAwareHelp(); program .name('bktide') .description('Buildkite CLI tool') .version(resolveAppVersion()) .configureHelp(customHelp) .showSuggestionAfterError() .option('--log-level <level>', 'Set logging level (trace, debug, info, warn, error, fatal)', 'info') .option('-d, --debug', 'Show debug information for errors') .option('--no-cache', 'Disable caching of API responses') .option('--cache-ttl <milliseconds>', 'Set cache time-to-live in milliseconds', parseInt) .option('--clear-cache', 'Clear all cached data before executing command') .option('-t, --token <token>', 'Buildkite API token (set BUILDKITE_API_TOKEN or BK_TOKEN env var)', process.env.BUILDKITE_API_TOKEN || process.env.BK_TOKEN) .option('--save-token', 'Save the token to system keychain for future use') .option('-f, --format <format>', 'Output format for results and errors (plain, json, alfred)', 'plain') .option('--color <mode>', 'Color output: auto|always|never', 'auto') .option('-q, --quiet', 'Suppress non-error output (plain format only)') .option('--tips', 'Show helpful tips and suggestions') .option('--no-tips', 'Hide helpful tips and suggestions') .option('--ascii', 'Use ASCII symbols instead of Unicode') .option('--full', 'Show all log lines (for step logs)') .option('--lines <n>', 'Show last N lines (default: 50)', '50') .option('--save <path>', 'Save logs to file'); // Add hooks for handling options program .hook('preAction', (_thisCommand, actionCommand) => { // Cast to our extended command type const cmd = actionCommand; // Merge global options with command-specific options const globalOpts = program.opts(); const commandOpts = cmd.opts(); const mergedOptions = { ...globalOpts, ...commandOpts }; // Set the global error format from the command line options if (mergedOptions.format) { setErrorFormat(mergedOptions.format); } // Apply color mode if (mergedOptions.color) { const mode = String(mergedOptions.color).toLowerCase(); // Respect NO_COLOR when mode is never; clear when always if (mode === 'never') { process.env.NO_COLOR = '1'; } else if (mode === 'always') { // Explicitly enable color by unsetting NO_COLOR; downstream code should still TTY-check if (process.env.NO_COLOR) { delete process.env.NO_COLOR; } process.env.BKTIDE_COLOR_MODE = 'always'; } else { // auto process.env.BKTIDE_COLOR_MODE = 'auto'; } } if (mergedOptions.cacheTtl && (isNaN(mergedOptions.cacheTtl) || mergedOptions.cacheTtl <= 0)) { logger.error('cache-ttl must be a positive number'); process.exitCode = 1; return; } if (mergedOptions.cache === false && mergedOptions.cacheTtl) { logger.warn('--no-cache and --cache-ttl used together. Cache will be disabled regardless of TTL setting.'); } // Validate count options if (mergedOptions.count && (isNaN(parseInt(mergedOptions.count)) || parseInt(mergedOptions.count) <= 0)) { logger.error('count must be a positive number'); process.exitCode = 1; return; } cmd.mergedOptions = mergedOptions; const commandName = cmd.name(); if (commandName === 'pipelines') { // Parse positional arg if provided and --org not specified const positionalOrg = cmd.args?.[0]; if (positionalOrg && !mergedOptions.org) { mergedOptions.org = positionalOrg; } cmd.pipelineOptions = { organization: mergedOptions.org, count: mergedOptions.count ? parseInt(mergedOptions.count) : undefined, filter: mergedOptions.filter }; if (mergedOptions.debug) { logger.debug('Pipeline options:', cmd.pipelineOptions); } } else if (commandName === 'builds') { // Parse positional reference if provided const positionalRef = cmd.args?.[0]; if (positionalRef && !mergedOptions.org && !mergedOptions.pipeline) { try { const ref = parseBuildkiteReference(positionalRef); mergedOptions.org = ref.org; mergedOptions.pipeline = ref.pipeline; } catch { // Invalid format - will be handled by existing validation or API if (mergedOptions.debug) { logger.debug(`Could not parse reference: ${positionalRef}`); } } } cmd.buildOptions = { organization: mergedOptions.org, pipeline: mergedOptions.pipeline, branch: mergedOptions.branch, state: mergedOptions.state, count: mergedOptions.count ? parseInt(mergedOptions.count) : 10, page: mergedOptions.page ? parseInt(mergedOptions.page) : 1, filter: mergedOptions.filter }; if (mergedOptions.debug) { logger.debug('Build options:', cmd.buildOptions); } } else if (commandName === 'annotations') { // Attach the build argument to options cmd.mergedOptions.buildArg = cmd.args?.[0]; if (mergedOptions.debug) { logger.debug('Annotations build arg:', cmd.mergedOptions.buildArg); logger.debug('Annotations context filter:', mergedOptions.context); } } else if (commandName === 'build') { // Attach the build argument to options cmd.mergedOptions.buildArg = cmd.args?.[0]; if (mergedOptions.debug) { logger.debug('Build arg:', cmd.mergedOptions.buildArg); logger.debug('Build options:', mergedOptions); } } else if (commandName === 'snapshot') { // Attach the build-ref argument to options cmd.mergedOptions.buildRef = cmd.args?.[0]; if (mergedOptions.debug) { logger.debug('Snapshot build-ref:', cmd.mergedOptions.buildRef); logger.debug('Snapshot options:', mergedOptions); } } if (mergedOptions.debug) { logger.debug(`Executing command: ${commandName}`); logger.debug('Options:', mergedOptions); } }) .hook('postAction', (_thisCommand, actionCommand) => { // Cast to our extended command type const cmd = actionCommand; // Accessing the custom property const options = cmd.mergedOptions || {}; if (options.debug) { logger.debug(`Command ${cmd.name()} completed`); } }); program .command('viewer') .description('Show logged in user information') .action(createCommandHandler(ShowViewer)); program .command('orgs') .description('List organizations') .action(createCommandHandler(ListOrganizations)); program .command('pipelines') .description('List pipelines for an organization') .argument('[org]', 'Organization slug (shorthand for --org)') .option('-o, --org <org>', 'Organization slug (optional - will search all your orgs if not specified)') .option('-n, --count <count>', 'Limit to specified number of pipelines per organization') .option('--filter <name>', 'Filter pipelines by name (case insensitive)') .action(createCommandHandler(ListPipelines)); // Update the builds command to include REST API filtering options program .command('builds') .description('List builds for the current user') .argument('[reference]', 'Pipeline reference (org/pipeline) - shorthand for --org and --pipeline') .option('-o, --org <org>', 'Organization slug (optional - will search all your orgs if not specified)') .option('-p, --pipeline <pipeline>', 'Filter by pipeline slug') .option('-b, --branch <branch>', 'Filter by branch name') .option('-s, --state <state>', 'Filter by build state (running, scheduled, passed, failing, failed, canceled, etc.)') .option('-n, --count <count>', 'Number of builds per page', '10') .option('--page <page>', 'Page number', '1') .option('--filter <filter>', 'Fuzzy filter builds by name or other properties') .action(createCommandHandler(ListBuilds)); // Add token management command program .command('token') .description('Manage API tokens') .option('--check', 'Check if a token is stored in the system keychain') .option('--store', 'Store a token in the system keychain') .option('--reset', 'Delete the stored token from system keychain') .action(createCommandHandler(ManageToken)); // Add annotations command program .command('annotations') .description('Show annotations for a build') .argument('<build>', 'Build reference (org/pipeline/number or @https://buildkite.com/org/pipeline/builds/number)') .option('--context <context>', 'Filter annotations by context (e.g., rspec, build-resources)') .action(createCommandHandler(ListAnnotations)); // Add build command program .command('build') .description('Show details for a specific build') .argument('<build>', 'Build reference (org/pipeline/number or @https://buildkite.com/org/pipeline/builds/number)') .option('--jobs', 'Show job summary and details') .option('--failed', 'Show only failed job details (implies --jobs)') .option('--all-jobs', 'Show all jobs without grouping limit') .option('--annotations', 'Show annotation details with context') .option('--annotations-full', 'Show complete annotation content') .option('--full', 'Show all available information') .option('--summary', 'Single-line summary only (for scripts)') .option('-w, --watch', 'Watch build until completion') .option('--timeout <minutes>', 'Max wait time in minutes (default: 30)', '30') .option('--poll-interval <seconds>', 'Initial poll interval in seconds (default: 5)', '5') .action(createCommandHandler(ShowBuild)); // Add snapshot command program .command('snapshot') .description('Fetch and save build data locally for offline analysis. Omit build-ref to auto-detect from git branch.') .argument('[build-ref]', 'Build reference (org/pipeline/number or URL). Omit to auto-detect from git branch.') .option('-b, --branch <branch>', 'Override branch detection for branch-aware snapshot') .option('-o, --org <org>', 'Organization slug (required if you belong to multiple orgs)') .option('--output-dir <path>', 'Output directory for snapshot') .option('--json', 'Output manifest JSON to stdout') .option('--failed', 'Only fetch failed steps (default behavior)') .option('--all', 'Fetch all steps, not just failed ones') .option('--force', 'Force full re-fetch, bypassing change detection') .option('-w, --watch', 'Watch build until completion, then snapshot') .option('--timeout <minutes>', 'Max wait time in minutes (default: 30)', '30') .option('--poll-interval <seconds>', 'Initial poll interval in seconds (default: 5)', '5') .option('--artifacts', 'Download build artifacts into snapshot directory') .option('--artifact-glob <pattern>', 'Filter artifacts to download (e.g. "*.patch", "**/*.xml")') .action(createCommandHandler(Snapshot)); // Add pipeline command program .command('pipeline') .description('Show pipeline details and recent builds') .argument('<reference>', 'Pipeline reference (org/pipeline or URL)') .option('-n, --count <n>', 'Number of recent builds to show', '20') .action(async function (reference) { try { const options = this.mergedOptions || this.opts(); const token = await BaseCommand.getToken(options); const handler = new ShowPipeline({ token, debug: options.debug, format: options.format, quiet: options.quiet, tips: options.tips, }); const exitCode = await handler.execute({ ...options, reference, count: options.count ? parseInt(options.count) : 20, }); process.exitCode = exitCode; } catch (error) { const debug = this.mergedOptions?.debug || this.opts().debug || false; displayCLIError(error, debug); process.exitCode = 1; } }); // Add logs command program .command('logs') .description('Show logs for a build step') .argument('<build-ref>', 'Build reference (org/pipeline/build or URL)') .argument('[step-id]', 'Step/job ID (or include in URL with ?sid=)') .option('--full', 'Show all log lines') .option('--lines <n>', 'Show last N lines', '50') .option('--save <path>', 'Save logs to file') .option('-f, --follow', 'Follow logs as they stream (until job completes)') .option('--poll-interval <seconds>', 'Polling interval in seconds (default: 3)', '3') .action(async function (buildRef, stepId) { try { const options = this.mergedOptions || this.opts(); const token = await BaseCommand.getToken(options); const handler = new ShowLogs({ token, debug: options.debug, format: options.format, quiet: options.quiet, tips: options.tips, }); const exitCode = await handler.execute({ ...options, buildRef, stepId, full: options.full, lines: options.lines ? parseInt(options.lines) : 50, save: options.save, follow: options.follow, pollInterval: options.pollInterval ? parseInt(options.pollInterval) : 3, }); process.exitCode = exitCode; } catch (error) { const debug = this.mergedOptions?.debug || this.opts().debug || false; displayCLIError(error, debug); process.exitCode = 1; } }); // Add artifacts command with list and download subcommands const artifactsCmd = program .command('artifacts') .description('Manage build artifacts'); artifactsCmd .command('list <build-ref>') .description('List artifacts for a build') .action(async function (buildRef) { try { const options = this.mergedOptions || this.opts(); const token = await BaseCommand.getToken(options); const handler = new ArtifactsList({ token, debug: options.debug, format: options.format, quiet: options.quiet, tips: options.tips }); process.exitCode = await handler.execute({ ...options, buildRef }); } catch (error) { const debug = this.mergedOptions?.debug || this.opts().debug || false; displayCLIError(error, debug); process.exitCode = 1; } }); artifactsCmd .command('download <build-ref>') .description('Download artifacts for a build') .option('--id <id>', 'Download a specific artifact by ID') .option('--path <glob>', 'Download artifacts matching a path glob (e.g. "*.patch", "**/*.xml")') .option('--out <dir>', 'Output directory (default: ./)', './') .action(async function (buildRef) { try { const options = this.mergedOptions || this.opts(); const token = await BaseCommand.getToken(options); const handler = new ArtifactsDownload({ token, debug: options.debug, format: options.format, quiet: options.quiet, tips: options.tips }); process.exitCode = await handler.execute({ ...options, buildRef }); } catch (error) { const debug = this.mergedOptions?.debug || this.opts().debug || false; displayCLIError(error, debug); process.exitCode = 1; } }); // Add completions command program .command('completions [shell]') .description('Generate shell completions') .action(async (shell) => { const handler = new GenerateCompletions(); const exitCode = await handler.execute({ shell, quiet: program.opts().quiet, debug: program.opts().debug }); process.exitCode = exitCode; }); // Add prime command program .command('prime') .description('Output LLM/AI agent rules for Buildkite CI integration') .addHelpText('after', ` Examples: $ bktide prime # View rules $ bktide prime >> ~/.claude/CLAUDE.md # Append to Claude Code memory `) .action(createCommandHandler(Prime)); program .command('boom') .description('Test error handling') .option('--type <type>', 'Type of error to throw (basic, api, object)', 'basic') .option('--format <format>', 'Output format (plain, json, alfred)', 'plain') .action((options) => { switch (options.type) { case 'api': const apiError = new Error('API request failed'); apiError.response = { errors: [ { message: 'Invalid token', path: ['viewer'] } ] }; throw apiError; case 'object': throw { message: 'This is not an Error instance', code: 'CUSTOM_ERROR' }; case 'basic': default: throw new Error('Boom! This is a test error'); } }); // Apply log level from command line options before parsing const options = program.opts(); if (options.debug) { // Debug mode takes precedence over log-level setLogLevel('debug'); logger.debug('Debug mode enabled via --debug flag'); } else if (options.logLevel) { setLogLevel(options.logLevel); logger.debug(`Log level set to ${options.logLevel} via --log-level option`); } logger.debug({ pid: process.pid, }, 'Buildkite CLI started'); // Handle unknown commands by trying to parse as Buildkite references program.on('command:*', (operands) => { const potentialReference = operands[0]; (async () => { try { const { parseBuildkiteReference } = await import('./utils/parseBuildkiteReference.js'); // Try to parse as Buildkite reference (will throw if invalid) parseBuildkiteReference(potentialReference); // If parsing succeeds, route to SmartShow const token = await BaseCommand.getToken(options); const smartShowCommand = new SmartShow(); const smartShowOptions = { reference: potentialReference, token, format: options.format, debug: options.debug, full: options.full, lines: options.lines ? parseInt(options.lines) : undefined, save: options.save, cache: options.cache !== false, cacheTtl: options.cacheTtl, clearCache: options.clearCache, quiet: options.quiet, tips: options.tips, }; const exitCode = await smartShowCommand.execute(smartShowOptions); process.exit(exitCode); } catch (parseError) { // If parsing fails, show unknown command error logger.error(`Unknown command: ${potentialReference}`); logger.error(`Run 'bktide --help' for usage information`); process.exit(1); } })(); }); // Parse command line arguments program.parse(); //# sourceMappingURL=index.js.map