/** * CLI entry point. * For now this only wires the typed modules together; actual command handling comes later. */ import { Command, InvalidArgumentError, Option } from "commander" import path from "path" import { fileURLToPath } from "url" import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" import { resolveConfigLocation } from "./config/location" import { SettingsService } from "./settings/service" import { BinaryResolver } from "./settings/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" import { ServerMeta } from "./api-types" import { InstanceStore } from "./storage/instance-store" import { InstanceEventBridge } from "./workspaces/instance-events" import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" import { resolveUi } from "./ui/remote-ui" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" const require = createRequire(import.meta.url) const packageJson = require("../package.json") as { version: string } const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") interface CliOptions { host: string https: boolean http: boolean httpsPort: number httpPort: number tlsKeyPath?: string tlsCertPath?: string tlsCaPath?: string tlsSANs?: string rootDir: string configPath: string unrestrictedRoot: boolean logLevel?: string logDestination?: string uiStaticDir: string uiDevServer?: string uiAutoUpdate: boolean uiNoUpdate: boolean uiManifestUrl?: string launch: boolean authUsername: string authPassword?: string generateToken: boolean dangerouslySkipAuth: boolean } const DEFAULT_HOST = "127.0.0.1" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_HTTPS_PORT = 9898 const DEFAULT_HTTP_PORT = 9899 function parseCliOptions(argv: string[]): CliOptions { const program = new Command() .name("codenomad") .description("CodeNomad CLI server") .version(packageJson.version, "-v, --version", "Show the CLI version") .addOption(new Option("--host ", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--https ", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true")) .addOption(new Option("--http ", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false")) .addOption(new Option("--https-port ", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort)) .addOption(new Option("--http-port ", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort)) .addOption(new Option("--tls-key ", "TLS private key (PEM)").env("CLI_TLS_KEY")) .addOption(new Option("--tls-cert ", "TLS certificate (PEM)").env("CLI_TLS_CERT")) .addOption(new Option("--tls-ca ", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tlsSANs ", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption( new Option("--workspace-root ", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()), ) .addOption(new Option("--root ").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--config ", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH)) .addOption(new Option("--log-level ", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL")) .addOption(new Option("--log-destination ", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION")) .addOption( new Option("--ui-dir ", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR), ) .addOption(new Option("--ui-dev-server ", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER")) .addOption(new Option("--ui-no-update", "Disable remote UI updates").env("CLI_UI_NO_UPDATE").default(false)) .addOption(new Option("--ui-auto-update ", "Enable remote UI updates (true|false)").env("CLI_UI_AUTO_UPDATE").default("true")) .addOption(new Option("--ui-manifest-url ", "Remote UI manifest URL").env("CLI_UI_MANIFEST_URL")) .addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false)) .addOption( new Option("--username ", "Username for server authentication") .env("CODENOMAD_SERVER_USERNAME") .default(DEFAULT_AUTH_USERNAME), ) .addOption(new Option("--password ", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) .addOption( new Option("--generate-token", "Emit a one-time bootstrap token for desktop") .env("CODENOMAD_GENERATE_TOKEN") .default(false), ) .addOption( new Option( "--dangerously-skip-auth", "Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).", ) .env("CODENOMAD_SKIP_AUTH") .default(false), ) program.parse(argv, { from: "user" }) const parsed = program.opts<{ host: string https?: string http?: string httpsPort: number httpPort: number tlsKey?: string tlsCert?: string tlsCa?: string tlsSANs?: string workspaceRoot?: string root?: string unrestrictedRoot?: boolean config: string logLevel?: string logDestination?: string uiDir: string uiDevServer?: string uiNoUpdate?: boolean uiAutoUpdate?: string uiManifestUrl?: string launch?: boolean username: string password?: string generateToken?: boolean dangerouslySkipAuth?: boolean }>() const parseBooleanEnv = (value: string | undefined): boolean => { const normalized = (value ?? "").trim().toLowerCase() return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on" } const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const normalizedHost = resolveHost(parsed.host) const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase() const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes" const httpsEnabled = parseBooleanEnv(parsed.https) const httpEnabled = parseBooleanEnv(parsed.http) if (!httpsEnabled && !httpEnabled) { throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)") } return { host: normalizedHost, https: httpsEnabled, http: httpEnabled, httpsPort: parsed.httpsPort, httpPort: parsed.httpPort, tlsKeyPath: parsed.tlsKey, tlsCertPath: parsed.tlsCert, tlsCaPath: parsed.tlsCa, tlsSANs: parsed.tlsSANs, rootDir: resolvedRoot, configPath: parsed.config, unrestrictedRoot: Boolean(parsed.unrestrictedRoot), logLevel: parsed.logLevel, logDestination: parsed.logDestination, uiStaticDir: parsed.uiDir, uiDevServer: parsed.uiDevServer, uiAutoUpdate, uiNoUpdate: Boolean(parsed.uiNoUpdate), uiManifestUrl: parsed.uiManifestUrl, launch: Boolean(parsed.launch), authUsername: parsed.username, authPassword: parsed.password, generateToken: Boolean(parsed.generateToken), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), } } function parsePort(input: string): number { const value = Number(input) if (!Number.isInteger(value) || value < 0 || value > 65535) { throw new InvalidArgumentError("Port must be an integer between 0 and 65535") } return value } function resolveHost(input: string | undefined): string { const trimmed = input?.trim() if (!trimmed) return DEFAULT_HOST if (trimmed === "0.0.0.0") { return "0.0.0.0" } if (trimmed === "localhost") { return DEFAULT_HOST } return trimmed } function programHasArg(argv: string[], flag: string): boolean { return argv.includes(flag) } async function main() { const options = parseCliOptions(process.argv.slice(2)) const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" }) const workspaceLogger = logger.child({ component: "workspace" }) const configLogger = logger.child({ component: "config" }) const eventLogger = logger.child({ component: "events" }) const logOptions = { ...options, authPassword: options.authPassword ? "[REDACTED]" : undefined, } logger.info({ options: logOptions }, "Starting CodeNomad CLI server") if (options.dangerouslySkipAuth) { logger.warn( "DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).", ) } const eventBus = new EventBus(eventLogger) const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const configLocation = resolveConfigLocation(options.configPath) const configDir = configLocation.baseDir if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") } const serverMeta: ServerMeta = { localUrl: "http://localhost:0", remoteUrl: undefined, eventsUrl: `/api/events`, host: options.host, listeningMode: isLoopbackHost(options.host) ? "local" : "all", localPort: 0, remotePort: undefined, hostLabel: options.host, workspaceRoot: options.rootDir, addresses: [], } const authManager = new AuthManager( { configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, dangerouslySkipAuth: options.dangerouslySkipAuth, }, logger.child({ component: "auth" }), ) if (options.generateToken && !options.dangerouslySkipAuth) { const token = authManager.issueBootstrapToken() if (token) { console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`) } } const tlsResolution = resolveHttpsOptions({ enabled: options.https, configDir, host: options.host, tlsKeyPath: options.tlsKeyPath, tlsCertPath: options.tlsCertPath, tlsCaPath: options.tlsCaPath, tlsSANs: options.tlsSANs, logger: logger.child({ component: "tls" }), }) const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined const settings = new SettingsService(configLocation, eventBus, configLogger) const binaryResolver = new BinaryResolver(settings) const workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, settings, binaryResolver, eventBus, logger: workspaceLogger, getServerBaseUrl: () => serverMeta.localUrl, nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const instanceStore = new InstanceStore(configLocation.instancesDir) const speechService = new SpeechService(settings, logger.child({ component: "speech" })) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, logger: logger.child({ component: "instance-events" }), }) const uiDirEnvOverride = Boolean(process.env.CLI_UI_DIR) const uiDirCliOverride = programHasArg(process.argv.slice(2), "--ui-dir") const uiOverrideIsExplicit = uiDirEnvOverride || uiDirCliOverride const uiDirOverride = uiOverrideIsExplicit ? options.uiStaticDir : undefined const autoUpdateEnabled = options.uiAutoUpdate && !options.uiNoUpdate const uiResolution = await resolveUi({ serverVersion: packageJson.version, bundledUiDir: DEFAULT_UI_STATIC_DIR, autoUpdate: autoUpdateEnabled, overrideUiDir: uiDirOverride, uiDevServerUrl: options.uiDevServer, manifestUrl: options.uiManifestUrl, logger: logger.child({ component: "ui" }), }) serverMeta.serverVersion = packageJson.version serverMeta.ui = { version: uiResolution.uiVersion, source: uiResolution.source, } serverMeta.support = { supported: uiResolution.supported, message: uiResolution.message, latestServerVersion: uiResolution.latestServerVersion, latestServerUrl: uiResolution.latestServerUrl, minServerVersion: uiResolution.minServerVersion, } const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase() const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim() const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-") const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion) const devReleaseMonitor = enableDevUpdateChecks ? startDevReleaseMonitor({ currentVersion: packageJson.version, repo: githubRepo, logger: logger.child({ component: "updates" }), onUpdate: (release) => { serverMeta.update = release }, }) : null if (uiResolution.uiDevServerUrl && options.https) { throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true") } const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0 const httpBindPort = httpPortExplicit ? options.httpPort : 0 // Listener binding rules: // - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP). // - Remote access disabled: both listen on loopback. // - HTTP-only mode: respect --host (used for dev/testing). const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1" const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1" const servers: Array> = [] const httpServer = options.http ? createHttpServer({ bindHost: httpBindHost, bindPort: httpBindPort, defaultPort: options.httpPort, protocol: "http", workspaceManager, settings, fileSystemBrowser, eventBus, serverMeta, instanceStore, speechService, authManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, logger, }) : null const httpsServer = options.https ? createHttpServer({ bindHost: httpsBindHost, bindPort: httpsBindPort, defaultPort: options.httpsPort, protocol: "https", httpsOptions: tlsResolution?.httpsOptions, workspaceManager, settings, fileSystemBrowser, eventBus, serverMeta, instanceStore, speechService, authManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, logger, }) : null if (httpServer) servers.push(httpServer) if (httpsServer) servers.push(httpsServer) const [httpStart, httpsStart] = await Promise.all([ httpServer ? httpServer.start() : Promise.resolve(null), httpsServer ? httpsServer.start() : Promise.resolve(null), ]) const localStart = httpStart ?? httpsStart if (!localStart) { throw new Error("No listeners started") } const remoteStart = httpsStart ?? httpStart const localProtocol: "http" | "https" = httpStart ? "http" : "https" const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http" // Use an explicit IPv4 loopback address for the "local" URL. // On macOS, `localhost` often resolves to ::1 first, and it is possible to have // another instance bound on IPv6 while this instance binds IPv4 (or vice versa), // which can lead clients to talk to the wrong process. const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` let remoteUrl: string | undefined if (remoteStart) { const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) let remoteHost = options.host if (wantsAll) { if (options.host === "0.0.0.0") { const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost" } } else { remoteHost = "localhost" } remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` } serverMeta.localUrl = localUrl serverMeta.localPort = localStart.port serverMeta.remoteUrl = remoteUrl serverMeta.remotePort = remoteStart?.port serverMeta.host = options.host serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" if (serverMeta.remotePort && remoteUrl) { serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) } else { serverMeta.addresses = [] } console.log(`Local Connection URL : ${serverMeta.localUrl}`) if (serverMeta.remoteUrl) { console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) } if (options.launch) { await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" })) } let shuttingDown = false const shutdown = async () => { if (shuttingDown) { logger.info("Shutdown already in progress, ignoring signal") return } shuttingDown = true logger.info("Received shutdown signal, stopping workspaces and server") const shutdownWorkspaces = (async () => { try { instanceEventBridge.shutdown() } catch (error) { logger.warn({ err: error }, "Instance event bridge shutdown failed") } try { await workspaceManager.shutdown() logger.info("Workspace manager shutdown complete") } catch (error) { logger.error({ err: error }, "Workspace manager shutdown failed") } })() const shutdownHttp = (async () => { try { await Promise.allSettled(servers.map((srv) => srv.stop())) logger.info("HTTP server(s) stopped") } catch (error) { logger.error({ err: error }, "Failed to stop HTTP server") } })() await Promise.allSettled([shutdownWorkspaces, shutdownHttp]) // no-op: remote UI manifest replaces GitHub release monitor devReleaseMonitor?.stop() logger.info("Exiting process") process.exit(0) } process.on("SIGINT", shutdown) process.on("SIGTERM", shutdown) } main().catch((error) => { const logger = createLogger({ component: "app" }) logger.error({ err: error }, "CLI server crashed") process.exit(1) })