UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

343 lines 12 kB
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}`); } };