@neuralnomads/codenomad-dev 0.10.3-dev-20260215-4f6c8523 → 0.11.1-dev-20260216-e16c5752
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/dist/events/bus.js +4 -4
- package/dist/index.js +8 -18
- package/dist/opencode-config/package.json +2 -2
- package/dist/server/http-server.js +2 -2
- package/dist/server/routes/settings.js +96 -0
- package/dist/settings/binaries.js +37 -0
- package/dist/settings/merge-patch.js +33 -0
- package/dist/settings/migrate.js +233 -0
- package/dist/settings/service.js +39 -0
- package/dist/settings/yaml-doc-store.js +96 -0
- package/dist/workspaces/manager.js +4 -3
- package/package.json +1 -1
- package/public/assets/index-Bi993fzd.js +1 -0
- package/public/assets/{index-YrKmkw3o.css → index-IUhCGbWv.css} +1 -1
- package/public/assets/{loading-CuOFA7ML.js → loading-Bu5vIfc2.js} +1 -1
- package/public/assets/main-DN9FHv8o.js +190 -0
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/sw.js +1 -1
- package/public/ui-version.json +1 -1
- package/dist/config/binaries.js +0 -148
- package/dist/config/store.js +0 -200
- package/dist/server/routes/config.js +0 -59
- package/public/assets/index-D0PYs_Ly.js +0 -1
- package/public/assets/main-EgCUCdV9.js +0 -190
package/dist/events/bus.js
CHANGED
|
@@ -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("
|
|
24
|
-
this.on("
|
|
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("
|
|
35
|
-
this.off("
|
|
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 {
|
|
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
|
|
192
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
binaryRegistry,
|
|
290
|
+
settings,
|
|
301
291
|
fileSystemBrowser,
|
|
302
292
|
eventBus,
|
|
303
293
|
serverMeta,
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
4
|
+
import { applyMergePatch, 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 normalizeDoc(input) {
|
|
11
|
+
if (!isPlainObject(input)) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
return input;
|
|
15
|
+
}
|
|
16
|
+
export class YamlDocStore {
|
|
17
|
+
constructor(filePath, logger) {
|
|
18
|
+
this.filePath = filePath;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.cache = {};
|
|
21
|
+
this.loaded = false;
|
|
22
|
+
}
|
|
23
|
+
load() {
|
|
24
|
+
if (this.loaded) {
|
|
25
|
+
return this.cache;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
if (!fs.existsSync(this.filePath)) {
|
|
29
|
+
this.cache = {};
|
|
30
|
+
this.loaded = true;
|
|
31
|
+
return this.cache;
|
|
32
|
+
}
|
|
33
|
+
const content = fs.readFileSync(this.filePath, "utf-8");
|
|
34
|
+
const parsed = parseYaml(content);
|
|
35
|
+
this.cache = normalizeDoc(parsed);
|
|
36
|
+
this.loaded = true;
|
|
37
|
+
return this.cache;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object");
|
|
41
|
+
this.cache = {};
|
|
42
|
+
this.loaded = true;
|
|
43
|
+
return this.cache;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
get() {
|
|
47
|
+
return this.load();
|
|
48
|
+
}
|
|
49
|
+
replace(next) {
|
|
50
|
+
const normalized = normalizeDoc(next);
|
|
51
|
+
this.cache = normalized;
|
|
52
|
+
this.loaded = true;
|
|
53
|
+
this.persist();
|
|
54
|
+
return this.cache;
|
|
55
|
+
}
|
|
56
|
+
mergePatch(patch) {
|
|
57
|
+
if (!isPlainObject(patch)) {
|
|
58
|
+
throw new Error("Patch must be a JSON object");
|
|
59
|
+
}
|
|
60
|
+
const current = this.get();
|
|
61
|
+
const next = applyMergePatch(current, patch);
|
|
62
|
+
return this.replace(next);
|
|
63
|
+
}
|
|
64
|
+
getOwner(owner) {
|
|
65
|
+
const doc = this.get();
|
|
66
|
+
const value = doc?.[owner];
|
|
67
|
+
return normalizeDoc(value);
|
|
68
|
+
}
|
|
69
|
+
replaceOwner(owner, value) {
|
|
70
|
+
const doc = this.get();
|
|
71
|
+
const nextDoc = { ...doc, [owner]: normalizeDoc(value) };
|
|
72
|
+
this.replace(nextDoc);
|
|
73
|
+
return nextDoc[owner];
|
|
74
|
+
}
|
|
75
|
+
mergePatchOwner(owner, patch) {
|
|
76
|
+
if (!isPlainObject(patch)) {
|
|
77
|
+
throw new Error("Patch must be a JSON object");
|
|
78
|
+
}
|
|
79
|
+
const doc = this.get();
|
|
80
|
+
const currentOwner = normalizeDoc(doc?.[owner]);
|
|
81
|
+
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch));
|
|
82
|
+
const nextDoc = { ...doc, [owner]: nextOwner };
|
|
83
|
+
this.replace(nextDoc);
|
|
84
|
+
return nextOwner;
|
|
85
|
+
}
|
|
86
|
+
persist() {
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
89
|
+
const yaml = stringifyYaml(this.cache);
|
|
90
|
+
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8");
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -49,7 +49,7 @@ export class WorkspaceManager {
|
|
|
49
49
|
}
|
|
50
50
|
async create(folder, name) {
|
|
51
51
|
const id = `${Date.now().toString(36)}`;
|
|
52
|
-
const binary = this.options.
|
|
52
|
+
const binary = this.options.binaryResolver.resolveDefault();
|
|
53
53
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path);
|
|
54
54
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder);
|
|
55
55
|
clearWorkspaceSearchCache(workspacePath);
|
|
@@ -72,8 +72,9 @@ export class WorkspaceManager {
|
|
|
72
72
|
}
|
|
73
73
|
this.workspaces.set(id, descriptor);
|
|
74
74
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor });
|
|
75
|
-
const
|
|
76
|
-
const
|
|
75
|
+
const serverConfig = this.options.settings.getOwner("config", "server");
|
|
76
|
+
const envVars = serverConfig?.environmentVariables;
|
|
77
|
+
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? envVars : {};
|
|
77
78
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME;
|
|
78
79
|
const opencodePassword = generateOpencodeServerPassword();
|
|
79
80
|
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword });
|