UNPKG

bitbucket-env-manager

Version:

Deploys Bitbucket Environment

371 lines (316 loc) 10.9 kB
#!/usr/bin/env node 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, };