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
333 lines • 14.4 kB
JavaScript
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 } 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';
// 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();
// 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');
// 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') {
// Create pipeline-specific options structure
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') {
// Create builds-specific options structure
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);
}
}
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')
.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')
.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)')
.action(createCommandHandler(ShowBuild));
// 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;
});
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');
}
});
program.parse();
// Apply log level from command line options
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');
//# sourceMappingURL=index.js.map