@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
343 lines • 12 kB
JavaScript
import fs from 'fs';
import path from 'path';
import inquirer from 'inquirer';
import { Firestore } from '@google-cloud/firestore';
import { isString } from 'es-toolkit/compat';
import { logger } from './logger.js';
import { dedupeMessages, isPlainObject, normalizeOptionalString } from './value.js';
const readJsonConfigFile = (filePath, missingMessage, dependencies = {}) => {
const {
existsSyncImpl = fs.existsSync,
loggerImpl = logger,
readFileSyncImpl = fs.readFileSync
} = dependencies;
if (!existsSyncImpl(filePath)) {
loggerImpl.error(missingMessage);
return null;
}
return JSON.parse(readFileSyncImpl(filePath, 'utf-8'));
};
export const getFirebaserc = (options = {}, dependencies = {}) => {
const {
allowMissing = false
} = options;
const {
cwd = process.cwd(),
existsSyncImpl = fs.existsSync,
loggerImpl = logger,
readFileSyncImpl = fs.readFileSync
} = dependencies;
const firebasercPath = path.join(cwd, '.firebaserc');
if (!existsSyncImpl(firebasercPath)) {
if (allowMissing) {
return null;
}
return readJsonConfigFile(firebasercPath, 'No .firebaserc file found. Make sure you run this command in the root of the Atlas project.', {
existsSyncImpl,
loggerImpl,
readFileSyncImpl
});
}
return JSON.parse(readFileSyncImpl(firebasercPath, 'utf-8'));
};
export const getRequiredFirebasercProjects = (dependencies = {}) => {
const {
getFirebasercImpl = getFirebaserc
} = dependencies;
const firebaserc = getFirebasercImpl({}, dependencies);
const projects = firebaserc?.projects;
if (!isPlainObject(projects)) {
throw new Error('No projects found in .firebaserc. Configure both production and development projects first.');
}
const developmentProjectId = normalizeOptionalString(projects.development);
const productionProjectId = normalizeOptionalString(projects.production);
if (!developmentProjectId || !productionProjectId) {
throw new Error('Both .firebaserc projects.production and projects.development are required to continue.');
}
return {
developmentProjectId,
productionProjectId,
projects
};
};
export const getFirebaseJson = (dependencies = {}) => {
const {
cwd = process.cwd(),
existsSyncImpl = fs.existsSync,
loggerImpl = logger,
readFileSyncImpl = fs.readFileSync
} = dependencies;
const firebaseJsonPath = path.join(cwd, 'firebase.json');
return readJsonConfigFile(firebaseJsonPath, 'No firebase.json file found. Make sure you run this command in the root of the Atlas project.', {
existsSyncImpl,
loggerImpl,
readFileSyncImpl
});
};
export const requireFirebaseJson = (dependencies = {}) => {
const {
getFirebaseJsonImpl = getFirebaseJson
} = dependencies;
const firebaseJson = getFirebaseJsonImpl({
...dependencies,
loggerImpl: {
error: message => {
throw new Error(message);
}
}
});
if (!firebaseJson) {
throw new Error('No firebase.json file found. Make sure you run this command in the root of the Atlas project.');
}
return firebaseJson;
};
const collectFirestoreConfigValues = (firebaseJson, propertyName) => {
const firestoreConfig = firebaseJson?.firestore;
if (isPlainObject(firestoreConfig)) {
return dedupeMessages([normalizeOptionalString(firestoreConfig[propertyName])]);
}
if (!Array.isArray(firestoreConfig)) {
return [];
}
return dedupeMessages(firestoreConfig.map(entry => {
if (!isPlainObject(entry)) {
return null;
}
return normalizeOptionalString(entry[propertyName]);
}));
};
const resolveFirestoreConfigSourceLabel = (firebaseJson, propertyName) => {
const configuredValues = collectFirestoreConfigValues(firebaseJson, propertyName);
if (configuredValues.length === 0) {
return null;
}
return configuredValues.join(', ');
};
export const resolveFirestoreIndexesSourceLabel = firebaseJson => resolveFirestoreConfigSourceLabel(firebaseJson, 'indexes');
export const resolveFirestoreRulesSourceLabel = firebaseJson => resolveFirestoreConfigSourceLabel(firebaseJson, 'rules');
export const selectProject = (firebasercPath, options = {}, dependencies = {}) => {
const {
promptMessage = 'Select a Google Cloud project:',
environment
} = options;
const {
existsSyncImpl = fs.existsSync,
promptImpl = inquirer.prompt,
readFileSyncImpl = fs.readFileSync
} = dependencies;
if (!existsSyncImpl(firebasercPath)) {
throw new Error('.firebaserc not found. Make sure you run this cmd from the root of your project. ' + 'A .firebaserc configuration file is required to continue.');
}
const config = JSON.parse(readFileSyncImpl(firebasercPath, 'utf-8'));
if (!config.projects) {
throw new Error('No projects defined. Please add a projects property to your .firebaserc file.');
}
if (environment) {
if (!config.projects[environment]) {
throw new Error(`No project found for environment ${environment}. ` + `Please add a projects.${environment} property to your .firebaserc file.`);
}
return Promise.resolve({
projectId: config.projects[environment],
config
});
}
return promptImpl([{
type: 'select',
name: 'projectId',
message: promptMessage,
choices: Object.entries(config.projects).map(([projectEnvironment, projectId]) => ({
name: `${projectId} (${projectEnvironment})`,
value: projectId,
short: projectId
}))
}]).then(({
projectId
}) => ({
projectId,
config
}));
};
export const normalizeFunctionsSourcePath = sourcePath => sourcePath.trim().replace(/\\/g, '/');
export const formatFunctionsTargetLabel = (rootDir, targetDir) => {
const relativePath = path.relative(rootDir, targetDir).split(path.sep).join('/');
if (relativePath.length === 0) {
return 'project root';
}
if (relativePath.startsWith('..')) {
return targetDir.split(path.sep).join('/');
}
return `/${relativePath}`;
};
export const createFunctionsTarget = ({
codebase = null,
source
}, rootDir) => {
const normalizedSource = normalizeFunctionsSourcePath(source);
const targetDir = path.resolve(rootDir, normalizedSource);
return {
codebase,
source: normalizedSource,
targetDir,
targetLabel: formatFunctionsTargetLabel(rootDir, targetDir)
};
};
const toFunctionsConfigEntries = functionsConfig => {
if (Array.isArray(functionsConfig)) {
return functionsConfig;
}
if (isPlainObject(functionsConfig)) {
return [functionsConfig];
}
return [];
};
export const getConfiguredFunctionsTargets = (rootDir, dependencies = {}) => {
const {
existsSyncImpl = fs.existsSync,
readFileSyncImpl = fs.readFileSync
} = dependencies;
const firebaseJsonPath = path.join(rootDir, 'firebase.json');
if (!existsSyncImpl(firebaseJsonPath)) {
return [];
}
try {
const firebaseJson = JSON.parse(readFileSyncImpl(firebaseJsonPath, 'utf-8'));
return toFunctionsConfigEntries(firebaseJson.functions).flatMap(entry => {
if (!entry || typeof entry !== 'object') {
return [];
}
if (!isString(entry.source) || entry.source.trim().length === 0) {
return [];
}
return [createFunctionsTarget({
codebase: isString(entry.codebase) && entry.codebase.trim().length > 0 ? entry.codebase.trim() : null,
source: entry.source
}, rootDir)];
});
} catch (error) {
throw new Error(`Failed to read firebase.json in ${rootDir}. ${error.message}`);
}
};
const resolveFallbackFunctionsTargetPath = (codebaseOrPath, functionsRootDir, rootDir) => {
if (path.isAbsolute(codebaseOrPath)) {
return codebaseOrPath;
}
if (codebaseOrPath.includes('/') || codebaseOrPath.includes('\\') || codebaseOrPath.startsWith('.')) {
return path.resolve(rootDir, codebaseOrPath);
}
return path.join(functionsRootDir, codebaseOrPath);
};
export const selectFunctionsTarget = async ({
allYes = false,
loggerImpl = logger,
promptImpl = inquirer.prompt,
promptMessage = 'Select the Cloud Functions codebase:',
targets
}) => {
if (targets.length === 0) {
return null;
}
if (targets.length === 1) {
return targets[0];
}
if (allYes) {
loggerImpl.error('Multiple Cloud Functions codebases were found in firebase.json. Re-run this command with --codebase <name-or-path>.', {
exit: true,
exitCode: 1
});
return null;
}
const {
source
} = await promptImpl([{
type: 'select',
name: 'source',
loop: false,
message: promptMessage,
choices: targets.map(target => ({
name: target.codebase ? `${target.codebase} (${target.source})` : target.source,
short: target.codebase ?? target.source,
value: target.source
}))
}]);
return targets.find(target => target.source === source) ?? null;
};
export const resolveFunctionsTarget = async ({
allYes = false,
codebaseOrPath,
promptMessage,
rootDir
}, dependencies = {}) => {
const {
existsSyncImpl = fs.existsSync,
loggerImpl = logger,
promptImpl = inquirer.prompt,
readFileSyncImpl = fs.readFileSync
} = dependencies;
const functionsRootDir = path.join(rootDir, 'functions');
const configuredTargets = getConfiguredFunctionsTargets(rootDir, {
existsSyncImpl,
readFileSyncImpl
});
if (isString(codebaseOrPath) && codebaseOrPath.trim().length > 0) {
const normalizedSelection = codebaseOrPath.trim();
const matchingTarget = configuredTargets.find(target => target.codebase === normalizedSelection || target.source === normalizeFunctionsSourcePath(normalizedSelection) || target.targetLabel === normalizedSelection);
if (matchingTarget) {
return matchingTarget;
}
return createFunctionsTarget({
source: resolveFallbackFunctionsTargetPath(normalizedSelection, functionsRootDir, rootDir)
}, rootDir);
}
const selectedConfiguredTarget = await selectFunctionsTarget({
allYes,
loggerImpl,
promptImpl,
promptMessage,
targets: configuredTargets
});
if (configuredTargets.length > 1) {
return selectedConfiguredTarget;
}
if (selectedConfiguredTarget) {
return selectedConfiguredTarget;
}
return createFunctionsTarget({
source: functionsRootDir
}, rootDir);
};
export const normalizeFirestoreCollectionPath = collectionPath => {
if (!isString(collectionPath) || collectionPath.trim().length === 0) {
throw new Error('Firestore collection path is required.');
}
const normalizedPath = collectionPath.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
const segments = normalizedPath.split('/').filter(Boolean);
if (segments.length === 0) {
throw new Error('Firestore collection path is required.');
}
if (segments.length % 2 === 0) {
throw new Error(`Firestore collection path must point to a collection, but received document path: ${normalizedPath}.`);
}
return segments.join('/');
};
export const sampleFirestoreDocuments = async (input, dependencies = {}) => {
const projectId = isString(input?.projectId) && input.projectId.trim().length > 0 ? input.projectId.trim() : null;
if (!projectId) {
throw new Error('Firestore document sampling requires a valid project id.');
}
const collectionPath = normalizeFirestoreCollectionPath(input?.collectionPath ?? '');
const requestedLimit = Number.parseInt(input?.limit ?? 20, 10);
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 20;
const createFirestoreClient = dependencies.createFirestoreClient ?? (options => new Firestore(options));
const firestoreClient = dependencies.firestoreClient ?? createFirestoreClient({
projectId
});
try {
const snapshot = await firestoreClient.collection(collectionPath).limit(limit).get();
return snapshot.docs.map(document => ({
data: document.data(),
id: document.id
}));
} catch (error) {
throw new Error(`Could not sample Firestore collection ${collectionPath} in project ${projectId}. ${error.message}`);
}
};