import { basename } from "node:path"; import { readFile } from "node:fs/promises"; import { ensureDir, exists } from "./utils.js"; import { ApiError } from "./errors.js"; import { opencodeConfigPath } from "./workspace-files.js"; import { readJsoncFile } from "./jsonc.js"; import type { ReloadReason, WorkspaceInfo } from "./types.js"; 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[]; }; function normalizePreset(preset: string | null | undefined): string { const trimmed = preset?.trim() ?? ""; if (!trimmed) return "starter"; return trimmed; } function errorStringField(error: unknown, field: "code" | "path" | "syscall"): string | undefined { if (typeof error !== "object" || error === null) return undefined; const value = Object.getOwnPropertyDescriptor(error, field)?.value; return typeof value === "string" ? value : undefined; } /** * Build the default per-workspace openwork config metadata. The openwork * config is now stored in the runtime DB (see * `seedOpenworkWorkspaceConfigIfEmpty`), not in `.opencode/openwork.json`, so * this no longer writes a file. Exposed so the workspace-creation route can * seed the DB row with the same defaults. */ export function defaultWorkspaceOpenworkConfig(workspaceRoot: string, preset: string): WorkspaceOpenworkConfig { return { version: 1, workspace: { name: basename(workspaceRoot) || "Workspace", createdAt: Date.now(), preset, }, authorizedRoots: [workspaceRoot], reload: null, }; } async function ensureOpencodeConfig(workspaceRoot: string): Promise { const path = opencodeConfigPath(workspaceRoot); if (await exists(path)) { await readJsoncFile>(path, {}, { allowInvalid: true }); } return false; } export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: string): Promise { const preset = normalizePreset(presetInput); if (!workspaceRoot.trim()) { throw new ApiError(400, "invalid_workspace_path", "workspace path is required"); } try { await ensureDir(workspaceRoot); } catch (error) { throw new ApiError(409, "workspace_inaccessible", "Workspace path is not accessible", { path: workspaceRoot, fsCode: errorStringField(error, "code"), syscall: errorStringField(error, "syscall"), fsPath: errorStringField(error, "path"), }); } const reloadReasons = new Set(); if (await ensureOpencodeConfig(workspaceRoot)) reloadReasons.add("config"); // openwork config is seeded into the runtime DB by the caller, not written // as a file here. void preset; return { changed: reloadReasons.size > 0, reloadReasons: Array.from(reloadReasons), }; } /** * Provision workspace files for every workspace that has local files to set up. * * Skips remote workspaces (which live on a host and may even carry a non-empty * remote `directory`) and any workspace without a resolved local path. Either * would otherwise reach ensureWorkspaceFiles() — which throws * `invalid_workspace_path` on a blank path — and abort server startup. Local * provisioning failures are logged and skipped so one stale path cannot abort * startup. Shared by the embedded-server and CLI boot paths. */ export async function ensureLocalWorkspaceFiles( workspaces: ReadonlyArray>, ): Promise { for (const workspace of workspaces) { if (workspace.workspaceType === "remote" || !workspace.path.trim()) continue; try { await ensureWorkspaceFiles(workspace.path, workspace.preset); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`Failed to provision workspace files at ${workspace.path}: ${message}`); } } } export async function readRawOpencodeConfig(path: string): Promise<{ exists: boolean; content: string | null }> { const hasFile = await exists(path); if (!hasFile) { return { exists: false, content: null }; } const content = await readFile(path, "utf8"); return { exists: true, content }; }