feat(config): migrate to YAML config and state.yaml

This commit is contained in:
Shantur Rathore
2026-02-12 19:03:53 +00:00
parent cb0d601b09
commit d3484ec3af
12 changed files with 535 additions and 72 deletions

19
package-lock.json generated
View File

@@ -11879,6 +11879,21 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"dev": true, "dev": true,
@@ -11974,7 +11989,8 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server" "@neuralnomads/codenomad": "file:../server",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -12017,6 +12033,7 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -5,6 +5,7 @@ import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
@@ -39,6 +40,36 @@ interface CliEntryResolution {
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
const resolved = resolveConfigPath(target)
if (isYamlPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
}
if (isJsonPath(resolved)) {
const baseDir = path.dirname(resolved)
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
}
// Treat as directory.
return {
configYamlPath: path.join(resolved, "config.yaml"),
legacyJsonPath: path.join(resolved, "config.json"),
}
}
function resolveConfigPath(configPath?: string): string { function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) { if (target.startsWith("~/")) {
@@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string {
function readListeningModeFromConfig(): ListeningMode { function readListeningModeFromConfig(): ListeningMode {
try { try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG) const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8") let parsed: any = null
const parsed = JSON.parse(content) if (existsSync(configYamlPath)) {
const content = readFileSync(configYamlPath, "utf-8")
parsed = parseYaml(content)
} else if (existsSync(legacyJsonPath)) {
const content = readFileSync(legacyJsonPath, "utf-8")
parsed = JSON.parse(content)
} else {
return "local"
}
const mode = parsed?.preferences?.listeningMode const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") { if (mode === "local" || mode === "all") {
return mode return mode

View File

@@ -36,7 +36,8 @@
}, },
"dependencies": { "dependencies": {
"@neuralnomads/codenomad": "file:../server", "@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui" "@codenomad/ui": "file:../ui",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

View File

@@ -34,6 +34,7 @@
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@@ -0,0 +1,78 @@
import os from "os"
import path from "path"
export interface ConfigLocation {
/** Resolved absolute base directory containing all persisted server data. */
baseDir: string
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
configYamlPath: string
/** Canonical YAML state file path (always in baseDir). */
stateYamlPath: string
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
legacyJsonPath: string
/** Directory for per-instance persisted data (chat history etc.). */
instancesDir: string
}
function resolvePath(inputPath: string): string {
if (inputPath.startsWith("~/")) {
return path.join(os.homedir(), inputPath.slice(2))
}
return path.resolve(inputPath)
}
function isYamlPath(filePath: string): boolean {
const lower = filePath.toLowerCase()
return lower.endsWith(".yaml") || lower.endsWith(".yml")
}
function isJsonPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".json")
}
/**
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
*
* Supported inputs:
* - Directory: "~/.config/codenomad"
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
* - Legacy JSON file: "~/.config/codenomad/config.json"
*/
export function resolveConfigLocation(raw: string): ConfigLocation {
const trimmed = (raw ?? "").trim()
const fallback = "~/.config/codenomad/config.json"
const input = trimmed.length > 0 ? trimmed : fallback
const resolvedInput = resolvePath(input)
if (isYamlPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: resolvedInput,
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}
if (isJsonPath(resolvedInput)) {
const baseDir = path.dirname(resolvedInput)
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: resolvedInput,
instancesDir: path.join(baseDir, "instances"),
}
}
const baseDir = resolvedInput
return {
baseDir,
configYamlPath: path.join(baseDir, "config.yaml"),
stateYamlPath: path.join(baseDir, "state.yaml"),
legacyJsonPath: path.join(baseDir, "config.json"),
instancesDir: path.join(baseDir, "instances"),
}
}

View File

@@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z.object({ const PreferencesSchema = z
.object({
showThinkingBlocks: z.boolean().default(false), showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true), showTimelineTools: z.boolean().default(true),
@@ -31,7 +32,9 @@ const PreferencesSchema = z.object({
osNotificationsAllowWhenVisible: z.boolean().default(false), osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true), notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true), notifyOnIdle: z.boolean().default(true),
}) })
// Preserve unknown preference keys so newer configs survive older binaries.
.passthrough()
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({
path: z.string(), path: z.string(),
@@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
label: z.string().optional(), label: z.string().optional(),
}) })
const ConfigFileSchema = z.object({ const ConfigFileSchema = z
preferences: PreferencesSchema.default({}), .object({
recentFolders: z.array(RecentFolderSchema).default([]), preferences: PreferencesSchema.default({}),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), recentFolders: z.array(RecentFolderSchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(), opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
}) theme: z.enum(["light", "dark", "system"]).optional(),
})
// Preserve unknown top-level keys so optional future features survive downgrades.
.passthrough()
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
const ConfigYamlSchema = z
.object({
preferences: PreferencesSchema.default({}),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
.passthrough()
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
const StateFileSchema = z
.object({
recentFolders: z.array(RecentFolderSchema).default([]),
})
.passthrough()
const DEFAULT_CONFIG = ConfigFileSchema.parse({}) const DEFAULT_CONFIG = ConfigFileSchema.parse({})
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
const DEFAULT_STATE = StateFileSchema.parse({})
export { export {
ModelPreferenceSchema, ModelPreferenceSchema,
@@ -62,7 +86,11 @@ export {
RecentFolderSchema, RecentFolderSchema,
OpenCodeBinarySchema, OpenCodeBinarySchema,
ConfigFileSchema, ConfigFileSchema,
ConfigYamlSchema,
StateFileSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
} }
export type ModelPreference = z.infer<typeof ModelPreferenceSchema> export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
@@ -72,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema> export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema> export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema> export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
export type StateFile = z.infer<typeof StateFileSchema>

View File

@@ -1,15 +1,27 @@
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { Logger } from "../logger" import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema" import {
ConfigFile,
ConfigFileSchema,
ConfigYamlSchema,
DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
StateFile,
StateFileSchema,
} from "./schema"
import type { ConfigLocation } from "./location"
export class ConfigStore { export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG private cache: ConfigFile = DEFAULT_CONFIG
private state: StateFile = DEFAULT_STATE
private loaded = false private loaded = false
constructor( constructor(
private readonly configPath: string, private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined, private readonly eventBus: EventBus | undefined,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -20,19 +32,37 @@ export class ConfigStore {
} }
try { try {
const resolved = this.resolvePath(this.configPath) const configYamlPath = this.location.configYamlPath
if (fs.existsSync(resolved)) { const stateYamlPath = this.location.stateYamlPath
const content = fs.readFileSync(resolved, "utf-8") const legacyJsonPath = this.location.legacyJsonPath
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed) if (fs.existsSync(configYamlPath)) {
this.logger.debug({ resolved }, "Loaded existing config file") const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
const stateDoc = fs.existsSync(stateYamlPath)
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
: DEFAULT_STATE
this.state = stateDoc
this.cache = this.mergeDocs(configDoc, stateDoc)
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
} else if (fs.existsSync(legacyJsonPath)) {
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
this.state = migrated.state
this.cache = migrated.config
} else { } else {
this.cache = DEFAULT_CONFIG // Fresh install: write defaults.
this.logger.debug({ resolved }, "No config file found, using defaults") this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
this.persist()
this.logger.debug(
{ configYamlPath, stateYamlPath },
"No config files found, created default YAML config/state",
)
} }
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults") this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
this.cache = DEFAULT_CONFIG this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
} }
this.loaded = true this.loaded = true
@@ -48,9 +78,30 @@ export class ConfigStore {
this.commit(validated) this.commit(validated)
} }
/**
* Apply a merge-patch update to the current config.
* - Missing keys are preserved.
* - Object values are merged recursively.
* - Explicit `null` deletes keys.
* - Arrays are replaced.
*/
mergePatch(patch: unknown) {
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
throw new Error("Config patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current as any, patch as any)
const validated = ConfigFileSchema.parse(next)
this.commit(validated)
}
private commit(next: ConfigFile) { private commit(next: ConfigFile) {
this.cache = next this.cache = next
this.loaded = true this.loaded = true
this.state = {
...this.state,
recentFolders: next.recentFolders,
}
this.persist() this.persist()
const published = Boolean(this.eventBus) const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
@@ -60,19 +111,134 @@ export class ConfigStore {
private persist() { private persist() {
try { try {
const resolved = this.resolvePath(this.configPath) const configYamlPath = this.location.configYamlPath
fs.mkdirSync(path.dirname(resolved), { recursive: true }) const stateYamlPath = this.location.stateYamlPath
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file") fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
const stateYaml = stringifyYaml(this.state as any)
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
} catch (error) { } catch (error) {
this.logger.warn({ err: error }, "Failed to persist config") this.logger.warn({ err: error }, "Failed to persist config")
} }
} }
private resolvePath(filePath: string) { private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
if (filePath.startsWith("~/")) { const merged = {
return path.join(process.env.HOME ?? "", filePath.slice(2)) ...(configDoc as any),
// State wins for recent folders.
recentFolders: stateDoc.recentFolders ?? [],
} }
return path.resolve(filePath)
return ConfigFileSchema.parse(merged)
}
private readYamlFile<T>(
filePath: string,
fallback: T,
schema: { parse: (value: unknown) => T },
label: string,
): T {
try {
const content = fs.readFileSync(filePath, "utf-8")
const parsed = parseYaml(content)
return schema.parse(parsed ?? {})
} catch (error) {
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
return fallback
}
}
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
const configYamlPath = this.location.configYamlPath
const stateYamlPath = this.location.stateYamlPath
const content = fs.readFileSync(legacyJsonPath, "utf-8")
const parsed = JSON.parse(content)
const legacy = ConfigFileSchema.parse(parsed)
const state: StateFile = StateFileSchema.parse({
...DEFAULT_STATE,
recentFolders: legacy.recentFolders ?? [],
})
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
// Persist YAML docs first, then move legacy aside.
try {
fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
}
try {
const bakPath = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bakPath)
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
} catch (error) {
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
}
return { config: merged, state }
} }
} }
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
const clone: Record<string, unknown> = { ...(config as any) }
delete clone.recentFolders
return clone as any
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
function applyMergePatch(current: any, patch: any): any {
// RFC 7396-ish merge patch with explicit null deletes.
if (!isPlainObject(patch)) {
return patch
}
const base = isPlainObject(current) ? { ...current } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
if (isPlainObject(value) && isPlainObject(base[key])) {
base[key] = applyMergePatch(base[key], value)
continue
}
// Arrays and scalars replace.
base[key] = value
}
return base
}
function pickBackupPath(legacyJsonPath: string): string {
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
const preferred = `${base}.json.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${base}.json.bak.${Date.now()}`
}

View File

@@ -9,6 +9,7 @@ import { createRequire } from "module"
import { createHttpServer } from "./server/http-server" import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager" import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store" import { ConfigStore } from "./config/store"
import { resolveConfigLocation } from "./config/location"
import { BinaryRegistry } from "./config/binaries" import { BinaryRegistry } from "./config/binaries"
import { FileSystemBrowser } from "./filesystem/browser" import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
@@ -210,13 +211,6 @@ function resolveHost(input: string | undefined): string {
return trimmed return trimmed
} }
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean { function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag) return argv.includes(flag)
} }
@@ -245,7 +239,8 @@ async function main() {
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.") const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath)) const configLocation = resolveConfigLocation(options.configPath)
const configDir = configLocation.baseDir
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) { if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together") throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
@@ -266,7 +261,7 @@ async function main() {
const authManager = new AuthManager( const authManager = new AuthManager(
{ {
configPath: options.configPath, configPath: configLocation.configYamlPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
generateToken: options.generateToken, generateToken: options.generateToken,
@@ -295,7 +290,16 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger) const configStore = new ConfigStore(configLocation, eventBus, configLogger)
// Eagerly load config at boot so migrations run immediately
// (instead of waiting for the first /api/config request).
try {
configStore.get()
} catch (error) {
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
}
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger) const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({ const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir, rootDir: options.rootDir,
@@ -307,7 +311,7 @@ async function main() {
nodeExtraCaCertsPath, nodeExtraCaCertsPath,
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore(configLocation.instancesDir)
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,

View File

@@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { ConfigStore } from "../../config/store" import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries" import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps { interface RouteDeps {
configStore: ConfigStore configStore: ConfigStore
@@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get()) app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => { app.put("/api/config/app", async (request, reply) => {
const body = ConfigFileSchema.parse(request.body ?? {}) // Backwards compatible: treat PUT as a merge-patch update.
deps.configStore.replace(body) try {
return deps.configStore.get() deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.patch("/api/config/app", async (request, reply) => {
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
}) })
app.get("/api/config/binaries", async () => { app.get("/api/config/binaries", async () => {

View File

@@ -636,6 +636,7 @@ dependencies = [
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -3894,6 +3895,19 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.12.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"
@@ -5015,6 +5029,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.7" version = "2.5.7"

View File

@@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
tauri = { version = "2.5.2", features = [ "devtools"] } tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9"
regex = "1" regex = "1"
once_cell = "1" once_cell = "1"
parking_lot = "0.12" parking_lot = "0.12"

View File

@@ -145,12 +145,33 @@ struct AppConfig {
preferences: Option<PreferencesConfig>, preferences: Option<PreferencesConfig>,
} }
fn resolve_config_path() -> PathBuf { fn resolve_config_locations() -> (PathBuf, PathBuf) {
let raw = env::var("CLI_CONFIG") let raw = env::var("CLI_CONFIG")
.ok() .ok()
.filter(|value| !value.trim().is_empty()) .filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
let expanded = expand_home(&raw);
let lower = raw.trim().to_lowercase();
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (expanded, base.join("config.json"));
}
if lower.ends_with(".json") {
let base = expanded
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| expanded.clone());
return (base.join("config.yaml"), expanded);
}
// Treat as directory.
(expanded.join("config.yaml"), expanded.join("config.json"))
} }
fn expand_home(path: &str) -> PathBuf { fn expand_home(path: &str) -> PathBuf {
@@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf {
} }
fn resolve_listening_mode() -> String { fn resolve_listening_mode() -> String {
let path = resolve_config_path(); let (yaml_path, json_path) = resolve_config_locations();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(content) = fs::read_to_string(&yaml_path) {
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
// Legacy fallback.
if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) { if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config if let Some(mode) = config
.preferences .preferences
@@ -260,7 +300,14 @@ impl CliProcessManager {
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) { if let Err(err) = Self::spawn_cli(
app.clone(),
status_arc.clone(),
child_arc,
ready_flag,
token_arc,
dev,
) {
log_line(&format!("cli spawn failed: {err}")); log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock(); let mut locked = status_arc.lock();
locked.state = CliState::Error; locked.state = CliState::Error;
@@ -369,7 +416,9 @@ impl CliProcessManager {
if !supports_user_shell() { if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() { if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed.")); return Err(anyhow::anyhow!(
"Node binary not found. Make sure Node.js is installed."
));
} }
} }
@@ -420,7 +469,6 @@ impl CliProcessManager {
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
.lock() .lock()
.as_mut() .as_mut()
@@ -433,10 +481,24 @@ impl CliProcessManager {
.map(BufReader::new); .map(BufReader::new);
if let Some(reader) = stdout { if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone); Self::process_stream(
reader,
"stdout",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone); Self::process_stream(
reader,
"stderr",
&app_clone,
&status_clone,
&ready_clone,
&token_clone,
);
} }
}); });
@@ -509,8 +571,14 @@ impl CliProcessManager {
if locked.error.is_none() { if locked.error.is_none() {
locked.error = err_msg.clone(); locked.error = err_msg.clone();
} }
log_line(&format!("cli process exited before ready: {:?}", locked.error)); log_line(&format!(
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()})); "cli process exited before ready: {:?}",
locked.error
));
let _ = app_clone.emit(
"cli:error",
json!({"message": locked.error.clone().unwrap_or_default()}),
);
} else { } else {
locked.state = CliState::Stopped; locked.state = CliState::Stopped;
log_line("cli process stopped cleanly"); log_line("cli process stopped cleanly");
@@ -574,13 +642,25 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok()) .and_then(|m| m.as_str().parse::<u16>().ok())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}")); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{port}"),
);
continue; continue;
} }
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) { if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) { if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port)); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
format!("http://localhost:{}", port),
);
continue; continue;
} }
} }
@@ -719,7 +799,12 @@ impl CliEntry {
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()]; let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--generate-token".to_string(),
];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: plain HTTP + Vite dev server proxy.
@@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
std::env::current_dir() std::env::current_dir()
.ok() .ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")), .map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe() std::env::current_exe().ok().and_then(|ex| {
.ok() ex.parent()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))), .map(|p| p.join("../node_modules/tsx/dist/cli.js"))
}),
]; ];
first_existing(candidates) first_existing(candidates)
@@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root(); let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![ let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")), base.as_ref()
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")), base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")), base.as_ref().map(|p| p.join("server/dist/index.js")),
]; ];
@@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
candidates.push(Some(resources.join("resources/server/dist/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js"))); candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js"))); candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js"))); candidates.push(Some(
resources.join("resources/server/dist/server/index.js"),
));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots { for root in linux_resource_roots {
@@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
first_existing(candidates) first_existing(candidates)
} }
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> { fn build_shell_command_string(
entry: &CliEntry,
cli_args: &[String],
) -> anyhow::Result<ShellCommand> {
let shell = default_shell(); let shell = default_shell();
let mut quoted: Vec<String> = Vec::new(); let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary)); quoted.push(shell_escape(&entry.node_binary));
@@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String {
"''".to_string() "''".to_string()
} else if !input } else if !input
.chars() .chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' )) .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
{ {
input.to_string() input.to_string()
} else { } else {