UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

174 lines 6.04 kB
import { execFileSync, spawn } from 'child_process'; import { isFunction, isString } from 'es-toolkit/compat'; import { runWithSuspendedSpinner, shouldSuspendSpinnerForStdio } from './logger.js'; const DEFAULT_TERRAFORM_HEARTBEAT_INTERVAL_MS = 30_000; const TERRAFORM_EXECUTABLE = 'terraform'; const TERRAFORM_WORKSPACE_NOT_FOUND_PATTERN = /workspace\s+"?[^"]+"?\s+does(?: not|n't) exist|currently selected workspace|no existing workspaces?/iu; export const TERRAFORM_INSTALL_MESSAGE = 'Please install Terraform and ensure it is available on your PATH to use this feature. ' + 'See https://developer.hashicorp.com/terraform/tutorials/gcp-get-started/install-cli for installation instructions.'; export const createTerraformStringVariableArgument = (name, value) => { if (!isString(name) || name.trim().length === 0) { throw new Error('Terraform variable name must be a non-empty string.'); } const normalizedValue = String(value).replaceAll('\\', '/'); return `-var=${name}=${normalizedValue}`; }; export const ensureTerraformWorkspace = async (workspaceName, options = {}, dependencies = {}) => { if (!isString(workspaceName) || workspaceName.trim().length === 0) { throw new Error('Terraform workspace name must be a non-empty string.'); } const normalizedWorkspaceName = workspaceName.trim(); const runTerraformCommandImpl = dependencies.runTerraformCommand ?? runTerraformCommand; const selectCommand = ['workspace', 'select', normalizedWorkspaceName]; try { await runTerraformCommandImpl(selectCommand, { captureOutput: true, cwd: options.cwd }); return { command: selectCommand, name: normalizedWorkspaceName, status: 'selected' }; } catch (error) { if (!TERRAFORM_WORKSPACE_NOT_FOUND_PATTERN.test(error.message)) { throw error; } } const createCommand = ['workspace', 'new', normalizedWorkspaceName]; await runTerraformCommandImpl(createCommand, { captureOutput: true, cwd: options.cwd }); return { command: createCommand, name: normalizedWorkspaceName, status: 'created' }; }; const normalizeTerraformArgs = command => { if (Array.isArray(command)) { const args = command.map(value => String(value)); if (args.length === 0) { throw new Error('Terraform command arguments must not be empty.'); } return args; } if (!isString(command) || command.trim().length === 0) { throw new Error('Terraform command must be a non-empty string or argument array.'); } const args = command.match(/"[^"]*"|'[^']*'|[^\s]+/g)?.map(argument => { if (argument.startsWith('"') && argument.endsWith('"') || argument.startsWith("'") && argument.endsWith("'")) { return argument.slice(1, -1); } return argument; }); if (!args || args.length === 0) { throw new Error('Terraform command arguments must not be empty.'); } return args; }; export const runTerraformCommand = async (command, options = {}, dependencies = {}) => { const args = normalizeTerraformArgs(command); const { isTerraformInstalledImpl = isTerraformInstalled, spawnImpl = spawn } = dependencies; const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_TERRAFORM_HEARTBEAT_INTERVAL_MS; const captureOutput = options.captureOutput === true; const shouldSuspendSpinner = !captureOutput && shouldSuspendSpinnerForStdio(options.stdio); if (!isTerraformInstalledImpl()) { throw new Error(TERRAFORM_INSTALL_MESSAGE); } return runWithSuspendedSpinner(() => new Promise((resolve, reject) => { const startedAt = Date.now(); const tfProcess = spawnImpl(TERRAFORM_EXECUTABLE, args, { cwd: options.cwd, stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : options.stdio ?? 'inherit', env: { ...process.env, ...options.env } }); let stdout = ''; let stderr = ''; const heartbeatTimer = heartbeatIntervalMs > 0 && isFunction(options.onHeartbeat) ? setInterval(() => { options.onHeartbeat({ args, captureOutput, cwd: options.cwd, elapsedMs: Date.now() - startedAt }); }, heartbeatIntervalMs) : null; heartbeatTimer?.unref?.(); options.onStart?.({ args, captureOutput, cwd: options.cwd, startedAt }); if (captureOutput) { tfProcess.stdout?.on('data', chunk => { stdout += chunk.toString(); }); tfProcess.stderr?.on('data', chunk => { stderr += chunk.toString(); }); } tfProcess.on('close', code => { if (heartbeatTimer) { clearInterval(heartbeatTimer); } const elapsedMs = Date.now() - startedAt; if (code === 0) { options.onSuccess?.({ args, captureOutput, cwd: options.cwd, elapsedMs, stderr, stdout }); resolve(captureOutput ? stdout : undefined); } else { options.onFailure?.({ args, captureOutput, cwd: options.cwd, elapsedMs, exitCode: code, stderr, stdout }); reject(new Error(`Terraform command "${args.join(' ')}" failed with exit code ${code}${captureOutput && stderr.trim().length > 0 ? `: ${stderr.trim()}` : ''}`)); } }); tfProcess.on('error', error => { if (heartbeatTimer) { clearInterval(heartbeatTimer); } options.onFailure?.({ args, captureOutput, cwd: options.cwd, elapsedMs: Date.now() - startedAt, error, stderr, stdout }); reject(new Error(`Failed to run Terraform command "${args.join(' ')}": ${error.message}`)); }); }), shouldSuspendSpinner); }; export const isTerraformInstalled = (dependencies = {}) => { const { execFileSyncImpl = execFileSync } = dependencies; try { execFileSyncImpl(TERRAFORM_EXECUTABLE, ['-version'], { stdio: 'ignore' }); return true; } catch { return false; } };