diff --git a/AGENTS.md b/AGENTS.md index f6d042bc3..bb97b445a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Design principles for hot reload: * **Conservative triggers**: only reload when a file that OpenCode reads at startup actually changes inside `.opencode/` or `opencode.json`. Ignore metadata files like `openwork.json`, `.DS_Store`, etc. * **Workspace-scoped**: reload state is keyed per workspace. Switching workspaces never leaks reload signals from one workspace to another. * **Session-aware**: when sessions are actively running, queue reload signals. Promote to visible reload (toast or auto-reload) only after all active sessions finish. This avoids interrupting in-flight tool calls. -* **Auto-reload setting**: each workspace can opt into automatic reload via `.opencode/openwork.json` (`reload.auto`). When enabled, the engine reloads automatically once queued signals are ready and no sessions are active. +* **Auto-reload setting**: each workspace can opt into automatic reload via `.openwork/openwork.json` (`reload.auto`). When enabled, the engine reloads automatically once queued signals are ready and no sessions are active. * **Session continuity**: before reload, capture running session IDs, agents, and models. After reload, optionally relaunch those sessions so the user experiences seamless continuity. * **Per-workspace isolation**: the desktop file watcher only watches the runtime-connected workspace root and its `.opencode/` directory. This can differ briefly from the UI-selected workspace while the user browses another workspace. The server reload event store is already keyed by `workspaceId`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d573baf85..cc01290cb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,7 +69,7 @@ Agents, skills, and commands should model the following as OpenWork server behav - workspace creation and initialization - writes to `.opencode/`, `opencode.json`, and `opencode.jsonc` -- OpenWork workspace config writes (`.opencode/openwork.json`) +- OpenWork workspace config writes (`.openwork/openwork.json`) - workspace template export/import, including shareable `.opencode/**` files and `opencode.json` state - workspace template starter-session materialization from portable blueprint config (not copied runtime session history) - share-bundle publish/fetch flows used by OpenWork template links diff --git a/apps/app/src/i18n/locales/ca.ts b/apps/app/src/i18n/locales/ca.ts index 465884bc1..684f5f250 100644 --- a/apps/app/src/i18n/locales/ca.ts +++ b/apps/app/src/i18n/locales/ca.ts @@ -1721,7 +1721,7 @@ export default { "settings.window_appearance_desc": "Personalitza l'aspecte de la finestra.", "settings.worker_id_label": "worker {id}", "settings.worker_unresolved": "worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Configuració del workspace", "settings.workspace_debug_events_label": "Esdeveniments de depuració del workspace", "settings.workspace_fallback_name": "Workspace", diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 4d138322f..3883959b5 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -1762,7 +1762,7 @@ export default { "settings.window_appearance_desc": "Customize window appearance.", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Workspace config", "settings.workspace_debug_events_label": "Workspace debug events", "settings.workspace_fallback_name": "Workspace", diff --git a/apps/app/src/i18n/locales/es.ts b/apps/app/src/i18n/locales/es.ts index 8c10fe8dc..f2ddfdfee 100644 --- a/apps/app/src/i18n/locales/es.ts +++ b/apps/app/src/i18n/locales/es.ts @@ -1721,7 +1721,7 @@ export default { "settings.window_appearance_desc": "Personaliza la apariencia de la ventana.", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {entorno de ejecuciónWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Configuración del espacio de trabajo", "settings.workspace_debug_events_label": "Eventos de depuración del espacio de trabajo", "settings.workspace_fallback_name": "workspace", diff --git a/apps/app/src/i18n/locales/fr.ts b/apps/app/src/i18n/locales/fr.ts index d770b68f4..ffd546622 100644 --- a/apps/app/src/i18n/locales/fr.ts +++ b/apps/app/src/i18n/locales/fr.ts @@ -1721,7 +1721,7 @@ export default { "settings.window_appearance_desc": "Personnalisez l'apparence de la fenêtre.", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Configuration de l'espace de travail", "settings.workspace_debug_events_label": "Événements de débogage de l'espace de travail", "settings.workspace_fallback_name": "Espace de travail", diff --git a/apps/app/src/i18n/locales/ja.ts b/apps/app/src/i18n/locales/ja.ts index 2ce53e587..f4e8c05ae 100644 --- a/apps/app/src/i18n/locales/ja.ts +++ b/apps/app/src/i18n/locales/ja.ts @@ -1703,7 +1703,7 @@ export default { "settings.window_appearance_desc": "ウィンドウの外観をカスタマイズします。", "settings.worker_id_label": "ワーカー{id}", "settings.worker_unresolved": "ワーカー{runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "ワークスペース設定", "settings.workspace_debug_events_label": "ワークスペースデバッグイベント", "settings.workspace_fallback_name": "ワークスペース", diff --git a/apps/app/src/i18n/locales/pt-BR.ts b/apps/app/src/i18n/locales/pt-BR.ts index 3bd175d53..b8e47cf49 100644 --- a/apps/app/src/i18n/locales/pt-BR.ts +++ b/apps/app/src/i18n/locales/pt-BR.ts @@ -1704,7 +1704,7 @@ export default { "settings.window_appearance_desc": "Personalizar aparência da janela.", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Config do workspace", "settings.workspace_debug_events_label": "Eventos de depuração do workspace", "settings.workspace_fallback_name": "Workspace", diff --git a/apps/app/src/i18n/locales/th.ts b/apps/app/src/i18n/locales/th.ts index 4d657e2fb..c60156b66 100644 --- a/apps/app/src/i18n/locales/th.ts +++ b/apps/app/src/i18n/locales/th.ts @@ -1704,7 +1704,7 @@ export default { "settings.window_appearance_desc": "ปรับแต่งรูปลักษณ์หน้าต่าง", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "การตั้งค่าพื้นที่ทำงาน", "settings.workspace_debug_events_label": "เหตุการณ์ดีบักพื้นที่ทำงาน", "settings.workspace_fallback_name": "พื้นที่ทำงาน", diff --git a/apps/app/src/i18n/locales/vi.ts b/apps/app/src/i18n/locales/vi.ts index 22b15e121..6d67952b1 100644 --- a/apps/app/src/i18n/locales/vi.ts +++ b/apps/app/src/i18n/locales/vi.ts @@ -1704,7 +1704,7 @@ export default { "settings.window_appearance_desc": "Tùy chỉnh giao diện cửa sổ.", "settings.worker_id_label": "Worker {id}", "settings.worker_unresolved": "Worker {runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "Cấu hình workspace", "settings.workspace_debug_events_label": "Sự kiện gỡ lỗi workspace", "settings.workspace_fallback_name": "Workspace", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index ba681fec1..1c2a9d8c5 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -1707,7 +1707,7 @@ export default { "settings.window_appearance_desc": "自定义窗口外观。", "settings.worker_id_label": "工作区{id}", "settings.worker_unresolved": "工作区{runtimeWorkspaceId}", - "settings.workspace_config_desc": ".opencode/openwork.json", + "settings.workspace_config_desc": ".openwork/openwork.json", "settings.workspace_config_title": "工作区配置", "settings.workspace_debug_events_label": "工作区调试事件", "settings.workspace_fallback_name": "工作区", diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index c63ab8633..7475f5a68 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -350,7 +350,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) }); if (!result.ok) { throw new Error( - result.stderr || result.stdout || "Failed to write .opencode/openwork.json", + result.stderr || result.stdout || "Failed to write .openwork/openwork.json", ); } return true; diff --git a/apps/app/src/react-app/domains/settings/state/extensions-store.ts b/apps/app/src/react-app/domains/settings/state/extensions-store.ts index cdb91cb57..ac5f40c19 100644 --- a/apps/app/src/react-app/domains/settings/state/extensions-store.ts +++ b/apps/app/src/react-app/domains/settings/state/extensions-store.ts @@ -365,7 +365,7 @@ export function createExtensionsStore(options: { config: config as never, }); if (!result.ok) { - throw new Error(result.stderr || result.stdout || "Failed to write .opencode/openwork.json"); + throw new Error(result.stderr || result.stdout || "Failed to write .openwork/openwork.json"); } return true; } diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 4257e57bb..36615f019 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -339,17 +339,29 @@ function defaultWorkspaceOpenworkConfig(workspacePath) { }; } +function resolveOpenworkConfigReadPath(workspacePath) { + const newPath = path.join(workspacePath, ".openwork", "openwork.json"); + if (existsSync(newPath)) return newPath; + const legacyPath = path.join(workspacePath, ".opencode", "openwork.json"); + if (existsSync(legacyPath)) return legacyPath; + return newPath; +} + async function readWorkspaceOpenworkConfig(workspacePath) { - const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); + const openworkPath = resolveOpenworkConfigReadPath(workspacePath); if (!(await pathExists(openworkPath))) { return defaultWorkspaceOpenworkConfig(workspacePath); } - const raw = await readFile(openworkPath, "utf8"); - return JSON.parse(raw); + try { + const raw = await readFile(openworkPath, "utf8"); + return JSON.parse(raw); + } catch { + return defaultWorkspaceOpenworkConfig(workspacePath); + } } async function writeWorkspaceOpenworkConfig(workspacePath, config) { - const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); + const openworkPath = path.join(workspacePath, ".openwork", "openwork.json"); await mkdir(path.dirname(openworkPath), { recursive: true }); await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); return execResult(true, `Wrote ${openworkPath}`); diff --git a/apps/desktop/src-tauri/src/commands/misc.rs b/apps/desktop/src-tauri/src/commands/misc.rs index e44f88610..b5b8ad103 100644 --- a/apps/desktop/src-tauri/src/commands/misc.rs +++ b/apps/desktop/src-tauri/src/commands/misc.rs @@ -164,12 +164,9 @@ fn current_openwork_state_paths(app: &AppHandle) -> Result, String> ]; if let Some(home) = home_dir() { - paths.push( - home.join("OpenWork") - .join("Welcome") - .join(".opencode") - .join("openwork.json"), - ); + let welcome = home.join("OpenWork").join("Welcome"); + let resolved = resolve_openwork_config_read_path(&welcome); + paths.push(resolved); } Ok(paths) @@ -228,10 +225,22 @@ fn validate_server_name(name: &str) -> Result { Ok(trimmed.to_string()) } +fn resolve_openwork_config_read_path(workspace_root: &Path) -> PathBuf { + let new_path = workspace_root.join(".openwork").join("openwork.json"); + if new_path.exists() { + return new_path; + } + let legacy_path = workspace_root.join(".opencode").join("openwork.json"); + if legacy_path.exists() { + return legacy_path; + } + new_path +} + fn read_workspace_openwork_config( workspace_path: &Path, ) -> Result { - let openwork_path = workspace_path.join(".opencode").join("openwork.json"); + let openwork_path = resolve_openwork_config_read_path(workspace_path); if !openwork_path.exists() { let mut cfg = WorkspaceOpenworkConfig::default(); let workspace_value = workspace_path.to_string_lossy().to_string(); diff --git a/apps/desktop/src-tauri/src/commands/workspace.rs b/apps/desktop/src-tauri/src/commands/workspace.rs index 13d495ba5..4eb07f759 100644 --- a/apps/desktop/src-tauri/src/commands/workspace.rs +++ b/apps/desktop/src-tauri/src/commands/workspace.rs @@ -19,6 +19,18 @@ use walkdir::WalkDir; use zip::write::FileOptions; use zip::{CompressionMethod, ZipArchive, ZipWriter}; +fn resolve_openwork_config_read_path(workspace_root: &Path) -> PathBuf { + let new_path = workspace_root.join(".openwork").join("openwork.json"); + if new_path.exists() { + return new_path; + } + let legacy_path = workspace_root.join(".opencode").join("openwork.json"); + if legacy_path.exists() { + return legacy_path; + } + new_path +} + fn build_workspace_list(state: crate::types::WorkspaceState) -> WorkspaceList { let watched_id = if state.watched_workspace_id.trim().is_empty() { None @@ -570,18 +582,13 @@ pub fn workspace_add_authorized_root( return Err("folderPath is required".to_string()); } - let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") - .join("openwork.json"); - - if let Some(parent) = openwork_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; - } + let ws = PathBuf::from(&workspace_path); + let openwork_read_path = resolve_openwork_config_read_path(&ws); + let openwork_write_path = ws.join(".openwork").join("openwork.json"); - let mut config: WorkspaceOpenworkConfig = if openwork_path.exists() { - let raw = fs::read_to_string(&openwork_path) - .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; + let mut config: WorkspaceOpenworkConfig = if openwork_read_path.exists() { + let raw = fs::read_to_string(&openwork_read_path) + .map_err(|e| format!("Failed to read {}: {e}", openwork_read_path.display()))?; serde_json::from_str(&raw).unwrap_or_default() } else { let mut cfg = WorkspaceOpenworkConfig::default(); @@ -595,11 +602,16 @@ pub fn workspace_add_authorized_root( config.authorized_roots.push(folder_path); } + if let Some(parent) = openwork_write_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + fs::write( - &openwork_path, + &openwork_write_path, serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + .map_err(|e| format!("Failed to write {}: {e}", openwork_write_path.display()))?; Ok(ExecResult { ok: true, @@ -619,9 +631,8 @@ pub fn workspace_openwork_read( return Err("workspacePath is required".to_string()); } - let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") - .join("openwork.json"); + let ws = PathBuf::from(&workspace_path); + let openwork_path = resolve_openwork_config_read_path(&ws); if !openwork_path.exists() { let mut cfg = WorkspaceOpenworkConfig::default(); @@ -648,7 +659,7 @@ pub fn workspace_openwork_write( } let openwork_path = PathBuf::from(&workspace_path) - .join(".opencode") + .join(".openwork") .join("openwork.json"); if let Some(parent) = openwork_path.parent() { @@ -730,9 +741,12 @@ fn collect_workspace_entries( } } - let opencode_dir = workspace_root.join(".opencode"); - if opencode_dir.exists() { - for entry in WalkDir::new(&opencode_dir) { + for dir_name in [".opencode", ".openwork"] { + let dir = workspace_root.join(dir_name); + if !dir.exists() { + continue; + } + for entry in WalkDir::new(&dir) { let entry = entry.map_err(|e| e.to_string())?; if !entry.file_type().is_file() { continue; @@ -897,7 +911,7 @@ pub fn workspace_import_config( }) { return Err("Archive contains an unsafe path".to_string()); } - if !(name == "opencode.json" || name.starts_with(".opencode/")) { + if !(name == "opencode.json" || name.starts_with(".opencode/") || name.starts_with(".openwork/")) { continue; } if let Some(file_name) = entry_path.file_name().and_then(|entry| entry.to_str()) { @@ -924,17 +938,19 @@ pub fn workspace_import_config( } let opencode_dir = target_path.join(".opencode"); - if !opencode_dir.exists() { - return Err("Archive is missing .opencode config".to_string()); + let openwork_dir = target_path.join(".openwork"); + if !opencode_dir.exists() && !openwork_dir.exists() { + return Err("Archive is missing workspace config".to_string()); } - let openwork_path = target_path.join(".opencode").join("openwork.json"); + let openwork_read_path = resolve_openwork_config_read_path(&target_path); + let openwork_write_path = target_path.join(".openwork").join("openwork.json"); let mut preset = "starter".to_string(); let mut workspace_name = name.clone().filter(|value| !value.trim().is_empty()); - if openwork_path.exists() { - let raw = fs::read_to_string(&openwork_path) - .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; + if openwork_read_path.exists() { + let raw = fs::read_to_string(&openwork_read_path) + .map_err(|e| format!("Failed to read {}: {e}", openwork_read_path.display()))?; if let Ok(mut config) = serde_json::from_str::(&raw) { config.authorized_roots = vec![target_dir.clone()]; if let Some(workspace) = &config.workspace { @@ -950,23 +966,27 @@ pub fn workspace_import_config( } } } + if let Some(parent) = openwork_write_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } fs::write( - &openwork_path, + &openwork_write_path, serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + .map_err(|e| format!("Failed to write {}: {e}", openwork_write_path.display()))?; } } else { let config = WorkspaceOpenworkConfig::new(&target_dir, &preset, now_ms()); - if let Some(parent) = openwork_path.parent() { + if let Some(parent) = openwork_write_path.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; } fs::write( - &openwork_path, + &openwork_write_path, serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?, ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + .map_err(|e| format!("Failed to write {}: {e}", openwork_write_path.display()))?; } let name = workspace_name diff --git a/apps/desktop/src-tauri/src/workspace/files.rs b/apps/desktop/src-tauri/src/workspace/files.rs index 7b4f0ea9a..61795d585 100644 --- a/apps/desktop/src-tauri/src/workspace/files.rs +++ b/apps/desktop/src-tauri/src/workspace/files.rs @@ -537,18 +537,19 @@ pub fn ensure_workspace_files(workspace_path: &str, preset: &str) -> Result<(), .map_err(|e| format!("Failed to write {}: {e}", config_path.display()))?; } - let openwork_path = root.join(".opencode").join("openwork.json"); - if !openwork_path.exists() { + let openwork_new_path = root.join(".openwork").join("openwork.json"); + let openwork_legacy_path = root.join(".opencode").join("openwork.json"); + if !openwork_new_path.exists() && !openwork_legacy_path.exists() { let openwork = WorkspaceOpenworkConfig::new(workspace_path, preset, now_ms()); - fs::create_dir_all(openwork_path.parent().unwrap()) - .map_err(|e| format!("Failed to create {}: {e}", openwork_path.display()))?; + fs::create_dir_all(openwork_new_path.parent().unwrap()) + .map_err(|e| format!("Failed to create {}: {e}", openwork_new_path.display()))?; fs::write( - &openwork_path, + &openwork_new_path, serde_json::to_string_pretty(&openwork).map_err(|e| e.to_string())?, ) - .map_err(|e| format!("Failed to write {}: {e}", openwork_path.display()))?; + .map_err(|e| format!("Failed to write {}: {e}", openwork_new_path.display()))?; } Ok(()) diff --git a/apps/desktop/src-tauri/src/workspace/watch.rs b/apps/desktop/src-tauri/src/workspace/watch.rs index 5bb7a4ddc..023985300 100644 --- a/apps/desktop/src-tauri/src/workspace/watch.rs +++ b/apps/desktop/src-tauri/src/workspace/watch.rs @@ -165,6 +165,13 @@ pub fn update_workspace_watch( .map_err(|e| format!("Failed to watch .opencode: {e}"))?; } + let openwork_dir = root.join(".openwork"); + if openwork_dir.exists() { + watcher + .watch(&openwork_dir, RecursiveMode::Recursive) + .map_err(|e| format!("Failed to watch .openwork: {e}"))?; + } + *state .root .lock() diff --git a/apps/orchestrator/src/cli.ts b/apps/orchestrator/src/cli.ts index 5dfb2aa88..3a74f4652 100644 --- a/apps/orchestrator/src/cli.ts +++ b/apps/orchestrator/src/cli.ts @@ -19,7 +19,7 @@ import { writeFile, realpath, } from "node:fs/promises"; -import { readdirSync, statSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { createServer as createHttpServer } from "node:http"; import { homedir, hostname, networkInterfaces, tmpdir } from "node:os"; @@ -2761,7 +2761,11 @@ function resolveRouterDataDir(flags: Map): string { } function resolveWorkspaceOpenworkConfigPath(workspaceRoot: string): string { - return join(workspaceRoot, ".opencode", "openwork.json"); + const newPath = join(workspaceRoot, ".openwork", "openwork.json"); + if (existsSync(newPath)) return newPath; + const legacyPath = join(workspaceRoot, ".opencode", "openwork.json"); + if (existsSync(legacyPath)) return legacyPath; + return newPath; } function resolveOpencodeRouterConfigPath(): string { @@ -2850,10 +2854,11 @@ async function resolveOpencodeRouterEnabled( return { enabled: envValue, source: "env" }; } - const openworkConfigPath = resolveWorkspaceOpenworkConfigPath(workspaceRoot); + const openworkConfigReadPath = resolveWorkspaceOpenworkConfigPath(workspaceRoot); + const openworkConfigWritePath = join(workspaceRoot, ".openwork", "openwork.json"); let openworkConfig: Record = {}; try { - const raw = await readFile(openworkConfigPath, "utf8"); + const raw = await readFile(openworkConfigReadPath, "utf8"); openworkConfig = asRecord(JSON.parse(raw)); } catch { openworkConfig = {}; @@ -2882,9 +2887,9 @@ async function resolveOpencodeRouterEnabled( }; try { - await mkdir(dirname(openworkConfigPath), { recursive: true }); + await mkdir(dirname(openworkConfigWritePath), { recursive: true }); await writeFile( - openworkConfigPath, + openworkConfigWritePath, `${JSON.stringify(nextOpenworkConfig, null, 2)}\n`, "utf8", ); @@ -2892,7 +2897,7 @@ async function resolveOpencodeRouterEnabled( logger.warn( "Failed to persist messaging enabled default", { - path: openworkConfigPath, + path: openworkConfigWritePath, error: error instanceof Error ? error.message : String(error), }, "openwork-orchestrator", diff --git a/apps/server-v2/src/files.test.ts b/apps/server-v2/src/files.test.ts index 2dd2a71de..783b2f0c3 100644 --- a/apps/server-v2/src/files.test.ts +++ b/apps/server-v2/src/files.test.ts @@ -191,7 +191,7 @@ test("file routes cover simple content, file sessions, inbox, artifacts, and rel expect(inboxList.status).toBe(200); expect(inboxListBody.data.items[0].name).toBe("hello.txt"); - const outboxDir = path.join(workspaceRoot, ".opencode", "openwork", "outbox"); + const outboxDir = path.join(workspaceRoot, ".openwork", "outbox"); fs.mkdirSync(outboxDir, { recursive: true }); fs.writeFileSync(path.join(outboxDir, "artifact.bin"), "artifact", "utf8"); @@ -223,7 +223,7 @@ test("remote workspace config and file routes proxy through the local server", a ok: true, data: { effective: { opencode: { permission: { external_directory: { "/srv/alpha/*": "allow" } } }, openwork: {} }, - materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.opencode/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" }, + materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.openwork/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" }, stored: { openwork: { reload: { auto: true } }, opencode: {} }, updatedAt: new Date().toISOString(), workspaceId: "remote-alpha", @@ -236,7 +236,7 @@ test("remote workspace config and file routes proxy through the local server", a ok: true, data: { effective: { opencode: { permission: { external_directory: { "/srv/alpha/*": "allow", "/srv/shared/*": "allow" } } }, openwork: {} }, - materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.opencode/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" }, + materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.openwork/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" }, stored: { openwork: { reload: { auto: true } }, opencode: {} }, updatedAt: new Date().toISOString(), workspaceId: "remote-alpha", diff --git a/apps/server-v2/src/services/config-materialization-service.ts b/apps/server-v2/src/services/config-materialization-service.ts index 124c0f4ed..b2126847a 100644 --- a/apps/server-v2/src/services/config-materialization-service.ts +++ b/apps/server-v2/src/services/config-materialization-service.ts @@ -320,7 +320,19 @@ export function createConfigMaterializationService(input: { if (!configDir) { throw new RouteError(500, "internal_error", `Workspace ${workspace.id} is missing its config directory.`); } - return path.join(configDir, ".opencode", "openwork.json"); + const newPath = path.join(configDir, ".openwork", "openwork.json"); + if (fs.existsSync(newPath)) return newPath; + const legacyPath = path.join(configDir, ".opencode", "openwork.json"); + if (fs.existsSync(legacyPath)) return legacyPath; + return newPath; + } + + function workspaceOpenworkWritePath(workspace: WorkspaceRecord) { + const configDir = workspace.configDir?.trim(); + if (!configDir) { + throw new RouteError(500, "internal_error", `Workspace ${workspace.id} is missing its config directory.`); + } + return path.join(configDir, ".openwork", "openwork.json"); } function compatibilityOpencodeConfigPath(workspace: WorkspaceRecord) { @@ -330,7 +342,18 @@ export function createConfigMaterializationService(input: { function compatibilityOpenworkConfigPath(workspace: WorkspaceRecord) { const dataDir = workspace.dataDir?.trim(); - return dataDir ? path.join(dataDir, ".opencode", "openwork.json") : null; + if (!dataDir) return null; + const newPath = path.join(dataDir, ".openwork", "openwork.json"); + if (fs.existsSync(newPath)) return newPath; + const legacyPath = path.join(dataDir, ".opencode", "openwork.json"); + if (fs.existsSync(legacyPath)) return legacyPath; + return newPath; + } + + function compatibilityOpenworkWritePath(workspace: WorkspaceRecord) { + const dataDir = workspace.dataDir?.trim(); + if (!dataDir) return null; + return path.join(dataDir, ".openwork", "openwork.json"); } function workspaceSkillRoots(workspace: WorkspaceRecord) { @@ -589,10 +612,10 @@ export function createConfigMaterializationService(input: { }, materialized: { compatibilityOpencodePath: compatibilityOpencodeConfigPath(workspace), - compatibilityOpenworkPath: compatibilityOpenworkConfigPath(workspace), + compatibilityOpenworkPath: compatibilityOpenworkWritePath(workspace), configDir: workspace.configDir, configOpencodePath: workspaceOpencodeConfigPath(workspace), - configOpenworkPath: workspaceOpenworkConfigPath(workspace), + configOpenworkPath: workspaceOpenworkWritePath(workspace), }, stored: { opencode: storedOpencode, @@ -714,6 +737,7 @@ export function createConfigMaterializationService(input: { workspace.configDir, workspace.dataDir, workspace.dataDir ? path.join(workspace.dataDir, ".opencode") : null, + workspace.dataDir ? path.join(workspace.dataDir, ".openwork") : null, ].filter((value): value is string => Boolean(value)); }, diff --git a/apps/server-v2/src/services/workspace-file-service.ts b/apps/server-v2/src/services/workspace-file-service.ts index e2beb8c81..4f17d7d8f 100644 --- a/apps/server-v2/src/services/workspace-file-service.ts +++ b/apps/server-v2/src/services/workspace-file-service.ts @@ -311,11 +311,11 @@ function parseOperations(input: unknown) { } function resolveInboxDir(workspaceRoot: string) { - return path.join(workspaceRoot, ".opencode", "openwork", "inbox"); + return path.join(workspaceRoot, ".openwork", "inbox"); } function resolveOutboxDir(workspaceRoot: string) { - return path.join(workspaceRoot, ".opencode", "openwork", "outbox"); + return path.join(workspaceRoot, ".openwork", "outbox"); } function encodeArtifactId(relativePath: string) { diff --git a/apps/server/README.md b/apps/server/README.md index cf10678cb..d5bebe6d2 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -109,7 +109,7 @@ Token management (host/owner auth): Inbox/outbox: -- `POST /workspace/:id/inbox` (multipart upload into `.opencode/openwork/inbox/`) +- `POST /workspace/:id/inbox` (multipart upload into `.openwork/inbox/`) - `GET /workspace/:id/artifacts` - `GET /workspace/:id/artifacts/:artifactId` - `POST /workspace/:id/files/sessions` diff --git a/apps/server/src/config-migration.test.ts b/apps/server/src/config-migration.test.ts new file mode 100644 index 000000000..9d6a44840 --- /dev/null +++ b/apps/server/src/config-migration.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { migrateOpenworkConfig } from "./config-migration.js"; + +function createTempWorkspace(): string { + const dir = join(tmpdir(), `ow-migration-test-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeLegacyConfig(root: string, content: string) { + const dir = join(root, ".opencode"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openwork.json"), content, "utf8"); +} + +function writeNewConfig(root: string, content: string) { + const dir = join(root, ".openwork"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openwork.json"), content, "utf8"); +} + +function cleanup(root: string) { + rmSync(root, { recursive: true, force: true }); +} + +describe("migrateOpenworkConfig", () => { + let workspace: string; + + beforeEach(() => { + workspace = createTempWorkspace(); + }); + + afterEach(() => { + cleanup(workspace); + }); + + test("no legacy config", () => { + const result = migrateOpenworkConfig(workspace); + expect(result.status).toBe("no_legacy_config"); + }); + + test("migrates legacy to new path", () => { + const config = '{"version":1,"name":"test"}'; + writeLegacyConfig(workspace, config); + + const result = migrateOpenworkConfig(workspace); + expect(result.status).toBe("migrated"); + + const newPath = join(workspace, ".openwork", "openwork.json"); + expect(existsSync(newPath)).toBe(true); + expect(readFileSync(newPath, "utf8")).toBe(config); + + // legacy file preserved + const legacyPath = join(workspace, ".opencode", "openwork.json"); + expect(existsSync(legacyPath)).toBe(true); + }); + + test("idempotent: same content = already_migrated", () => { + const config = '{"version":1}'; + writeLegacyConfig(workspace, config); + writeNewConfig(workspace, config); + + const result = migrateOpenworkConfig(workspace); + expect(result.status).toBe("already_migrated"); + }); + + test("both exist with different content: keeps new, does not overwrite", () => { + writeLegacyConfig(workspace, '{"version":1}'); + writeNewConfig(workspace, '{"version":2,"edited":true}'); + + const result = migrateOpenworkConfig(workspace); + expect(result.status).toBe("skipped_newer_target"); + + // new file unchanged + const content = readFileSync(join(workspace, ".openwork", "openwork.json"), "utf8"); + expect(content).toBe('{"version":2,"edited":true}'); + }); + + test("migration is copy not move: legacy stays", () => { + writeLegacyConfig(workspace, '{"keep":"me"}'); + + migrateOpenworkConfig(workspace); + + expect(existsSync(join(workspace, ".opencode", "openwork.json"))).toBe(true); + expect(existsSync(join(workspace, ".openwork", "openwork.json"))).toBe(true); + }); + + test("repeated migration after first is idempotent", () => { + writeLegacyConfig(workspace, '{"v":1}'); + + const first = migrateOpenworkConfig(workspace); + expect(first.status).toBe("migrated"); + + const second = migrateOpenworkConfig(workspace); + expect(second.status).toBe("already_migrated"); + + const third = migrateOpenworkConfig(workspace); + expect(third.status).toBe("already_migrated"); + }); +}); diff --git a/apps/server/src/config-migration.ts b/apps/server/src/config-migration.ts new file mode 100644 index 000000000..e042da355 --- /dev/null +++ b/apps/server/src/config-migration.ts @@ -0,0 +1,46 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const NEW_CONFIG_DIR = ".openwork"; +const LEGACY_CONFIG_DIR = ".opencode"; +const CONFIG_FILENAME = "openwork.json"; + +export type MigrationResult = + | { status: "migrated"; from: string; to: string } + | { status: "already_migrated" } + | { status: "no_legacy_config" } + | { status: "skipped_newer_target"; reason: string } + | { status: "error"; reason: string }; + +export function migrateOpenworkConfig(workspaceRoot: string): MigrationResult { + const legacyPath = join(workspaceRoot, LEGACY_CONFIG_DIR, CONFIG_FILENAME); + const newPath = join(workspaceRoot, NEW_CONFIG_DIR, CONFIG_FILENAME); + + if (!existsSync(legacyPath)) { + return { status: "no_legacy_config" }; + } + + try { + if (existsSync(newPath)) { + const legacyContent = readFileSync(legacyPath, "utf8"); + const newContent = readFileSync(newPath, "utf8"); + + if (legacyContent === newContent) { + return { status: "already_migrated" }; + } + + return { + status: "skipped_newer_target", + reason: "both .opencode/openwork.json and .openwork/openwork.json exist with different content, keeping .openwork/ version", + }; + } + + mkdirSync(dirname(newPath), { recursive: true }); + copyFileSync(legacyPath, newPath); + + return { status: "migrated", from: legacyPath, to: newPath }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { status: "error", reason: `filesystem error during migration: ${message}` }; + } +} diff --git a/apps/server/src/portable-files.test.ts b/apps/server/src/portable-files.test.ts index dcd01820c..04f9d1718 100644 --- a/apps/server/src/portable-files.test.ts +++ b/apps/server/src/portable-files.test.ts @@ -84,7 +84,7 @@ describe("portable files", () => { ).toThrow(/not allowed/i); expect(() => - planPortableFiles(workspaceRoot, [{ path: ".opencode/openwork.json", content: "{}" }]), + planPortableFiles(workspaceRoot, [{ path: ".openwork/openwork.json", content: "{}" }]), ).toThrow(/not allowed/i); expect(() => diff --git a/apps/server/src/reload-watcher.ts b/apps/server/src/reload-watcher.ts index e58eee41b..2a98114ce 100644 --- a/apps/server/src/reload-watcher.ts +++ b/apps/server/src/reload-watcher.ts @@ -71,11 +71,15 @@ function startWorkspaceReloadWatcher(input: { const root = resolve(workspace.path); const trees: DirectoryTreeWatcher[] = []; + let legacyConfigWatcher: FSWatcher | null = null; + let openworkConfigWatcher: FSWatcher | null = null; const closeAll = () => { for (const tree of trees) { tree.close(); } rootWatcher?.close(); + legacyConfigWatcher?.close(); + openworkConfigWatcher?.close(); }; const record = (reason: ReloadReason, trigger?: ReloadTrigger) => { @@ -97,7 +101,7 @@ function startWorkspaceReloadWatcher(input: { return; } - if (name === "opencode.json" || name === "opencode.jsonc") { + if (name === "opencode.json" || name === "opencode.jsonc" || name === "openwork.json") { record("config", { type: "config", name, @@ -116,8 +120,8 @@ function startWorkspaceReloadWatcher(input: { return; } - // If .opencode is created/removed, rescan the relevant trees. - if (name === ".opencode") { + // If .opencode or .openwork is created/removed, rescan the relevant trees. + if (name === ".opencode" || name === ".openwork") { for (const tree of trees) tree.scheduleRescan(); } }, @@ -139,6 +143,77 @@ function startWorkspaceReloadWatcher(input: { } const opencodeRoot = join(root, ".opencode"); + const openworkRoot = join(root, ".openwork"); + + // Watch .opencode/ for legacy openwork.json edits. + if (existsSync(opencodeRoot)) { + try { + legacyConfigWatcher = watch( + opencodeRoot, + { persistent: false }, + (_eventType, filename) => { + const raw = filename ? filename.toString() : ""; + const name = raw.trim(); + if (name === "openwork.json") { + record("config", { + type: "config", + name, + action: "updated", + path: join(opencodeRoot, name), + }); + } + }, + ); + legacyConfigWatcher.on("error", (error) => { + logger?.log("warn", "Reload watcher legacy config error", { + workspaceId: workspace.id, + dir: opencodeRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + logger?.log("warn", "Reload watcher legacy config failed", { + workspaceId: workspace.id, + dir: opencodeRoot, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Watch .openwork/ for openwork.json edits. + if (existsSync(openworkRoot)) { + try { + openworkConfigWatcher = watch( + openworkRoot, + { persistent: false }, + (_eventType, filename) => { + const raw = filename ? filename.toString() : ""; + const name = raw.trim(); + if (name === "openwork.json") { + record("config", { + type: "config", + name, + action: "updated", + path: join(openworkRoot, name), + }); + } + }, + ); + openworkConfigWatcher.on("error", (error) => { + logger?.log("warn", "Reload watcher openwork config error", { + workspaceId: workspace.id, + dir: openworkRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + logger?.log("warn", "Reload watcher openwork config failed", { + workspaceId: workspace.id, + dir: openworkRoot, + error: error instanceof Error ? error.message : String(error), + }); + } + } trees.push( createDirectoryTreeWatcher({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6cab6abeb..1b9965228 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -18,7 +18,7 @@ import { recordAudit, readAuditEntries, readLastAudit } from "./audit.js"; import { ReloadEventStore } from "./events.js"; import { startReloadWatchers } from "./reload-watcher.js"; import { parseFrontmatter } from "./frontmatter.js"; -import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js"; +import { opencodeConfigPath, openworkConfigPath, newOpenworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js"; import { ensureDir, exists, hashToken, shortId } from "./utils.js"; import { workspaceIdForPath } from "./workspaces.js"; import { ensureWorkspaceFiles, readRawOpencodeConfig } from "./workspace-init.js"; @@ -766,8 +766,8 @@ function buildCapabilities(config: ServerConfig): Capabilities { files: { injection: writeEnabled && inboxEnabled, outbox: outboxEnabled, - inboxPath: ".opencode/openwork/inbox/", - outboxPath: ".opencode/openwork/outbox/", + inboxPath: ".openwork/inbox/", + outboxPath: ".openwork/outbox/", maxBytes, }, }, @@ -839,11 +839,11 @@ function resolveBrowserProvider(): Capabilities["toolProviders"]["browser"] { } function resolveInboxDir(workspaceRoot: string): string { - return join(workspaceRoot, ".opencode", "openwork", "inbox"); + return join(workspaceRoot, ".openwork", "inbox"); } function resolveOutboxDir(workspaceRoot: string): string { - return join(workspaceRoot, ".opencode", "openwork", "outbox"); + return join(workspaceRoot, ".openwork", "outbox"); } export function normalizeWorkspaceRelativePath(input: string, options: { allowSubdirs: boolean }): string { @@ -4942,10 +4942,10 @@ async function reloadOpencodeEngine(config: ServerConfig, workspace: WorkspaceIn } async function writeOpenworkConfig(workspaceRoot: string, payload: Record, merge: boolean): Promise { - const path = openworkConfigPath(workspaceRoot); + const configPath = newOpenworkConfigPath(workspaceRoot); const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload; - await ensureDir(join(workspaceRoot, ".opencode")); - await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8"); + await ensureDir(dirname(configPath)); + await writeFile(configPath, JSON.stringify(next, null, 2) + "\n", "utf8"); } async function requireApproval( diff --git a/apps/server/src/toy-ui.ts b/apps/server/src/toy-ui.ts index 1e4c1ede0..60edb4e78 100644 --- a/apps/server/src/toy-ui.ts +++ b/apps/server/src/toy-ui.ts @@ -460,13 +460,13 @@ export const TOY_UI_HTML = ` -
Uploads go to .opencode/openwork/inbox/ inside the workspace.
+
Uploads go to .openwork/inbox/ inside the workspace.
- Downloads read from .opencode/openwork/outbox/. + Downloads read from .openwork/outbox/.
diff --git a/apps/server/src/workspace-files.test.ts b/apps/server/src/workspace-files.test.ts new file mode 100644 index 000000000..8eb2413d1 --- /dev/null +++ b/apps/server/src/workspace-files.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { openworkConfigPath, legacyOpenworkConfigPath, newOpenworkConfigPath } from "./workspace-files.js"; + +function createTempWorkspace(): string { + const dir = join(tmpdir(), `ow-wf-test-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function cleanup(root: string) { + rmSync(root, { recursive: true, force: true }); +} + +describe("openworkConfigPath", () => { + let workspace: string; + + beforeEach(() => { + workspace = createTempWorkspace(); + }); + + afterEach(() => { + cleanup(workspace); + }); + + test("returns new path when only .openwork/ exists", () => { + const dir = join(workspace, ".openwork"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openwork.json"), "{}", "utf8"); + + expect(openworkConfigPath(workspace)).toBe(join(workspace, ".openwork", "openwork.json")); + }); + + test("returns legacy path when only .opencode/ exists", () => { + const dir = join(workspace, ".opencode"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openwork.json"), "{}", "utf8"); + + expect(openworkConfigPath(workspace)).toBe(join(workspace, ".opencode", "openwork.json")); + }); + + test("prefers new path when both exist", () => { + for (const d of [".openwork", ".opencode"]) { + const dir = join(workspace, d); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "openwork.json"), "{}", "utf8"); + } + + expect(openworkConfigPath(workspace)).toBe(join(workspace, ".openwork", "openwork.json")); + }); + + test("defaults to new path when neither exists", () => { + expect(openworkConfigPath(workspace)).toBe(join(workspace, ".openwork", "openwork.json")); + }); + + test("legacyOpenworkConfigPath always returns .opencode path", () => { + expect(legacyOpenworkConfigPath(workspace)).toBe(join(workspace, ".opencode", "openwork.json")); + }); + + test("newOpenworkConfigPath always returns .openwork path", () => { + expect(newOpenworkConfigPath(workspace)).toBe(join(workspace, ".openwork", "openwork.json")); + }); +}); diff --git a/apps/server/src/workspace-files.ts b/apps/server/src/workspace-files.ts index 301a6b4a9..cd180149b 100644 --- a/apps/server/src/workspace-files.ts +++ b/apps/server/src/workspace-files.ts @@ -9,8 +9,24 @@ export function opencodeConfigPath(workspaceRoot: string): string { return jsoncPath; } +const NEW_CONFIG_DIR = ".openwork"; +const LEGACY_CONFIG_DIR = ".opencode"; +const CONFIG_FILENAME = "openwork.json"; + export function openworkConfigPath(workspaceRoot: string): string { - return join(workspaceRoot, ".opencode", "openwork.json"); + const newPath = join(workspaceRoot, NEW_CONFIG_DIR, CONFIG_FILENAME); + if (existsSync(newPath)) return newPath; + const legacyPath = join(workspaceRoot, LEGACY_CONFIG_DIR, CONFIG_FILENAME); + if (existsSync(legacyPath)) return legacyPath; + return newPath; +} + +export function legacyOpenworkConfigPath(workspaceRoot: string): string { + return join(workspaceRoot, LEGACY_CONFIG_DIR, CONFIG_FILENAME); +} + +export function newOpenworkConfigPath(workspaceRoot: string): string { + return join(workspaceRoot, NEW_CONFIG_DIR, CONFIG_FILENAME); } export function projectSkillsDir(workspaceRoot: string): string { diff --git a/apps/server/src/workspace-init.ts b/apps/server/src/workspace-init.ts index 349790fb9..bea4e1777 100644 --- a/apps/server/src/workspace-init.ts +++ b/apps/server/src/workspace-init.ts @@ -1,9 +1,10 @@ -import { basename, join } from "node:path"; +import { basename, dirname, 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, newOpenworkConfigPath, opencodeConfigPath } from "./workspace-files.js"; +import { migrateOpenworkConfig } from "./config-migration.js"; import { readJsoncFile, writeJsoncFile } from "./jsonc.js"; const OPENWORK_AGENT = `--- @@ -51,8 +52,9 @@ async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: stri authorizedRoots: [workspaceRoot], reload: null, }; - await ensureDir(join(workspaceRoot, ".opencode")); - await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8"); + const writePath = newOpenworkConfigPath(workspaceRoot); + await ensureDir(dirname(writePath)); + await writeFile(writePath, JSON.stringify(config, null, 2) + "\n", "utf8"); } async function ensureOpencodeConfig(workspaceRoot: string): Promise { @@ -85,6 +87,22 @@ export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: s throw new ApiError(400, "invalid_workspace_path", "workspace path is required"); } await ensureDir(workspaceRoot); + + // migrate legacy .opencode/openwork.json -> .openwork/openwork.json + try { + const migration = migrateOpenworkConfig(workspaceRoot); + if (migration.status === "migrated") { + console.info(JSON.stringify({ scope: "openwork.config-migration", status: "migrated", from: migration.from, to: migration.to })); + } + } catch (error) { + console.warn(JSON.stringify({ + scope: "openwork.config-migration", + status: "failed", + workspaceRoot, + error: error instanceof Error ? error.message : String(error), + })); + } + await ensureOpencodeConfig(workspaceRoot); await ensureOpenworkAgent(workspaceRoot); await ensureWorkspaceOpenworkConfig(workspaceRoot, preset); diff --git a/prds/server-v2-plan/tauri-audit.md b/prds/server-v2-plan/tauri-audit.md index cb30d958e..c9f0cb16a 100644 --- a/prds/server-v2-plan/tauri-audit.md +++ b/prds/server-v2-plan/tauri-audit.md @@ -398,7 +398,7 @@ Reasoning: the app should keep only transient selection and reconnect state. The ### `workspace_add_authorized_root()`, `workspace_openwork_read()`, `workspace_openwork_write()` -- What they do: manage `.opencode/openwork.json` data for authorized roots and related settings. +- What they do: manage `.openwork/openwork.json` data for authorized roots and related settings. - Called from and when: called from settings/permissions UI. - Ends up calling: workspace-local config file reads and writes; in the ideal model this is server-side materialization into the OpenWork-managed workspace config directory.