@dyyz1993/codenomad 0.15.0 → 0.15.2

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.
Files changed (49) hide show
  1. package/dist/auth/session-manager.js +28 -0
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +2 -2
  4. package/dist/filesystem/search.js +6 -6
  5. package/dist/index.js +14 -5
  6. package/dist/server/routes/auth-pages/login.html +30 -19
  7. package/dist/server/routes/events.js +26 -0
  8. package/dist/server/routes/settings.js +6 -6
  9. package/dist/server/routes/speech.js +1 -1
  10. package/dist/server/routes/workspaces.js +1 -1
  11. package/dist/settings/binaries.js +8 -8
  12. package/dist/settings/service.js +17 -17
  13. package/dist/settings/yaml-doc-store.js +24 -20
  14. package/dist/sidecars/manager.js +17 -13
  15. package/dist/speech/service.js +9 -9
  16. package/dist/workspaces/instance-events.js +11 -0
  17. package/dist/workspaces/manager.js +51 -11
  18. package/dist/workspaces/runtime.js +9 -5
  19. package/package.json +1 -1
  20. package/public/assets/{ChangesTab-BRNmSjeC.js → ChangesTab-Dt-TsQMo.js} +2 -2
  21. package/public/assets/{DiffToolbar-DPPgfx42.js → DiffToolbar-BD79zzZs.js} +1 -1
  22. package/public/assets/{FilesTab-sEeGPOoz.js → FilesTab-CgB87mzq.js} +2 -2
  23. package/public/assets/{GitChangesTab-tJ-TCq0c.js → GitChangesTab-x-snPvzz.js} +2 -2
  24. package/public/assets/{SplitFilePanel-CHqNLZvU.js → SplitFilePanel-CA0D92l1.js} +1 -1
  25. package/public/assets/{StatusTab-DMd0ByCA.js → StatusTab-DJdKuILW.js} +1 -1
  26. package/public/assets/{align-justify-BWapkGTe.js → align-justify-BnvEPTpu.js} +1 -1
  27. package/public/assets/{bundle-full-CXlKQh5B.js → bundle-full-DPp09th5.js} +1 -1
  28. package/public/assets/{diff-viewer-BFtW5J8P.js → diff-viewer-DYUu2t45.js} +1 -1
  29. package/public/assets/{index-Bdy3MTIn.js → index-4Era_AEC.js} +1 -1
  30. package/public/assets/{index-D6RMBXUk.js → index-BCGPLzO4.js} +2 -2
  31. package/public/assets/{index-Dty5bSHf.js → index-BDgGoLTZ.js} +1 -1
  32. package/public/assets/{index-D-NvysdX.js → index-CbKJK9y3.js} +1 -1
  33. package/public/assets/{index-Ctq8RqRf.js → index-CpWDdROQ.js} +1 -1
  34. package/public/assets/{index-DYWkPhKo.js → index-CtBkkWFK.js} +1 -1
  35. package/public/assets/{index-D_FDiI6a.js → index-D7V1TD3s.js} +1 -1
  36. package/public/assets/{index-H16e4Rqc.js → index-PHCXc0OA.js} +1 -1
  37. package/public/assets/{index-BvRe9GiS.js → index-nVoKL-cq.js} +1 -1
  38. package/public/assets/{loading-W2Y_wR0P.js → loading-B-F_vBbv.js} +1 -1
  39. package/public/assets/{main-Bala0YJ4.js → main-DBbZ3aZQ.js} +7 -7
  40. package/public/assets/{markdown-Db8cvZ4Z.js → markdown-B_Zj0Onk.js} +3 -3
  41. package/public/assets/{monaco-viewer-DWQqXKm3.js → monaco-viewer-YY9yfE-p.js} +8 -8
  42. package/public/assets/{tool-call-D0BYXlFe.js → tool-call-CwR9rE_d.js} +3 -3
  43. package/public/assets/{unified-picker-QgONWj_1.js → unified-picker-_n2WoeTG.js} +1 -1
  44. package/public/assets/{wrap-text-CEH9wOr_.js → wrap-text-B7hZaBx4.js} +1 -1
  45. package/public/index.html +3 -3
  46. package/public/loading.html +3 -3
  47. package/public/sw.js +1 -1
  48. package/dist/opencode-config/package-lock.json +0 -380
  49. package/dist/opencode-config/package.json +0 -9
@@ -1,7 +1,10 @@
1
1
  import crypto from "crypto";
2
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
3
+ const CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
2
4
  export class SessionManager {
3
5
  constructor() {
4
6
  this.sessions = new Map();
7
+ this.cleanupTimer = setInterval(() => this.cleanupExpired(), CLEANUP_INTERVAL_MS);
5
8
  }
6
9
  createSession(username) {
7
10
  const id = crypto.randomBytes(32).toString("base64url");
@@ -14,4 +17,29 @@ export class SessionManager {
14
17
  return undefined;
15
18
  return this.sessions.get(id);
16
19
  }
20
+ validateSession(sessionId) {
21
+ const info = this.sessions.get(sessionId);
22
+ if (!info)
23
+ return null;
24
+ if (Date.now() - info.createdAt > SESSION_TTL_MS) {
25
+ this.sessions.delete(sessionId);
26
+ return null;
27
+ }
28
+ return info;
29
+ }
30
+ cleanupExpired() {
31
+ const now = Date.now();
32
+ for (const [id, info] of this.sessions) {
33
+ if (now - info.createdAt > SESSION_TTL_MS) {
34
+ this.sessions.delete(id);
35
+ }
36
+ }
37
+ }
38
+ shutdown() {
39
+ if (this.cleanupTimer) {
40
+ clearInterval(this.cleanupTimer);
41
+ this.cleanupTimer = undefined;
42
+ }
43
+ this.sessions.clear();
44
+ }
17
45
  }
@@ -5,10 +5,10 @@ describe("workspace search cache", () => {
5
5
  beforeEach(() => {
6
6
  clearWorkspaceSearchCache();
7
7
  });
8
- it("expires cached candidates after the TTL", () => {
8
+ it("expires cached candidates after the TTL", async () => {
9
9
  const workspacePath = "/tmp/workspace";
10
10
  const startTime = 1000;
11
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime);
11
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-a")], startTime);
12
12
  const beforeExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1);
13
13
  assert.ok(beforeExpiry);
14
14
  assert.equal(beforeExpiry.length, 1);
@@ -16,13 +16,13 @@ describe("workspace search cache", () => {
16
16
  const afterExpiry = getWorkspaceCandidates(workspacePath, startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1);
17
17
  assert.equal(afterExpiry, undefined);
18
18
  });
19
- it("replaces cached entries when manually refreshed", () => {
19
+ it("replaces cached entries when manually refreshed", async () => {
20
20
  const workspacePath = "/tmp/workspace";
21
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5000);
21
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-a")], 5000);
22
22
  const initial = getWorkspaceCandidates(workspacePath, 5001);
23
23
  assert.ok(initial);
24
24
  assert.equal(initial[0].name, "file-a");
25
- refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6000);
25
+ await refreshWorkspaceCandidates(workspacePath, async () => [createEntry("file-b")], 6000);
26
26
  const refreshed = getWorkspaceCandidates(workspacePath, 6001);
27
27
  assert.ok(refreshed);
28
28
  assert.equal(refreshed[0].name, "file-b");
@@ -13,9 +13,9 @@ export function getWorkspaceCandidates(rootDir, now = Date.now()) {
13
13
  }
14
14
  return cloneEntries(cached.candidates);
15
15
  }
16
- export function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
16
+ export async function refreshWorkspaceCandidates(rootDir, builder, now = Date.now()) {
17
17
  const key = normalizeKey(rootDir);
18
- const freshCandidates = builder();
18
+ const freshCandidates = await builder();
19
19
  if (!freshCandidates || freshCandidates.length === 0) {
20
20
  workspaceCandidateCache.delete(key);
21
21
  return [];
@@ -1,4 +1,4 @@
1
- import fs from "fs";
1
+ import { readdir, stat } from "node:fs/promises";
2
2
  import path from "path";
3
3
  import fuzzysort from "fuzzysort";
4
4
  import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache";
@@ -6,7 +6,7 @@ const DEFAULT_LIMIT = 100;
6
6
  const MAX_LIMIT = 200;
7
7
  const MAX_CANDIDATES = 8000;
8
8
  const IGNORED_DIRECTORIES = new Set([".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map((name) => name.toLowerCase()));
9
- export function searchWorkspaceFiles(rootDir, query, options = {}) {
9
+ export async function searchWorkspaceFiles(rootDir, query, options = {}) {
10
10
  const trimmedQuery = query.trim();
11
11
  if (!trimmedQuery) {
12
12
  throw new Error("Search query is required");
@@ -21,7 +21,7 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
21
21
  entries = getWorkspaceCandidates(normalizedRoot);
22
22
  }
23
23
  if (!entries) {
24
- entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
24
+ entries = await refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot));
25
25
  }
26
26
  }
27
27
  catch (error) {
@@ -45,7 +45,7 @@ export function searchWorkspaceFiles(rootDir, query, options = {}) {
45
45
  }
46
46
  return matches.map((match) => match.obj.entry);
47
47
  }
48
- function collectCandidates(rootDir) {
48
+ async function collectCandidates(rootDir) {
49
49
  const queue = [""];
50
50
  const entries = [];
51
51
  while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
@@ -53,7 +53,7 @@ function collectCandidates(rootDir) {
53
53
  const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir;
54
54
  let dirents;
55
55
  try {
56
- dirents = fs.readdirSync(absoluteDir, { withFileTypes: true });
56
+ dirents = await readdir(absoluteDir, { withFileTypes: true });
57
57
  }
58
58
  catch {
59
59
  continue;
@@ -68,7 +68,7 @@ function collectCandidates(rootDir) {
68
68
  }
69
69
  let stats;
70
70
  try {
71
- stats = fs.statSync(absolutePath);
71
+ stats = await stat(absolutePath);
72
72
  }
73
73
  catch {
74
74
  continue;
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ function parseCliOptions(argv) {
48
48
  .addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
49
49
  .addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
50
50
  .addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
51
+ .addOption(new Option("--port <number>", "Shorthand for --http-port").argParser(parsePort))
51
52
  .addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
52
53
  .addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
53
54
  .addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
@@ -94,12 +95,18 @@ function parseCliOptions(argv) {
94
95
  if (upgrade === undefined && !httpsEnabled && !httpEnabled) {
95
96
  throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)");
96
97
  }
98
+ const resolvedHttpPort = parsed.port ?? parsed.httpPort;
99
+ const portExplicit = parsed.port !== undefined;
100
+ const httpsPortExplicit = parsed.httpsPort !== DEFAULT_HTTPS_PORT;
101
+ const resolvedHttpsPort = portExplicit && !httpsPortExplicit && resolvedHttpPort !== DEFAULT_HTTP_PORT
102
+ ? resolvedHttpPort + 1
103
+ : parsed.httpsPort;
97
104
  return {
98
105
  host: normalizedHost,
99
106
  https: httpsEnabled,
100
107
  http: httpEnabled,
101
- httpsPort: parsed.httpsPort,
102
- httpPort: parsed.httpPort,
108
+ httpsPort: resolvedHttpsPort,
109
+ httpPort: resolvedHttpPort,
103
110
  tlsKeyPath: parsed.tlsKey,
104
111
  tlsCertPath: parsed.tlsCert,
105
112
  tlsCaPath: parsed.tlsCa,
@@ -225,7 +232,7 @@ async function main() {
225
232
  });
226
233
  const instanceStore = new InstanceStore(configLocation.instancesDir);
227
234
  const speechService = new SpeechService(settings, logger.child({ component: "speech" }));
228
- const sidecarManager = new SideCarManager({
235
+ const sidecarManager = await SideCarManager.create({
229
236
  settings,
230
237
  eventBus,
231
238
  logger: logger.child({ component: "sidecars" }),
@@ -247,6 +254,7 @@ async function main() {
247
254
  overrideUiDir: uiDirOverride,
248
255
  uiDevServerUrl: options.uiDevServer,
249
256
  manifestUrl: options.uiManifestUrl,
257
+ configDir,
250
258
  logger: logger.child({ component: "ui" }),
251
259
  });
252
260
  serverMeta.serverVersion = packageJson.version;
@@ -288,8 +296,9 @@ async function main() {
288
296
  channel: pluginChannel,
289
297
  logger: logger.child({ component: "voice-mode" }),
290
298
  });
291
- const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT);
292
- const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT);
299
+ const portArgPassed = programHasArg(process.argv.slice(2), "--port");
300
+ const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) || portArgPassed;
301
+ const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || programHasArg(process.argv.slice(2), "--port") || Boolean(process.env.CLI_HTTP_PORT);
293
302
  const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0;
294
303
  const httpBindPort = httpPortExplicit ? options.httpPort : 0;
295
304
  // Listener binding rules:
@@ -101,28 +101,39 @@
101
101
  showError("Username and password are required.")
102
102
  return
103
103
  }
104
- try {
105
- const res = await fetch("/api/auth/login", {
106
- method: "POST",
107
- headers: { "Content-Type": "application/json" },
108
- body: JSON.stringify({ username, password }),
109
- credentials: "include",
110
- })
111
- if (!res.ok) {
112
- let message = ""
113
- try {
114
- const json = await res.json()
115
- message = json && json.error ? String(json.error) : ""
116
- } catch {
117
- message = ""
104
+
105
+ const MAX_RETRIES = 3
106
+ const RETRY_DELAY_MS = 2000
107
+
108
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
109
+ try {
110
+ const res = await fetch("/api/auth/login", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ username, password }),
114
+ credentials: "include",
115
+ })
116
+ if (!res.ok) {
117
+ let message = ""
118
+ try {
119
+ const json = await res.json()
120
+ message = json && json.error ? String(json.error) : ""
121
+ } catch {
122
+ message = ""
123
+ }
124
+ showError(message || `Login failed (${res.status})`)
125
+ return
118
126
  }
119
- showError(message || `Login failed (${res.status})`)
127
+ window.location.replace("/")
120
128
  return
129
+ } catch (e) {
130
+ if (attempt < MAX_RETRIES) {
131
+ showError(`Connection failed, retrying (${attempt}/${MAX_RETRIES})...`)
132
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS))
133
+ } else {
134
+ showError("Service connection failed. The port may have changed. Please refresh the page and try again.")
135
+ }
121
136
  }
122
- // Replace history entry so Back doesn't return to /login.
123
- window.location.replace("/")
124
- } catch (e) {
125
- showError(e && e.message ? e.message : String(e))
126
137
  }
127
138
  }
128
139
 
@@ -3,6 +3,7 @@ let nextClientId = 0;
3
3
  const ConnectionQuerySchema = z.object({
4
4
  clientId: z.string().trim().min(1),
5
5
  connectionId: z.string().trim().min(1),
6
+ workspaceId: z.string().trim().optional(),
6
7
  });
7
8
  const PongBodySchema = ConnectionQuerySchema.extend({
8
9
  pingTs: z.number().optional(),
@@ -20,7 +21,32 @@ export function registerEventRoutes(app, deps) {
20
21
  reply.raw.setHeader("Connection", "keep-alive");
21
22
  reply.raw.flushHeaders?.();
22
23
  reply.hijack();
24
+ const filterWorkspaceId = connection.workspaceId;
25
+ const getEventWorkspaceId = (event) => {
26
+ switch (event.type) {
27
+ case "workspace.created":
28
+ case "workspace.started":
29
+ case "workspace.error":
30
+ return event.workspace?.id;
31
+ case "workspace.stopped":
32
+ return event.workspaceId;
33
+ case "workspace.log":
34
+ return event.entry.workspaceId;
35
+ case "instance.event":
36
+ case "instance.eventStatus":
37
+ case "instance.dataChanged":
38
+ return event.instanceId;
39
+ default:
40
+ return undefined;
41
+ }
42
+ };
23
43
  const send = (event) => {
44
+ if (filterWorkspaceId) {
45
+ const eventWsId = getEventWorkspaceId(event);
46
+ if (eventWsId && eventWsId !== filterWorkspaceId) {
47
+ return;
48
+ }
49
+ }
24
50
  deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched");
25
51
  if (deps.logger.isLevelEnabled("trace")) {
26
52
  deps.logger.trace({ clientId, event }, "SSE event payload");
@@ -10,10 +10,10 @@ function validateBinaryPath(binaryPath) {
10
10
  }
11
11
  export function registerSettingsRoutes(app, deps) {
12
12
  // Full-document access
13
- app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")));
13
+ app.get("/api/storage/config", async () => sanitizeConfigDoc(await deps.settings.getDoc("config")));
14
14
  app.patch("/api/storage/config", async (request, reply) => {
15
15
  try {
16
- return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}));
16
+ return sanitizeConfigDoc(await deps.settings.mergePatchDoc("config", request.body ?? {}));
17
17
  }
18
18
  catch (error) {
19
19
  reply.code(400);
@@ -21,11 +21,11 @@ export function registerSettingsRoutes(app, deps) {
21
21
  }
22
22
  });
23
23
  app.get("/api/storage/config/:owner", async (request) => {
24
- return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner));
24
+ return sanitizeConfigOwner(request.params.owner, await deps.settings.getOwner("config", request.params.owner));
25
25
  });
26
26
  app.patch("/api/storage/config/:owner", async (request, reply) => {
27
27
  try {
28
- return sanitizeConfigOwner(request.params.owner, deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}));
28
+ return sanitizeConfigOwner(request.params.owner, await deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}));
29
29
  }
30
30
  catch (error) {
31
31
  reply.code(400);
@@ -35,7 +35,7 @@ export function registerSettingsRoutes(app, deps) {
35
35
  app.get("/api/storage/state", async () => deps.settings.getDoc("state"));
36
36
  app.patch("/api/storage/state", async (request, reply) => {
37
37
  try {
38
- return deps.settings.mergePatchDoc("state", request.body ?? {});
38
+ return await deps.settings.mergePatchDoc("state", request.body ?? {});
39
39
  }
40
40
  catch (error) {
41
41
  reply.code(400);
@@ -47,7 +47,7 @@ export function registerSettingsRoutes(app, deps) {
47
47
  });
48
48
  app.patch("/api/storage/state/:owner", async (request, reply) => {
49
49
  try {
50
- return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {});
50
+ return await deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {});
51
51
  }
52
52
  catch (error) {
53
53
  reply.code(400);
@@ -23,7 +23,7 @@ function getSpeechErrorMessage(error, fallback) {
23
23
  return error instanceof Error ? error.message : fallback;
24
24
  }
25
25
  export function registerSpeechRoutes(app, deps) {
26
- app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities());
26
+ app.get("/api/speech/capabilities", async () => await deps.speechService.getCapabilities());
27
27
  app.post("/api/speech/transcribe", async (request, reply) => {
28
28
  try {
29
29
  const body = TranscribeBodySchema.parse(request.body ?? {});
@@ -77,7 +77,7 @@ export function registerWorkspaceRoutes(app, deps) {
77
77
  app.get("/api/workspaces/:id/files/search", async (request, reply) => {
78
78
  try {
79
79
  const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {});
80
- return deps.workspaceManager.searchFiles(request.params.id, query.q, {
80
+ return await deps.workspaceManager.searchFiles(request.params.id, query.q, {
81
81
  limit: query.limit,
82
82
  type: query.type,
83
83
  refresh: query.refresh,
@@ -3,15 +3,15 @@ function prettyLabel(p) {
3
3
  const last = parts[parts.length - 1] || p;
4
4
  return last || p;
5
5
  }
6
- function readUiBinaries(settings) {
7
- const ui = settings.getOwner("state", "ui");
6
+ async function readUiBinaries(settings) {
7
+ const ui = await settings.getOwner("state", "ui");
8
8
  const list = ui?.opencodeBinaries;
9
9
  if (!Array.isArray(list))
10
10
  return [];
11
11
  return list.filter((item) => item && typeof item === "object" && typeof item.path === "string");
12
12
  }
13
- function readDefaultBinaryPath(settings) {
14
- const server = settings.getOwner("config", "server");
13
+ async function readDefaultBinaryPath(settings) {
14
+ const server = await settings.getOwner("config", "server");
15
15
  const value = server?.opencodeBinary;
16
16
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
17
17
  }
@@ -19,12 +19,12 @@ export class BinaryResolver {
19
19
  constructor(settings) {
20
20
  this.settings = settings;
21
21
  }
22
- list() {
22
+ async list() {
23
23
  return readUiBinaries(this.settings);
24
24
  }
25
- resolveDefault() {
26
- const binaries = this.list();
27
- const configuredDefault = readDefaultBinaryPath(this.settings);
25
+ async resolveDefault() {
26
+ const binaries = await this.list();
27
+ const configuredDefault = await readDefaultBinaryPath(this.settings);
28
28
  const fallback = binaries[0]?.path;
29
29
  const path = configuredDefault ?? fallback ?? "opencode";
30
30
  const entry = binaries.find((b) => b.path === path);
@@ -51,46 +51,46 @@ export class SettingsService {
51
51
  this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }));
52
52
  this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }));
53
53
  }
54
- getDoc(kind) {
54
+ async getDoc(kind) {
55
55
  if (kind !== "config") {
56
56
  return this.stateStore.get();
57
57
  }
58
- const current = this.configStore.get();
58
+ const current = await this.configStore.get();
59
59
  const normalized = normalizeConfigDoc(current);
60
60
  if (!isDeepEqual(current, normalized)) {
61
- this.configStore.replace(normalized);
61
+ await this.configStore.replace(normalized);
62
62
  }
63
63
  return normalized;
64
64
  }
65
- mergePatchDoc(kind, patch) {
65
+ async mergePatchDoc(kind, patch) {
66
66
  const updated = kind === "config"
67
- ? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
68
- : this.stateStore.mergePatch(patch);
69
- this.publish(kind, "*");
67
+ ? await this.configStore.replace(normalizeConfigDoc(await this.configStore.mergePatch(patch)))
68
+ : await this.stateStore.mergePatch(patch);
69
+ await this.publish(kind, "*");
70
70
  return updated;
71
71
  }
72
- getOwner(kind, owner) {
72
+ async getOwner(kind, owner) {
73
73
  if (kind !== "config") {
74
74
  return this.stateStore.getOwner(owner);
75
75
  }
76
76
  return owner === "server"
77
- ? normalizeServerConfigOwner(this.getDoc("config").server)
78
- : this.getDoc("config")[owner];
77
+ ? normalizeServerConfigOwner((await this.getDoc("config")).server)
78
+ : (await this.getDoc("config"))[owner];
79
79
  }
80
- mergePatchOwner(kind, owner, patch) {
80
+ async mergePatchOwner(kind, owner, patch) {
81
81
  const updated = kind === "config"
82
82
  ? owner === "server"
83
- ? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
84
- : this.configStore.mergePatchOwner(owner, patch)
85
- : this.stateStore.mergePatchOwner(owner, patch);
86
- this.publish(kind, owner, updated);
83
+ ? await this.configStore.replaceOwner(owner, normalizeServerConfigOwner(await this.configStore.mergePatchOwner(owner, patch)))
84
+ : await this.configStore.mergePatchOwner(owner, patch)
85
+ : await this.stateStore.mergePatchOwner(owner, patch);
86
+ await this.publish(kind, owner, updated);
87
87
  return updated;
88
88
  }
89
- publish(kind, owner, value) {
89
+ async publish(kind, owner, value) {
90
90
  if (!this.eventBus)
91
91
  return;
92
92
  const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged";
93
- const nextValue = value ?? this.getOwner(kind, owner);
93
+ const nextValue = value ?? await this.getOwner(kind, owner);
94
94
  const payload = {
95
95
  type,
96
96
  owner,
@@ -1,4 +1,5 @@
1
- import fs from "fs";
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { constants } from "node:fs";
2
3
  import path from "path";
3
4
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
4
5
  import { applyMergePatch, isPlainObject } from "./merge-patch";
@@ -20,17 +21,20 @@ export class YamlDocStore {
20
21
  this.cache = {};
21
22
  this.loaded = false;
22
23
  }
23
- load() {
24
+ async load() {
24
25
  if (this.loaded) {
25
26
  return this.cache;
26
27
  }
27
28
  try {
28
- if (!fs.existsSync(this.filePath)) {
29
+ try {
30
+ await access(this.filePath, constants.F_OK);
31
+ }
32
+ catch {
29
33
  this.cache = {};
30
34
  this.loaded = true;
31
35
  return this.cache;
32
36
  }
33
- const content = fs.readFileSync(this.filePath, "utf-8");
37
+ const content = await readFile(this.filePath, "utf-8");
34
38
  const parsed = parseYaml(content);
35
39
  this.cache = normalizeDoc(parsed);
36
40
  this.loaded = true;
@@ -43,51 +47,51 @@ export class YamlDocStore {
43
47
  return this.cache;
44
48
  }
45
49
  }
46
- get() {
50
+ async get() {
47
51
  return this.load();
48
52
  }
49
- replace(next) {
53
+ async replace(next) {
50
54
  const normalized = normalizeDoc(next);
51
55
  this.cache = normalized;
52
56
  this.loaded = true;
53
- this.persist();
57
+ await this.persist();
54
58
  return this.cache;
55
59
  }
56
- mergePatch(patch) {
60
+ async mergePatch(patch) {
57
61
  if (!isPlainObject(patch)) {
58
62
  throw new Error("Patch must be a JSON object");
59
63
  }
60
- const current = this.get();
64
+ const current = await this.get();
61
65
  const next = applyMergePatch(current, patch);
62
66
  return this.replace(next);
63
67
  }
64
- getOwner(owner) {
65
- const doc = this.get();
68
+ async getOwner(owner) {
69
+ const doc = await this.get();
66
70
  const value = doc?.[owner];
67
71
  return normalizeDoc(value);
68
72
  }
69
- replaceOwner(owner, value) {
70
- const doc = this.get();
73
+ async replaceOwner(owner, value) {
74
+ const doc = await this.get();
71
75
  const nextDoc = { ...doc, [owner]: normalizeDoc(value) };
72
- this.replace(nextDoc);
76
+ await this.replace(nextDoc);
73
77
  return nextDoc[owner];
74
78
  }
75
- mergePatchOwner(owner, patch) {
79
+ async mergePatchOwner(owner, patch) {
76
80
  if (!isPlainObject(patch)) {
77
81
  throw new Error("Patch must be a JSON object");
78
82
  }
79
- const doc = this.get();
83
+ const doc = await this.get();
80
84
  const currentOwner = normalizeDoc(doc?.[owner]);
81
85
  const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch));
82
86
  const nextDoc = { ...doc, [owner]: nextOwner };
83
- this.replace(nextDoc);
87
+ await this.replace(nextDoc);
84
88
  return nextOwner;
85
89
  }
86
- persist() {
90
+ async persist() {
87
91
  try {
88
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
+ await mkdir(path.dirname(this.filePath), { recursive: true });
89
93
  const yaml = stringifyYaml(this.cache);
90
- fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8");
94
+ await writeFile(this.filePath, ensureTrailingNewline(yaml), "utf-8");
91
95
  }
92
96
  catch (error) {
93
97
  this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc");
@@ -4,17 +4,21 @@ export class SideCarManager {
4
4
  this.options = options;
5
5
  this.configs = new Map();
6
6
  this.runtime = new Map();
7
- for (const record of this.loadConfiguredSideCars()) {
8
- this.configs.set(record.id, record);
9
- this.runtime.set(record.id, { status: "stopped" });
7
+ }
8
+ static async create(options) {
9
+ const instance = new SideCarManager(options);
10
+ for (const record of await instance.loadConfiguredSideCars()) {
11
+ instance.configs.set(record.id, record);
12
+ instance.runtime.set(record.id, { status: "stopped" });
10
13
  }
11
14
  queueMicrotask(() => {
12
- for (const record of this.configs.values()) {
13
- void this.refreshPortSideCar(record.id).catch((error) => {
14
- this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
15
+ for (const record of instance.configs.values()) {
16
+ void instance.refreshPortSideCar(record.id).catch((error) => {
17
+ instance.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port");
15
18
  });
16
19
  }
17
20
  });
21
+ return instance;
18
22
  }
19
23
  async list() {
20
24
  await this.refreshPortStatuses();
@@ -45,7 +49,7 @@ export class SideCarManager {
45
49
  };
46
50
  this.configs.set(record.id, record);
47
51
  this.runtime.set(record.id, { status: "stopped" });
48
- this.persistConfigs();
52
+ await this.persistConfigs();
49
53
  await this.refreshPortSideCar(record.id);
50
54
  return this.toSideCar(record);
51
55
  }
@@ -56,7 +60,7 @@ export class SideCarManager {
56
60
  record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure;
57
61
  record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode;
58
62
  record.updatedAt = new Date().toISOString();
59
- this.persistConfigs();
63
+ await this.persistConfigs();
60
64
  await this.refreshPortSideCar(id);
61
65
  return this.toSideCar(record);
62
66
  }
@@ -66,7 +70,7 @@ export class SideCarManager {
66
70
  return false;
67
71
  this.configs.delete(id);
68
72
  this.runtime.delete(id);
69
- this.persistConfigs();
73
+ await this.persistConfigs();
70
74
  this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id });
71
75
  return true;
72
76
  }
@@ -140,12 +144,12 @@ export class SideCarManager {
140
144
  }
141
145
  return record;
142
146
  }
143
- persistConfigs() {
147
+ async persistConfigs() {
144
148
  const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record }));
145
- this.options.settings.mergePatchOwner("config", "server", { sidecars });
149
+ await this.options.settings.mergePatchOwner("config", "server", { sidecars });
146
150
  }
147
- loadConfiguredSideCars() {
148
- const serverConfig = this.options.settings.getOwner("config", "server");
151
+ async loadConfiguredSideCars() {
152
+ const serverConfig = await this.options.settings.getOwner("config", "server");
149
153
  const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [];
150
154
  const records = [];
151
155
  for (const item of list) {