From d3484ec3af51ac27ff03dbdbb88199c4b47fa5b9 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 12 Feb 2026 19:03:53 +0000 Subject: [PATCH] feat(config): migrate to YAML config and state.yaml --- package-lock.json | 19 +- .../electron/main/process-manager.ts | 48 +++- packages/electron-app/package.json | 3 +- packages/server/package.json | 1 + packages/server/src/config/location.ts | 78 +++++++ packages/server/src/config/schema.ts | 46 +++- packages/server/src/config/store.ts | 206 ++++++++++++++++-- packages/server/src/index.ts | 26 ++- packages/server/src/server/routes/config.ts | 24 +- packages/tauri-app/Cargo.lock | 20 ++ packages/tauri-app/src-tauri/Cargo.toml | 1 + .../tauri-app/src-tauri/src/cli_manager.rs | 135 ++++++++++-- 12 files changed, 535 insertions(+), 72 deletions(-) create mode 100644 packages/server/src/config/location.ts diff --git a/package-lock.json b/package-lock.json index b0157bb3..fdfe54b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11879,6 +11879,21 @@ "dev": true, "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": { "version": "17.7.2", "dev": true, @@ -11974,7 +11989,8 @@ "license": "MIT", "dependencies": { "@codenomad/ui": "file:../ui", - "@neuralnomads/codenomad": "file:../server" + "@neuralnomads/codenomad": "file:../server", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", @@ -12017,6 +12033,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 45cf7f26..fdbf822b 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -5,6 +5,7 @@ import { EventEmitter } from "events" import { existsSync, readFileSync } from "fs" import os from "os" import path from "path" +import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" const nodeRequire = createRequire(import.meta.url) @@ -39,6 +40,36 @@ interface CliEntryResolution { 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 { const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH if (target.startsWith("~/")) { @@ -53,10 +84,19 @@ function resolveHostForMode(mode: ListeningMode): string { function readListeningModeFromConfig(): ListeningMode { try { - const configPath = resolveConfigPath(process.env.CLI_CONFIG) - if (!existsSync(configPath)) return "local" - const content = readFileSync(configPath, "utf-8") - const parsed = JSON.parse(content) + const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) + + let parsed: any = null + 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 if (mode === "local" || mode === "all") { return mode diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 37945ffe..85be725b 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -36,7 +36,8 @@ }, "dependencies": { "@neuralnomads/codenomad": "file:../server", - "@codenomad/ui": "file:../ui" + "@codenomad/ui": "file:../ui", + "yaml": "^2.4.2" }, "devDependencies": { "7zip-bin": "^5.2.0", diff --git a/packages/server/package.json b/packages/server/package.json index 5aad36da..4b2f7982 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,7 @@ "node-forge": "^1.3.3", "pino": "^9.4.0", "undici": "^6.19.8", + "yaml": "^2.4.2", "yauzl": "^2.10.0", "zod": "^3.23.8" }, diff --git a/packages/server/src/config/location.ts b/packages/server/src/config/location.ts new file mode 100644 index 00000000..b8d150f2 --- /dev/null +++ b/packages/server/src/config/location.ts @@ -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"), + } +} diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 829d491c..c4781113 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({ const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema) const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema) -const PreferencesSchema = z.object({ +const PreferencesSchema = z + .object({ showThinkingBlocks: z.boolean().default(false), thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), showTimelineTools: z.boolean().default(true), @@ -31,7 +32,9 @@ const PreferencesSchema = z.object({ osNotificationsAllowWhenVisible: z.boolean().default(false), notifyOnNeedsInput: z.boolean().default(true), notifyOnIdle: z.boolean().default(true), -}) + }) + // Preserve unknown preference keys so newer configs survive older binaries. + .passthrough() const RecentFolderSchema = z.object({ path: z.string(), @@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({ label: z.string().optional(), }) -const ConfigFileSchema = z.object({ - preferences: PreferencesSchema.default({}), - recentFolders: z.array(RecentFolderSchema).default([]), - opencodeBinaries: z.array(OpenCodeBinarySchema).default([]), - theme: z.enum(["light", "dark", "system"]).optional(), -}) +const ConfigFileSchema = z + .object({ + preferences: PreferencesSchema.default({}), + recentFolders: z.array(RecentFolderSchema).default([]), + 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_YAML = ConfigYamlSchema.parse({}) +const DEFAULT_STATE = StateFileSchema.parse({}) export { ModelPreferenceSchema, @@ -62,7 +86,11 @@ export { RecentFolderSchema, OpenCodeBinarySchema, ConfigFileSchema, + ConfigYamlSchema, + StateFileSchema, DEFAULT_CONFIG, + DEFAULT_CONFIG_YAML, + DEFAULT_STATE, } export type ModelPreference = z.infer @@ -72,3 +100,5 @@ export type Preferences = z.infer export type RecentFolder = z.infer export type OpenCodeBinary = z.infer export type ConfigFile = z.infer +export type ConfigYamlFile = z.infer +export type StateFile = z.infer diff --git a/packages/server/src/config/store.ts b/packages/server/src/config/store.ts index dda49e40..5d736f9c 100644 --- a/packages/server/src/config/store.ts +++ b/packages/server/src/config/store.ts @@ -1,15 +1,27 @@ import fs from "fs" import path from "path" +import { parse as parseYaml, stringify as stringifyYaml } from "yaml" import { EventBus } from "../events/bus" 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 { private cache: ConfigFile = DEFAULT_CONFIG + private state: StateFile = DEFAULT_STATE private loaded = false constructor( - private readonly configPath: string, + private readonly location: ConfigLocation, private readonly eventBus: EventBus | undefined, private readonly logger: Logger, ) {} @@ -20,19 +32,37 @@ export class ConfigStore { } try { - const resolved = this.resolvePath(this.configPath) - if (fs.existsSync(resolved)) { - const content = fs.readFileSync(resolved, "utf-8") - const parsed = JSON.parse(content) - this.cache = ConfigFileSchema.parse(parsed) - this.logger.debug({ resolved }, "Loaded existing config file") + const configYamlPath = this.location.configYamlPath + const stateYamlPath = this.location.stateYamlPath + const legacyJsonPath = this.location.legacyJsonPath + + if (fs.existsSync(configYamlPath)) { + 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 { - this.cache = DEFAULT_CONFIG - this.logger.debug({ resolved }, "No config file found, using defaults") + // Fresh install: write 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) { - this.logger.warn({ err: error }, "Failed to load config, using defaults") - this.cache = DEFAULT_CONFIG + this.logger.warn({ err: error }, "Failed to load config/state, using defaults") + this.state = DEFAULT_STATE + this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE) } this.loaded = true @@ -48,9 +78,30 @@ export class ConfigStore { 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) { this.cache = next this.loaded = true + this.state = { + ...this.state, + recentFolders: next.recentFolders, + } this.persist() const published = Boolean(this.eventBus) this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) @@ -60,19 +111,134 @@ export class ConfigStore { private persist() { try { - const resolved = this.resolvePath(this.configPath) - fs.mkdirSync(path.dirname(resolved), { recursive: true }) - fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8") - this.logger.debug({ resolved }, "Persisted config file") + const configYamlPath = this.location.configYamlPath + const stateYamlPath = this.location.stateYamlPath + + 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) { this.logger.warn({ err: error }, "Failed to persist config") } } - private resolvePath(filePath: string) { - if (filePath.startsWith("~/")) { - return path.join(process.env.HOME ?? "", filePath.slice(2)) + private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile { + const merged = { + ...(configDoc as any), + // State wins for recent folders. + recentFolders: stateDoc.recentFolders ?? [], } - return path.resolve(filePath) + + return ConfigFileSchema.parse(merged) + } + + private readYamlFile( + 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 & Record { + const clone: Record = { ...(config as any) } + delete clone.recentFolders + return clone as any +} + +function isPlainObject(value: unknown): value is Record { + 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()}` +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 38b07d80..54753885 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,7 @@ import { createRequire } from "module" import { createHttpServer } from "./server/http-server" import { WorkspaceManager } from "./workspaces/manager" import { ConfigStore } from "./config/store" +import { resolveConfigLocation } from "./config/location" import { BinaryRegistry } from "./config/binaries" import { FileSystemBrowser } from "./filesystem/browser" import { EventBus } from "./events/bus" @@ -210,13 +211,6 @@ function resolveHost(input: string | undefined): string { 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 { 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 configDir = path.dirname(resolvePath(options.configPath)) + 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") @@ -266,7 +261,7 @@ async function main() { const authManager = new AuthManager( { - configPath: options.configPath, + configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, generateToken: options.generateToken, @@ -295,7 +290,16 @@ async function main() { 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 workspaceManager = new WorkspaceManager({ rootDir: options.rootDir, @@ -307,7 +311,7 @@ async function main() { nodeExtraCaCertsPath, }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) - const instanceStore = new InstanceStore() + const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, diff --git a/packages/server/src/server/routes/config.ts b/packages/server/src/server/routes/config.ts index fed364af..ea4e03c5 100644 --- a/packages/server/src/server/routes/config.ts +++ b/packages/server/src/server/routes/config.ts @@ -2,7 +2,6 @@ import { FastifyInstance } from "fastify" import { z } from "zod" import { ConfigStore } from "../../config/store" import { BinaryRegistry } from "../../config/binaries" -import { ConfigFileSchema } from "../../config/schema" interface RouteDeps { configStore: ConfigStore @@ -27,10 +26,25 @@ const BinaryValidateSchema = z.object({ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/config/app", async () => deps.configStore.get()) - app.put("/api/config/app", async (request) => { - const body = ConfigFileSchema.parse(request.body ?? {}) - deps.configStore.replace(body) - return deps.configStore.get() + app.put("/api/config/app", async (request, reply) => { + // Backwards compatible: treat PUT as a merge-patch update. + 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.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 () => { diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index ede1e424..e388ea42 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -636,6 +636,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yaml", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -3894,6 +3895,19 @@ dependencies = [ "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]] name = "serialize-to-javascript" version = "0.1.2" @@ -5015,6 +5029,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 99496fe7..f119c846 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] } tauri = { version = "2.5.2", features = [ "devtools"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" regex = "1" once_cell = "1" parking_lot = "0.12" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 9e3242ea..ad0860f9 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -145,12 +145,33 @@ struct AppConfig { preferences: Option, } -fn resolve_config_path() -> PathBuf { +fn resolve_config_locations() -> (PathBuf, PathBuf) { let raw = env::var("CLI_CONFIG") .ok() .filter(|value| !value.trim().is_empty()) .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 { @@ -163,8 +184,27 @@ fn expand_home(path: &str) -> PathBuf { } fn resolve_listening_mode() -> String { - let path = resolve_config_path(); - if let Ok(content) = fs::read_to_string(path) { + let (yaml_path, json_path) = resolve_config_locations(); + + if let Ok(content) = fs::read_to_string(&yaml_path) { + if let Ok(config) = serde_yaml::from_str::(&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::(&content) { if let Some(mode) = config .preferences @@ -260,7 +300,14 @@ impl CliProcessManager { let ready_flag = self.ready.clone(); let token_arc = self.bootstrap_token.clone(); 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}")); let mut locked = status_arc.lock(); locked.state = CliState::Error; @@ -369,7 +416,9 @@ impl CliProcessManager { if !supports_user_shell() { 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(); thread::spawn(move || { - let stdout = child_clone .lock() .as_mut() @@ -433,10 +481,24 @@ impl CliProcessManager { .map(BufReader::new); 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 { - 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() { locked.error = err_msg.clone(); } - log_line(&format!("cli process exited before ready: {:?}", locked.error)); - let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()})); + log_line(&format!( + "cli process exited before ready: {:?}", + locked.error + )); + let _ = app_clone.emit( + "cli:error", + json!({"message": locked.error.clone().unwrap_or_default()}), + ); } else { locked.state = CliState::Stopped; 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(|m| m.as_str().parse::().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; } if let Ok(value) = serde_json::from_str::(line) { 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; } } @@ -719,7 +799,12 @@ impl CliEntry { } fn build_args(&self, dev: bool, host: &str) -> Vec { - 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 { // Dev: plain HTTP + Vite dev server proxy. @@ -761,9 +846,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option { std::env::current_dir() .ok() .map(|p| p.join("node_modules/tsx/dist/cli.js")), - std::env::current_exe() - .ok() - .and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))), + std::env::current_exe().ok().and_then(|ex| { + ex.parent() + .map(|p| p.join("../node_modules/tsx/dist/cli.js")) + }), ]; first_existing(candidates) @@ -786,7 +872,8 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { let base = workspace_root(); let mut candidates: Vec> = vec![ 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/index.js")), ]; @@ -801,7 +888,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { 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/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")]; for root in linux_resource_roots { @@ -820,8 +909,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option { first_existing(candidates) } -fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result { - +fn build_shell_command_string( + entry: &CliEntry, + cli_args: &[String], +) -> anyhow::Result { let shell = default_shell(); let mut quoted: Vec = Vec::new(); quoted.push(shell_escape(&entry.node_binary)); @@ -852,7 +943,7 @@ fn shell_escape(input: &str) -> String { "''".to_string() } else if !input .chars() - .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' )) + .any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!')) { input.to_string() } else {