@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
174 lines • 6.04 kB
JavaScript
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;
}
};