import * as chalk from 'chalk'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as semver from 'semver'; import {command, defaultValue, flag, help, option, namespace} from 'oo-cli'; import {appContext} from '../../lib/AppContext'; import {die} from '../../lib/die'; import {formatError} from '../../lib/formatError'; import {handleInterrupt} from '../../lib/handleInterrupt'; import {TerminalOutput} from '../../lib/TerminalOutput'; import {TerminalPassthru} from '../../lib/TeminalPassthru'; import {Tracking} from '../../lib/Tracking'; /** * Dev Server command - starts the local development server for OCP apps * This is a top-level command (no namespace decorator), so it runs as `ocp dev-server` */ @namespace('dev-server') export class DevServerCommand { @option('p') @help('Port to run the server on') @defaultValue('3000') public port!: number; @option('c') @help('Path to custom config file') public config?: string; @option @help('Path to the OCP app root directory') public path?: string; @flag('v') @help('Enable verbose logging') @defaultValue(false) public verbose!: boolean; @flag('no-open') @help('Don\'t automatically open browser') @defaultValue(false) public noOpen!: boolean; private PACKAGE_NAME = '@optimizely/ocp-local-env'; @command @help('Start the local development server for OCP apps') public async start() { handleInterrupt(); try { // Ensure ocp-local-env is installed and up-to-date await this.ensureOcpLocalEnv(); // Parse and validate port const port = Number(this.port) || 3000; if (isNaN(port) || port < 0 || port >= 65536) { die(`Invalid port: ${this.port}. Port must be a number between 0 and 65535.`); } // Set environment variables that ocp-local-env expects process.env.OCP_LOCAL_PORT = String(port); if (this.config) { process.env.OCP_LOCAL_CONFIG_PATH = this.config; } process.env.OCP_LOCAL_VERBOSE = this.verbose ? 'true' : 'false'; process.env.OCP_LOCAL_OPEN_BROWSER = this.noOpen ? 'false' : 'true'; if (this.path) { process.env.APP_ROOT_DIR = this.path; } // Track dev server start (fire-and-forget) try { const ctx = appContext(); Tracking.track({ action: 'dev_server_started', appId: ctx.appId || 'unknown', command: 'dev-server start', version: ctx.version, }); } catch { // Ignore - don't let tracking errors affect the command } // Import and call startServer from ocp-local-env (installed globally) // Get the global npm modules path and construct the full package path const globalRoot = TerminalOutput.exec('npm root -g').stdout.trim(); const packagePath = path.join(globalRoot, this.PACKAGE_NAME); // @ts-ignore - Package is installed globally, not in node_modules const {startServer} = await import(packagePath); console.log(chalk.gray('Starting OCP local development server...')); await startServer(); } catch (e: any) { die(formatError(e)); } } /** * Ensure @optimizely/ocp-local-env is installed globally and up-to-date. * Checks once per day and auto-installs or auto-upgrades as needed. */ private async ensureOcpLocalEnv(): Promise { // Ensure ~/.ocp directory exists const ocpDir = path.join(os.homedir(), '.ocp'); if (!fs.existsSync(ocpDir)) { fs.mkdirSync(ocpDir, {recursive: true}); } const checkFile = path.join(os.homedir(), '.ocp', 'last-ocp-local-env-check'); // Check if we should skip (< 24 hours since last check) if (fs.existsSync(checkFile)) { const stats = fs.statSync(checkFile); const hoursSinceCheck = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60); if (hoursSinceCheck < 24) { return; // Skip check } } // Check if installed globally const installedVersion = this.getInstalledVersion(); if (!installedVersion) { // Not installed - auto-install console.log(chalk.gray('Installing @optimizely/ocp-local-env globally...')); this.installGlobalPackage(false); fs.writeFileSync(checkFile, ''); return; } // Check for updates const latestVersion = await this.getLatestVersion(); if (latestVersion && semver.gt(latestVersion, installedVersion)) { // Newer version available - auto-upgrade console.log(chalk.gray(`Upgrading @optimizely/ocp-local-env (${installedVersion} → ${latestVersion})...`)); this.installGlobalPackage(true); } // Touch check file fs.writeFileSync(checkFile, ''); } /** * Check if a package is installed globally and return its version. * @returns The installed version or null if not installed */ private getInstalledVersion(): string | null { try { const result = TerminalOutput.exec(`npm list -g --depth=0 --json ${this.PACKAGE_NAME}`); if (result.status === 0) { const data = JSON.parse(result.stdout); return data.dependencies?.[this.PACKAGE_NAME]?.version || null; } return null; } catch { return null; } } /** * Get the latest version of a package from npm registry. * @returns The latest version or null if unavailable */ private async getLatestVersion(): Promise { try { const result = TerminalOutput.exec(`npm view ${this.PACKAGE_NAME} version`); if (result.status === 0) { return result.stdout.trim() || null; } return null; } catch { return null; } } /** * Install or update a global npm package. * @param isUpgrade Whether this is an upgrade (affects success message) * @returns True if successful, false otherwise */ private installGlobalPackage(isUpgrade: boolean): boolean { const spec = isUpgrade ? `${this.PACKAGE_NAME}@latest` : this.PACKAGE_NAME; const result = TerminalPassthru.exec(`npm install -g ${spec}`); if (result.status === 0) { if (isUpgrade) { // Get the newly installed version for the success message const newVersion = this.getInstalledVersion(); console.log(chalk.green(`✓ Updated to version ${newVersion}`)); } else { console.log(chalk.green('✓ Installed @optimizely/ocp-local-env')); } return true; } else { console.log(chalk.red('Installation failed. Please install manually with:')); console.log(chalk.red(` npm install -g ${this.PACKAGE_NAME}`)); return false; } } }