@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
962 lines • 34.4 kB
JavaScript
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();