import { exec, ExecException } from 'child_process';
import { getConfig, getAzureScopeArgs } from './config.js';
import { withRetry, isTransientError } from './retry.js';
import { logger } from './logger.js';
import { redactCommand } from './redaction.js';
import { generateCommandSummary, formatSummaryForDisplay } from './command-summary.js';
import { globalRateLimiters } from './rate-limiter.js';
import { randomUUID } from 'crypto';
export interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
success: boolean;
parsedOutput?: unknown;
parseError?: string;
summary?: string;
correlationId?: string;
}
export interface ExecuteOptions {
timeout?: number;
cwd?: string;
env?: Record<string, string>;
applyScope?: boolean;
enableRetry?: boolean;
showSummary?: boolean;
skipRateLimit?: boolean;
}
// Allowlist of safe environment variables to pass to child process
const SAFE_ENV_VARS = new Set([
'PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM',
'AZURE_CONFIG_DIR', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID',
'NODE_ENV', 'TZ', 'TMPDIR', 'TEMP', 'TMP'
]);
/**
* Gets safe environment variables (prevents leaking secrets)
*/
function getSafeEnv(additionalEnv?: Record<string, string>): Record<string, string> {
const safeEnv: Record<string, string> = {};
// Only include allowlisted environment variables
for (const [key, value] of Object.entries(process.env)) {
if (SAFE_ENV_VARS.has(key) && value) {
safeEnv[key] = value;
}
}
// Add additional env vars (assumed safe from caller)
if (additionalEnv) {
Object.assign(safeEnv, additionalEnv);
}
return safeEnv;
}
async function execInternal(command: string, options: ExecuteOptions = {}): Promise<CommandResult> {
const config = getConfig();
const timeout = options.timeout ?? config.commandTimeoutMs;
const correlationId = randomUUID();
// Generate and log command summary (redacted version)
const redacted = redactCommand(command);
let summaryText: string | undefined;
if (options.showSummary !== false) {
try {
const summary = generateCommandSummary(command);
summaryText = formatSummaryForDisplay(summary);
logger.info('Command summary', {
correlationId,
summary: summaryText,
riskLevel: summary.riskLevel
});
} catch (error) {
logger.warn('Failed to generate command summary', {
correlationId,
error: error instanceof Error ? error.message : 'unknown'
});
}
}
logger.debug('Executing command', {
correlationId,
command: redacted,
timeout
});
return new Promise(resolve => {
exec(command, {
timeout,
cwd: options.cwd,
env: getSafeEnv(options.env), // Use safe environment
maxBuffer: 10 * 1024 * 1024,
}, (error: ExecException | null, stdout: string, stderr: string) => {
const exitCode = error?.code ?? 0;
const success = exitCode === 0;
let parsedOutput: unknown;
let parseError: string | undefined;
if (stdout.trim()) {
try {
parsedOutput = JSON.parse(stdout);
} catch (e) {
parseError = `JSON parse failed: ${e instanceof Error ? e.message : 'unknown'}`;
}
}
const result: CommandResult = {
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode,
success,
parsedOutput,
parseError,
summary: summaryText,
correlationId
};
if (success) {
logger.debug('Command succeeded', {
correlationId,
command: redacted,
exitCode
});
} else {
logger.warn('Command failed', {
correlationId,
command: redacted,
exitCode,
stderr: redactCommand(stderr.trim()) // Redact stderr too
});
}
resolve(result);
});
});
}
export async function executeCommand(command: string, options: ExecuteOptions = {}): Promise<CommandResult> {
const { enableRetry = false, skipRateLimit = false } = options;
// Apply rate limiting (unless explicitly skipped)
if (!skipRateLimit) {
const rateLimiter = globalRateLimiters.getLimiter({
limiterId: 'azure-cli',
maxRequests: 60,
windowMs: 60000
});
const rateCheck = rateLimiter.checkLimit('global');
if (!rateCheck.allowed) {
logger.warn('Rate limit hit, waiting...', {
retryAfterMs: rateCheck.retryAfterMs,
currentCount: rateCheck.currentCount,
limit: rateCheck.limit
});
await rateLimiter.waitForSlot('global');
}
}
if (!enableRetry) return execInternal(command, options);
return withRetry(
async () => {
const result = await execInternal(command, options);
if (!result.success && isTransientError(result.stderr)) throw new Error(result.stderr);
return result;
},
'executeCommand',
{ isRetryable: e => isTransientError(e) }
);
}
export async function executeAzCommand(command: string, options: ExecuteOptions = {}): Promise<CommandResult> {
const { applyScope = false, enableRetry = true } = options;
let cmd = command;
if (!cmd.includes('--output') && !cmd.includes('-o ')) cmd += ' --output json';
if (applyScope) {
const scope = getAzureScopeArgs();
if (scope.length) cmd += ' ' + scope.join(' ');
}
return executeCommand(cmd, { ...options, enableRetry });
}
export async function validateAzureCLI(): Promise<boolean> {
const result = await executeCommand('az --version', { enableRetry: false });
return result.success;
}
export async function getAzureAccountInfo(): Promise<{ user?: string; tenantId?: string; subscriptionId?: string } | null> {
const result = await executeAzCommand('az account show', { enableRetry: false });
if (!result.success || !result.parsedOutput) return null;
const account = result.parsedOutput as { user?: { name?: string }; tenantId?: string; id?: string };
return { user: account.user?.name, tenantId: account.tenantId, subscriptionId: account.id };
}