UNPKG

bitbucket-env-manager

Version:

Deploys Bitbucket Environment

505 lines (436 loc) 14.5 kB
#!/usr/bin/env node 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, };