From 5ec7ef3d4cfd9ca6e52445683571e47e5a477815 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 28 May 2026 17:20:45 -0700 Subject: [PATCH] refactor(config): centralize workspace config helpers in openwork-server Make apps/server the single source of truth for the per-workspace openwork.json schema/defaults and the opencode.json default writer: - workspace-files.ts now exports WorkspaceOpenworkConfig plus defaultWorkspaceOpenworkConfig / readWorkspaceOpenworkConfig / writeWorkspaceOpenworkConfig (pure, no Electron return shape). - workspace-init.ts exports ensureOpencodeConfig and its ensureWorkspaceOpenworkConfig reuses the shared default/writer. - Electron main.mjs imports these lazily from the compiled server bundle (absolute-path dynamic import, mirroring runtime.mjs's dev-vs-packaged candidate resolution) and drops its drifting local copies of defaultWorkspaceOpenworkConfig, read/writeWorkspaceOpenworkConfig, ensureDefaultWorkspaceOpencodeConfig and workspaceOpencodeConfigPath. Behavior-preserving: same file paths, same schema, same config content. The check-electron-bridge validator only matches case labels, so the swapped handler bodies are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/electron/main.mjs | 121 ++++++++++++++--------------- apps/server/src/workspace-files.ts | 61 ++++++++++++++- apps/server/src/workspace-init.ts | 42 +++------- 3 files changed, 130 insertions(+), 94 deletions(-) diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index baa4d821d0..765ba138f4 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -17,7 +17,7 @@ import { import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, Menu, WebContentsView, clipboard, dialog, ipcMain, nativeImage, nativeTheme, session, shell, systemPreferences } from "electron"; import { configureFakeMediaForTests, installMediaPermissionHandlers } from "./media-permissions.mjs"; @@ -43,7 +43,6 @@ const APP_IDENTIFIER = isDevMode ? DEV_APP_IDENTIFIER : TAURI_APP_IDENTIFIER; const RELEASE_DOWNLOAD_BASE_URL = "https://github.com/different-ai/openwork/releases/latest/download"; const RELEASE_PAGE_URL = "https://github.com/different-ai/openwork/releases/latest"; const DOCS_PAGE_URL = "https://openworklabs.com/docs"; -const BROWSER_PLUGIN = "opencode-chrome-devtools"; const COMPUTER_USE_HELPER_APP_NAME = "OpenWork Computer Use.app"; const COMPUTER_USE_HELPER_EXECUTABLE = "ComputerUse"; @@ -1424,43 +1423,46 @@ function validateSkillName(raw) { return trimmed; } -function defaultWorkspaceOpenworkConfig(workspacePath, preset = null) { - return { - version: 1, - workspace: workspacePath - ? { - name: path.basename(workspacePath) || "Workspace", - createdAt: Date.now(), - preset: preset || null, - } - : null, - authorizedRoots: workspacePath ? [workspacePath] : [], - reload: null, - }; -} +// Workspace config helpers (openwork.json schema + opencode.json defaults) live +// in the openwork-server library so the Electron main process and the in-process +// server share one source of truth. The compiled bundle is imported lazily by +// absolute path, mirroring runtime.mjs's dev-vs-packaged candidate resolution. +let serverConfigHelpersPromise = null; -async function workspaceOpencodeConfigPath(workspacePath) { - const candidates = [ - path.join(workspacePath, "opencode.jsonc"), - path.join(workspacePath, "opencode.json"), - path.join(workspacePath, ".opencode", "opencode.jsonc"), - path.join(workspacePath, ".opencode", "opencode.json"), +function serverDistCandidates(file) { + const devPath = path.resolve(__dirname, "..", "..", "server", "dist", file); + const packagedPaths = [ + path.resolve(__dirname, "..", "server", "dist", file), + ...(process.resourcesPath ? [path.resolve(process.resourcesPath, "server", "dist", file)] : []), ]; - for (const candidate of candidates) { - if (await pathExists(candidate)) return candidate; - } - return candidates[0]; -} - -async function ensureDefaultWorkspaceOpencodeConfig(workspacePath) { - const configPath = await workspaceOpencodeConfigPath(workspacePath); - if (await pathExists(configPath)) return false; - await writeJsonFileAtomic(configPath, { - $schema: "https://opencode.ai/config.json", - default_agent: "openwork", - plugin: [BROWSER_PLUGIN], - }); - return true; + return process.env.OPENWORK_DEV_MODE === "1" + ? [devPath, ...packagedPaths] + : [...packagedPaths, devPath]; +} + +function loadServerConfigHelpers() { + if (serverConfigHelpersPromise) return serverConfigHelpersPromise; + serverConfigHelpersPromise = (async () => { + const filesCandidates = serverDistCandidates("workspace-files.js"); + const initCandidates = serverDistCandidates("workspace-init.js"); + const filesPath = filesCandidates.find((candidate) => existsSync(candidate)); + const initPath = initCandidates.find((candidate) => existsSync(candidate)); + if (!filesPath || !initPath) { + serverConfigHelpersPromise = null; + throw new Error( + `Cannot find openwork-server config bundle. Checked: ${[...filesCandidates, ...initCandidates].join(", ")}`, + ); + } + const files = await import(pathToFileURL(filesPath).href); + const init = await import(pathToFileURL(initPath).href); + return { + defaultWorkspaceOpenworkConfig: files.defaultWorkspaceOpenworkConfig, + readWorkspaceOpenworkConfig: files.readWorkspaceOpenworkConfig, + writeWorkspaceOpenworkConfig: files.writeWorkspaceOpenworkConfig, + ensureOpencodeConfig: init.ensureOpencodeConfig, + }; + })(); + return serverConfigHelpersPromise; } async function normalizeLocalWorkspacePath(rawPath) { @@ -1569,22 +1571,6 @@ async function discoverOpenworkWorkspace({ hostUrl, token, hostToken, directory return selectOpenworkWorkspaceForConnection(list, directory); } -async function readWorkspaceOpenworkConfig(workspacePath) { - const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); - if (!(await pathExists(openworkPath))) { - return defaultWorkspaceOpenworkConfig(workspacePath); - } - const raw = await readFile(openworkPath, "utf8"); - return JSON.parse(raw); -} - -async function writeWorkspaceOpenworkConfig(workspacePath, config) { - const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); - await mkdir(path.dirname(openworkPath), { recursive: true }); - await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - return execResult(true, `Wrote ${openworkPath}`); -} - async function readWorkspaceState() { const state = await readJsonFile(workspaceStatePath(), EMPTY_WORKSPACE_LIST); const selectedId = @@ -2174,8 +2160,14 @@ async function handleDesktopInvoke(event, command, ...args) { workspaceType: "local", }); await mkdir(path.join(folderPath, ".opencode"), { recursive: true }); - await ensureDefaultWorkspaceOpencodeConfig(folderPath); - await writeWorkspaceOpenworkConfig(folderPath, defaultWorkspaceOpenworkConfig(folderPath, preset)); + { + const helpers = await loadServerConfigHelpers(); + await helpers.ensureOpencodeConfig(folderPath); + await helpers.writeWorkspaceOpenworkConfig( + folderPath, + helpers.defaultWorkspaceOpenworkConfig(folderPath, preset), + ); + } return mutateWorkspaceState((state) => { const workspacePathKey = normalizeWorkspacePathKey(workspace.path); @@ -2356,22 +2348,29 @@ async function handleDesktopInvoke(event, command, ...args) { if (!workspacePath || !authorizedRoot) { throw new Error("workspacePath and folderPath are required"); } - const config = await readWorkspaceOpenworkConfig(workspacePath); + const helpers = await loadServerConfigHelpers(); + const config = await helpers.readWorkspaceOpenworkConfig(workspacePath); if (!Array.isArray(config.authorizedRoots)) { config.authorizedRoots = []; } if (!config.authorizedRoots.includes(authorizedRoot)) { config.authorizedRoots.push(authorizedRoot); } - return writeWorkspaceOpenworkConfig(workspacePath, config); + const written = await helpers.writeWorkspaceOpenworkConfig(workspacePath, config); + return execResult(true, `Wrote ${written}`); } case "workspaceOpenworkRead": - return readWorkspaceOpenworkConfig(String(args[0]?.workspacePath ?? "").trim()); - case "workspaceOpenworkWrite": - return writeWorkspaceOpenworkConfig( + return (await loadServerConfigHelpers()).readWorkspaceOpenworkConfig( String(args[0]?.workspacePath ?? "").trim(), - args[0]?.config ?? defaultWorkspaceOpenworkConfig(""), ); + case "workspaceOpenworkWrite": { + const helpers = await loadServerConfigHelpers(); + const written = await helpers.writeWorkspaceOpenworkConfig( + String(args[0]?.workspacePath ?? "").trim(), + args[0]?.config ?? helpers.defaultWorkspaceOpenworkConfig(""), + ); + return execResult(true, `Wrote ${written}`); + } case "workspaceExportConfig": { const input = args[0] ?? {}; const workspaceId = String(input.workspaceId ?? "").trim(); diff --git a/apps/server/src/workspace-files.ts b/apps/server/src/workspace-files.ts index dd8d4a7a6b..06c4acd262 100644 --- a/apps/server/src/workspace-files.ts +++ b/apps/server/src/workspace-files.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; export function opencodeConfigPath(workspaceRoot: string): string { const jsoncPath = join(workspaceRoot, "opencode.jsonc"); @@ -28,3 +29,61 @@ export function projectCommandsDir(workspaceRoot: string): string { export function projectPluginsDir(workspaceRoot: string): string { return join(workspaceRoot, ".opencode", "plugins"); } + +/** + * Shape of the per-workspace `openwork.json` metadata file. This is the single + * source of truth for the schema; the desktop Electron main process imports + * these helpers from the compiled server bundle rather than redefining them. + */ +export type WorkspaceOpenworkConfig = { + version: number; + workspace?: { + name?: string | null; + createdAt?: number | null; + preset?: string | null; + } | null; + authorizedRoots: string[]; + reload?: { + auto?: boolean; + resume?: boolean; + } | null; +}; + +export function defaultWorkspaceOpenworkConfig( + workspaceRoot: string, + preset: string | null = null, +): WorkspaceOpenworkConfig { + return { + version: 1, + workspace: workspaceRoot + ? { + name: basename(workspaceRoot) || "Workspace", + createdAt: Date.now(), + preset: preset || null, + } + : null, + authorizedRoots: workspaceRoot ? [workspaceRoot] : [], + reload: null, + }; +} + +export async function readWorkspaceOpenworkConfig( + workspaceRoot: string, +): Promise { + const configPath = openworkConfigPath(workspaceRoot); + if (!existsSync(configPath)) { + return defaultWorkspaceOpenworkConfig(workspaceRoot); + } + const raw = await readFile(configPath, "utf8"); + return JSON.parse(raw) as WorkspaceOpenworkConfig; +} + +export async function writeWorkspaceOpenworkConfig( + workspaceRoot: string, + config: WorkspaceOpenworkConfig, +): Promise { + const configPath = openworkConfigPath(workspaceRoot); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + return configPath; +} diff --git a/apps/server/src/workspace-init.ts b/apps/server/src/workspace-init.ts index 9c732bfdb5..a2a9518b2c 100644 --- a/apps/server/src/workspace-init.ts +++ b/apps/server/src/workspace-init.ts @@ -1,9 +1,14 @@ -import { basename, join } from "node:path"; +import { join } from "node:path"; import { readFile, writeFile } from "node:fs/promises"; import { ensureDir, exists } from "./utils.js"; import { ApiError } from "./errors.js"; -import { openworkConfigPath, opencodeConfigPath } from "./workspace-files.js"; +import { + openworkConfigPath, + opencodeConfigPath, + defaultWorkspaceOpenworkConfig, + writeWorkspaceOpenworkConfig, +} from "./workspace-files.js"; import { readJsoncFile, updateJsoncPath, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js"; import type { ReloadReason } from "./types.js"; @@ -69,20 +74,6 @@ Hard rule: never copy private memory into repo files. Store only redacted summar ${OPENWORK_ARTIFACT_GUIDANCE} `; -type WorkspaceOpenworkConfig = { - version: number; - workspace?: { - name?: string | null; - createdAt?: number | null; - preset?: string | null; - } | null; - authorizedRoots: string[]; - reload?: { - auto?: boolean; - resume?: boolean; - } | null; -}; - type EnsureWorkspaceFilesResult = { changed: boolean; reloadReasons: ReloadReason[]; @@ -99,25 +90,12 @@ function isSchemaOnlyOpencodeConfig(config: Record): boolean { } async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: string): Promise { - const path = openworkConfigPath(workspaceRoot); - if (await exists(path)) return false; - const now = Date.now(); - const config: WorkspaceOpenworkConfig = { - version: 1, - workspace: { - name: basename(workspaceRoot) || "Workspace", - createdAt: now, - preset, - }, - authorizedRoots: [workspaceRoot], - reload: null, - }; - await ensureDir(join(workspaceRoot, ".opencode")); - await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8"); + if (await exists(openworkConfigPath(workspaceRoot))) return false; + await writeWorkspaceOpenworkConfig(workspaceRoot, defaultWorkspaceOpenworkConfig(workspaceRoot, preset)); return true; } -async function ensureOpencodeConfig(workspaceRoot: string): Promise { +export async function ensureOpencodeConfig(workspaceRoot: string): Promise { const path = opencodeConfigPath(workspaceRoot); if (await exists(path)) { await readJsoncFile>(path, {});