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