bitbucket-env-manager
Version:
Deploys Bitbucket Environment
505 lines (436 loc) • 14.5 kB
JavaScript
const dotenv = require('dotenv');
const fs = require('fs-extra');
const path = require('path');
const { select, confirm, isCancel, cancel, log, note, multiselect } = require('@clack/prompts');
const color = require('picocolors');
const { capitalizeFirstLetter } = require('./helpers');
const { names } = require('./config');
const { findEnvFiles } = require('./ui');
/**
* Strips surrounding quotes (single or double) and trims whitespace from a value.
*/
const stripQuotesAndTrim = (value) => {
if (!value || typeof value !== 'string') return value;
let trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
trimmed = trimmed.slice(1, -1);
}
return trimmed;
};
/**
* Strips inline comments from a value.
* Rules:
* 1. If value is quoted, preserve it as-is (quotes handled by stripQuotesAndTrim)
* 2. If value starts with #, it's a comment → empty
* 3. If # is preceded by whitespace, strip from there → keep before
* 4. Otherwise keep as-is (e.g., value#tag)
*/
const stripInlineComment = (value) => {
if (!value || typeof value !== 'string') return value;
const trimmed = value.trim();
// Preserve quoted values (quotes will be handled by stripQuotesAndTrim)
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed;
}
if (trimmed.startsWith('#')) return '';
const commentMatch = trimmed.match(/\s+#/);
if (commentMatch) {
return trimmed.substring(0, commentMatch.index).trim();
}
return trimmed;
};
const getRankLabel = (rank) => {
const labels = {
'-1': 'Repo Vars',
'0': 'Test',
'1': 'Staging',
'2': 'Production',
};
return labels[String(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;
};
const getEnvFileNameParams = (filename) => {
const splits = filename.split('.');
const splitsLength = splits.length;
const verify_start = splits[0] === '' && splits[1] === 'env';
// Handle .env.repo files
if (splitsLength === 3 && verify_start && splits[2] === 'repo') {
return {
is_repo: true,
env_name: 'Repository',
rank: -1,
};
}
const env_name = splits[splitsLength - 1];
const rankRaw = splits[splitsLength - 2];
const rank = Number(rankRaw);
if (splitsLength !== 4 || !verify_start || !env_name || ![0, 1, 2].includes(rank)) {
throw new Error(
'Specify env filename in the format .env.{rank}.{env_name} or .env.repo. 0|1|2 for rank'
);
}
return {
is_repo: false,
env_name: capitalizeFirstLetter(env_name),
rank,
};
};
const validateEnvFileName = (filename) => {
try {
getEnvFileNameParams(filename);
return true;
} catch (e) {
return false;
}
};
/**
* Read info.json to get repo details for an env file
*/
const getRepoInfo = (envFilePath) => {
const dir = path.dirname(envFilePath);
const infoJsonPath = path.join(dir, names.infoJsonFile);
if (!fs.existsSync(infoJsonPath)) {
return null;
}
try {
const infoRaw = fs.readFileSync(infoJsonPath, { encoding: 'utf-8' });
const infoJson = JSON.parse(infoRaw);
return {
workspace: infoJson.workspace || 'unknown',
repo: infoJson.repo || 'unknown',
secured: infoJson.secured || [],
envs_config: infoJson.envs_config || {},
dir,
};
} catch (e) {
return null;
}
};
const runConvert = 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 null;
}
const allEnvFiles = findEnvFiles(projectEnvsPath);
// Filter to only valid env files
const validEnvFiles = allEnvFiles.filter((f) => validateEnvFileName(path.basename(f)));
if (validEnvFiles.length === 0) {
log.error(`No valid .env files found in ${names.projectEnvsFolder}`);
log.info('Expected format: .env.{0|1|2}.{env_name} or .env.repo');
return null;
}
// Group files by directory (since same repo name could exist in different folders)
const repoGroups = {};
for (const f of validEnvFiles) {
const repoInfo = getRepoInfo(f);
const dir = path.dirname(f);
const relativeDir = stripProjectEnvsPrefix(path.relative(basePath, dir));
const repoName = repoInfo?.repo || path.basename(dir);
// Use directory as key to handle same repo name in different folders
const repoKey = dir;
if (!repoGroups[repoKey]) {
repoGroups[repoKey] = {
repo: repoName,
workspace: repoInfo?.workspace || '',
dir,
relativeDir,
files: [],
};
}
repoGroups[repoKey].files.push(f);
}
const repoDirs = Object.keys(repoGroups);
// Build color map for consistent group coloring
const colorMap = new Map();
// Step 1: Select repo(s)
let selectedRepoDirs;
if (repoDirs.length === 1) {
// Only one repo, auto-select it
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.files.length} env(s)`,
};
}),
];
const selection = await multiselect({
message: 'Select repositories to deploy',
options: repoOptions,
required: true,
});
if (isCancel(selection)) {
cancel('Operation cancelled.');
return null;
}
// Handle "Select all" option
if (selection.includes('__all__')) {
selectedRepoDirs = repoDirs;
} else {
selectedRepoDirs = selection;
}
} else {
const singleRepoDir = await select({
message: 'Select repository',
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.files.length} env(s)`,
};
}),
});
if (isCancel(singleRepoDir)) {
cancel('Operation cancelled.');
return null;
}
selectedRepoDirs = [singleRepoDir];
}
// Step 2: For each selected repo, select environments
let selectedFiles = [];
for (const repoDir of selectedRepoDirs) {
const group = repoGroups[repoDir];
const repoFiles = group.files;
const repo = group.repo;
// Sort files by rank
repoFiles.sort((a, b) => {
const rankA = getEnvFileNameParams(path.basename(a)).rank;
const rankB = getEnvFileNameParams(path.basename(b)).rank;
return rankA - rankB;
});
const envOptions = repoFiles.map((f) => {
const fileName = path.basename(f);
const { env_name, rank } = getEnvFileNameParams(fileName);
const rankLabel = getRankLabel(rank);
return {
value: f,
label: `${env_name}`,
hint: rankLabel,
};
});
if (repoFiles.length === 1) {
// Only one env, auto-select
selectedFiles.push(repoFiles[0]);
const { env_name, rank } = getEnvFileNameParams(path.basename(repoFiles[0]));
log.info(`${color.cyan(repo)}: ${env_name} (${getRankLabel(rank)})`);
} else if (multiple) {
// Add "Select all" option for environments
const envOptionsWithAll = [
{
value: '__all__',
label: color.bold('Select all environments'),
hint: `${repoFiles.length} envs`,
},
...envOptions,
];
const repoSelection = await multiselect({
message: `${color.cyan(repo)}: Select environments`,
options: envOptionsWithAll,
required: true,
});
if (isCancel(repoSelection)) {
cancel('Operation cancelled.');
return null;
}
// Handle "Select all" option
if (repoSelection.includes('__all__')) {
selectedFiles = selectedFiles.concat(repoFiles);
} else {
selectedFiles = selectedFiles.concat(repoSelection);
}
} else {
const singleEnv = await select({
message: `${color.cyan(repo)}: Select environment`,
options: envOptions,
});
if (isCancel(singleEnv)) {
cancel('Operation cancelled.');
return null;
}
selectedFiles.push(singleEnv);
}
}
if (selectedFiles.length === 0) {
log.warn('No files selected');
return null;
}
// Sort files so .env.repo comes first
selectedFiles = selectedFiles.sort((a, b) => {
const aIsRepo = a.endsWith('.env.repo');
const bIsRepo = b.endsWith('.env.repo');
if (aIsRepo && !bIsRepo) return -1;
if (!aIsRepo && bIsRepo) return 1;
return 0;
});
// Show summary of what will be deployed
const summaryLines = selectedFiles.map((f) => {
const fileName = path.basename(f);
const repoInfo = getRepoInfo(f);
const { env_name } = getEnvFileNameParams(fileName);
const repo = repoInfo?.repo || 'unknown';
const secured = repoInfo?.secured || [];
// Quick count of vars
const cfg = dotenv.config({ path: f });
const varsKeys = cfg.parsed ? Object.keys(cfg.parsed).filter((k) => cfg.parsed[k]) : [];
const totalVars = varsKeys.length;
const securedCount = varsKeys.filter((k) => secured.includes(k)).length;
const varsInfo = securedCount > 0
? color.dim(`(${totalVars} vars, ${color.yellow(`${securedCount} secured`)})`)
: color.dim(`(${totalVars} vars)`);
return ` ${color.cyan(repo)} ${color.dim('->')} ${env_name} ${varsInfo}`;
});
note(summaryLines.join('\n'), `Deploying ${selectedFiles.length} environment(s)`);
const proceedConfirm = await confirm({
message: 'Proceed with deployment?',
initialValue: true,
});
if (isCancel(proceedConfirm) || !proceedConfirm) {
cancel('Operation cancelled.');
return null;
}
const jsonCfgs = [];
for (const fPath of selectedFiles) {
const jsonCfg = await getCfgForEnvFile(fPath);
if (jsonCfg) {
jsonCfgs.push(jsonCfg);
}
}
return jsonCfgs;
} catch (e) {
log.error(e.message);
return null;
}
};
const getCfgForEnvFile = async (filePath) => {
const filename = path.basename(filePath);
const { env_name, rank, is_repo } = getEnvFileNameParams(filename);
const repoInfo = getRepoInfo(filePath);
if (!repoInfo) {
const parent = path.dirname(filePath);
throw new Error(
`Please create ${names.infoJsonFile} in ${parent} with {workspace:, repo:, secured:[]}`
);
}
const { workspace, repo, secured, envs_config = {} } = repoInfo;
const cfg = dotenv.config({ path: filePath });
if (!cfg.parsed) {
throw new Error(`Failed to parse ${filename}`);
}
const vars = Object.keys(cfg.parsed)
.map((key) => {
const withoutComment = stripInlineComment(cfg.parsed[key]);
const cleanedValue = stripQuotesAndTrim(withoutComment);
return {
secured: secured.includes(key),
key,
rawValue: cleanedValue,
value: cleanedValue.replace(/\n/g, '\\n'),
};
})
.filter((v) => v.value);
const found = Object.keys(envs_config).find((a) => a.toLowerCase() === env_name.toLowerCase());
const env_config = {
admin_only: found && envs_config[found] ? !!envs_config[found].admin_only : false,
};
const jsonCfg = {
workspace,
repo,
env_name,
rank,
vars,
env_config,
is_repo: rank === -1,
};
// Display variables preview
const title = is_repo
? `${repo} / Repository Variables`
: `${repo} / ${env_name}`;
const varLines = vars.map((v) => {
const key = v.key.padEnd(28);
if (v.secured) {
return ` ${color.cyan(key)} ${color.yellow('(secured)')}`;
}
const valueDisplay = JSON.stringify(v.value);
const truncated = valueDisplay.length > 40 ? valueDisplay.slice(0, 37) + '...' : valueDisplay;
return ` ${key} ${color.dim(truncated)}`;
});
note(varLines.join('\n'), `${title} (${vars.length} vars)`);
const varsConfirm = await confirm({
message: 'Variables look correct?',
initialValue: true,
});
if (isCancel(varsConfirm) || !varsConfirm) {
cancel('Operation cancelled.');
return null;
}
return jsonCfg;
};
module.exports = {
runConvert,
};