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
228 lines • 8.89 kB
JavaScript
import { logger } from './logger.js';
import { BuildkiteClient } from './BuildkiteClient.js';
import { BuildkiteRestClient } from './BuildkiteRestClient.js';
import { isRunningInAlfred } from '../utils/alfred.js';
import { Progress } from '../ui/progress.js';
const SERVICE_NAME = 'bktide';
const ACCOUNT_KEY = 'default';
/**
* Manages secure storage of credentials using the system's keychain
*/
export class CredentialManager {
entry;
constructor(serviceName = SERVICE_NAME, accountName = ACCOUNT_KEY) {
// Do not load keyring when running under Alfred
if (!isRunningInAlfred()) {
// Lazy init via dynamic import to avoid resolving native module in Alfred context
// Note: constructor remains sync; actual instantiation is deferred in ensureEntry
void this.ensureEntry(serviceName, accountName);
}
}
async ensureEntry(serviceName = SERVICE_NAME, accountName = ACCOUNT_KEY) {
if (this.entry)
return this.entry;
if (isRunningInAlfred())
return undefined;
try {
const keyring = await import('@napi-rs/keyring');
this.entry = new keyring.Entry(serviceName, accountName);
return this.entry;
}
catch (error) {
logger.debug('Failed to initialize keyring Entry, continuing without keychain', error);
this.entry = undefined;
return undefined;
}
}
/**
* Stores a token in the system keychain
* @param token The Buildkite API token to store
* @returns true if token was successfully stored
*/
async saveToken(token) {
if (isRunningInAlfred()) {
// In Alfred path, we do not persist tokens programmatically
throw new Error('In Alfred, set token via Workflow Configuration (User Configuration).');
}
try {
const entry = await this.ensureEntry();
if (!entry)
throw new Error('Keyring unavailable');
await entry.setPassword(token);
logger.debug('Token saved to system keychain');
return true;
}
catch (error) {
logger.error('Failed to save token to system keychain', error);
return false;
}
}
/**
* Retrieves the stored token from the system keychain
* @returns The stored token or undefined if not found
*/
async getToken() {
// Alfred: use env var only
if (isRunningInAlfred()) {
return process.env.BUILDKITE_API_TOKEN || process.env.BK_TOKEN || undefined;
}
try {
const entry = await this.ensureEntry();
if (!entry)
return undefined;
const token = entry.getPassword();
return token || undefined;
}
catch {
return undefined;
}
}
/**
* Deletes the stored token from the system keychain
* @returns true if token was successfully deleted
*/
async deleteToken() {
if (isRunningInAlfred()) {
// Nothing to delete in keyring under Alfred
return true;
}
try {
const entry = await this.ensureEntry();
if (!entry)
return false;
await entry.deletePassword();
logger.debug('Token deleted from system keychain');
return true;
}
catch (error) {
logger.error('Failed to delete token from system keychain', error);
return false;
}
}
/**
* Checks if a token exists in the system keychain
* @returns true if a token exists
*/
async hasToken() {
const token = await this.getToken();
return !!token;
}
/**
* Validates if a token is valid by making test API calls to both GraphQL and REST APIs
* @param token Optional token to validate. If not provided, will use the stored token.
* @param options Optional configuration for progress display
* @returns Object containing validation status for both GraphQL and REST APIs
*/
async validateToken(token, options) {
try {
// If no token provided, try to get the stored one
const tokenToValidate = token || await this.getToken();
if (!tokenToValidate) {
logger.debug('No token provided for validation');
return {
valid: false,
canListOrganizations: false,
organizations: {}
};
}
// Create clients with the token
const graphqlClient = new BuildkiteClient(tokenToValidate, { debug: false, caching: false });
const restClient = new BuildkiteRestClient(tokenToValidate, { debug: false });
// First check if we can list organizations
let orgSlugs = [];
try {
orgSlugs = await graphqlClient.getOrganizations().then(orgs => orgs.map(org => org.slug));
logger.debug('Successfully retrieved organization slugs');
}
catch (error) {
logger.debug('Failed to retrieve organization slugs', {
error: error instanceof Error ? error.message : String(error),
cause: error instanceof Error && error.cause ? error.cause : undefined
});
return {
valid: false,
canListOrganizations: false,
organizations: {}
};
}
const organizations = {};
let allValid = true;
// Determine if we should show progress
const showProgress = options?.showProgress !== false &&
!isRunningInAlfred() &&
orgSlugs.length > 0;
const progress = showProgress ? Progress.bar({
total: orgSlugs.length * 3,
label: 'Validating token access',
format: options?.format
}) : null;
let checkCount = 0;
// Validate each organization
for (const orgSlug of orgSlugs) {
const orgStatus = {
graphql: false,
builds: false,
organizations: false
};
// Check GraphQL access
if (progress) {
progress.update(checkCount++, `Checking GraphQL access for ${orgSlug}`);
}
try {
await graphqlClient.getViewer();
orgStatus.graphql = true;
}
catch (error) {
logger.debug(`GraphQL validation failed for organization ${orgSlug}`, error);
allValid = false;
}
// Check build access
if (progress) {
progress.update(checkCount++, `Checking build access for ${orgSlug}`);
}
try {
await restClient.hasBuildAccess(orgSlug);
orgStatus.builds = true;
}
catch (error) {
logger.debug(`Build access validation failed for organization ${orgSlug}`, error);
allValid = false;
}
// Check organization access
if (progress) {
progress.update(checkCount++, `Checking organization access for ${orgSlug}`);
}
try {
await restClient.hasOrganizationAccess(orgSlug);
orgStatus.organizations = true;
}
catch (error) {
logger.debug(`Organization access validation failed for organization ${orgSlug}`, error);
allValid = false;
}
organizations[orgSlug] = orgStatus;
}
// Complete the progress bar
if (progress) {
const successCount = Object.values(organizations)
.filter(org => org.graphql && org.builds && org.organizations)
.length;
progress.complete(`✓ Validated ${orgSlugs.length} organizations (${successCount} fully accessible)`);
}
return {
valid: allValid,
canListOrganizations: true,
organizations
};
}
catch (error) {
logger.debug('Token validation failed', error);
return {
valid: false,
canListOrganizations: false,
organizations: {}
};
}
}
}
//# sourceMappingURL=CredentialManager.js.map