Refine CLI args and lifecycle logging

This commit is contained in:
Shantur Rathore
2025-11-17 22:08:50 +00:00
parent 40e8c90bab
commit a3f02befa7
7 changed files with 1505 additions and 33 deletions

1333
packages/cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"pino": "^9.4.0",
"zod": "^3.23.8"

View File

@@ -30,7 +30,7 @@ export class BinaryRegistry {
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.info({ path: request.path }, "Registering OpenCode binary")
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
@@ -56,7 +56,7 @@ export class BinaryRegistry {
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.info({ id }, "Updating OpenCode binary")
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
@@ -77,7 +77,7 @@ export class BinaryRegistry {
}
remove(id: string) {
this.logger.info({ id }, "Removing OpenCode binary")
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
const update: ConfigFileUpdate = { opencodeBinaries: remaining }

View File

@@ -35,7 +35,7 @@ export class ConfigStore {
this.logger.debug({ resolved }, "Loaded existing config file")
} else {
this.cache = DEFAULT_CONFIG
this.logger.info({ resolved }, "No config file found, using defaults")
this.logger.debug({ resolved }, "No config file found, using defaults")
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults")
@@ -59,7 +59,7 @@ export class ConfigStore {
this.cache = ConfigFileSchema.parse(merged)
this.persist()
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.info("Config updated")
this.logger.debug("Config updated")
}
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {

View File

@@ -2,6 +2,8 @@
* CLI entry point.
* For now this only wires the typed modules together; actual command handling comes later.
*/
import { Command, InvalidArgumentError, Option } from "commander"
import packageJson from "../package.json"
import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
@@ -21,42 +23,68 @@ interface CliOptions {
logDestination?: string
}
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function parseCliOptions(argv: string[]): CliOptions {
// TODO: replace with commander/yargs; this is placeholder logic.
const args = new Map<string, string>()
for (let i = 0; i < argv.length; i += 2) {
const key = argv[i]
const value = argv[i + 1]
if (key && key.startsWith("--") && value) {
args.set(key.slice(2), value)
}
}
const program = new Command()
.name("codenomad-cli")
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(new Option("--root <path>", "Workspace root directory").default(process.cwd()))
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
program.parse(argv, { from: "user" })
const parsed = program.opts<{
host: string
port: number
root: string
config: string
logLevel?: string
logDestination?: string
}>()
return {
port: Number(args.get("port") ?? process.env.CLI_PORT ?? 5777),
host: args.get("host") ?? process.env.CLI_HOST ?? "127.0.0.1",
rootDir: args.get("root") ?? process.cwd(),
configPath: args.get("config") ?? process.env.CLI_CONFIG ?? "~/.config/codenomad/config.json",
logLevel: args.get("log-level") ?? process.env.CLI_LOG_LEVEL,
logDestination: args.get("log-destination") ?? process.env.CLI_LOG_DESTINATION,
port: parsed.port,
host: parsed.host,
rootDir: parsed.root,
configPath: parsed.config,
logLevel: parsed.logLevel,
logDestination: parsed.logDestination,
}
}
function parsePort(input: string): number {
const value = Number(input)
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 1 and 65535")
}
return value
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination })
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" })
logger.info({ options }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(logger)
const configStore = new ConfigStore(options.configPath, eventBus, logger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, logger)
const eventBus = new EventBus(eventLogger)
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
eventBus,
logger,
logger: workspaceLogger,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
const instanceStore = new InstanceStore()
@@ -78,9 +106,9 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
logger,
})
await server.start()
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
@@ -116,7 +144,7 @@ async function main() {
}
main().catch((error) => {
const logger = createLogger()
const logger = createLogger({ component: "app" })
logger.error({ err: error }, "CLI server crashed")
process.exit(1)
})

View File

@@ -1,3 +1,4 @@
import { Transform } from "node:stream"
import pino, { Logger as PinoLogger } from "pino"
export type Logger = PinoLogger
@@ -5,16 +6,128 @@ export type Logger = PinoLogger
interface LoggerOptions {
level?: string
destination?: string
component?: string
}
const LEVEL_LABELS: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
}
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
export function createLogger(options: LoggerOptions = {}): Logger {
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
const baseComponent = options.component ?? "app"
const loggerOptions = {
level,
base: { component: baseComponent },
timestamp: false,
} as const
if (destination && destination !== "stdout") {
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
return pino({ level }, stream)
return pino(loggerOptions, stream)
}
return pino({ level })
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
lifecycleStream.pipe(process.stdout)
return pino(loggerOptions, lifecycleStream)
}
interface LifecycleStreamOptions {
restrictInfoToLifecycle: boolean
}
class LifecycleLogStream extends Transform {
private buffer = ""
constructor(private readonly options: LifecycleStreamOptions) {
super()
}
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
this.buffer += chunk.toString()
let newlineIndex = this.buffer.indexOf("\n")
while (newlineIndex >= 0) {
const line = this.buffer.slice(0, newlineIndex)
this.buffer = this.buffer.slice(newlineIndex + 1)
this.pushFormatted(line)
newlineIndex = this.buffer.indexOf("\n")
}
callback()
}
_flush(callback: () => void) {
if (this.buffer.length > 0) {
this.pushFormatted(this.buffer)
this.buffer = ""
}
callback()
}
private pushFormatted(line: string) {
if (!line.trim()) {
return
}
let entry: Record<string, unknown>
try {
entry = JSON.parse(line)
} catch {
return
}
const levelNumber = typeof entry.level === "number" ? entry.level : 30
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
return
}
const message = typeof entry.msg === "string" ? entry.msg : ""
const metadata = this.formatMetadata(entry)
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
this.push(`${formatted}\n`)
}
private formatMetadata(entry: Record<string, unknown>): string {
const pairs: string[] = []
for (const [key, value] of Object.entries(entry)) {
if (OMITTED_FIELDS.has(key)) {
continue
}
if (key === "err" && value && typeof value === "object") {
const err = value as { type?: string; message?: string; stack?: string }
const errLabel = err.type ?? "Error"
const errMessage = err.message ? `: ${err.message}` : ""
pairs.push(`err=${errLabel}${errMessage}`)
if (err.stack) {
pairs.push(`stack="${err.stack}"`)
}
continue
}
pairs.push(`${key}=${this.stringifyValue(value)}`)
}
return pairs.join(" ").trim()
}
private stringifyValue(value: unknown): string {
if (value === undefined) return "undefined"
if (value === null) return "null"
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Error) return value.message ?? value.name
return JSON.stringify(value)
}
}

View File

@@ -13,7 +13,6 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { Logger } from "../logger"
interface HttpServerDeps {
host: string
@@ -25,12 +24,10 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
logger: Logger
}
export function createHttpServer(deps: HttpServerDeps) {
const fastifyLogger = deps.logger.child({ module: "http" })
const app = Fastify({ logger: fastifyLogger as any })
const app = Fastify({ logger: false })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {