@neuralnomads/codenomad-dev 0.10.3-dev-20260215-35ff359c → 0.10.3-dev-20260215-f3b9ee4e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
31
31
  npx @neuralnomads/codenomad --launch
32
32
  ```
33
33
 
34
+ To list all CLI options:
35
+
36
+ ```sh
37
+ npx @neuralnomads/codenomad --help
38
+ ```
39
+
34
40
  On startup, CodeNomad prints two URLs:
35
41
 
36
42
  - `Local Connection URL : ...` (used by desktop shells)
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
44
50
  codenomad --launch
45
51
  ```
46
52
 
53
+ ### Install Locally (per-project)
54
+ If you prefer to install CodeNomad into a project and run the local binary:
55
+
56
+ ```sh
57
+ npm install @neuralnomads/codenomad
58
+ npx codenomad --launch
59
+ ```
60
+
61
+ (`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
62
+
47
63
  ### Common Flags
48
64
  You can configure the server using flags or environment variables:
49
65
 
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
63
79
  | `--config <path>` | `CLI_CONFIG` | Config file location |
64
80
  | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
65
81
  | `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
82
+ | `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
66
83
  | `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
67
84
  | `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
68
85
  | `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
69
86
  | `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
87
+ | `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
88
+ | `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
89
+ | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
90
+ | `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
91
+ | `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
92
+
93
+ ### Dev Releases (Advanced)
94
+ If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
95
+
96
+ ```sh
97
+ npx @neuralnomads/codenomad-dev --launch
98
+ ```
99
+
100
+ These environment variables control how CodeNomad checks for dev updates:
101
+
102
+ | Env Variable | Description |
103
+ |-------------|-------------|
104
+ | `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
105
+ | `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
70
106
 
71
107
  ### HTTP vs HTTPS
72
108
 
@@ -20,8 +20,8 @@ export class EventBus extends EventEmitter {
20
20
  this.on("workspace.error", handler);
21
21
  this.on("workspace.stopped", handler);
22
22
  this.on("workspace.log", handler);
23
- this.on("config.appChanged", handler);
24
- this.on("config.binariesChanged", handler);
23
+ this.on("storage.configChanged", handler);
24
+ this.on("storage.stateChanged", handler);
25
25
  this.on("instance.dataChanged", handler);
26
26
  this.on("instance.event", handler);
27
27
  this.on("instance.eventStatus", handler);
@@ -31,8 +31,8 @@ export class EventBus extends EventEmitter {
31
31
  this.off("workspace.error", handler);
32
32
  this.off("workspace.stopped", handler);
33
33
  this.off("workspace.log", handler);
34
- this.off("config.appChanged", handler);
35
- this.off("config.binariesChanged", handler);
34
+ this.off("storage.configChanged", handler);
35
+ this.off("storage.stateChanged", handler);
36
36
  this.off("instance.dataChanged", handler);
37
37
  this.off("instance.event", handler);
38
38
  this.off("instance.eventStatus", handler);
package/dist/index.js CHANGED
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url";
8
8
  import { createRequire } from "module";
9
9
  import { createHttpServer } from "./server/http-server";
10
10
  import { WorkspaceManager } from "./workspaces/manager";
11
- import { ConfigStore } from "./config/store";
12
11
  import { resolveConfigLocation } from "./config/location";
13
- import { BinaryRegistry } from "./config/binaries";
12
+ import { SettingsService } from "./settings/service";
13
+ import { BinaryResolver } from "./settings/binaries";
14
14
  import { FileSystemBrowser } from "./filesystem/browser";
15
15
  import { EventBus } from "./events/bus";
16
16
  import { InstanceStore } from "./storage/instance-store";
@@ -188,20 +188,12 @@ async function main() {
188
188
  logger: logger.child({ component: "tls" }),
189
189
  });
190
190
  const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined;
191
- const configStore = new ConfigStore(configLocation, eventBus, configLogger);
192
- // Eagerly load config at boot so migrations run immediately
193
- // (instead of waiting for the first /api/config request).
194
- try {
195
- configStore.get();
196
- }
197
- catch (error) {
198
- configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults");
199
- }
200
- const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger);
191
+ const settings = new SettingsService(configLocation, eventBus, configLogger);
192
+ const binaryResolver = new BinaryResolver(settings);
201
193
  const workspaceManager = new WorkspaceManager({
202
194
  rootDir: options.rootDir,
203
- configStore,
204
- binaryRegistry,
195
+ settings,
196
+ binaryResolver,
205
197
  eventBus,
206
198
  logger: workspaceLogger,
207
199
  getServerBaseUrl: () => serverMeta.localUrl,
@@ -276,8 +268,7 @@ async function main() {
276
268
  defaultPort: options.httpPort,
277
269
  protocol: "http",
278
270
  workspaceManager,
279
- configStore,
280
- binaryRegistry,
271
+ settings,
281
272
  fileSystemBrowser,
282
273
  eventBus,
283
274
  serverMeta,
@@ -296,8 +287,7 @@ async function main() {
296
287
  protocol: "https",
297
288
  httpsOptions: tlsResolution?.httpsOptions,
298
289
  workspaceManager,
299
- configStore,
300
- binaryRegistry,
290
+ settings,
301
291
  fileSystemBrowser,
302
292
  eventBus,
303
293
  serverMeta,
@@ -4,6 +4,6 @@
4
4
  "private": true,
5
5
  "license": "MIT",
6
6
  "dependencies": {
7
- "@opencode-ai/plugin": "1.1.53"
7
+ "@opencode-ai/plugin": "1.2.4"
8
8
  }
9
- }
9
+ }
@@ -7,7 +7,7 @@ import path from "path";
7
7
  import { fetch } from "undici";
8
8
  import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees";
9
9
  import { registerWorkspaceRoutes } from "./routes/workspaces";
10
- import { registerConfigRoutes } from "./routes/config";
10
+ import { registerSettingsRoutes } from "./routes/settings";
11
11
  import { registerFilesystemRoutes } from "./routes/filesystem";
12
12
  import { registerMetaRoutes } from "./routes/meta";
13
13
  import { registerEventRoutes } from "./routes/events";
@@ -181,7 +181,7 @@ export function createHttpServer(deps) {
181
181
  reply.code(404).send({ message: "UI bundle missing" });
182
182
  });
183
183
  registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
184
- registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry });
184
+ registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger });
185
185
  registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
186
186
  registerMetaRoutes(app, { serverMeta: deps.serverMeta });
187
187
  registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger });
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { spawnSync } from "child_process";
3
+ import { buildSpawnSpec } from "../../workspaces/runtime";
4
+ const ValidateBinarySchema = z.object({
5
+ path: z.string(),
6
+ });
7
+ function validateBinaryPath(binaryPath) {
8
+ if (!binaryPath) {
9
+ return { valid: false, error: "Missing binary path" };
10
+ }
11
+ const spec = buildSpawnSpec(binaryPath, ["--version"]);
12
+ try {
13
+ const result = spawnSync(spec.command, spec.args, {
14
+ encoding: "utf8",
15
+ windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
16
+ });
17
+ if (result.error) {
18
+ return { valid: false, error: result.error.message };
19
+ }
20
+ if (result.status !== 0) {
21
+ const stderr = result.stderr?.trim();
22
+ const stdout = result.stdout?.trim();
23
+ const combined = stderr || stdout;
24
+ const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`;
25
+ return { valid: false, error };
26
+ }
27
+ const stdout = (result.stdout ?? "").trim();
28
+ const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0);
29
+ const normalized = firstLine?.trim();
30
+ const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/);
31
+ const version = versionMatch?.[1];
32
+ return { valid: true, version };
33
+ }
34
+ catch (error) {
35
+ return { valid: false, error: error instanceof Error ? error.message : String(error) };
36
+ }
37
+ }
38
+ export function registerSettingsRoutes(app, deps) {
39
+ // Full-document access
40
+ app.get("/api/storage/config", async () => deps.settings.getDoc("config"));
41
+ app.patch("/api/storage/config", async (request, reply) => {
42
+ try {
43
+ return deps.settings.mergePatchDoc("config", request.body ?? {});
44
+ }
45
+ catch (error) {
46
+ reply.code(400);
47
+ return { error: error instanceof Error ? error.message : "Invalid patch" };
48
+ }
49
+ });
50
+ app.get("/api/storage/config/:owner", async (request) => {
51
+ return deps.settings.getOwner("config", request.params.owner);
52
+ });
53
+ app.patch("/api/storage/config/:owner", async (request, reply) => {
54
+ try {
55
+ return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {});
56
+ }
57
+ catch (error) {
58
+ reply.code(400);
59
+ return { error: error instanceof Error ? error.message : "Invalid patch" };
60
+ }
61
+ });
62
+ app.get("/api/storage/state", async () => deps.settings.getDoc("state"));
63
+ app.patch("/api/storage/state", async (request, reply) => {
64
+ try {
65
+ return deps.settings.mergePatchDoc("state", request.body ?? {});
66
+ }
67
+ catch (error) {
68
+ reply.code(400);
69
+ return { error: error instanceof Error ? error.message : "Invalid patch" };
70
+ }
71
+ });
72
+ app.get("/api/storage/state/:owner", async (request) => {
73
+ return deps.settings.getOwner("state", request.params.owner);
74
+ });
75
+ app.patch("/api/storage/state/:owner", async (request, reply) => {
76
+ try {
77
+ return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {});
78
+ }
79
+ catch (error) {
80
+ reply.code(400);
81
+ return { error: error instanceof Error ? error.message : "Invalid patch" };
82
+ }
83
+ });
84
+ // Binary validation helper (used by UI when adding binaries)
85
+ app.post("/api/storage/binaries/validate", async (request, reply) => {
86
+ try {
87
+ const body = ValidateBinarySchema.parse(request.body ?? {});
88
+ return validateBinaryPath(body.path);
89
+ }
90
+ catch (error) {
91
+ deps.logger.warn({ err: error }, "Failed to validate binary");
92
+ reply.code(400);
93
+ return { valid: false, error: error instanceof Error ? error.message : "Invalid request" };
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,37 @@
1
+ function prettyLabel(p) {
2
+ const parts = p.split(/[\\/]/);
3
+ const last = parts[parts.length - 1] || p;
4
+ return last || p;
5
+ }
6
+ function readUiBinaries(settings) {
7
+ const ui = settings.getOwner("state", "ui");
8
+ const list = ui?.opencodeBinaries;
9
+ if (!Array.isArray(list))
10
+ return [];
11
+ return list.filter((item) => item && typeof item === "object" && typeof item.path === "string");
12
+ }
13
+ function readDefaultBinaryPath(settings) {
14
+ const server = settings.getOwner("config", "server");
15
+ const value = server?.opencodeBinary;
16
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
17
+ }
18
+ export class BinaryResolver {
19
+ constructor(settings) {
20
+ this.settings = settings;
21
+ }
22
+ list() {
23
+ return readUiBinaries(this.settings);
24
+ }
25
+ resolveDefault() {
26
+ const binaries = this.list();
27
+ const configuredDefault = readDefaultBinaryPath(this.settings);
28
+ const fallback = binaries[0]?.path;
29
+ const path = configuredDefault ?? fallback ?? "opencode";
30
+ const entry = binaries.find((b) => b.path === path);
31
+ return {
32
+ path,
33
+ label: entry?.label ?? prettyLabel(path),
34
+ version: entry?.version,
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,33 @@
1
+ export function isPlainObject(value) {
2
+ if (!value || typeof value !== "object")
3
+ return false;
4
+ if (Array.isArray(value))
5
+ return false;
6
+ const proto = Object.getPrototypeOf(value);
7
+ return proto === Object.prototype || proto === null;
8
+ }
9
+ /**
10
+ * RFC 7396-ish merge patch with explicit null deletes.
11
+ * - Objects merge recursively
12
+ * - Arrays/scalars replace
13
+ * - null deletes keys
14
+ */
15
+ export function applyMergePatch(current, patch) {
16
+ if (!isPlainObject(patch)) {
17
+ return patch;
18
+ }
19
+ const base = isPlainObject(current) ? { ...current } : {};
20
+ for (const [key, value] of Object.entries(patch)) {
21
+ if (value === null) {
22
+ delete base[key];
23
+ continue;
24
+ }
25
+ const existing = base[key];
26
+ if (isPlainObject(value) && isPlainObject(existing)) {
27
+ base[key] = applyMergePatch(existing, value);
28
+ continue;
29
+ }
30
+ base[key] = value;
31
+ }
32
+ return base;
33
+ }
@@ -0,0 +1,233 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
+ import { isPlainObject } from "./merge-patch";
5
+ function ensureTrailingNewline(content) {
6
+ if (!content)
7
+ return "\n";
8
+ return content.endsWith("\n") ? content : `${content}\n`;
9
+ }
10
+ function safeReadYaml(filePath, logger) {
11
+ try {
12
+ const content = fs.readFileSync(filePath, "utf-8");
13
+ return parseYaml(content);
14
+ }
15
+ catch (error) {
16
+ logger.warn({ err: error, filePath }, "Failed to read YAML file during migration");
17
+ return null;
18
+ }
19
+ }
20
+ function safeReadJson(filePath, logger) {
21
+ try {
22
+ const content = fs.readFileSync(filePath, "utf-8");
23
+ return JSON.parse(content);
24
+ }
25
+ catch (error) {
26
+ logger.warn({ err: error, filePath }, "Failed to read JSON file during migration");
27
+ return null;
28
+ }
29
+ }
30
+ function writeYaml(filePath, doc, logger) {
31
+ try {
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ const yaml = stringifyYaml(doc);
34
+ fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8");
35
+ }
36
+ catch (error) {
37
+ logger.warn({ err: error, filePath }, "Failed to write YAML file during migration");
38
+ }
39
+ }
40
+ function pickBackupPath(filePath) {
41
+ const preferred = `${filePath}.bak`;
42
+ if (!fs.existsSync(preferred)) {
43
+ return preferred;
44
+ }
45
+ return `${filePath}.bak.${Date.now()}`;
46
+ }
47
+ function normalizeDoc(value) {
48
+ return isPlainObject(value) ? value : {};
49
+ }
50
+ function looksLikeNewOwnerDoc(value) {
51
+ const doc = normalizeDoc(value);
52
+ // Heuristic: owner-bucket docs have at least one of these roots.
53
+ return Boolean(doc.ui || doc.server || doc.app || doc.legacy);
54
+ }
55
+ function looksLikeLegacyConfig(value) {
56
+ const doc = normalizeDoc(value);
57
+ return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders);
58
+ }
59
+ function looksLikeLegacyState(value) {
60
+ const doc = normalizeDoc(value);
61
+ return Boolean(doc.recentFolders);
62
+ }
63
+ function omitKeys(source, keys) {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(source)) {
66
+ if (keys.has(k))
67
+ continue;
68
+ out[k] = v;
69
+ }
70
+ return out;
71
+ }
72
+ function mapLegacyToOwnerDocs(legacyConfig, legacyState) {
73
+ const cfg = normalizeDoc(legacyConfig);
74
+ const st = normalizeDoc(legacyState);
75
+ const outConfig = {};
76
+ const outState = {};
77
+ const uiConfig = {};
78
+ const uiSettings = {};
79
+ const serverConfig = {};
80
+ const uiState = {};
81
+ // theme -> config.ui.theme
82
+ if (typeof cfg.theme === "string") {
83
+ uiConfig.theme = cfg.theme;
84
+ }
85
+ const preferences = normalizeDoc(cfg.preferences);
86
+ if (Object.keys(preferences).length > 0) {
87
+ // Server-owned stable keys
88
+ const envVars = preferences.environmentVariables;
89
+ if (isPlainObject(envVars)) {
90
+ serverConfig.environmentVariables = envVars;
91
+ }
92
+ const listeningMode = preferences.listeningMode;
93
+ if (typeof listeningMode === "string") {
94
+ serverConfig.listeningMode = listeningMode;
95
+ }
96
+ const lastUsedBinary = preferences.lastUsedBinary;
97
+ if (typeof lastUsedBinary === "string") {
98
+ serverConfig.opencodeBinary = lastUsedBinary;
99
+ }
100
+ // UI-owned state keys (drop preferences)
101
+ const modelRecents = preferences.modelRecents;
102
+ const modelFavorites = preferences.modelFavorites;
103
+ const modelThinkingSelections = preferences.modelThinkingSelections;
104
+ const models = {};
105
+ if (Array.isArray(modelRecents)) {
106
+ models.recents = modelRecents;
107
+ }
108
+ if (Array.isArray(modelFavorites)) {
109
+ models.favorites = modelFavorites;
110
+ }
111
+ if (isPlainObject(modelThinkingSelections)) {
112
+ models.thinkingSelections = modelThinkingSelections;
113
+ }
114
+ if (Object.keys(models).length > 0) {
115
+ uiState.models = models;
116
+ }
117
+ // Remaining preferences are treated as stable UI settings.
118
+ const moved = new Set([
119
+ "environmentVariables",
120
+ "listeningMode",
121
+ "lastUsedBinary",
122
+ "modelRecents",
123
+ "modelFavorites",
124
+ "modelThinkingSelections",
125
+ ]);
126
+ Object.assign(uiSettings, omitKeys(preferences, moved));
127
+ }
128
+ // recentFolders lives in legacy state (yaml) or legacy config.json
129
+ const recentFolders = (st.recentFolders ?? cfg.recentFolders);
130
+ if (Array.isArray(recentFolders)) {
131
+ uiState.recentFolders = recentFolders;
132
+ }
133
+ // opencodeBinaries -> state.ui.opencodeBinaries
134
+ if (Array.isArray(cfg.opencodeBinaries)) {
135
+ uiState.opencodeBinaries = cfg.opencodeBinaries;
136
+ }
137
+ if (Object.keys(uiSettings).length > 0) {
138
+ uiConfig.settings = uiSettings;
139
+ }
140
+ if (Object.keys(uiConfig).length > 0) {
141
+ outConfig.ui = uiConfig;
142
+ }
143
+ if (Object.keys(serverConfig).length > 0) {
144
+ outConfig.server = serverConfig;
145
+ }
146
+ if (Object.keys(uiState).length > 0) {
147
+ outState.ui = uiState;
148
+ }
149
+ // Unknown top-level keys -> legacy.unknown
150
+ const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"]);
151
+ const unknownConfig = omitKeys(cfg, knownConfigKeys);
152
+ if (Object.keys(unknownConfig).length > 0) {
153
+ outConfig.legacy = { unknown: unknownConfig };
154
+ }
155
+ const knownStateKeys = new Set(["recentFolders"]);
156
+ const unknownState = omitKeys(st, knownStateKeys);
157
+ if (Object.keys(unknownState).length > 0) {
158
+ outState.legacy = { unknown: unknownState };
159
+ }
160
+ return { config: outConfig, state: outState };
161
+ }
162
+ /**
163
+ * Migrate older config/state layouts into owner-bucket YAML docs.
164
+ *
165
+ * Legacy inputs supported:
166
+ * - config.yaml with { preferences, opencodeBinaries, theme }
167
+ * - state.yaml with { recentFolders }
168
+ * - legacy config.json with full ConfigFile schema
169
+ */
170
+ export function migrateSettingsLayout(location, logger) {
171
+ const configYamlPath = location.configYamlPath;
172
+ const stateYamlPath = location.stateYamlPath;
173
+ const legacyJsonPath = location.legacyJsonPath;
174
+ const configExists = fs.existsSync(configYamlPath);
175
+ const stateExists = fs.existsSync(stateYamlPath);
176
+ const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null;
177
+ const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null;
178
+ const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc);
179
+ const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc);
180
+ if (configIsNew && stateIsNew) {
181
+ return;
182
+ }
183
+ const legacyJsonExists = fs.existsSync(legacyJsonPath);
184
+ const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc));
185
+ const shouldMigrateFromJson = !configExists && legacyJsonExists;
186
+ if (!hasLegacyYaml && !shouldMigrateFromJson) {
187
+ // Either fresh install or partially written docs; let stores create on first write.
188
+ return;
189
+ }
190
+ const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc;
191
+ const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc;
192
+ const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState);
193
+ try {
194
+ fs.mkdirSync(location.baseDir, { recursive: true });
195
+ }
196
+ catch (error) {
197
+ logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration");
198
+ }
199
+ // Backup legacy files before rewriting.
200
+ if (configExists) {
201
+ try {
202
+ const bak = pickBackupPath(configYamlPath);
203
+ fs.renameSync(configYamlPath, bak);
204
+ logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml");
205
+ }
206
+ catch (error) {
207
+ logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml");
208
+ }
209
+ }
210
+ if (stateExists) {
211
+ try {
212
+ const bak = pickBackupPath(stateYamlPath);
213
+ fs.renameSync(stateYamlPath, bak);
214
+ logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml");
215
+ }
216
+ catch (error) {
217
+ logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml");
218
+ }
219
+ }
220
+ if (shouldMigrateFromJson) {
221
+ try {
222
+ const bak = pickBackupPath(legacyJsonPath);
223
+ fs.renameSync(legacyJsonPath, bak);
224
+ logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup");
225
+ }
226
+ catch (error) {
227
+ logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup");
228
+ }
229
+ }
230
+ writeYaml(configYamlPath, config, logger);
231
+ writeYaml(stateYamlPath, state, logger);
232
+ logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout");
233
+ }
@@ -0,0 +1,39 @@
1
+ import { YamlDocStore } from "./yaml-doc-store";
2
+ import { migrateSettingsLayout } from "./migrate";
3
+ export class SettingsService {
4
+ constructor(location, eventBus, logger) {
5
+ this.location = location;
6
+ this.eventBus = eventBus;
7
+ this.logger = logger;
8
+ migrateSettingsLayout(location, logger);
9
+ this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }));
10
+ this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }));
11
+ }
12
+ getDoc(kind) {
13
+ return kind === "config" ? this.configStore.get() : this.stateStore.get();
14
+ }
15
+ mergePatchDoc(kind, patch) {
16
+ const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch);
17
+ this.publish(kind, "*");
18
+ return updated;
19
+ }
20
+ getOwner(kind, owner) {
21
+ return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner);
22
+ }
23
+ mergePatchOwner(kind, owner, patch) {
24
+ const updated = kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch);
25
+ this.publish(kind, owner, updated);
26
+ return updated;
27
+ }
28
+ publish(kind, owner, value) {
29
+ if (!this.eventBus)
30
+ return;
31
+ const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged";
32
+ const payload = {
33
+ type,
34
+ owner,
35
+ value: value ?? this.getOwner(kind, owner),
36
+ };
37
+ this.eventBus.publish(payload);
38
+ }
39
+ }