Skip to content

Commit

Permalink
better clihandler mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
miunau committed Jan 6, 2025
1 parent b047c4e commit 0c644f9
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 80 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"bin": {
"salakala": "./dist/cli.js"
},
"version": "0.5.0",
"version": "0.5.1",
"description": "Generate .env files from various secret providers",
"type": "module",
"scripts": {
Expand Down
140 changes: 117 additions & 23 deletions src/lib/CliHandler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { spawn } from "child_process";

/**
* Represents the result of a CLI command execution
* @class CliResponse
*/
export class CliResponse {
/** Final state of the command execution */
state: 'ok' | 'try-again' | 'error' | 'catastrophic';
/** Standard output from the command */
stdout: string;
/** Standard error output from the command */
stderr: string;
/** Optional message providing additional context */
message?: string;
/** Original error object if an error occurred */
error?: Error;
/** Exit code from the command */
code: number;

constructor({
Expand All @@ -26,12 +36,51 @@ export class CliResponse {
}
}

function prune(str: string) {
// Censor things in `--session=""`
/**
* Sanitizes command strings by censoring sensitive session information
* @param str - The command string to sanitize
* @returns The sanitized command string
*/
function censor(str: string) {
// Censor things in `--session=""` to prevent logging sensitive data
return str.replace(/--session="[^"]*"/g, '--session="****"');
}

/**
* Prunes annoying punycode warning from Google Cloud SDK output
* @param str - The string to prune
* @returns The pruned string
*/
function prune(str: string) {
const pruneMessages = [
/\(node:.*\) \[DEP0040\] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.\n/g,
/\(Use `node --trace-deprecation ...` to show where the warning was created\)\n/g
];
return str.replaceAll(pruneMessages[0], '').replaceAll(pruneMessages[1], '');
}

/**
* Handles execution of CLI commands with advanced error handling and output control
* @class CliHandler
*/
export class CliHandler {
/**
* Executes a CLI command with configurable options
* @param command - The command to execute
* @param options - Configuration options for command execution
* @param options.env - Additional environment variables
* @param options.interactive - Whether to pipe input/output to parent process
* @param options.stdio - Custom stdio configuration
* @param options.expectedSuccess - Regex patterns indicating successful output
* @param options.expectedFailure - Regex patterns indicating expected failures
* @param options.onStdout - Callback for stdout data
* @param options.onStderr - Callback for stderr data
* @param options.onClose - Callback for process close
* @param options.debug - Enable debug logging
* @param options.passwordPrompt - String pattern indicating a password prompt, after which output should be censored
* @param options.password - Password to input when passwordPrompt is detected (for non-interactive mode)
* @returns Promise<CliResponse> - Resolution of command execution
*/
run(command: string, options: {
env?: NodeJS.ProcessEnv;
interactive?: boolean;
Expand All @@ -42,14 +91,21 @@ export class CliHandler {
onStderr?: (data: string) => void;
onClose?: (code: number) => void;
debug?: boolean;
passwordPrompt?: string;
password?: string;
} = {}): Promise<CliResponse> {

let errorValue: Error | null = null;
let passwordPromptSeen = false;
let userInputSeen = false;
let passwordSent = false;
const textDecoder = new TextDecoder();
const textEncoder = new TextEncoder();

return new Promise((resolve, reject) => {
console.log(`🔒🐟 Running command: ${prune(command)}`);
console.log(` Running: ${censor(command)}`);

// Spawn process with inherited TTY settings for proper color support
const child = spawn(command, {
shell: true,
stdio: options.interactive ? ['inherit', 'pipe', 'pipe'] : (options.stdio ?? 'pipe'),
Expand All @@ -63,29 +119,67 @@ export class CliHandler {
});

let stdout = '', stderr = '';
let lineAtPasswordPrompt = '';

child.stdout?.on('data', (data) => {
if (options.interactive) {
process.stdout.write(data);
}
const line = textDecoder.decode(data);
function handleStd(data: Buffer, type: 'stdout' | 'stderr') {
const prunedData = prune(textDecoder.decode(data));
let line = prunedData;
if (options.debug) {
console.log(`[spawn:stdout] ${line}`);
console.log(`[spawn:${type}] ${line}`);
}
// Handle password input in non-interactive mode
if (
!options.interactive &&
options.passwordPrompt &&
options.password &&
line.includes(options.passwordPrompt) &&
!passwordSent
) {
child.stdin?.write(options.password + '\n');
passwordSent = true;
return;
}

if(!options.interactive) {
type === 'stdout' ? stdout += line : stderr += line;
type === 'stdout' ? options.onStdout?.(line) : options.onStderr?.(line);
return;
} else {
// Check for password prompt
if (options.passwordPrompt && line.includes(options.passwordPrompt)) {
if(options.debug) console.log(`🐛 Password prompt seen in ${type}: ${options.passwordPrompt}`);
passwordPromptSeen = true;
lineAtPasswordPrompt = line;
}
// If we've seen a password prompt and this is a new line (likely user input)
if (passwordPromptSeen && !userInputSeen && line !== lineAtPasswordPrompt) {
userInputSeen = true;
if(options.debug) console.log(`🐛 User input seen in ${type}: ${line}`);
}
// Censor output after password prompt and user input
if (passwordPromptSeen && userInputSeen && !line.includes(options.passwordPrompt || '')) {
if(options.debug) console.log(`🐛 Censoring output after password prompt and user input`);
line = '';
}
type === 'stdout' ? process.stdout.write(textEncoder.encode(line)) : process.stderr.write(textEncoder.encode(line));
if(!passwordPromptSeen && !userInputSeen) {
if(options.debug) console.log(`🐛 Adding to ${type}: ${line}`);
type === 'stdout' ? stdout += line : stderr += line;
type === 'stdout' ? options.onStdout?.(line) : options.onStderr?.(line);
} else {
if(options.debug) console.log(`🐛 Adding to ${type}: ${prunedData}`);
type === 'stdout' ? stdout += prunedData : stderr += prunedData;
type === 'stdout' ? options.onStdout?.(prunedData) : options.onStderr?.(prunedData);
}
}
stdout += line;
options.onStdout?.(line);
}

child.stdout?.on('data', (data) => {
handleStd(data, 'stdout');
});

child.stderr?.on('data', (data) => {
if (options.interactive) {
process.stderr.write(data);
}
const line = textDecoder.decode(data);
if (options.debug) {
console.log(`[spawn:stderr] ${line}`);
}
stderr += line;
options.onStderr?.(line);
handleStd(data, 'stderr');
});

if (options.debug) {
Expand All @@ -105,12 +199,12 @@ export class CliHandler {
console.log(`[spawn:close] stdout: ${stdout}`);
console.log(`[spawn:close] stderr: ${stderr}`);
}

// Handle different exit scenarios
if (code === 0) {
resolve(new CliResponse({stdout, stderr, code: code || 0, state: 'ok', message: ''}));
} else {
if (options.debug) {
console.log(`[spawn:close] Command failed with code ${code} and error: ${errorValue?.message}`);
}
// Handle specific error cases with descriptive messages
if(errorValue && errorValue.message.includes('ENOENT')) {
resolve(new CliResponse({stdout, stderr, code: code || 0, state: 'catastrophic', message: `Command not found: ${command}`, error: errorValue}));
} else if(errorValue && errorValue.message.includes('EACCES')) {
Expand Down
10 changes: 3 additions & 7 deletions src/lib/SecretsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,17 @@ export class SecretsManager {
const provider = this.providers.get(prefix)!;
for (const [envVar, secretPath] of secretGroup.entries()) {
try {
console.info(`Getting value for ${envVar} from ${secretPath}`);
console.info(`🔒 Fetching ${envVar} from ${secretPath}`);
const secretValue = await provider.getSecret(secretPath);
secrets[envVar] = secretValue;
} catch (error: unknown) {
const err = error instanceof Error ? error.message : String(error);
errors.push(new Error(`Failed to get value for ${envVar} using ${secretPath}:\n- ${err}`));
// If an error occurs, throw it immediately
throw new Error(`Failed to get value for ${envVar} using ${secretPath}:\n- ${err}`);
}
}
}

// If any errors occurred, throw them all together
if (errors.length > 0) {
throw new Error(errors.map(e => e.message).join('\n'));
}

return secrets;
}
}
32 changes: 17 additions & 15 deletions src/lib/providers/1Password.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execSync } from 'child_process';
import { SecretProvider } from '../SecretProvider.js';
import { CliHandler } from '../CliHandler.js';

/**
* Provider for accessing secrets stored in 1Password using the 1Password CLI (op).
Expand All @@ -14,14 +14,12 @@ import { SecretProvider } from '../SecretProvider.js';
* @see {@link https://developer.1password.com/docs/cli/reference} for 1Password CLI documentation
*/
export class OnePasswordProvider extends SecretProvider {
/**
* Cached session token for reuse across multiple secret retrievals.
* @private
*/
private sessionToken: string | null = null;
private cli: CliHandler;

constructor() {
super();
this.cli = new CliHandler();
// Use service account token if available
if (process.env.OP_SERVICE_ACCOUNT_TOKEN) {
this.sessionToken = process.env.OP_SERVICE_ACCOUNT_TOKEN;
Expand Down Expand Up @@ -55,11 +53,15 @@ export class OnePasswordProvider extends SecretProvider {
// Only attempt interactive signin if not using service account token
if (!process.env.OP_SERVICE_ACCOUNT_TOKEN) {
try {
// Get the session token by signing in
this.sessionToken = execSync('op signin --raw', {
encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'pipe']
}).trim();
console.log('🔑 1Password needs to login. You are interacting with 1Password CLI now.');
const loginResponse = await this.cli.run('op signin --raw', {
interactive: true,
passwordPrompt: 'Enter the password for'
});
if (loginResponse.state !== 'ok') {
throw new Error(loginResponse.error?.message || loginResponse.message || 'Unable to run op signin');
}
this.sessionToken = loginResponse.stdout.trim();

// Retry with the new session token
return await this.getSecretValue(path, this.sessionToken);
Expand Down Expand Up @@ -93,12 +95,12 @@ export class OnePasswordProvider extends SecretProvider {
? `op read "${path}" --session="${sessionToken}"`
: `op read "${path}"`;

const result = execSync(command, {
encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'pipe']
});
const response = await this.cli.run(command);
if (response.state !== 'ok') {
throw new Error(response.error?.message || response.message || 'Unable to read secret');
}

const value = result.trim();
const value = response.stdout.trim();
if (!value) {
throw new Error(`No value found for secret at path '${path}'`);
}
Expand Down
15 changes: 10 additions & 5 deletions src/lib/providers/Bitwarden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,21 +186,26 @@ export class BitwardenProvider extends SecretProvider {
}
this.sessionKey = sessionResponse.stdout;
} else {
console.log('🔒🐟 No BW_CLIENTID, BW_CLIENTSECRET, or BW_PASSWORD found, trying interactive login');
// Check login status
const loginStatusResponse = await this.cli.run('bw login --check');
if(loginStatusResponse.state !== 'ok' || !loginStatusResponse.stdout.includes("You are logged in")) {
// Try to login
console.log('🔒🐟 Trying to login. You are interacting with Bitwarden CLI now.');
const loginResponse = await this.cli.run('bw login --raw', { interactive: true });
console.log('🔑 Bitwarden needs to login. You are interacting with Bitwarden CLI now.');
const loginResponse = await this.cli.run('bw login --raw', {
interactive: true,
passwordPrompt: 'Master password'
});
if(loginResponse.state !== 'ok') {
throw new Error(loginResponse.error?.message || loginResponse.message || 'Unable to run bw login');
}
this.sessionKey = loginResponse.stdout;
} else {
// Unlock
console.log('🔒🐟 Unlocking session. You are interacting with Bitwarden CLI now.');
const sessionResponse = await this.cli.run('bw unlock --raw', { interactive: true });
console.log('🔑 Bitwarden needs to unlock your session. You are interacting with Bitwarden CLI now.');
const sessionResponse = await this.cli.run('bw unlock --raw', {
interactive: true,
passwordPrompt: 'Master password'
});
if(sessionResponse.state !== 'ok') {
throw new Error(sessionResponse.error?.message || sessionResponse.message || 'Unable to run bw unlock');
}
Expand Down
Loading

0 comments on commit 0c644f9

Please sign in to comment.