UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

600 lines 17.8 kB
import fs from 'fs'; import path from 'path'; import chokidar from 'chokidar'; import killPort from 'kill-port'; import { rimraf } from 'rimraf'; import { spawn } from 'child_process'; import { debounce } from 'es-toolkit/function'; import link from './link.js'; import { normalizeStartOptions, runStartPreflightChecks } from './preflight.js'; import { logger, execSync, parseEnv } from '../../utils/index.js'; import { ensureAtlasAiContextForStart } from '../ai/startRefreshRuntime.js'; import { outputStartUpdateNotices } from './updateChecks.js'; const DEV_SERVER_PORT = 3000; const DOCKER_COMPOSE_FILE_CANDIDATES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; const createRuntimeState = () => ({ cleanupPromise: null, devServerProcess: null, dockerStarted: false, expectedDevServerExit: null, shutdownLogged: false, shuttingDown: false, signalHandlersRegistered: false, watcher: null }); const formatPathLabel = (rootDir, targetPath) => { const relativePath = path.relative(rootDir, targetPath).split(path.sep).join('/'); if (relativePath.length === 0) { return 'project root'; } if (relativePath.startsWith('..')) { return targetPath.split(path.sep).join('/'); } return `/${relativePath}`; }; const getSignalExitCode = signal => { switch (signal) { case 'SIGINT': return 130; case 'SIGTERM': return 143; default: return 1; } }; const createDevServerInvocation = platform => { if (platform === 'win32') { return { args: ['/d', '/s', '/c', 'npm', 'run', 'start'], command: 'cmd.exe' }; } return { args: ['run', 'start'], command: 'npm' }; }; const resolveComposeFilePath = (rootDir, existsSyncImpl = fs.existsSync) => { for (const composeFileName of DOCKER_COMPOSE_FILE_CANDIDATES) { const composeFilePath = path.join(rootDir, composeFileName); if (existsSyncImpl(composeFilePath)) { return composeFilePath; } } return null; }; const outputStartSummary = ({ composeFilePath, loggerImpl = logger, options, rootDir, summaryRows = [] }) => { const watchStatus = options.link ? options.watch ? 'enabled' : 'disabled' : options.watch ? 'ignored (requires --link)' : 'disabled'; loggerImpl.summary('Start plan', [{ label: 'Frontend', value: formatPathLabel(rootDir, path.join(rootDir, 'app')), tone: 'accent' }, { label: 'Docker Compose', value: formatPathLabel(rootDir, composeFilePath), tone: 'warning' }, { label: 'Link local packages', value: options.link ? 'enabled' : 'disabled', tone: options.link ? 'warning' : null }, { label: 'Watch linked packages', value: watchStatus, tone: watchStatus === 'enabled' ? 'warning' : watchStatus.includes('ignored') ? 'warning' : null }, { label: 'Clear cache', value: options.clearCache ? 'enabled' : 'disabled', tone: options.clearCache ? 'warning' : null }, { label: 'Atlas AI refresh', value: options.aiRefresh, tone: options.aiRefresh === 'off' ? null : 'warning' }, { label: 'Detached frontend', value: options.detached ? 'enabled' : 'disabled', tone: options.detached ? 'warning' : null }, ...summaryRows], { spacing: 'after' }); }; const validateEnv = ({ existsSyncImpl = fs.existsSync, loggerImpl = logger, parseEnvImpl = parseEnv, rootDir }) => { const envFilePath = path.join(rootDir, 'app', '.env.local'); if (!existsSyncImpl(envFilePath)) { loggerImpl.summary('Missing app environment', [{ label: 'File', value: formatPathLabel(rootDir, envFilePath), tone: 'accent' }, { label: 'Token', value: 'REACT_APP_API_TOKEN or REACT_APP_TOKEN', tone: 'warning' }, { label: 'Login', value: 'REACT_APP_API_LOGIN or REACT_APP_LOGIN', tone: 'warning' }], { spacing: 'after' }); loggerImpl.error('Missing /app/.env.local. Create it before starting the local development environment.', { exit: true, exitCode: 1 }); return false; } const env = parseEnvImpl(envFilePath); const missingRows = []; if (!env.REACT_APP_API_TOKEN && !env.REACT_APP_TOKEN) { missingRows.push({ label: 'Token', value: 'REACT_APP_API_TOKEN or REACT_APP_TOKEN', tone: 'warning' }); } if (!env.REACT_APP_API_LOGIN && !env.REACT_APP_LOGIN) { missingRows.push({ label: 'Login', value: 'REACT_APP_API_LOGIN or REACT_APP_LOGIN', tone: 'warning' }); } if (missingRows.length > 0) { loggerImpl.summary('Missing app environment', [{ label: 'File', value: formatPathLabel(rootDir, envFilePath), tone: 'accent' }, ...missingRows], { spacing: 'after' }); loggerImpl.error('Cannot start the local development environment until the required app environment variables are configured. ' + 'Run `atlas auth print-developer-token` to generate a developer token when needed.', { exit: true, exitCode: 1 }); return false; } return true; }; const validateDocker = ({ execSyncImpl = execSync, existsSyncImpl = fs.existsSync, loggerImpl = logger, rootDir }) => { const composeFilePath = resolveComposeFilePath(rootDir, existsSyncImpl); if (!composeFilePath) { loggerImpl.error('No Docker Compose file found. Add one of docker-compose.yml, docker-compose.yaml, ' + 'compose.yml or compose.yaml to the project root.', { exit: true, exitCode: 1 }); return null; } try { execSyncImpl('docker compose version', { cwd: rootDir }); } catch { loggerImpl.error('Docker Compose is not available. Install Docker Desktop or the Docker Compose plugin and try again. ' + 'https://docs.docker.com/get-docker/', { exit: true, exitCode: 1 }); return null; } return composeFilePath; }; const stopDevServer = async (runtimeState, dependencies = {}) => { const { killPortImpl = killPort, loggerImpl = logger } = dependencies; const activeProcess = runtimeState.devServerProcess; if (activeProcess) { runtimeState.expectedDevServerExit = activeProcess; runtimeState.devServerProcess = null; try { activeProcess.kill('SIGTERM'); } catch (error) { loggerImpl.debug?.('Failed to stop the frontend development server cleanly.', error); } } try { await killPortImpl(DEV_SERVER_PORT); } catch (error) { loggerImpl.debug?.('Port 3000 did not need to be cleared before shutdown.', error); } }; const stopWatcher = async runtimeState => { const activeWatcher = runtimeState.watcher; if (!activeWatcher) { return; } runtimeState.watcher = null; await activeWatcher.close(); }; const stopDockerStack = (runtimeState, { execSyncImpl = execSync, loggerImpl = logger, rootDir }) => { if (!runtimeState.dockerStarted) { return; } try { execSyncImpl('docker compose down', { cwd: rootDir, stdio: 'inherit' }); } catch (error) { loggerImpl.warning('Failed to stop the Docker services automatically.'); loggerImpl.debug?.(error); } finally { runtimeState.dockerStarted = false; } }; const cleanupRuntime = async (runtimeState, { execSyncImpl = execSync, killPortImpl = killPort, loggerImpl = logger, rootDir }) => { if (runtimeState.cleanupPromise) { return runtimeState.cleanupPromise; } runtimeState.cleanupPromise = (async () => { runtimeState.shuttingDown = true; if (!runtimeState.shutdownLogged) { loggerImpl.break(); loggerImpl.warning('Shutting down...'); runtimeState.shutdownLogged = true; } await stopWatcher(runtimeState); await stopDevServer(runtimeState, { killPortImpl, loggerImpl }); stopDockerStack(runtimeState, { execSyncImpl, loggerImpl, rootDir }); })(); return runtimeState.cleanupPromise; }; const registerShutdownHandlers = (runtimeState, { cleanupImpl, exitImpl = process.exit, processImpl = process }) => { if (runtimeState.signalHandlersRegistered) { return; } for (const signal of ['SIGINT', 'SIGTERM']) { processImpl.once(signal, async () => { await cleanupImpl(); exitImpl(getSignalExitCode(signal)); }); } runtimeState.signalHandlersRegistered = true; }; const startDevServer = async ({ appDir, detached = false, onUnexpectedExit, platform = process.platform, runtimeState }, dependencies = {}) => { const { killPortImpl = killPort, loggerImpl = logger, spawnImpl = spawn } = dependencies; await stopDevServer(runtimeState, { killPortImpl, loggerImpl }); const devServerInvocation = createDevServerInvocation(platform); let childProcess; try { childProcess = spawnImpl(devServerInvocation.command, devServerInvocation.args, { cwd: appDir, detached, shell: false, stdio: detached ? 'ignore' : 'inherit' }); } catch (error) { loggerImpl.error('Could not start the frontend development server.', { details: error, exit: false }); if (onUnexpectedExit) { await onUnexpectedExit(1); return null; } throw error; } runtimeState.devServerProcess = childProcess; childProcess.on('error', error => { loggerImpl.error('Could not start the frontend development server.', { details: error, exit: false }); if (!detached && onUnexpectedExit) { Promise.resolve(onUnexpectedExit(1)).catch(unexpectedExitError => { loggerImpl.error('Failed to clean up after a frontend startup error.', { details: unexpectedExitError, exit: false }); }); } }); childProcess.on('exit', (code, signal) => { if (runtimeState.expectedDevServerExit === childProcess) { runtimeState.expectedDevServerExit = null; return; } if (runtimeState.devServerProcess === childProcess) { runtimeState.devServerProcess = null; } if (runtimeState.shuttingDown || detached) { return; } loggerImpl.warning(`The frontend development server stopped${signal ? ` (${signal})` : code === null ? '' : ` with exit code ${code}`}.`); if (onUnexpectedExit) { Promise.resolve(onUnexpectedExit(code ?? 1)).catch(unexpectedExitError => { loggerImpl.error('Failed to clean up after the frontend development server stopped.', { details: unexpectedExitError, exit: false }); }); } }); if (detached && typeof childProcess.unref === 'function') { childProcess.unref(); } return childProcess; }; const watchLocalPackages = async ({ appDir, deps, detached = false, linkImpl = link, loggerImpl = logger, runtimeState, startDevServerImpl = startDevServer, watchImpl = chokidar.watch }) => { await stopWatcher(runtimeState); const restartDebounced = debounce(async changedPath => { const formattedTimestamp = new Date().toLocaleTimeString(); loggerImpl.log(chalk => [chalk.green(`[${formattedTimestamp}]`), chalk.yellow('Reload:'), chalk.cyan(changedPath)]); loggerImpl.break(); try { await linkImpl(changedPath); loggerImpl.callout(' Restarting the development server... ', 'yellow'); await startDevServerImpl({ appDir, detached, runtimeState }, { loggerImpl }); } catch (error) { loggerImpl.error('Failed to reload linked local packages.', { details: error, exit: false }); } }, 2e3); runtimeState.watcher = watchImpl(Array.from(deps.values()), { atomic: 1000, ignoreInitial: true, ignored: /node_modules|dist|\.git|\.test|[\\/]test([\\/]|$)/ }).on('change', restartDebounced); }; export default async (options = {}, dependencies = {}) => { const { chokidarWatchImpl = chokidar.watch, cwd = process.cwd(), execSyncImpl = execSync, existsSyncImpl = fs.existsSync, exitImpl = process.exit, killPortImpl = killPort, linkImpl = link, loggerImpl = logger, outputStartUpdateNoticesImpl = outputStartUpdateNotices, parseEnvImpl = parseEnv, platform = process.platform, processImpl = process, readFileSyncImpl = fs.readFileSync, rimrafSyncImpl = rimraf.sync, runAiStartRefreshImpl = ensureAtlasAiContextForStart, runStartPreflightChecksImpl = runStartPreflightChecks, spawnImpl = spawn, watchLocalPackagesImpl = watchLocalPackages } = dependencies; const normalizedOptions = normalizeStartOptions(options); const rootDir = cwd; const appDir = path.join(rootDir, 'app'); const appPackagePath = path.join(appDir, 'package.json'); const runtimeState = createRuntimeState(); if (!existsSyncImpl(appPackagePath)) { loggerImpl.error('package.json not found in /app. Make sure you run this command in the root directory of your project.', { exit: true, exitCode: 1 }); return; } if (!validateEnv({ existsSyncImpl, loggerImpl, parseEnvImpl, rootDir })) { return; } const composeFilePath = validateDocker({ execSyncImpl, existsSyncImpl, loggerImpl, rootDir }); if (!composeFilePath) { return; } if (normalizedOptions.watch && !normalizedOptions.link) { loggerImpl.warning('Ignoring --watch because local package linking is disabled.'); } registerShutdownHandlers(runtimeState, { cleanupImpl: () => cleanupRuntime(runtimeState, { execSyncImpl, killPortImpl, loggerImpl, rootDir }), exitImpl, processImpl }); const preflightState = await runStartPreflightChecksImpl({ rootDir }, { collectStartUpdateNoticesImpl: dependencies.collectStartUpdateNoticesImpl, createRequireImpl: dependencies.createRequireImpl, existsSyncImpl, getFirebasercImpl: dependencies.getFirebasercImpl, loggerImpl, readFileSyncImpl, runCommand: dependencies.runCommand, runFrontendToolingPreflightImpl: dependencies.runFrontendToolingPreflightImpl, runSearchApplyImpl: dependencies.runSearchApplyImpl, writeGeneratedFeatureConfig: dependencies.writeGeneratedFeatureConfig }); if (!preflightState.shouldContinue) { loggerImpl.error(preflightState.blockingError, { exit: true, exitCode: 1 }); return; } const aiRefreshResult = await runAiStartRefreshImpl({ aiRefresh: normalizedOptions.aiRefresh, mode: normalizedOptions.aiRefresh }, { ...dependencies, fsImpl: dependencies.fsImpl ?? { ...fs, existsSync: existsSyncImpl, mkdirSync: dependencies.mkdirSyncImpl ?? fs.mkdirSync, readFileSync: readFileSyncImpl, readdirSync: dependencies.readdirSyncImpl ?? fs.readdirSync, writeFileSync: dependencies.writeFileSyncImpl ?? fs.writeFileSync } }, rootDir); for (const warning of aiRefreshResult.warnings ?? []) { loggerImpl.warning(warning); } outputStartSummary({ composeFilePath, loggerImpl, options: normalizedOptions, rootDir, summaryRows: [...preflightState.summaryRows, ...(aiRefreshResult.summaryRows ?? [])] }); outputStartUpdateNoticesImpl(preflightState.notices, { loggerImpl }); if (normalizedOptions.clearCache) { loggerImpl.info('Clearing cache...'); rimrafSyncImpl(path.join(appDir, 'node_modules', '.vite')); } if (normalizedOptions.link) { loggerImpl.info('Looking to link local packages from link.json...'); const deps = await linkImpl(); if (deps.size > 0) { process.env.ATLAS_LINKED_PACKAGES = Array.from(deps.keys()).join(','); } if (deps.size === 0) { loggerImpl.info('[link] No local packages to link'); } else if (normalizedOptions.watch) { loggerImpl.break(); loggerImpl.callout(' Watching local packages for changes... ', 'yellow'); loggerImpl.break(); await watchLocalPackagesImpl({ appDir, deps, detached: normalizedOptions.detached === true, linkImpl, loggerImpl, runtimeState, watchImpl: chokidarWatchImpl, startDevServerImpl: (startConfig, startDependencies = {}) => startDevServer({ ...startConfig, platform, runtimeState, onUnexpectedExit: async exitCode => { await cleanupRuntime(runtimeState, { execSyncImpl, killPortImpl, loggerImpl, rootDir }); exitImpl(exitCode); } }, { killPortImpl, loggerImpl, spawnImpl, ...startDependencies }) }); } } try { execSyncImpl('docker compose up -d', { cwd: rootDir, stdio: 'inherit' }); runtimeState.dockerStarted = true; } catch { loggerImpl.break(); loggerImpl.error('Could not start the Docker services. Please check the error above.', { exit: false }); loggerImpl.break(); loggerImpl.log('1. Make sure Docker is running.'); loggerImpl.log('2. Make sure no other Atlas project instance is currently running.'); loggerImpl.log('3. Make sure Docker is configured to access the Artifact Registry:'); loggerImpl.log(chalk => chalk.italic(' gcloud auth configure-docker europe-west1-docker.pkg.dev')); loggerImpl.log('4. Restart Docker if the problem persists.'); loggerImpl.break(); exitImpl(1); return; } await startDevServer({ appDir, platform, runtimeState, detached: normalizedOptions.detached === true, onUnexpectedExit: async exitCode => { await cleanupRuntime(runtimeState, { execSyncImpl, killPortImpl, loggerImpl, rootDir }); exitImpl(exitCode); } }, { killPortImpl, loggerImpl, spawnImpl }); };