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