bitbucket-env-manager
Version:
Deploys Bitbucket Environment
371 lines (316 loc) • 10.9 kB
JavaScript
const fs = require('fs-extra');
const path = require('path');
const { confirm, spinner, log, note, isCancel, cancel, select, multiselect } = require('@clack/prompts');
const color = require('picocolors');
const { names, apiSleepTime } = require('./config');
const {
guarded_get_envs,
guarded_get_vars,
guarded_get_repo_vars,
} = require('./guarded.api');
const { delay, capitalizeFirstLetter } = require('./helpers');
const { findFiles } = require('./ui');
/**
* Parse info.json file to get workspace and repo
*/
const getInfoJsonParams = (infoJsonPath) => {
const infoRaw = fs.readFileSync(infoJsonPath, { encoding: 'utf-8' });
const infoJson = JSON.parse(infoRaw);
if (!infoJson.workspace || !infoJson.repo) {
throw new Error(
`Please specify {workspace:, repo:} under "${names.infoJsonFile}" file`
);
}
return {
workspace: infoJson.workspace,
repo: infoJson.repo,
};
};
/**
* Format current date as YYYY-MM-DD
*/
const getDateFolder = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Convert variables array to .env file format
*/
const formatEnvFileContent = (variables) => {
const lines = [];
lines.push(`# Downloaded from Bitbucket on ${new Date().toISOString()}`);
lines.push(`# NOTE: Secured variables show empty values - they cannot be retrieved from the API`);
lines.push('');
for (const v of variables) {
if (v.secured) {
lines.push(`# [SECURED] ${v.key}=<value hidden by Bitbucket>`);
} else {
const escapedValue = v.value.replace(/\n/g, '\\n');
lines.push(`${v.key}="${escapedValue}"`);
}
}
return lines.join('\n');
};
/**
* Get environment type name based on rank
*/
const getRankName = (rank) => {
const rankMap = {
0: 'Test',
1: 'Staging',
2: 'Production',
};
return rankMap[rank] || 'Unknown';
};
/**
* Color palette for differentiating project groups
*/
const groupColors = [
color.blue,
color.magenta,
color.green,
color.yellow,
color.cyan,
color.red,
];
/**
* Get a consistent color for a path based on its top-level parent
*/
const getGroupColor = (relativePath, colorMap) => {
if (!relativePath) return color.dim;
const topLevel = relativePath.split(path.sep)[0];
if (!colorMap.has(topLevel)) {
colorMap.set(topLevel, groupColors[colorMap.size % groupColors.length]);
}
return colorMap.get(topLevel);
};
/**
* Format path with color based on its parent group
*/
const formatColoredPath = (relativePath, colorFn) => {
if (!relativePath) return color.dim('.');
return colorFn(relativePath);
};
/**
* Strip project_envs prefix from path for cleaner display
*/
const stripProjectEnvsPrefix = (relativePath) => {
if (!relativePath) return relativePath;
const prefix = names.projectEnvsFolder + path.sep;
if (relativePath.startsWith(prefix)) {
return relativePath.slice(prefix.length);
}
return relativePath;
};
/**
* Main download function
*/
const download = async ({ multiple = false } = {}) => {
try {
const basePath = process.cwd();
const projectEnvsPath = path.join(basePath, names.projectEnvsFolder);
// Check if project_envs folder exists
if (!fs.existsSync(projectEnvsPath)) {
log.error(`No ${names.projectEnvsFolder} folder found in current directory`);
return;
}
const infoJsonFiles = findFiles(projectEnvsPath, names.infoJsonFile);
if (infoJsonFiles.length === 0) {
log.error(`No ${names.infoJsonFile} files found in ${names.projectEnvsFolder}`);
return;
}
// Build repo groups from info.json files
const repoGroups = {};
for (const f of infoJsonFiles) {
const dir = path.dirname(f);
const relativeDir = stripProjectEnvsPrefix(path.relative(basePath, dir));
try {
const { workspace, repo } = getInfoJsonParams(f);
repoGroups[dir] = {
repo,
workspace,
dir,
relativeDir,
infoJsonPath: f,
};
} catch (e) {
// Skip invalid info.json files
}
}
const repoDirs = Object.keys(repoGroups);
if (repoDirs.length === 0) {
log.error('No valid info.json files found');
return;
}
// Build color map for consistent group coloring
const colorMap = new Map();
// Select repositories
let selectedRepoDirs;
if (repoDirs.length === 1) {
selectedRepoDirs = repoDirs;
const group = repoGroups[repoDirs[0]];
const groupColor = getGroupColor(group.relativeDir, colorMap);
log.info(`Repository: ${color.cyan(group.repo)} (${formatColoredPath(group.relativeDir, groupColor)})`);
} else if (multiple) {
// Build options with "Select all" at the top
const repoOptions = [
{
value: '__all__',
label: color.bold('Select all repositories'),
hint: `${repoDirs.length} repos`,
},
...repoDirs.map((dir) => {
const group = repoGroups[dir];
const groupColor = getGroupColor(group.relativeDir, colorMap);
return {
value: dir,
label: `${color.cyan(group.repo)} (${formatColoredPath(group.relativeDir, groupColor)})`,
hint: group.workspace,
};
}),
];
const selection = await multiselect({
message: 'Select repositories to download',
options: repoOptions,
required: true,
});
if (isCancel(selection)) {
cancel('Operation cancelled.');
return;
}
// Handle "Select all" option
if (selection.includes('__all__')) {
selectedRepoDirs = repoDirs;
} else {
selectedRepoDirs = selection;
}
} else {
const singleRepoDir = await select({
message: 'Select repository to download',
options: repoDirs.map((dir) => {
const group = repoGroups[dir];
const groupColor = getGroupColor(group.relativeDir, colorMap);
return {
value: dir,
label: `${color.cyan(group.repo)} (${formatColoredPath(group.relativeDir, groupColor)})`,
hint: group.workspace,
};
}),
});
if (isCancel(singleRepoDir)) {
cancel('Operation cancelled.');
return;
}
selectedRepoDirs = [singleRepoDir];
}
// Process each selected repository
for (const repoDir of selectedRepoDirs) {
const group = repoGroups[repoDir];
const { workspace, repo } = group;
const projectDir = group.dir;
const mainParams = {
repo_slug: repo,
workspace: workspace,
};
if (selectedRepoDirs.length > 1) {
log.info('');
}
note(`${color.dim('Workspace:')} ${workspace}\n${color.dim('Repository:')} ${repo}`, 'Target');
// Create output directory
const dateFolder = getDateFolder();
const outputDir = path.join(projectDir, 'remote', dateFolder);
// Check if directory already exists
if (fs.existsSync(outputDir)) {
const overwrite = await confirm({
message: `Directory ${color.cyan(outputDir)} already exists. Overwrite?`,
initialValue: false,
});
if (isCancel(overwrite) || !overwrite) {
cancel('Operation cancelled.');
return;
}
}
fs.ensureDirSync(outputDir);
const s = spinner();
s.start('Fetching environments...');
const envsListString = await guarded_get_envs(mainParams);
const envsList = JSON.parse(envsListString).values;
// Sort environments by rank
envsList.sort((a, b) => {
const rankA = (a.environment_type && a.environment_type.rank) || 0;
const rankB = (b.environment_type && b.environment_type.rank) || 0;
return rankA - rankB;
});
const downloadedFiles = [];
// Download each environment's variables
for (let i = 0; i < envsList.length; i++) {
const env = envsList[i];
await delay(apiSleepTime);
s.message(`Downloading ${color.cyan(env.name)} ${color.dim(`(${i + 1}/${envsList.length})`)}`);
const varsString = await guarded_get_vars({
...mainParams,
environment_uuid: env.uuid,
});
const vars = JSON.parse(varsString).values;
const rank = (env.environment_type && env.environment_type.rank) || 0;
const envName = capitalizeFirstLetter(env.name);
const envFileName = `.env.${rank}.${envName}`;
const envFilePath = path.join(outputDir, envFileName);
const content = formatEnvFileContent(vars);
fs.writeFileSync(envFilePath, content, 'utf-8');
downloadedFiles.push({
name: envFileName,
type: getRankName(rank),
envName: envName,
varCount: vars.length,
securedCount: vars.filter(v => v.secured).length,
});
}
// Download repository variables
s.message('Downloading repository variables...');
await delay(apiSleepTime);
const repoVarsString = await guarded_get_repo_vars(mainParams);
const repoVars = JSON.parse(repoVarsString).values;
const repoFileName = '.env.repo';
const repoFilePath = path.join(outputDir, repoFileName);
const repoContent = formatEnvFileContent(repoVars);
fs.writeFileSync(repoFilePath, repoContent, 'utf-8');
downloadedFiles.push({
name: repoFileName,
type: 'Repository',
envName: 'Repository Variables',
varCount: repoVars.length,
securedCount: repoVars.filter(v => v.secured).length,
});
s.stop(color.green('Download complete!'));
// Display summary
const maxNameLen = Math.max(...downloadedFiles.map(f => f.name.length), 15);
const maxTypeLen = Math.max(...downloadedFiles.map(f => f.type.length), 10);
const summaryLines = downloadedFiles.map(file => {
const name = file.name.padEnd(maxNameLen);
const type = `[${file.type}]`.padEnd(maxTypeLen + 2);
const vars = `${file.varCount} vars`;
const secured = file.securedCount > 0
? color.yellow(` (${file.securedCount} secured)`)
: '';
return `${name} ${color.dim(type)} ${vars}${secured}`;
});
note(summaryLines.join('\n'), `Downloaded to ${outputDir}`);
// Warn about secured variables
const totalSecured = downloadedFiles.reduce((sum, f) => sum + f.securedCount, 0);
if (totalSecured > 0) {
log.warn(`${totalSecured} secured variable(s) have placeholder values.`);
log.info('Bitbucket API does not expose secured variable values.');
}
}
} catch (e) {
log.error(e.message);
}
};
module.exports = {
download,
};