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