Refine CLI args and lifecycle logging
This commit is contained in:
1333
packages/cli/package-lock.json
generated
Normal file
1333
packages/cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user