Files
openwork/apps/server/src/workspace-init.ts
T

127 lines
4.3 KiB
TypeScript

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<boolean> {
const path = opencodeConfigPath(workspaceRoot);
if (await exists(path)) {
await readJsoncFile<Record<string, unknown>>(path, {}, { allowInvalid: true });
}
return false;
}
export async function ensureWorkspaceFiles(workspaceRoot: string, presetInput: string): Promise<EnsureWorkspaceFilesResult> {
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<ReloadReason>();
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<Pick<WorkspaceInfo, "path" | "preset" | "workspaceType">>,
): Promise<void> {
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 };
}