UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

962 lines 34.4 kB
import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; import inquirer from 'inquirer'; import { dedupeMessages, normalizeOptionalString } from '../../utils/value.js'; import { execSync, getConfiguredFunctionsTargets, listModuleExports, logger, parseEnv, resolveFunctionsTarget, selectProject } from '../../utils/index.js'; const DEFAULT_FUNCTIONS_ENTRYPOINT = 'index.js'; const FUNCTIONS_SELECTOR_PREFIX = 'functions:'; const PROJECT_ENVIRONMENT_ALIAS_PRIORITY = ['development', 'production']; const normalizeFunctionNames = functionNames => dedupeMessages((Array.isArray(functionNames) ? functionNames : []).map(normalizeOptionalString)); const createRuntimeEnvironmentCandidates = ({ projectEnvironment, projectId, targetDir }) => { const seenFileNames = new Set(); const candidates = [{ fileName: `.env.${projectEnvironment}`, projectSelector: projectEnvironment }, { fileName: `.env.${projectId}`, projectSelector: projectId }].filter(candidate => normalizeOptionalString(candidate.fileName) && normalizeOptionalString(candidate.projectSelector)); return candidates.filter(candidate => { if (seenFileNames.has(candidate.fileName)) { return false; } seenFileNames.add(candidate.fileName); return true; }).map(candidate => ({ ...candidate, envFilePath: path.join(targetDir, candidate.fileName) })); }; const formatRuntimeEnvironmentCandidateNames = deploymentPlan => deploymentPlan.runtimeEnvCandidates.map(candidate => candidate.fileName).join(' or '); const formatRuntimeEnvironmentCandidatePaths = deploymentPlan => deploymentPlan.runtimeEnvCandidates.map(candidate => `${deploymentPlan.target.targetLabel}/${candidate.fileName}`).join(' or '); const formatRuntimeEnvironmentSummaryValue = deploymentPlan => { if (deploymentPlan.runtimeEnv) { return deploymentPlan.runtimeEnvFileName ? `${deploymentPlan.runtimeEnv} (${deploymentPlan.runtimeEnvFileName})` : deploymentPlan.runtimeEnv; } if (deploymentPlan.runtimeEnvFileName) { return `not configured in ${deploymentPlan.runtimeEnvFileName}`; } return `not configured in ${formatRuntimeEnvironmentCandidateNames(deploymentPlan)}`; }; const createFunctionsTargetKey = target => `${normalizeOptionalString(target.codebase) ?? ''}:${normalizeOptionalString(target.source) ?? ''}`; const normalizeFunctionDeployName = functionName => functionName.replace(/\./gu, '-'); const isPathLikeFunctionsSelectorPrefix = value => value.includes('/') || value.includes('\\') || value.startsWith('.'); const parseFunctionSelector = functionSelector => { const normalizedSelector = normalizeOptionalString(functionSelector); if (!normalizedSelector) { return null; } if (normalizedSelector.startsWith(FUNCTIONS_SELECTOR_PREFIX)) { const selectorBody = normalizedSelector.slice(FUNCTIONS_SELECTOR_PREFIX.length); const selectorParts = selectorBody.split(':'); if (selectorParts.length === 1) { const functionName = normalizeOptionalString(selectorParts[0]); if (!functionName) { throw new Error(`Function selector "${normalizedSelector}" is invalid. ` + 'Use <function>, <codebase>:<function>, or functions:<codebase>:<function>.'); } return { functionName, originalSelector: normalizedSelector, type: 'firebase-unscoped' }; } if (selectorParts.length !== 2) { throw new Error(`Function selector "${normalizedSelector}" is invalid. ` + 'Use <function>, <codebase>:<function>, or functions:<codebase>:<function>.'); } const [codebase, functionName] = selectorParts.map(normalizeOptionalString); if (!codebase || !functionName) { throw new Error(`Function selector "${normalizedSelector}" is invalid. ` + 'Use <function>, <codebase>:<function>, or functions:<codebase>:<function>.'); } if (isPathLikeFunctionsSelectorPrefix(codebase)) { throw new Error(`Function selector "${normalizedSelector}" uses a path-like prefix. ` + 'Use --codebase <name-or-path> for path-based targets and keep positional selectors Firebase-compatible.'); } return { codebase, functionName, originalSelector: normalizedSelector, type: 'qualified' }; } const separatorIndex = normalizedSelector.indexOf(':'); if (separatorIndex === -1) { return { functionName: normalizedSelector, originalSelector: normalizedSelector, type: 'bare' }; } const codebase = normalizeOptionalString(normalizedSelector.slice(0, separatorIndex)); const functionName = normalizeOptionalString(normalizedSelector.slice(separatorIndex + 1)); if (!codebase || !functionName) { throw new Error(`Function selector "${normalizedSelector}" is invalid. ` + 'Use <function>, <codebase>:<function>, or functions:<codebase>:<function>.'); } if (isPathLikeFunctionsSelectorPrefix(codebase)) { throw new Error(`Function selector "${normalizedSelector}" uses a path-like prefix. ` + 'Use --codebase <name-or-path> for path-based targets and keep positional selectors Firebase-compatible.'); } return { codebase, functionName, originalSelector: normalizedSelector, type: 'qualified' }; }; const getPackageJson = (targetDir, dependencies = {}) => { const packageJsonPath = path.join(targetDir, 'package.json'); const { existsSyncImpl = fs.existsSync, loggerImpl = logger, readFileSyncImpl = fs.readFileSync } = dependencies; if (!existsSyncImpl(packageJsonPath)) { loggerImpl.error(`No package.json file found in ${targetDir}. Make sure the selected Cloud Functions target is valid.`, { exit: true, exitCode: 1 }); return null; } return JSON.parse(readFileSyncImpl(packageJsonPath, 'utf-8')); }; const resolveFunctionsEntrypoint = (targetDir, packageJson) => path.resolve(targetDir, normalizeOptionalString(packageJson?.main) ?? DEFAULT_FUNCTIONS_ENTRYPOINT); const getDeployableFunctionNames = ({ packageJson, targetDir, targetLabel }, dependencies = {}) => { const entrypointPath = resolveFunctionsEntrypoint(targetDir, packageJson); const { existsSyncImpl = fs.existsSync, listModuleExportsImpl = listModuleExports, loggerImpl = logger } = dependencies; if (!existsSyncImpl(entrypointPath)) { loggerImpl.error(`The Cloud Functions entry file ${entrypointPath} does not exist. Update package.json#main or create the entry file before deploying.`, { exit: true, exitCode: 1 }); return []; } const availableFunctions = normalizeFunctionNames(listModuleExportsImpl(entrypointPath, { recursive: true }).filter(name => name !== 'default')); if (availableFunctions.length === 0) { loggerImpl.error(`No deployable function exports were found in ${targetLabel}.`, { exit: true, exitCode: 1 }); return []; } return availableFunctions; }; const formatQualifiedFunctionSelector = (target, functionName) => target.codebase ? `${target.codebase}:${functionName}` : functionName; const selectFunctionsInteractive = async ({ availableFunctions, promptImpl = inquirer.prompt, targetLabel }) => { const { selectedFunctions } = await promptImpl([{ loop: false, type: 'checkbox', name: 'selectedFunctions', message: `Select the functions you want to deploy from ${chalk.cyan(targetLabel)}:`, choices: availableFunctions.map(name => ({ name: ` ${name}`, short: name, value: name, checked: false })) }]); return normalizeFunctionNames(selectedFunctions); }; const selectFunctionsFromEditor = async ({ promptImpl = inquirer.prompt, targetLabel }) => { const { selectedFunctions } = await promptImpl([{ loop: false, type: 'editor', name: 'selectedFunctions', message: `Paste or type the functions you want to deploy from ${chalk.cyan(targetLabel)} ` + '(one per line):', default: '' }]); return normalizeFunctionNames(String(selectedFunctions ?? '').split(/\r?\n/u)); }; const resolveFunctionInputMode = async ({ allYes = false, commandOptions, promptImpl = inquirer.prompt }) => { if (commandOptions.interactive && commandOptions.editor) { throw new Error('Choose either --interactive or --editor, but not both.'); } if (commandOptions.interactive) { return 'interactive'; } if (commandOptions.editor) { return 'editor'; } if (allYes) { throw new Error('No functions were provided. Re-run the command with one or more function names or remove --all-yes to use interactive selection.'); } const { inputType } = await promptImpl([{ type: 'select', name: 'inputType', default: 'interactive', loop: false, message: 'How do you want to select the functions to deploy?', choices: [{ name: 'Interactive (recommended)', value: 'interactive', short: 'interactive' }, { name: 'Editor', value: 'editor', short: 'editor' }] }]); return inputType; }; const resolveProjectEnvironmentAlias = (projectId, config) => { const projects = config?.projects; if (!projects || typeof projects !== 'object') { return null; } const matchingAliases = Object.entries(projects).filter(([, configuredProjectId]) => configuredProjectId === projectId).map(([projectEnvironment]) => projectEnvironment); if (matchingAliases.length === 0) { return null; } const preferredAlias = PROJECT_ENVIRONMENT_ALIAS_PRIORITY.find(projectEnvironment => matchingAliases.includes(projectEnvironment)); if (preferredAlias) { return preferredAlias; } const nonDefaultAlias = matchingAliases.find(projectEnvironment => projectEnvironment !== 'default'); if (nonDefaultAlias) { return nonDefaultAlias; } return matchingAliases[0] ?? null; }; const resolveDefaultFunctionsTarget = async ({ allYes = false, codebaseOrPath, configuredTargets, rootDir }, dependencies = {}) => { const { existsSyncImpl = fs.existsSync, loggerImpl = logger, promptImpl = inquirer.prompt, readFileSyncImpl = fs.readFileSync } = dependencies; if (normalizeOptionalString(codebaseOrPath)) { return resolveFunctionsTarget({ allYes, codebaseOrPath, promptMessage: 'Select the Cloud Functions codebase to deploy from:', rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); } if (configuredTargets.length === 0) { return resolveFunctionsTarget({ allYes, promptMessage: 'Select the Cloud Functions codebase to deploy from:', rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); } if (configuredTargets.length === 1) { return configuredTargets[0]; } loggerImpl.error('Multiple Cloud Functions codebases were found in firebase.json. Re-run this command with --codebase <name-or-path> for bare function names, or qualify each function as <codebase>:<export>.', { exit: true, exitCode: 1 }); return null; }; const resolveUnscopedFunctionsTarget = async ({ allYes = false, configuredTargets, originalSelector, rootDir }, dependencies = {}) => { const { existsSyncImpl = fs.existsSync, loggerImpl = logger, promptImpl = inquirer.prompt, readFileSyncImpl = fs.readFileSync } = dependencies; const unscopedTargets = configuredTargets.filter(target => !target.codebase); if (unscopedTargets.length === 1) { return unscopedTargets[0]; } if (unscopedTargets.length > 1) { loggerImpl.error(`Function selector "${originalSelector}" targets the default Cloud Functions codebase, but multiple unscoped targets were found in firebase.json. Qualify the selector as <codebase>:<export> instead.`, { exit: true, exitCode: 1 }); return null; } if (configuredTargets.length === 0) { return resolveFunctionsTarget({ allYes, promptMessage: 'Select the Cloud Functions codebase to deploy from:', rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); } loggerImpl.error(`Function selector "${originalSelector}" targets the default Cloud Functions codebase, but firebase.json only configures named codebases. Use --codebase <name-or-path> for bare function names or qualify each function as <codebase>:<export>.`, { exit: true, exitCode: 1 }); return null; }; const resolveQualifiedFunctionsTarget = ({ codebase, configuredTargets, originalSelector }, dependencies = {}) => { const { loggerImpl = logger } = dependencies; const matchingTargets = configuredTargets.filter(target => target.codebase === codebase); if (matchingTargets.length === 1) { return matchingTargets[0]; } if (matchingTargets.length > 1) { loggerImpl.error(`Cloud Functions codebase "${codebase}" from "${originalSelector}" is configured more than once in firebase.json. Use unique codebase names before deploying.`, { exit: true, exitCode: 1 }); return null; } loggerImpl.error(`Cloud Functions codebase "${codebase}" from "${originalSelector}" was not found in firebase.json. Use a configured codebase name in selectors like <codebase>:<export>, or use --codebase <name-or-path> for bare function names.`, { exit: true, exitCode: 1 }); return null; }; const outputDeploymentSummary = ({ deploymentPlans, deploymentProjectSelector, loggerImpl = logger, projectEnvironment, projectId }) => { const functionCount = deploymentPlans.reduce((count, deploymentPlan) => count + deploymentPlan.functionNames.length, 0); if (deploymentPlans.length === 1) { const [deploymentPlan] = deploymentPlans; loggerImpl.summary('Deployment plan', [{ label: 'Project', value: projectId, tone: 'warning' }, { label: 'Environment alias', value: projectEnvironment, tone: 'accent' }, { label: 'Firebase project flag', value: deploymentProjectSelector, tone: 'accent' }, { label: 'Runtime Environment', value: formatRuntimeEnvironmentSummaryValue(deploymentPlan), tone: 'warning' }, deploymentPlan.target.codebase ? { label: 'Codebase', value: deploymentPlan.target.codebase, tone: 'warning' } : null, { label: 'Directory', value: deploymentPlan.target.targetLabel, tone: 'accent' }, { label: 'Node Version', value: deploymentPlan.nodeVersion, tone: 'warning' }, { label: 'Functions', value: `${deploymentPlan.functionNames.length} selected`, tone: 'warning' }], { spacing: 'after' }); loggerImpl.summary('Functions to deploy', deploymentPlan.functionNames.map(name => ({ value: name, tone: 'accent' })), { spacing: 'after' }); return; } loggerImpl.summary('Deployment plan', [{ label: 'Project', value: projectId, tone: 'warning' }, { label: 'Environment alias', value: projectEnvironment, tone: 'accent' }, { label: 'Firebase project flag', value: deploymentProjectSelector, tone: 'accent' }, { label: 'Codebases', value: `${deploymentPlans.length} selected`, tone: 'warning' }, { label: 'Functions', value: `${functionCount} selected`, tone: 'warning' }], { spacing: 'after' }); loggerImpl.summary('Cloud Functions codebases', deploymentPlans.map(deploymentPlan => ({ label: deploymentPlan.target.codebase ?? deploymentPlan.target.targetLabel, value: `${deploymentPlan.target.targetLabel} ` + `(Node ${deploymentPlan.nodeVersion}, ` + `Runtime ${formatRuntimeEnvironmentSummaryValue(deploymentPlan)})`, tone: 'accent' })), { spacing: 'after' }); loggerImpl.summary('Functions to deploy', deploymentPlans.flatMap(deploymentPlan => deploymentPlan.functionNames.map(functionName => ({ value: createFunctionDeployTarget(deploymentPlan.target, functionName), tone: 'accent' }))), { spacing: 'after' }); }; const createDeploymentTargetPlans = (deploymentGroups, dependencies = {}) => { const deploymentPlans = []; const { existsSyncImpl = fs.existsSync, listModuleExportsImpl = listModuleExports, loggerImpl = logger, readFileSyncImpl = fs.readFileSync } = dependencies; for (const deploymentGroup of deploymentGroups) { if (!existsSyncImpl(deploymentGroup.target.targetDir)) { loggerImpl.error(`The Cloud Functions directory ${deploymentGroup.target.targetLabel} does not exist. ` + 'Make sure your firebase.json source or --codebase option points to an existing directory.', { exit: true, exitCode: 1 }); return null; } const packageJson = getPackageJson(deploymentGroup.target.targetDir, { existsSyncImpl, loggerImpl, readFileSyncImpl }); if (!packageJson) { return null; } if (!packageJson.engines || !packageJson.engines.node) { loggerImpl.error('No node engine target defined. Add it to your package.json to continue, for example: "engines": { "node": "24" }.', { exit: true, exitCode: 1 }); return null; } const availableFunctions = getDeployableFunctionNames({ packageJson, targetDir: deploymentGroup.target.targetDir, targetLabel: deploymentGroup.target.targetLabel }, { existsSyncImpl, listModuleExportsImpl, loggerImpl }); if (availableFunctions.length === 0) { return null; } deploymentPlans.push({ ...deploymentGroup, availableFunctions, packageJson, nodeVersion: packageJson.engines.node }); } return deploymentPlans; }; const resolveRequestedFunctionMatches = (availableFunctionMap, availableFunctions, requestedName) => { const exactMatch = availableFunctionMap.get(requestedName) ?? availableFunctionMap.get(normalizeFunctionDeployName(requestedName)); if (exactMatch) { return [exactMatch]; } const normalizedRequestedName = normalizeFunctionDeployName(requestedName); return availableFunctions.filter(availableFunction => availableFunction.startsWith(`${requestedName}.`) || normalizeFunctionDeployName(availableFunction).startsWith(`${normalizedRequestedName}-`)); }; const resolveDeploymentPlanFunctionNames = (deploymentPlans, dependencies = {}) => { const { loggerImpl = logger } = dependencies; const resolvedDeploymentPlans = []; for (const deploymentPlan of deploymentPlans) { const availableFunctionMap = new Map(); const resolvedFunctionNames = []; for (const availableFunction of deploymentPlan.availableFunctions) { if (!availableFunctionMap.has(availableFunction)) { availableFunctionMap.set(availableFunction, availableFunction); } const normalizedDeployName = normalizeFunctionDeployName(availableFunction); if (!availableFunctionMap.has(normalizedDeployName)) { availableFunctionMap.set(normalizedDeployName, availableFunction); } } for (const requestedFunctionName of deploymentPlan.functionNames) { const matchedFunctionNames = resolveRequestedFunctionMatches(availableFunctionMap, deploymentPlan.availableFunctions, requestedFunctionName); if (matchedFunctionNames.length === 0) { loggerImpl.error(`The Cloud Functions target ${deploymentPlan.target.targetLabel} does not export ${formatQualifiedFunctionSelector(deploymentPlan.target, requestedFunctionName)}. Available exports: ${deploymentPlan.availableFunctions.join(', ')}.`, { exit: true, exitCode: 1 }); return null; } for (const matchedFunctionName of matchedFunctionNames) { if (!resolvedFunctionNames.includes(matchedFunctionName)) { resolvedFunctionNames.push(matchedFunctionName); } } } resolvedDeploymentPlans.push({ ...deploymentPlan, functionNames: resolvedFunctionNames }); } return resolvedDeploymentPlans; }; const hydrateDeploymentRuntimeEnvironments = (deploymentPlans, { projectEnvironment, projectId }, dependencies = {}) => { const { existsSyncImpl = fs.existsSync, parseEnvImpl = parseEnv } = dependencies; return deploymentPlans.map(deploymentPlan => { const runtimeEnvCandidates = createRuntimeEnvironmentCandidates({ projectEnvironment, projectId, targetDir: deploymentPlan.target.targetDir }); const runtimeEnvCandidate = runtimeEnvCandidates.find(candidate => existsSyncImpl(candidate.envFilePath)); const runtimeEnv = runtimeEnvCandidate ? normalizeOptionalString(parseEnvImpl(runtimeEnvCandidate.envFilePath)?.RUNTIME_ENVIRONMENT) : null; return { ...deploymentPlan, runtimeEnv, runtimeEnvCandidates, runtimeEnvFileName: runtimeEnvCandidate?.fileName ?? null, runtimeEnvFilePath: runtimeEnvCandidate?.envFilePath ?? null, runtimeEnvProjectSelector: runtimeEnvCandidate?.projectSelector ?? null }; }); }; const resolveDeploymentProjectSelector = ({ deploymentPlans, projectEnvironment }, dependencies = {}) => { const { loggerImpl = logger } = dependencies; const projectSelectors = dedupeMessages(deploymentPlans.map(deploymentPlan => deploymentPlan.runtimeEnvProjectSelector).filter(Boolean)); if (projectSelectors.length === 0) { return projectEnvironment; } if (projectSelectors.length === 1) { return projectSelectors[0]; } const projectSelectorDetails = deploymentPlans.filter(deploymentPlan => deploymentPlan.runtimeEnvProjectSelector).map(deploymentPlan => `${deploymentPlan.target.targetLabel} uses ${deploymentPlan.runtimeEnvFileName} ` + `with --project ${deploymentPlan.runtimeEnvProjectSelector}`).join(', '); loggerImpl.error(`Selected Cloud Functions codebases require different Firebase project flags based on their env files: ${projectSelectorDetails}. ` + 'Deploy those codebases separately or use the same env-file naming convention.', { exit: true, exitCode: 1 }); return null; }; const warnMissingRuntimeEnvironmentValues = ({ deploymentPlans, loggerImpl = logger }) => { for (const deploymentPlan of deploymentPlans) { if (deploymentPlan.runtimeEnvFileName && !deploymentPlan.runtimeEnv) { loggerImpl.warning(`No RUNTIME_ENVIRONMENT was found in ${deploymentPlan.target.targetLabel}/${deploymentPlan.runtimeEnvFileName}.`); } } }; const confirmMissingRuntimeEnvironmentFiles = async ({ allYes = false, deploymentPlans, loggerImpl = logger, promptImpl = inquirer.prompt }) => { const missingRuntimeEnvFilePlans = deploymentPlans.filter(deploymentPlan => !deploymentPlan.runtimeEnvFileName); if (missingRuntimeEnvFilePlans.length === 0) { return true; } for (const deploymentPlan of missingRuntimeEnvFilePlans) { loggerImpl.warning(`No Cloud Functions env file was found for ${deploymentPlan.target.targetLabel}. ` + `Checked ${formatRuntimeEnvironmentCandidatePaths(deploymentPlan)}.`); } if (allYes) { loggerImpl.error('Cloud Functions env files are missing. Re-run without --all-yes to confirm deploying without them.', { exit: true, exitCode: 1 }); return false; } const missingTargetLabels = missingRuntimeEnvFilePlans.map(deploymentPlan => chalk.cyan(deploymentPlan.target.targetLabel)).join(', '); const { isConfirmed } = await promptImpl([{ type: 'confirm', name: 'isConfirmed', default: false, message: `Deploy without a Cloud Functions env file for ${missingTargetLabels}?` }]); return isConfirmed; }; const resolveRequestedFunctionDeploymentGroups = async ({ allYes = false, codebaseOrPath, functionSelectors, rootDir }, dependencies = {}) => { const { existsSyncImpl = fs.existsSync, loggerImpl = logger, promptImpl = inquirer.prompt, readFileSyncImpl = fs.readFileSync } = dependencies; const configuredTargets = getConfiguredFunctionsTargets(rootDir, { existsSyncImpl, readFileSyncImpl }); const parsedSelectors = normalizeFunctionNames(functionSelectors).map(parseFunctionSelector).filter(Boolean); const needsDefaultTarget = parsedSelectors.some(parsedSelector => parsedSelector.type === 'bare'); const defaultTarget = needsDefaultTarget ? await resolveDefaultFunctionsTarget({ allYes, codebaseOrPath, configuredTargets, rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }) : null; const deploymentGroups = new Map(); const seenSelectors = new Set(); if (needsDefaultTarget && !defaultTarget) { return null; } for (const parsedSelector of parsedSelectors) { let target = null; if (parsedSelector.type === 'bare') { target = defaultTarget; } else if (parsedSelector.type === 'firebase-unscoped') { target = await resolveUnscopedFunctionsTarget({ allYes, configuredTargets, originalSelector: parsedSelector.originalSelector, rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); } else { target = resolveQualifiedFunctionsTarget({ codebase: parsedSelector.codebase, configuredTargets, originalSelector: parsedSelector.originalSelector }, { loggerImpl }); } if (!target) { return null; } const deploySelector = createFunctionDeployTarget(target, parsedSelector.functionName); if (seenSelectors.has(deploySelector)) { continue; } seenSelectors.add(deploySelector); const targetKey = createFunctionsTargetKey(target); if (!deploymentGroups.has(targetKey)) { deploymentGroups.set(targetKey, { functionNames: [], target }); } deploymentGroups.get(targetKey).functionNames.push(parsedSelector.functionName); } return Array.from(deploymentGroups.values()); }; const confirmFunctionsDeployment = async ({ allYes = false, functionCount, projectEnvironment, projectId, promptImpl = inquirer.prompt, targetCount = 1, targetLabel }) => { if (allYes) { return true; } const functionLabel = functionCount === 1 ? 'function' : 'functions'; const sourceLabel = targetCount > 1 ? ` from ${chalk.yellow(String(targetCount))} codebases` : targetLabel ? ` from ${chalk.cyan(targetLabel)}` : ''; const { isConfirmed } = await promptImpl([{ type: 'confirm', name: 'isConfirmed', default: false, message: `Deploy ${chalk.yellow(String(functionCount))} Firebase ${functionLabel}${sourceLabel} ` + `to ${chalk.yellow(projectId)} ` + `(${chalk.yellow(projectEnvironment)})?` }]); return isConfirmed; }; const createFunctionDeployTarget = (target, functionName) => { const codebasePrefix = target.codebase ? `${target.codebase}:` : ''; return `functions:${codebasePrefix}${normalizeFunctionDeployName(functionName)}`; }; export const createDeployFunctionsHandler = (dependencies = {}) => { const { execSyncImpl = execSync, existsSyncImpl = fs.existsSync, listModuleExportsImpl = listModuleExports, loggerImpl = logger, parseEnvImpl = parseEnv, promptImpl = inquirer.prompt, readFileSyncImpl = fs.readFileSync, selectProjectImpl = selectProject } = dependencies; return async (functionNames = [], commandOptions = {}) => { try { const rootDir = process.cwd(); const firebaseJsonPath = path.join(rootDir, 'firebase.json'); if (!existsSyncImpl(firebaseJsonPath)) { loggerImpl.error('No firebase.json file found. Make sure you run this command in the root of the Atlas project.', { exit: true, exitCode: 1 }); return false; } let selectedFunctions = normalizeFunctionNames(functionNames); let deploymentGroups = null; let deploymentPlans = null; if (selectedFunctions.length === 0) { const resolvedTarget = await resolveFunctionsTarget({ allYes: commandOptions.allYes === true, codebaseOrPath: commandOptions.codebase, promptMessage: 'Select the Cloud Functions codebase to deploy from:', rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); if (!resolvedTarget) { return false; } const inputMode = await resolveFunctionInputMode({ allYes: commandOptions.allYes === true, commandOptions, promptImpl }); const deploymentTargetPlans = createDeploymentTargetPlans([{ functionNames: [], target: resolvedTarget }], { existsSyncImpl, listModuleExportsImpl, loggerImpl, readFileSyncImpl }); if (!deploymentTargetPlans) { return false; } const [resolvedTargetPlan] = deploymentTargetPlans; const { availableFunctions } = resolvedTargetPlan; if (availableFunctions.length === 0) { return false; } selectedFunctions = inputMode === 'editor' ? await selectFunctionsFromEditor({ promptImpl, targetLabel: resolvedTargetPlan.target.targetLabel }) : await selectFunctionsInteractive({ availableFunctions, promptImpl, targetLabel: resolvedTargetPlan.target.targetLabel }); deploymentGroups = [{ functionNames: selectedFunctions, target: resolvedTargetPlan.target }]; deploymentPlans = [{ ...resolvedTargetPlan, functionNames: selectedFunctions }]; } else { deploymentGroups = await resolveRequestedFunctionDeploymentGroups({ allYes: commandOptions.allYes === true, codebaseOrPath: commandOptions.codebase, functionSelectors: selectedFunctions, rootDir }, { existsSyncImpl, loggerImpl, promptImpl, readFileSyncImpl }); if (!deploymentGroups) { return false; } } if (selectedFunctions.length === 0) { loggerImpl.error('No functions specified. Please specify at least one function to deploy.', { exit: true, exitCode: 1 }); return false; } const resolvedTargetPlans = deploymentPlans ?? createDeploymentTargetPlans(deploymentGroups, { existsSyncImpl, listModuleExportsImpl, loggerImpl, readFileSyncImpl }); if (!resolvedTargetPlans) { return false; } const resolvedDeploymentPlans = resolveDeploymentPlanFunctionNames(resolvedTargetPlans, { loggerImpl }); if (!resolvedDeploymentPlans) { return false; } const { config, projectId } = await selectProjectImpl('.firebaserc', { promptMessage: 'Select a Google Cloud Project to deploy to:' }); const projectEnvironment = resolveProjectEnvironmentAlias(projectId, config); if (!projectEnvironment) { loggerImpl.error(`The selected project ${projectId} is not mapped to an environment alias in .firebaserc.`, { exit: true, exitCode: 1 }); return false; } const deploymentPlansWithRuntime = hydrateDeploymentRuntimeEnvironments(resolvedDeploymentPlans, { projectEnvironment, projectId }, { existsSyncImpl, parseEnvImpl }); const deploymentProjectSelector = resolveDeploymentProjectSelector({ deploymentPlans: deploymentPlansWithRuntime, projectEnvironment }, { loggerImpl }); if (!deploymentProjectSelector) { return false; } outputDeploymentSummary({ deploymentPlans: deploymentPlansWithRuntime, deploymentProjectSelector, loggerImpl, projectEnvironment, projectId }); warnMissingRuntimeEnvironmentValues({ deploymentPlans: deploymentPlansWithRuntime, loggerImpl }); const canDeployWithoutRuntimeEnvFiles = await confirmMissingRuntimeEnvironmentFiles({ allYes: commandOptions.allYes === true, deploymentPlans: deploymentPlansWithRuntime, loggerImpl, promptImpl }); if (!canDeployWithoutRuntimeEnvFiles) { if (commandOptions.allYes !== true) { loggerImpl.warning('Deployment cancelled by the user.'); } return false; } const isConfirmed = await confirmFunctionsDeployment({ allYes: commandOptions.allYes === true, functionCount: deploymentPlansWithRuntime.reduce((count, deploymentPlan) => count + deploymentPlan.functionNames.length, 0), projectEnvironment, projectId, promptImpl, targetCount: deploymentPlansWithRuntime.length, targetLabel: deploymentPlansWithRuntime.length === 1 ? deploymentPlansWithRuntime[0].target.targetLabel : null }); if (!isConfirmed) { loggerImpl.warning('Deployment cancelled by the user.'); return false; } const spinner = loggerImpl.spinner('Deploying Cloud Functions...'); try { execSyncImpl('firebase deploy', { cwd: rootDir, only: deploymentPlansWithRuntime.flatMap(deploymentPlan => deploymentPlan.functionNames.map(functionName => createFunctionDeployTarget(deploymentPlan.target, functionName))).join(','), project: deploymentProjectSelector, stdio: 'inherit' }); spinner.succeed('Cloud Functions deployed successfully.'); } catch (error) { spinner.fail('Deployment failed. See the error above for details.'); loggerImpl.error(error, { exit: true, exitCode: 1 }); return false; } loggerImpl.success('Done!'); return true; } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { loggerImpl.error('Deployment cancelled by the user.', { exit: true, exitCode: 130 }); return false; } loggerImpl.error(error, { exit: true, exitCode: 1 }); return false; } }; }; export default createDeployFunctionsHandler();