UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

278 lines 11.1 kB
import fs from 'fs'; import path from 'path'; import { createRequire } from 'module'; import runSearchApply from '../search/apply.js'; import { logger } from '../../utils/index.js'; import { normalizeAtlasAiRefreshMode } from '../ai/consumerContextRuntime.js'; import { resolveSearchConfigLocation } from '../search/config/searchConfig.js'; import { collectStartUpdateNotices } from './updateChecks.js'; import { resolveStartDevelopmentProjectSelection } from './projectSelection.js'; const SEARCH_RUNTIME_PREFLIGHT_FAILURE_STATUSES = new Set(['dry-run', 'failed', 'skipped']); const FRONTEND_ESLINT_CONFIG_CANDIDATES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json']; const FRONTEND_SOURCE_FILE_CANDIDATES = ['src/main.tsx', 'src/main.ts', 'src/index.tsx', 'src/index.ts', 'src/main.jsx', 'src/main.js', 'src/index.jsx', 'src/index.js']; const FRONTEND_TYPESCRIPT_CONFIG_CANDIDATES = ['tsconfig.app.json', 'tsconfig.json', 'tsconfig.base.json']; const isSearchRuntimePreflightReady = searchResult => { const status = typeof searchResult?.status === 'string' ? searchResult.status.trim().toLowerCase() : null; if (!searchResult?.runtimeConfigArtifact?.filePath) { return false; } if (status && SEARCH_RUNTIME_PREFLIGHT_FAILURE_STATUSES.has(status)) { return false; } return true; }; const formatSearchRuntimePreflightFailureDetail = searchResult => { if (searchResult?.error instanceof Error) { return searchResult.error.message; } if (typeof searchResult?.status === 'string' && searchResult.status.length > 0) { return `Atlas search runtime config preflight returned status "${searchResult.status}".`; } return 'Atlas search runtime config preflight returned an unexpected result.'; }; const createSearchRuntimeSummaryRows = (projectSelection, searchResult) => [{ label: 'Search project', value: searchResult.projectId ?? projectSelection.projectId }, searchResult.environment ?? projectSelection.environment ? { label: 'Search environment', value: searchResult.environment ?? projectSelection.environment } : null, searchResult.runtimeConfigArtifact?.filePath ? { label: 'Search config', value: searchResult.runtimeConfigArtifact.filePath } : null, searchResult.region ? { label: 'Search region', value: searchResult.region } : null, searchResult.apiServiceUrl ? { label: 'Search API', value: searchResult.apiServiceUrl } : null, searchResult.syncServiceUrl ? { label: 'Search sync', value: searchResult.syncServiceUrl } : null].filter(Boolean); const resolveToolingTargetPath = (rootDir, appDir, relativeCandidates, existsSyncImpl) => { for (const baseDirectory of [appDir, rootDir]) { for (const relativeCandidate of relativeCandidates) { const candidatePath = path.join(baseDirectory, relativeCandidate); if (existsSyncImpl(candidatePath)) { return candidatePath; } } } return null; }; const createFrontendToolingSummaryRow = label => ({ label, tone: 'success', value: 'ready' }); const createFrontendToolingWarningRow = (label, message) => ({ label, tone: 'warning', value: `warning: ${message}` }); const getErrorMessage = error => { if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) { return error.message; } return String(error); }; const createFrontendToolingRequire = (appDir, createRequireImpl = createRequire) => createRequireImpl(path.join(appDir, 'package.json')); const formatTypeScriptDiagnostic = (typescriptModule, diagnostic) => { if (!diagnostic) { return 'Unknown TypeScript configuration error.'; } if (typeof typescriptModule?.flattenDiagnosticMessageText === 'function') { return typescriptModule.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); } return String(diagnostic.messageText ?? diagnostic); }; const validateFrontendEslint = async ({ appDir, eslintConfigPath, lintTargetPath }, dependencies) => { const appRequire = createFrontendToolingRequire(appDir, dependencies.createRequireImpl); let eslintModule; try { eslintModule = appRequire('eslint'); } catch (error) { throw new Error(`Atlas detected ESLint config at ${eslintConfigPath}, but could not load the eslint package. ${getErrorMessage(error)}`); } const ESLint = eslintModule?.ESLint ?? eslintModule?.default?.ESLint ?? null; if (typeof ESLint !== 'function') { throw new Error(`Atlas detected ESLint config at ${eslintConfigPath}, but the resolved eslint package does not expose the ESLint API.`); } try { const eslint = new ESLint({ cwd: appDir }); await eslint.calculateConfigForFile(lintTargetPath); } catch (error) { throw new Error(`Atlas could not load the app ESLint config from ${eslintConfigPath}. ${getErrorMessage(error)}`); } }; const validateFrontendTypeScript = ({ appDir, tsconfigPath }, dependencies) => { const appRequire = createFrontendToolingRequire(appDir, dependencies.createRequireImpl); let typescriptModule; try { typescriptModule = appRequire('typescript'); } catch (error) { throw new Error(`Atlas detected TypeScript config at ${tsconfigPath}, but could not load the typescript package. ${getErrorMessage(error)}`); } const readConfigResult = typescriptModule.readConfigFile(tsconfigPath, typescriptModule.sys.readFile); if (readConfigResult.error) { throw new Error(`Atlas could not read the app TypeScript config from ${tsconfigPath}. ${formatTypeScriptDiagnostic(typescriptModule, readConfigResult.error)}`); } const parsedConfig = typescriptModule.parseJsonConfigFileContent(readConfigResult.config, typescriptModule.sys, path.dirname(tsconfigPath)); if (Array.isArray(parsedConfig.errors) && parsedConfig.errors.length > 0) { throw new Error(`Atlas could not parse the app TypeScript config from ${tsconfigPath}. ${formatTypeScriptDiagnostic(typescriptModule, parsedConfig.errors[0])}`); } }; export const runFrontendToolingPreflight = async ({ rootDir }, dependencies = {}) => { const existsSyncImpl = dependencies.existsSyncImpl ?? fs.existsSync; const appDir = path.join(rootDir, 'app'); const summaryRows = []; const warnings = []; const eslintConfigPath = resolveToolingTargetPath(rootDir, appDir, FRONTEND_ESLINT_CONFIG_CANDIDATES, existsSyncImpl); const tsconfigPath = resolveToolingTargetPath(rootDir, appDir, FRONTEND_TYPESCRIPT_CONFIG_CANDIDATES, existsSyncImpl); if (!eslintConfigPath && !tsconfigPath) { return { status: 'skipped', summaryRows: [] }; } if (eslintConfigPath) { const lintTargetPath = resolveToolingTargetPath(rootDir, appDir, FRONTEND_SOURCE_FILE_CANDIDATES, existsSyncImpl) ?? path.join(appDir, 'src', 'index.tsx'); try { await validateFrontendEslint({ appDir, eslintConfigPath, lintTargetPath }, dependencies); summaryRows.push(createFrontendToolingSummaryRow('ESLint')); } catch (error) { const warningMessage = getErrorMessage(error); summaryRows.push(createFrontendToolingWarningRow('ESLint', warningMessage)); warnings.push(`App ESLint tooling warning: ${warningMessage}`); } } if (tsconfigPath) { try { validateFrontendTypeScript({ appDir, tsconfigPath }, dependencies); summaryRows.push(createFrontendToolingSummaryRow('TypeScript')); } catch (error) { const warningMessage = getErrorMessage(error); summaryRows.push(createFrontendToolingWarningRow('TypeScript', warningMessage)); warnings.push(`App TypeScript tooling warning: ${warningMessage}`); } } return { status: warnings.length > 0 ? 'warning' : 'ready', summaryRows, warnings }; }; export const normalizeStartOptions = (options = {}) => ({ ...options, aiRefresh: normalizeAtlasAiRefreshMode(options.aiRefresh ?? options['ai-refresh']), clearCache: options.clearCache === true || options['clear-cache'] === true }); export const runSearchRuntimePreflight = async ({ rootDir }, dependencies = {}) => { const existsSyncImpl = dependencies.existsSyncImpl ?? fs.existsSync; const loggerImpl = dependencies.loggerImpl ?? logger; const runSearchApplyImpl = dependencies.runSearchApplyImpl ?? runSearchApply; const configLocation = resolveSearchConfigLocation(rootDir, { existsSyncImpl }); if (!configLocation.hasPreferred) { return { status: 'skipped', summaryRows: [] }; } const projectSelection = resolveStartDevelopmentProjectSelection(rootDir, dependencies); if (!projectSelection) { return { blockingError: 'Cannot start the local development environment until the Atlas development project is configured in .firebaserc.', status: 'failed', summaryRows: [] }; } const searchResult = await runSearchApplyImpl({ generateConfigOnly: true, log: false, project: projectSelection.projectId }, { existsSyncImpl, readFileSyncImpl: dependencies.readFileSyncImpl, resolveProjectSelectionImpl: async () => ({ ...projectSelection, environment: null }), runCommand: dependencies.runCommand, writeGeneratedFeatureConfig: dependencies.writeGeneratedFeatureConfig }, rootDir); if (!isSearchRuntimePreflightReady(searchResult)) { loggerImpl.debug?.('Atlas search runtime config preflight did not complete successfully.', formatSearchRuntimePreflightFailureDetail(searchResult)); return { blockingError: 'Cannot start the local development environment until the Atlas search runtime config is generated successfully.', status: 'failed', summaryRows: [] }; } return { status: 'ready', summaryRows: createSearchRuntimeSummaryRows(projectSelection, searchResult) }; }; export const runStartPreflightChecks = async ({ rootDir }, dependencies = {}) => { const summaryRows = []; const warnings = []; const runSearchRuntimePreflightImpl = dependencies.runSearchRuntimePreflightImpl ?? runSearchRuntimePreflight; const runFrontendToolingPreflightImpl = dependencies.runFrontendToolingPreflightImpl ?? runFrontendToolingPreflight; const collectStartUpdateNoticesImpl = dependencies.collectStartUpdateNoticesImpl ?? collectStartUpdateNotices; const blockingChecks = [runSearchRuntimePreflightImpl, runFrontendToolingPreflightImpl]; for (const check of blockingChecks) { const checkResult = await check({ rootDir }, dependencies); summaryRows.push(...(checkResult.summaryRows ?? [])); warnings.push(...(checkResult.warnings ?? [])); if (checkResult.blockingError) { return { blockingError: checkResult.blockingError, notices: [], shouldContinue: false, summaryRows, warnings }; } } const notices = await collectStartUpdateNoticesImpl({ rootDir }, dependencies); return { notices, shouldContinue: true, summaryRows, warnings }; }; export default { normalizeStartOptions, runFrontendToolingPreflight, runSearchRuntimePreflight, runStartPreflightChecks };