UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

207 lines 9.57 kB
import { execFileSync as nodeExecFileSync, execSync as nodeExecSync } from 'child_process'; import { isString } from 'es-toolkit/compat'; import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import os from 'os'; import path from 'path'; import { logger, runWithSuspendedSpinner, shouldSuspendSpinnerForStdio } from './logger.js'; import { formatShellCommand, quoteShellArgument } from './shell.js'; const GCLOUD_NOT_FOUND_ERROR_PATTERN = /cannot find|not[ -]?found|does not exist|was not found|404|NOT_FOUND/iu; const LOCATION_NAMES = { 'africa-south1': 'Johannesburg', 'asia-east1': 'Taiwan', 'asia-east2': 'Hong Kong', 'asia-northeast1': 'Tokyo', 'asia-northeast2': 'Osaka', 'asia-northeast3': 'Seoul', 'asia-south1': 'Mumbai', 'asia-south2': 'Delhi', 'asia-southeast1': 'Singapore', 'asia-southeast2': 'Jakarta', 'australia-southeast1': 'Sydney', 'australia-southeast2': 'Melbourne', 'europe-central2': 'Warsaw', 'europe-north1': 'Finland', 'europe-north2': 'Stockholm', 'europe-southwest1': 'Madrid', 'europe-west1': 'Belgium', 'europe-west10': 'Berlin', 'europe-west12': 'Turin', 'europe-west2': 'London', 'europe-west3': 'Frankfurt', 'europe-west4': 'Netherlands', 'europe-west6': 'Zurich', 'europe-west8': 'Milan', 'europe-west9': 'Paris', 'me-central1': 'Doha', 'me-central2': 'Dammam', 'me-west1': 'Tel Aviv', 'northamerica-northeast1': 'Montreal', 'northamerica-northeast2': 'Toronto', 'northamerica-south1': 'Mexico', 'southamerica-east1': 'Sao Paulo', 'southamerica-west1': 'Santiago', 'us-central1': 'Iowa', 'us-east1': 'South Carolina', 'us-east4': 'Northern Virginia', 'us-east5': 'Columbus', 'us-south1': 'Dallas', 'us-west1': 'Oregon', 'us-west2': 'Los Angeles', 'us-west3': 'Salt Lake City', 'us-west4': 'Las Vegas', eur3: 'Europe', nam5: 'United States', nam7: 'United States' }; const FALLBACK_LOCATIONS = ['europe-west1 (Belgium)', 'europe-central2 (Warsaw)', 'europe-west2 (London)', 'europe-west3 (Frankfurt)', 'europe-west6 (Zurich)', 'us-central1 (Iowa)', 'us-east1 (South Carolina)', 'us-east4 (Northern Virginia)', 'us-west1 (Oregon)', 'northamerica-northeast1 (Montreal)', 'southamerica-east1 (Sao Paulo)', 'us-west2 (Los Angeles)', 'asia-northeast1 (Tokyo)', 'asia-southeast1 (Singapore)', 'australia-southeast1 (Sydney)']; const WINDOWS_GCLOUD_CANDIDATES = [process.env.CLOUDSDK_GCLOUD_CMD, process.env.CLOUDSDK_ROOT_DIR ? path.join(process.env.CLOUDSDK_ROOT_DIR, 'bin', 'gcloud.cmd') : null, process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'], 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd') : null, process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd') : null, process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin', 'gcloud.cmd') : null].filter(Boolean); export const GCLOUD_FLAGS_FILE_PLACEHOLDER = '[generated-at-execution]'; const GCLOUD_DICT_FLAG_DELIMITER_CANDIDATES = ['@', '#', '+', '~', ';']; const quotePowerShellArgument = value => { if (!isString(value)) { return String(value); } return `'${value.replace(/'/g, "''")}'`; }; export const resolveGcloudExecutable = () => { if (process.platform !== 'win32') { return 'gcloud'; } return WINDOWS_GCLOUD_CANDIDATES.find(candidate => existsSync(candidate)) ?? 'gcloud'; }; const resolveExecutableForCommandRunner = (runner, nativeRunner) => runner === nativeRunner ? resolveGcloudExecutable() : 'gcloud'; export const createGcloudShellCommand = (args, execute = nodeExecSync) => { const executable = resolveExecutableForCommandRunner(execute, nodeExecSync); return [quoteShellArgument(executable), ...args].join(' '); }; const selectGcloudDictFlagDelimiter = entries => { const delimiter = GCLOUD_DICT_FLAG_DELIMITER_CANDIDATES.find(candidate => entries.every(entry => !entry.includes(candidate))); if (!delimiter) { throw new Error('Failed to format gcloud environment variables because no safe custom delimiter was available.'); } return delimiter; }; export const formatGcloudEnvironmentVariableAssignments = environmentVariables => { const entries = Object.entries(environmentVariables ?? {}).map(([name, value]) => `${name}=${value}`); if (entries.some(entry => entry.includes(','))) { const delimiter = selectGcloudDictFlagDelimiter(entries); return `^${delimiter}^${entries.join(delimiter)}`; } return entries.join(','); }; export const renderGcloudFlagsFileContent = flags => `${JSON.stringify(flags, null, 2)}\n`; export const createGcloudRunJobExecuteCommand = ({ environmentVariables = {}, jobName, projectId, region }) => { const baseArgs = ['run', 'jobs', 'execute', jobName, `--project=${projectId}`, `--region=${region}`]; if (Object.keys(environmentVariables).length === 0) { return { args: baseArgs, command: formatShellCommand(['gcloud', ...baseArgs]), executable: 'gcloud', flagsFileContent: null }; } const args = [...baseArgs, `--flags-file=${GCLOUD_FLAGS_FILE_PLACEHOLDER}`]; const updateEnvVarsArgument = formatGcloudEnvironmentVariableAssignments(environmentVariables); return { args, command: formatShellCommand(['gcloud', ...baseArgs, `--update-env-vars=${updateEnvVarsArgument}`]), executable: 'gcloud', flagsFileContent: renderGcloudFlagsFileContent({ '--update-env-vars': environmentVariables }) }; }; export const materializeGcloudFlagsFileContent = content => { const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), 'atlas-gcloud-flags-')); const filePath = path.join(temporaryDirectory, 'flags.json'); writeFileSync(filePath, content, 'utf8'); return { cleanup: () => { rmSync(temporaryDirectory, { force: true, recursive: true }); }, filePath }; }; export const materializeGcloudCommandFlagsFile = command => { if (!command?.flagsFileContent) { return null; } const flagsFile = materializeGcloudFlagsFileContent(command.flagsFileContent); return { args: command.args.map(argument => argument === `--flags-file=${GCLOUD_FLAGS_FILE_PLACEHOLDER}` ? `--flags-file=${flagsFile.filePath}` : argument), cleanup: flagsFile.cleanup }; }; export const runGcloudFileCommand = (args, options = {}, runCommand = nodeExecFileSync) => { const executable = resolveExecutableForCommandRunner(runCommand, nodeExecFileSync); const shouldSuspendSpinner = shouldSuspendSpinnerForStdio(options.stdio); return runWithSuspendedSpinner(() => { if (executable.endsWith('.cmd')) { const command = `& ${quotePowerShellArgument(executable)} @(${args.map(quotePowerShellArgument).join(', ')})`; return runCommand('powershell.exe', ['-NoProfile', '-Command', command], options); } return runCommand(executable, args, options); }, shouldSuspendSpinner); }; export const getCommandErrorMessage = error => `${error?.stderr?.toString?.() ?? ''}\n${error?.message ?? ''}`; export const parseGcloudJsonOutput = (value, description) => { const normalizedDescription = isString(description) && description.trim().length > 0 ? description.trim() : 'gcloud JSON output'; if (!isString(value)) { throw new Error(`Could not parse ${normalizedDescription}. Expected JSON text from gcloud.`); } try { return JSON.parse(value); } catch (error) { throw new Error(`Could not parse ${normalizedDescription}. ${error.message}`); } }; export const isGcloudResourceNotFoundError = error => GCLOUD_NOT_FOUND_ERROR_PATTERN.test(getCommandErrorMessage(error)); export const enableGcloudServices = (projectId, services, options = {}) => { const enabledServices = [...new Set((services ?? []).filter(Boolean))].sort((left, right) => left.localeCompare(right)); if (enabledServices.length === 0) { return { enabledServices: [], projectId }; } runGcloudFileCommand(['services', 'enable', ...enabledServices, `--project=${projectId}`], { stdio: 'inherit' }, options.runCommand); return { enabledServices, projectId }; }; const getGcloudLocationList = (args, description, transformLocation, options = {}) => { const { loggerImpl = logger, runCommand = nodeExecFileSync } = options; try { const output = runGcloudFileCommand([...args, '--format=json'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }, runCommand); const locations = parseGcloudJsonOutput(output, description); if (!Array.isArray(locations)) { throw new Error(`Could not parse ${description}. Expected a JSON array from gcloud.`); } return locations.map(transformLocation).sort((left, right) => left.localeCompare(right)); } catch (error) { loggerImpl.warning(`Could not fetch ${description}: ${error.message}`); return FALLBACK_LOCATIONS; } }; export const getFirestoreLocations = async (projectId, options = {}) => getGcloudLocationList(['firestore', 'locations', 'list', `--project=${projectId}`], `Firestore locations for project ${projectId}`, location => `${location.locationId} (${location.displayName})`, options); export const getTasksLocations = async (projectId, options = {}) => getGcloudLocationList(['tasks', 'locations', 'list', `--project=${projectId}`], `Tasks locations for project ${projectId}`, location => { const displayName = LOCATION_NAMES[location.locationId] || location.locationId; return `${location.locationId} (${displayName})`; }, options);