mirror of
https://github.com/different-ai/openwork
synced 2026-05-14 11:06:25 +02:00
feat: openwork sync v0 (#280)
* docs: add openwork server PRD * feat: wire OpenWork server remote management * feat: add OpenWork server settings panel
This commit is contained in:
66
packages/server/src/approvals.ts
Normal file
66
packages/server/src/approvals.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { ApprovalConfig, ApprovalRequest } from "./types.js";
|
||||
import { shortId } from "./utils.js";
|
||||
|
||||
interface ApprovalResult {
|
||||
id: string;
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface PendingApproval {
|
||||
request: ApprovalRequest;
|
||||
resolve: (result: ApprovalResult) => void;
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class ApprovalService {
|
||||
private config: ApprovalConfig;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
|
||||
constructor(config: ApprovalConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
list(): ApprovalRequest[] {
|
||||
return Array.from(this.pending.values()).map((entry) => entry.request);
|
||||
}
|
||||
|
||||
async requestApproval(
|
||||
input: Omit<ApprovalRequest, "id" | "createdAt">,
|
||||
): Promise<ApprovalResult> {
|
||||
if (this.config.mode === "auto") {
|
||||
return { id: "auto", allowed: true };
|
||||
}
|
||||
const id = shortId();
|
||||
const request: ApprovalRequest = {
|
||||
...input,
|
||||
id,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const result = await new Promise<ApprovalResult>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
resolve({ id, allowed: false, reason: "timeout" });
|
||||
}, this.config.timeoutMs);
|
||||
|
||||
this.pending.set(id, { request, resolve, timeout });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
respond(id: string, reply: "allow" | "deny"): ApprovalResult | null {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return null;
|
||||
if (pending.timeout) clearTimeout(pending.timeout);
|
||||
this.pending.delete(id);
|
||||
const result: ApprovalResult = {
|
||||
id,
|
||||
allowed: reply === "allow",
|
||||
reason: reply === "allow" ? undefined : "denied",
|
||||
};
|
||||
pending.resolve(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
28
packages/server/src/audit.ts
Normal file
28
packages/server/src/audit.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import type { AuditEntry } from "./types.js";
|
||||
import { ensureDir, exists } from "./utils.js";
|
||||
|
||||
export function auditLogPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "openwork", "audit.jsonl");
|
||||
}
|
||||
|
||||
export async function recordAudit(workspaceRoot: string, entry: AuditEntry): Promise<void> {
|
||||
const path = auditLogPath(workspaceRoot);
|
||||
await ensureDir(dirname(path));
|
||||
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export async function readLastAudit(workspaceRoot: string): Promise<AuditEntry | null> {
|
||||
const path = auditLogPath(workspaceRoot);
|
||||
if (!(await exists(path))) return null;
|
||||
const content = await readFile(path, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
const last = lines[lines.length - 1];
|
||||
if (!last) return null;
|
||||
try {
|
||||
return JSON.parse(last) as AuditEntry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
9
packages/server/src/bun.d.ts
vendored
Normal file
9
packages/server/src/bun.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare const Bun: {
|
||||
serve: (options: {
|
||||
hostname: string;
|
||||
port: number;
|
||||
fetch: (request: Request) => Response | Promise<Response>;
|
||||
}) => {
|
||||
port: number;
|
||||
};
|
||||
};
|
||||
31
packages/server/src/cli.ts
Normal file
31
packages/server/src/cli.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
|
||||
import { startServer } from "./server.js";
|
||||
|
||||
const args = parseCliArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const config = await resolveServerConfig(args);
|
||||
const server = startServer(config);
|
||||
|
||||
const url = `http://${config.host}:${server.port}`;
|
||||
console.log(`OpenWork server listening on ${url}`);
|
||||
|
||||
if (config.tokenSource === "generated") {
|
||||
console.log(`Client token: ${config.token}`);
|
||||
}
|
||||
|
||||
if (config.hostTokenSource === "generated") {
|
||||
console.log(`Host token: ${config.hostToken}`);
|
||||
}
|
||||
|
||||
if (config.workspaces.length === 0) {
|
||||
console.log("No workspaces configured. Add --workspace or update server.json.");
|
||||
} else {
|
||||
console.log(`Workspaces: ${config.workspaces.length}`);
|
||||
}
|
||||
77
packages/server/src/commands.ts
Normal file
77
packages/server/src/commands.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { readdir, readFile, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { CommandItem } from "./types.js";
|
||||
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { projectCommandsDir } from "./workspace-files.js";
|
||||
import { validateCommandName, sanitizeCommandName } from "./validators.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
async function listCommandsInDir(dir: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: CommandItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
const filePath = join(dir, entry.name);
|
||||
const content = await readFile(filePath, "utf8");
|
||||
const { data, body } = parseFrontmatter(content);
|
||||
const name = typeof data.name === "string" ? data.name : entry.name.replace(/\.md$/, "");
|
||||
try {
|
||||
validateCommandName(name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name,
|
||||
description: typeof data.description === "string" ? data.description : undefined,
|
||||
template: body.trim(),
|
||||
agent: typeof data.agent === "string" ? data.agent : undefined,
|
||||
model: typeof data.model === "string" ? data.model : null,
|
||||
subtask: typeof data.subtask === "boolean" ? data.subtask : undefined,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listCommands(workspaceRoot: string, scope: "workspace" | "global"): Promise<CommandItem[]> {
|
||||
if (scope === "global") {
|
||||
const dir = join(homedir(), ".config", "opencode", "commands");
|
||||
return listCommandsInDir(dir, "global");
|
||||
}
|
||||
return listCommandsInDir(projectCommandsDir(workspaceRoot), "workspace");
|
||||
}
|
||||
|
||||
export async function upsertCommand(
|
||||
workspaceRoot: string,
|
||||
payload: { name: string; description?: string; template: string; agent?: string; model?: string | null; subtask?: boolean },
|
||||
): Promise<string> {
|
||||
if (!payload.template || payload.template.trim().length === 0) {
|
||||
throw new ApiError(400, "invalid_command_template", "Command template is required");
|
||||
}
|
||||
const sanitized = sanitizeCommandName(payload.name);
|
||||
validateCommandName(sanitized);
|
||||
const frontmatter = buildFrontmatter({
|
||||
name: sanitized,
|
||||
description: payload.description,
|
||||
agent: payload.agent,
|
||||
model: payload.model ?? null,
|
||||
subtask: payload.subtask ?? false,
|
||||
});
|
||||
const content = frontmatter + "\n" + payload.template.trim() + "\n";
|
||||
const dir = projectCommandsDir(workspaceRoot);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `${sanitized}.md`);
|
||||
await writeFile(path, content, "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function deleteCommand(workspaceRoot: string, name: string): Promise<void> {
|
||||
const sanitized = sanitizeCommandName(name);
|
||||
validateCommandName(sanitized);
|
||||
const path = join(projectCommandsDir(workspaceRoot), `${sanitized}.md`);
|
||||
await rm(path, { force: true });
|
||||
}
|
||||
214
packages/server/src/config.ts
Normal file
214
packages/server/src/config.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import type { ApprovalMode, ApprovalConfig, ServerConfig, WorkspaceConfig } from "./types.js";
|
||||
import { buildWorkspaceInfos } from "./workspaces.js";
|
||||
import { parseList, readJsonFile, shortId } from "./utils.js";
|
||||
|
||||
interface CliArgs {
|
||||
configPath?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
hostToken?: string;
|
||||
approvalMode?: ApprovalMode;
|
||||
approvalTimeoutMs?: number;
|
||||
workspaces: string[];
|
||||
corsOrigins?: string[];
|
||||
readOnly?: boolean;
|
||||
help?: boolean;
|
||||
}
|
||||
|
||||
interface FileConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
hostToken?: string;
|
||||
approval?: Partial<ApprovalConfig>;
|
||||
workspaces?: WorkspaceConfig[];
|
||||
corsOrigins?: string[];
|
||||
authorizedRoots?: string[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 8787;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
export function parseCliArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = { workspaces: [] };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (!value) continue;
|
||||
if (value === "--help" || value === "-h") {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (value === "--config") {
|
||||
args.configPath = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--host") {
|
||||
args.host = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--port") {
|
||||
const port = Number(argv[index + 1]);
|
||||
if (!Number.isNaN(port)) args.port = port;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--token") {
|
||||
args.token = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--host-token") {
|
||||
args.hostToken = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--approval") {
|
||||
const mode = argv[index + 1] as ApprovalMode | undefined;
|
||||
if (mode === "manual" || mode === "auto") args.approvalMode = mode;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--approval-timeout") {
|
||||
const timeout = Number(argv[index + 1]);
|
||||
if (!Number.isNaN(timeout)) args.approvalTimeoutMs = timeout;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--workspace") {
|
||||
const path = argv[index + 1];
|
||||
if (path) args.workspaces.push(path);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--cors") {
|
||||
args.corsOrigins = parseList(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (value === "--read-only") {
|
||||
args.readOnly = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function printHelp(): void {
|
||||
const message = [
|
||||
"openwork-server",
|
||||
"",
|
||||
"Options:",
|
||||
" --config <path> Path to server.json",
|
||||
" --host <host> Hostname (default 127.0.0.1)",
|
||||
" --port <port> Port (default 8787)",
|
||||
" --token <token> Client bearer token",
|
||||
" --host-token <token> Host approval token",
|
||||
" --approval <mode> manual | auto",
|
||||
" --approval-timeout <ms> Approval timeout",
|
||||
" --workspace <path> Workspace root (repeatable)",
|
||||
" --cors <origins> Comma-separated origins or *",
|
||||
" --read-only Disable writes",
|
||||
].join("\n");
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
async function loadFileConfig(configPath: string): Promise<FileConfig> {
|
||||
const parsed = await readJsonFile<FileConfig>(configPath);
|
||||
return parsed ?? {};
|
||||
}
|
||||
|
||||
export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
|
||||
const envConfigPath = process.env.OPENWORK_SERVER_CONFIG;
|
||||
const configPath = cli.configPath ?? envConfigPath ?? resolve(homedir(), ".config", "openwork", "server.json");
|
||||
const fileConfig = await loadFileConfig(configPath);
|
||||
const configDir = dirname(configPath);
|
||||
|
||||
const envWorkspaces = parseList(process.env.OPENWORK_WORKSPACES);
|
||||
const workspaceConfigs: WorkspaceConfig[] =
|
||||
cli.workspaces.length > 0
|
||||
? cli.workspaces.map((path) => ({ path }))
|
||||
: envWorkspaces.length > 0
|
||||
? envWorkspaces.map((path) => ({ path }))
|
||||
: fileConfig.workspaces ?? [];
|
||||
|
||||
const workspaces = buildWorkspaceInfos(workspaceConfigs, configDir);
|
||||
|
||||
const tokenFromEnv = process.env.OPENWORK_TOKEN;
|
||||
const hostTokenFromEnv = process.env.OPENWORK_HOST_TOKEN;
|
||||
|
||||
const token = cli.token ?? tokenFromEnv ?? fileConfig.token ?? shortId();
|
||||
const hostToken = cli.hostToken ?? hostTokenFromEnv ?? fileConfig.hostToken ?? shortId();
|
||||
|
||||
const tokenSource: ServerConfig["tokenSource"] = cli.token
|
||||
? "cli"
|
||||
: tokenFromEnv
|
||||
? "env"
|
||||
: fileConfig.token
|
||||
? "file"
|
||||
: "generated";
|
||||
|
||||
const hostTokenSource: ServerConfig["hostTokenSource"] = cli.hostToken
|
||||
? "cli"
|
||||
: hostTokenFromEnv
|
||||
? "env"
|
||||
: fileConfig.hostToken
|
||||
? "file"
|
||||
: "generated";
|
||||
|
||||
const approvalMode =
|
||||
cli.approvalMode ??
|
||||
(process.env.OPENWORK_APPROVAL_MODE as ApprovalMode | undefined) ??
|
||||
fileConfig.approval?.mode ??
|
||||
"manual";
|
||||
|
||||
const approvalTimeoutMs =
|
||||
cli.approvalTimeoutMs ??
|
||||
(process.env.OPENWORK_APPROVAL_TIMEOUT_MS ? Number(process.env.OPENWORK_APPROVAL_TIMEOUT_MS) : undefined) ??
|
||||
fileConfig.approval?.timeoutMs ??
|
||||
DEFAULT_TIMEOUT_MS;
|
||||
|
||||
const approval: ApprovalConfig = {
|
||||
mode: approvalMode === "auto" ? "auto" : "manual",
|
||||
timeoutMs: Number.isNaN(approvalTimeoutMs) ? DEFAULT_TIMEOUT_MS : approvalTimeoutMs,
|
||||
};
|
||||
|
||||
const envCorsOrigins = process.env.OPENWORK_CORS_ORIGINS;
|
||||
const parsedEnvCors = envCorsOrigins ? parseList(envCorsOrigins) : null;
|
||||
const corsOrigins = cli.corsOrigins ?? parsedEnvCors ?? fileConfig.corsOrigins ?? ["*"];
|
||||
|
||||
const envReadOnly = process.env.OPENWORK_READONLY;
|
||||
const parsedReadOnly = envReadOnly
|
||||
? ["true", "1", "yes"].includes(envReadOnly.toLowerCase())
|
||||
: undefined;
|
||||
const readOnly = cli.readOnly ?? parsedReadOnly ?? fileConfig.readOnly ?? false;
|
||||
|
||||
const authorizedRoots =
|
||||
fileConfig.authorizedRoots?.length
|
||||
? fileConfig.authorizedRoots.map((root) => resolve(configDir, root))
|
||||
: workspaces.map((workspace) => workspace.path);
|
||||
|
||||
const host = cli.host ?? process.env.OPENWORK_HOST ?? fileConfig.host ?? DEFAULT_HOST;
|
||||
const port = cli.port ?? (process.env.OPENWORK_PORT ? Number(process.env.OPENWORK_PORT) : undefined) ?? fileConfig.port ?? DEFAULT_PORT;
|
||||
|
||||
return {
|
||||
host,
|
||||
port: Number.isNaN(port) ? DEFAULT_PORT : port,
|
||||
token,
|
||||
hostToken,
|
||||
approval,
|
||||
corsOrigins,
|
||||
workspaces,
|
||||
authorizedRoots,
|
||||
readOnly,
|
||||
startedAt: Date.now(),
|
||||
tokenSource,
|
||||
hostTokenSource,
|
||||
};
|
||||
}
|
||||
22
packages/server/src/errors.ts
Normal file
22
packages/server/src/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ApiErrorBody } from "./types.js";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatError(err: ApiError): ApiErrorBody {
|
||||
return {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details,
|
||||
};
|
||||
}
|
||||
17
packages/server/src/frontmatter.ts
Normal file
17
packages/server/src/frontmatter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
export function parseFrontmatter(content: string): { data: Record<string, unknown>; body: string } {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!match) {
|
||||
return { data: {}, body: content };
|
||||
}
|
||||
const raw = match[1] ?? "";
|
||||
const data = (parse(raw) as Record<string, unknown>) ?? {};
|
||||
const body = content.slice(match[0].length);
|
||||
return { data, body };
|
||||
}
|
||||
|
||||
export function buildFrontmatter(data: Record<string, unknown>): string {
|
||||
const yaml = stringify(data).trimEnd();
|
||||
return `---\n${yaml}\n---\n`;
|
||||
}
|
||||
52
packages/server/src/jsonc.ts
Normal file
52
packages/server/src/jsonc.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
|
||||
import { dirname } from "node:path";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { ensureDir, exists } from "./utils.js";
|
||||
|
||||
interface ParseResult<T> {
|
||||
data: T;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export async function readJsoncFile<T>(path: string, fallback: T): Promise<ParseResult<T>> {
|
||||
if (!(await exists(path))) {
|
||||
return { data: fallback, raw: "" };
|
||||
}
|
||||
const raw = await readFile(path, "utf8");
|
||||
const errors: { error: number; offset: number; length: number }[] = [];
|
||||
const data = parse(raw, errors, { allowTrailingComma: true }) as T;
|
||||
if (errors.length > 0) {
|
||||
const details = errors.map((error) => ({
|
||||
code: printParseErrorCode(error.error),
|
||||
offset: error.offset,
|
||||
length: error.length,
|
||||
}));
|
||||
throw new ApiError(422, "invalid_jsonc", "Failed to parse JSONC", details);
|
||||
}
|
||||
return { data, raw };
|
||||
}
|
||||
|
||||
export async function updateJsoncTopLevel(path: string, updates: Record<string, unknown>): Promise<void> {
|
||||
const hasFile = await exists(path);
|
||||
if (!hasFile) {
|
||||
await ensureDir(dirname(path));
|
||||
const content = JSON.stringify(updates, null, 2) + "\n";
|
||||
await writeFile(path, content, "utf8");
|
||||
return;
|
||||
}
|
||||
|
||||
let content = await readFile(path, "utf8");
|
||||
const formattingOptions = { insertSpaces: true, tabSize: 2, eol: "\n" };
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const edits = modify(content, [key], value, { formattingOptions });
|
||||
content = applyEdits(content, edits);
|
||||
}
|
||||
await writeFile(path, content.endsWith("\n") ? content : content + "\n", "utf8");
|
||||
}
|
||||
|
||||
export async function writeJsoncFile(path: string, value: unknown): Promise<void> {
|
||||
await ensureDir(dirname(path));
|
||||
const content = JSON.stringify(value, null, 2) + "\n";
|
||||
await writeFile(path, content, "utf8");
|
||||
}
|
||||
53
packages/server/src/mcp.ts
Normal file
53
packages/server/src/mcp.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { minimatch } from "minimatch";
|
||||
import type { McpItem } from "./types.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
||||
import { opencodeConfigPath } from "./workspace-files.js";
|
||||
import { validateMcpConfig, validateMcpName } from "./validators.js";
|
||||
|
||||
function getMcpConfig(config: Record<string, unknown>): Record<string, Record<string, unknown>> {
|
||||
const mcp = config.mcp;
|
||||
if (!mcp || typeof mcp !== "object") return {};
|
||||
return mcp as Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function getDeniedToolPatterns(config: Record<string, unknown>): string[] {
|
||||
const tools = config.tools;
|
||||
if (!tools || typeof tools !== "object") return [];
|
||||
const deny = (tools as { deny?: unknown }).deny;
|
||||
if (!Array.isArray(deny)) return [];
|
||||
return deny.filter((item) => typeof item === "string") as string[];
|
||||
}
|
||||
|
||||
function isMcpDisabledByTools(config: Record<string, unknown>, name: string): boolean {
|
||||
const patterns = getDeniedToolPatterns(config);
|
||||
if (patterns.length === 0) return false;
|
||||
const candidates = [`mcp.${name}`, `mcp.${name}.*`, `mcp:${name}`, `mcp:${name}:*`, "mcp.*", "mcp:*"];
|
||||
return patterns.some((pattern) => candidates.some((candidate) => minimatch(candidate, pattern)));
|
||||
}
|
||||
|
||||
export async function listMcp(workspaceRoot: string): Promise<McpItem[]> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(config);
|
||||
return Object.entries(mcpMap).map(([name, entry]) => ({
|
||||
name,
|
||||
config: entry,
|
||||
source: "config.project",
|
||||
disabledByTools: isMcpDisabledByTools(config, name) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addMcp(workspaceRoot: string, name: string, config: Record<string, unknown>): Promise<void> {
|
||||
validateMcpName(name);
|
||||
validateMcpConfig(config);
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(data);
|
||||
mcpMap[name] = config;
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
||||
}
|
||||
|
||||
export async function removeMcp(workspaceRoot: string, name: string): Promise<void> {
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const mcpMap = getMcpConfig(data);
|
||||
delete mcpMap[name];
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { mcp: mcpMap });
|
||||
}
|
||||
20
packages/server/src/paths.ts
Normal file
20
packages/server/src/paths.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, resolve, sep } from "node:path";
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
export function assertAbsolute(path: string): void {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new ApiError(400, "invalid_path", "Path must be absolute");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWithinRoot(root: string, ...segments: string[]): Promise<string> {
|
||||
const resolvedRoot = await realpath(root);
|
||||
const candidate = resolve(resolvedRoot, ...segments);
|
||||
const resolvedCandidate = await realpath(candidate).catch(() => candidate);
|
||||
if (resolvedCandidate === resolvedRoot) return candidate;
|
||||
if (!resolvedCandidate.startsWith(resolvedRoot + sep)) {
|
||||
throw new ApiError(400, "path_escape", "Path escapes workspace root");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
92
packages/server/src/plugins.ts
Normal file
92
packages/server/src/plugins.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import type { PluginItem } from "./types.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel } from "./jsonc.js";
|
||||
import { opencodeConfigPath, projectPluginsDir } from "./workspace-files.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { validatePluginSpec } from "./validators.js";
|
||||
|
||||
function normalizePluginSpec(spec: string): string {
|
||||
const trimmed = spec.trim();
|
||||
if (trimmed.startsWith("file:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || trimmed.startsWith("git:")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const atIndex = trimmed.indexOf("@", 1);
|
||||
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
const atIndex = trimmed.indexOf("@");
|
||||
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
|
||||
}
|
||||
|
||||
function pluginListFromConfig(config: Record<string, unknown>): string[] {
|
||||
const plugin = config.plugin;
|
||||
if (typeof plugin === "string") return [plugin];
|
||||
if (Array.isArray(plugin)) return plugin.filter((item) => typeof item === "string") as string[];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function listPluginFiles(dir: string, scope: "project" | "global", workspaceRoot?: string): Promise<PluginItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: PluginItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".js") && !entry.name.endsWith(".ts")) continue;
|
||||
const absolutePath = join(dir, entry.name);
|
||||
const relativePath = workspaceRoot ? relative(workspaceRoot, absolutePath) : absolutePath;
|
||||
items.push({
|
||||
spec: `file://${absolutePath}`,
|
||||
source: scope === "project" ? "dir.project" : "dir.global",
|
||||
scope,
|
||||
path: relativePath,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listPlugins(workspaceRoot: string, includeGlobal: boolean): Promise<{ items: PluginItem[]; loadOrder: string[] }> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const items: PluginItem[] = pluginSpecs.map((spec) => ({
|
||||
spec,
|
||||
source: "config",
|
||||
scope: "project",
|
||||
}));
|
||||
|
||||
const projectDir = projectPluginsDir(workspaceRoot);
|
||||
items.push(...(await listPluginFiles(projectDir, "project", workspaceRoot)));
|
||||
|
||||
if (includeGlobal) {
|
||||
const globalDir = join(homedir(), ".config", "opencode", "plugins");
|
||||
items.push(...(await listPluginFiles(globalDir, "global")));
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
loadOrder: ["config.global", "config.project", "dir.global", "dir.project"],
|
||||
};
|
||||
}
|
||||
|
||||
export async function addPlugin(workspaceRoot: string, spec: string): Promise<void> {
|
||||
validatePluginSpec(spec);
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const normalized = normalizePluginSpec(spec);
|
||||
const existing = pluginSpecs.find((item) => normalizePluginSpec(item) === normalized);
|
||||
if (existing) return;
|
||||
pluginSpecs.push(spec);
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: pluginSpecs });
|
||||
}
|
||||
|
||||
export async function removePlugin(workspaceRoot: string, name: string): Promise<void> {
|
||||
const { data: config } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
const pluginSpecs = pluginListFromConfig(config);
|
||||
const normalized = normalizePluginSpec(name);
|
||||
const filtered = pluginSpecs.filter((item) => normalizePluginSpec(item) !== normalized);
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspaceRoot), { plugin: filtered });
|
||||
}
|
||||
647
packages/server/src/server.ts
Normal file
647
packages/server/src/server.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
import { readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import type { ApprovalRequest, Capabilities, ServerConfig, WorkspaceInfo, Actor } from "./types.js";
|
||||
import { ApprovalService } from "./approvals.js";
|
||||
import { addPlugin, listPlugins, removePlugin } from "./plugins.js";
|
||||
import { addMcp, listMcp, removeMcp } from "./mcp.js";
|
||||
import { listSkills, upsertSkill } from "./skills.js";
|
||||
import { deleteCommand, listCommands, upsertCommand } from "./commands.js";
|
||||
import { ApiError, formatError } from "./errors.js";
|
||||
import { readJsoncFile, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
|
||||
import { recordAudit, readLastAudit } from "./audit.js";
|
||||
import { parseFrontmatter } from "./frontmatter.js";
|
||||
import { opencodeConfigPath, openworkConfigPath, projectCommandsDir, projectSkillsDir } from "./workspace-files.js";
|
||||
import { ensureDir, exists, hashToken, shortId } from "./utils.js";
|
||||
import { sanitizeCommandName } from "./validators.js";
|
||||
|
||||
type AuthMode = "none" | "client" | "host";
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
regex: RegExp;
|
||||
keys: string[];
|
||||
auth: AuthMode;
|
||||
handler: (ctx: RequestContext) => Promise<Response>;
|
||||
}
|
||||
|
||||
interface RequestContext {
|
||||
request: Request;
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
config: ServerConfig;
|
||||
approvals: ApprovalService;
|
||||
actor?: Actor;
|
||||
}
|
||||
|
||||
export function startServer(config: ServerConfig) {
|
||||
const approvals = new ApprovalService(config.approval);
|
||||
const routes = createRoutes(config, approvals);
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: config.host,
|
||||
port: config.port,
|
||||
fetch: async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
if (request.method === "OPTIONS") {
|
||||
return withCors(new Response(null, { status: 204 }), request, config);
|
||||
}
|
||||
|
||||
const route = matchRoute(routes, request.method, url.pathname);
|
||||
if (!route) {
|
||||
return withCors(jsonResponse({ code: "not_found", message: "Not found" }, 404), request, config);
|
||||
}
|
||||
|
||||
try {
|
||||
const actor = route.auth === "host" ? requireHost(request, config) : route.auth === "client" ? requireClient(request, config) : undefined;
|
||||
const response = await route.handler({ request, url, params: route.params, config, approvals, actor });
|
||||
return withCors(response, request, config);
|
||||
} catch (error) {
|
||||
const apiError = error instanceof ApiError
|
||||
? error
|
||||
: new ApiError(500, "internal_error", "Unexpected server error");
|
||||
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
function matchRoute(routes: Route[], method: string, path: string) {
|
||||
for (const route of routes) {
|
||||
if (route.method !== method) continue;
|
||||
const match = path.match(route.regex);
|
||||
if (!match) continue;
|
||||
const params: Record<string, string> = {};
|
||||
route.keys.forEach((key, index) => {
|
||||
params[key] = decodeURIComponent(match[index + 1]);
|
||||
});
|
||||
return { ...route, params };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addRoute(routes: Route[], method: string, path: string, auth: AuthMode, handler: Route["handler"]) {
|
||||
const keys: string[] = [];
|
||||
const regex = pathToRegex(path, keys);
|
||||
routes.push({ method, regex, keys, auth, handler });
|
||||
}
|
||||
|
||||
function pathToRegex(path: string, keys: string[]): RegExp {
|
||||
const pattern = path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
||||
keys.push(key);
|
||||
return "([^/]+)";
|
||||
});
|
||||
return new RegExp(`^${pattern}$`);
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function withCors(response: Response, request: Request, config: ServerConfig) {
|
||||
const origin = request.headers.get("origin");
|
||||
const allowedOrigins = config.corsOrigins;
|
||||
let allowOrigin: string | null = null;
|
||||
if (allowedOrigins.includes("*")) {
|
||||
allowOrigin = "*";
|
||||
} else if (origin && allowedOrigins.includes(origin)) {
|
||||
allowOrigin = origin;
|
||||
}
|
||||
|
||||
if (!allowOrigin) return response;
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
||||
headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-OpenWork-Host-Token, X-OpenWork-Client-Id");
|
||||
headers.set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
||||
headers.set("Vary", "Origin");
|
||||
return new Response(response.body, { status: response.status, headers });
|
||||
}
|
||||
|
||||
function requireClient(request: Request, config: ServerConfig): Actor {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
const token = match?.[1];
|
||||
if (!token || token !== config.token) {
|
||||
throw new ApiError(401, "unauthorized", "Invalid bearer token");
|
||||
}
|
||||
const clientId = request.headers.get("x-openwork-client-id") ?? undefined;
|
||||
return { type: "remote", clientId, tokenHash: hashToken(token) };
|
||||
}
|
||||
|
||||
function requireHost(request: Request, config: ServerConfig): Actor {
|
||||
const token = request.headers.get("x-openwork-host-token");
|
||||
if (!token || token !== config.hostToken) {
|
||||
throw new ApiError(401, "unauthorized", "Invalid host token");
|
||||
}
|
||||
return { type: "host", tokenHash: hashToken(token) };
|
||||
}
|
||||
|
||||
function buildCapabilities(config: ServerConfig): Capabilities {
|
||||
const writeEnabled = !config.readOnly;
|
||||
return {
|
||||
skills: { read: true, write: writeEnabled, source: "openwork" },
|
||||
plugins: { read: true, write: writeEnabled },
|
||||
mcp: { read: true, write: writeEnabled },
|
||||
commands: { read: true, write: writeEnabled },
|
||||
config: { read: true, write: writeEnabled },
|
||||
};
|
||||
}
|
||||
|
||||
function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[] {
|
||||
const routes: Route[] = [];
|
||||
|
||||
addRoute(routes, "GET", "/health", "none", async () => {
|
||||
return jsonResponse({ ok: true, version: "0.1.0", uptimeMs: Date.now() - config.startedAt });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/capabilities", "client", async () => {
|
||||
return jsonResponse(buildCapabilities(config));
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspaces", "client", async () => {
|
||||
return jsonResponse({ items: config.workspaces });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/config", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const opencode = await readOpencodeConfig(workspace.path);
|
||||
const openwork = await readOpenworkConfig(workspace.path);
|
||||
const lastAudit = await readLastAudit(workspace.path);
|
||||
return jsonResponse({ opencode, openwork, updatedAt: lastAudit?.timestamp ?? null });
|
||||
});
|
||||
|
||||
addRoute(routes, "PATCH", "/workspace/:id/config", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const opencode = body.opencode as Record<string, unknown> | undefined;
|
||||
const openwork = body.openwork as Record<string, unknown> | undefined;
|
||||
|
||||
if (!opencode && !openwork) {
|
||||
throw new ApiError(400, "invalid_payload", "opencode or openwork updates required");
|
||||
}
|
||||
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "config.patch",
|
||||
summary: "Patch workspace config",
|
||||
paths: [opencode ? opencodeConfigPath(workspace.path) : null, openwork ? openworkConfigPath(workspace.path) : null].filter(Boolean) as string[],
|
||||
});
|
||||
|
||||
if (opencode) {
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
||||
}
|
||||
if (openwork) {
|
||||
await writeOpenworkConfig(workspace.path, openwork, true);
|
||||
}
|
||||
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "config.patch",
|
||||
target: "opencode.json",
|
||||
summary: "Patched workspace config",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return jsonResponse({ updatedAt: Date.now() });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/plugins", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
||||
const result = await listPlugins(workspace.path, includeGlobal);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/plugins", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const spec = String(body.spec ?? "");
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "plugins.add",
|
||||
summary: `Add plugin ${spec}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await addPlugin(workspace.path, spec);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "plugins.add",
|
||||
target: "opencode.json",
|
||||
summary: `Added ${spec}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const result = await listPlugins(workspace.path, false);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/plugins/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "plugins.remove",
|
||||
summary: `Remove plugin ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await removePlugin(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "plugins.remove",
|
||||
target: "opencode.json",
|
||||
summary: `Removed ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const result = await listPlugins(workspace.path, false);
|
||||
return jsonResponse(result);
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/skills", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const includeGlobal = ctx.url.searchParams.get("includeGlobal") === "true";
|
||||
const items = await listSkills(workspace.path, includeGlobal);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/skills", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const content = String(body.content ?? "");
|
||||
const description = body.description ? String(body.description) : undefined;
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "skills.upsert",
|
||||
summary: `Upsert skill ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "skills", name, "SKILL.md")],
|
||||
});
|
||||
const path = await upsertSkill(workspace.path, { name, content, description });
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "skills.upsert",
|
||||
target: path,
|
||||
summary: `Upserted skill ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ name, path, description: description ?? "", scope: "project" });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/mcp", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/mcp", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const configPayload = body.config as Record<string, unknown> | undefined;
|
||||
if (!configPayload) {
|
||||
throw new ApiError(400, "invalid_payload", "MCP config is required");
|
||||
}
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "mcp.add",
|
||||
summary: `Add MCP ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await addMcp(workspace.path, name, configPayload);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "mcp.add",
|
||||
target: "opencode.json",
|
||||
summary: `Added MCP ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/mcp/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "mcp.remove",
|
||||
summary: `Remove MCP ${name}`,
|
||||
paths: [opencodeConfigPath(workspace.path)],
|
||||
});
|
||||
await removeMcp(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "mcp.remove",
|
||||
target: "opencode.json",
|
||||
summary: `Removed MCP ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listMcp(workspace.path);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/commands", "client", async (ctx) => {
|
||||
const scope = ctx.url.searchParams.get("scope") === "global" ? "global" : "workspace";
|
||||
if (scope === "global") {
|
||||
requireHost(ctx.request, config);
|
||||
}
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const items = await listCommands(workspace.path, scope);
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/commands", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const name = String(body.name ?? "");
|
||||
const template = String(body.template ?? "");
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "commands.upsert",
|
||||
summary: `Upsert command ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
||||
});
|
||||
const path = await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description: body.description ? String(body.description) : undefined,
|
||||
template,
|
||||
agent: body.agent ? String(body.agent) : undefined,
|
||||
model: body.model ? String(body.model) : undefined,
|
||||
subtask: typeof body.subtask === "boolean" ? body.subtask : undefined,
|
||||
});
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "commands.upsert",
|
||||
target: path,
|
||||
summary: `Upserted command ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const items = await listCommands(workspace.path, "workspace");
|
||||
return jsonResponse({ items });
|
||||
});
|
||||
|
||||
addRoute(routes, "DELETE", "/workspace/:id/commands/:name", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const name = ctx.params.name ?? "";
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "commands.delete",
|
||||
summary: `Delete command ${name}`,
|
||||
paths: [join(workspace.path, ".opencode", "commands", `${sanitizeCommandName(name)}.md`)],
|
||||
});
|
||||
await deleteCommand(workspace.path, name);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "commands.delete",
|
||||
target: join(workspace.path, ".opencode", "commands"),
|
||||
summary: `Deleted command ${name}`,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/workspace/:id/export", "client", async (ctx) => {
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const exportPayload = await exportWorkspace(workspace);
|
||||
return jsonResponse(exportPayload);
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/workspace/:id/import", "client", async (ctx) => {
|
||||
ensureWritable(config);
|
||||
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||
const body = await readJsonBody(ctx.request);
|
||||
await requireApproval(ctx, {
|
||||
workspaceId: workspace.id,
|
||||
action: "config.import",
|
||||
summary: "Import workspace config",
|
||||
paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)],
|
||||
});
|
||||
await importWorkspace(workspace, body);
|
||||
await recordAudit(workspace.path, {
|
||||
id: shortId(),
|
||||
workspaceId: workspace.id,
|
||||
actor: ctx.actor ?? { type: "remote" },
|
||||
action: "config.import",
|
||||
target: "workspace",
|
||||
summary: "Imported workspace config",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return jsonResponse({ ok: true });
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
||||
return jsonResponse({ items: ctx.approvals.list() });
|
||||
});
|
||||
|
||||
addRoute(routes, "POST", "/approvals/:id", "host", async (ctx) => {
|
||||
const body = await readJsonBody(ctx.request);
|
||||
const reply = body.reply === "allow" ? "allow" : "deny";
|
||||
const result = ctx.approvals.respond(ctx.params.id, reply);
|
||||
if (!result) {
|
||||
throw new ApiError(404, "approval_not_found", "Approval request not found");
|
||||
}
|
||||
return jsonResponse({ ok: true, allowed: result.allowed });
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
|
||||
const workspace = config.workspaces.find((entry) => entry.id === id);
|
||||
if (!workspace) {
|
||||
throw new ApiError(404, "workspace_not_found", "Workspace not found");
|
||||
}
|
||||
const resolvedWorkspace = resolve(workspace.path);
|
||||
const authorized = await isAuthorizedRoot(resolvedWorkspace, config.authorizedRoots);
|
||||
if (!authorized) {
|
||||
throw new ApiError(403, "workspace_unauthorized", "Workspace is not authorized");
|
||||
}
|
||||
return { ...workspace, path: resolvedWorkspace };
|
||||
}
|
||||
|
||||
async function isAuthorizedRoot(workspacePath: string, roots: string[]): Promise<boolean> {
|
||||
const resolvedWorkspace = resolve(workspacePath);
|
||||
for (const root of roots) {
|
||||
const resolvedRoot = resolve(root);
|
||||
if (resolvedWorkspace === resolvedRoot) return true;
|
||||
if (resolvedWorkspace.startsWith(resolvedRoot + sep)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function ensureWritable(config: ServerConfig): void {
|
||||
if (config.readOnly) {
|
||||
throw new ApiError(403, "read_only", "Server is read-only");
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const json = await request.json();
|
||||
return json as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError(400, "invalid_json", "Invalid JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
async function readOpencodeConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
|
||||
const { data } = await readJsoncFile(opencodeConfigPath(workspaceRoot), {} as Record<string, unknown>);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function readOpenworkConfig(workspaceRoot: string): Promise<Record<string, unknown>> {
|
||||
const path = openworkConfigPath(workspaceRoot);
|
||||
if (!(await exists(path))) return {};
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ApiError(422, "invalid_json", "Failed to parse openwork.json");
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOpenworkConfig(workspaceRoot: string, payload: Record<string, unknown>, merge: boolean): Promise<void> {
|
||||
const path = openworkConfigPath(workspaceRoot);
|
||||
const next = merge ? { ...(await readOpenworkConfig(workspaceRoot)), ...payload } : payload;
|
||||
await ensureDir(join(workspaceRoot, ".opencode"));
|
||||
await writeFile(path, JSON.stringify(next, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
async function requireApproval(
|
||||
ctx: RequestContext,
|
||||
input: Omit<ApprovalRequest, "id" | "createdAt" | "actor">,
|
||||
): Promise<void> {
|
||||
const actor = ctx.actor ?? { type: "remote" };
|
||||
const result = await ctx.approvals.requestApproval({ ...input, actor });
|
||||
if (!result.allowed) {
|
||||
throw new ApiError(403, "write_denied", "Write request denied", {
|
||||
requestId: result.id,
|
||||
reason: result.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exportWorkspace(workspace: WorkspaceInfo) {
|
||||
const opencode = await readOpencodeConfig(workspace.path);
|
||||
const openwork = await readOpenworkConfig(workspace.path);
|
||||
const skills = await listSkills(workspace.path, false);
|
||||
const commands = await listCommands(workspace.path, "workspace");
|
||||
const skillContents = await Promise.all(
|
||||
skills.map(async (skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: await readFile(skill.path, "utf8"),
|
||||
})),
|
||||
);
|
||||
const commandContents = await Promise.all(
|
||||
commands.map(async (command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
template: command.template,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
exportedAt: Date.now(),
|
||||
opencode,
|
||||
openwork,
|
||||
skills: skillContents,
|
||||
commands: commandContents,
|
||||
};
|
||||
}
|
||||
|
||||
async function importWorkspace(workspace: WorkspaceInfo, payload: Record<string, unknown>): Promise<void> {
|
||||
const modes = (payload.mode as Record<string, string> | undefined) ?? {};
|
||||
const opencode = payload.opencode as Record<string, unknown> | undefined;
|
||||
const openwork = payload.openwork as Record<string, unknown> | undefined;
|
||||
const skills = (payload.skills as { name: string; content: string; description?: string }[] | undefined) ?? [];
|
||||
const commands = (payload.commands as { name: string; content?: string; description?: string; template?: string; agent?: string; model?: string | null; subtask?: boolean }[] | undefined) ?? [];
|
||||
|
||||
if (opencode) {
|
||||
if (modes.opencode === "replace") {
|
||||
await writeJsoncFile(opencodeConfigPath(workspace.path), opencode);
|
||||
} else {
|
||||
await updateJsoncTopLevel(opencodeConfigPath(workspace.path), opencode);
|
||||
}
|
||||
}
|
||||
|
||||
if (openwork) {
|
||||
if (modes.openwork === "replace") {
|
||||
await writeOpenworkConfig(workspace.path, openwork, false);
|
||||
} else {
|
||||
await writeOpenworkConfig(workspace.path, openwork, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
if (modes.skills === "replace") {
|
||||
await rm(projectSkillsDir(workspace.path), { recursive: true, force: true });
|
||||
}
|
||||
for (const skill of skills) {
|
||||
await upsertSkill(workspace.path, skill);
|
||||
}
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
if (modes.commands === "replace") {
|
||||
await rm(projectCommandsDir(workspace.path), { recursive: true, force: true });
|
||||
}
|
||||
for (const command of commands) {
|
||||
if (command.content) {
|
||||
const parsed = parseFrontmatter(command.content);
|
||||
const name = command.name || (typeof parsed.data.name === "string" ? parsed.data.name : "");
|
||||
const description = command.description || (typeof parsed.data.description === "string" ? parsed.data.description : undefined);
|
||||
if (!name) {
|
||||
throw new ApiError(400, "invalid_command", "Command name is required");
|
||||
}
|
||||
const template = parsed.body.trim();
|
||||
await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description,
|
||||
template,
|
||||
agent: typeof parsed.data.agent === "string" ? parsed.data.agent : undefined,
|
||||
model: typeof parsed.data.model === "string" ? parsed.data.model : undefined,
|
||||
subtask: typeof parsed.data.subtask === "boolean" ? parsed.data.subtask : undefined,
|
||||
});
|
||||
} else {
|
||||
const name = command.name ?? "";
|
||||
const template = command.template ?? "";
|
||||
await upsertCommand(workspace.path, {
|
||||
name,
|
||||
description: command.description,
|
||||
template,
|
||||
agent: command.agent,
|
||||
model: command.model,
|
||||
subtask: command.subtask,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/server/src/skills.ts
Normal file
117
packages/server/src/skills.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { SkillItem } from "./types.js";
|
||||
import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js";
|
||||
import { exists } from "./utils.js";
|
||||
import { validateDescription, validateSkillName } from "./validators.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { projectSkillsDir } from "./workspace-files.js";
|
||||
|
||||
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
|
||||
const roots: string[] = [];
|
||||
let current = resolve(workspaceRoot);
|
||||
while (true) {
|
||||
roots.push(current);
|
||||
const gitPath = join(current, ".git");
|
||||
if (await exists(gitPath)) break;
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function listSkillsInDir(dir: string, scope: "project" | "global"): Promise<SkillItem[]> {
|
||||
if (!(await exists(dir))) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const items: SkillItem[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillPath = join(dir, entry.name, "SKILL.md");
|
||||
if (!(await exists(skillPath))) continue;
|
||||
const content = await readFile(skillPath, "utf8");
|
||||
const { data } = parseFrontmatter(content);
|
||||
const name = typeof data.name === "string" ? data.name : entry.name;
|
||||
const description = typeof data.description === "string" ? data.description : "";
|
||||
try {
|
||||
validateSkillName(name);
|
||||
validateDescription(description);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (name !== entry.name) continue;
|
||||
items.push({
|
||||
name,
|
||||
description,
|
||||
path: skillPath,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function listSkills(workspaceRoot: string, includeGlobal: boolean): Promise<SkillItem[]> {
|
||||
const roots = await findWorkspaceRoots(workspaceRoot);
|
||||
const items: SkillItem[] = [];
|
||||
for (const root of roots) {
|
||||
const opencodeDir = join(root, ".opencode", "skills");
|
||||
const claudeDir = join(root, ".claude", "skills");
|
||||
items.push(...(await listSkillsInDir(opencodeDir, "project")));
|
||||
items.push(...(await listSkillsInDir(claudeDir, "project")));
|
||||
}
|
||||
|
||||
if (includeGlobal) {
|
||||
const globalOpenWork = join(homedir(), ".config", "opencode", "skills");
|
||||
const globalClaude = join(homedir(), ".claude", "skills");
|
||||
items.push(...(await listSkillsInDir(globalOpenWork, "global")));
|
||||
items.push(...(await listSkillsInDir(globalClaude, "global")));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.name)) return false;
|
||||
seen.add(item.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertSkill(
|
||||
workspaceRoot: string,
|
||||
payload: { name: string; content: string; description?: string },
|
||||
): Promise<string> {
|
||||
const name = payload.name.trim();
|
||||
validateSkillName(name);
|
||||
if (!payload.content) {
|
||||
throw new ApiError(400, "invalid_skill_content", "Skill content is required");
|
||||
}
|
||||
|
||||
let content = payload.content;
|
||||
const { data, body } = parseFrontmatter(payload.content);
|
||||
if (Object.keys(data).length > 0) {
|
||||
const frontmatterName = typeof data.name === "string" ? data.name : "";
|
||||
const frontmatterDescription = typeof data.description === "string" ? data.description : "";
|
||||
if (frontmatterName && frontmatterName !== name) {
|
||||
throw new ApiError(400, "invalid_skill_name", "Skill frontmatter name must match payload name");
|
||||
}
|
||||
validateDescription(frontmatterDescription || payload.description);
|
||||
const nextDescription = frontmatterDescription || payload.description || "";
|
||||
const frontmatter = buildFrontmatter({
|
||||
...data,
|
||||
name,
|
||||
description: nextDescription,
|
||||
});
|
||||
content = frontmatter + body.replace(/^\n/, "");
|
||||
} else {
|
||||
validateDescription(payload.description);
|
||||
const frontmatter = buildFrontmatter({ name, description: payload.description });
|
||||
content = frontmatter + payload.content.replace(/^\n/, "");
|
||||
}
|
||||
|
||||
const baseDir = projectSkillsDir(workspaceRoot);
|
||||
const skillDir = join(baseDir, name);
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
await writeFile(skillPath, content.endsWith("\n") ? content : content + "\n", "utf8");
|
||||
return skillPath;
|
||||
}
|
||||
111
packages/server/src/types.ts
Normal file
111
packages/server/src/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type WorkspaceType = "local" | "remote";
|
||||
|
||||
export type ApprovalMode = "manual" | "auto";
|
||||
|
||||
export interface WorkspaceConfig {
|
||||
path: string;
|
||||
name?: string;
|
||||
workspaceType?: WorkspaceType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
workspaceType: WorkspaceType;
|
||||
baseUrl?: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalConfig {
|
||||
mode: ApprovalMode;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
hostToken: string;
|
||||
approval: ApprovalConfig;
|
||||
corsOrigins: string[];
|
||||
workspaces: WorkspaceInfo[];
|
||||
authorizedRoots: string[];
|
||||
readOnly: boolean;
|
||||
startedAt: number;
|
||||
tokenSource: "cli" | "env" | "file" | "generated";
|
||||
hostTokenSource: "cli" | "env" | "file" | "generated";
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
skills: { read: boolean; write: boolean; source: "openwork" | "opencode" };
|
||||
plugins: { read: boolean; write: boolean };
|
||||
mcp: { read: boolean; write: boolean };
|
||||
commands: { read: boolean; write: boolean };
|
||||
config: { read: boolean; write: boolean };
|
||||
}
|
||||
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface PluginItem {
|
||||
spec: string;
|
||||
source: "config" | "dir.project" | "dir.global";
|
||||
scope: "project" | "global";
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface McpItem {
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
source: "config.project" | "config.global" | "config.remote";
|
||||
disabledByTools?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillItem {
|
||||
name: string;
|
||||
path: string;
|
||||
description: string;
|
||||
scope: "project" | "global";
|
||||
}
|
||||
|
||||
export interface CommandItem {
|
||||
name: string;
|
||||
description?: string;
|
||||
template: string;
|
||||
agent?: string;
|
||||
model?: string | null;
|
||||
subtask?: boolean;
|
||||
scope: "workspace" | "global";
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
type: "remote" | "host";
|
||||
clientId?: string;
|
||||
tokenHash?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
action: string;
|
||||
summary: string;
|
||||
paths: string[];
|
||||
createdAt: number;
|
||||
actor: Actor;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
actor: Actor;
|
||||
action: string;
|
||||
target: string;
|
||||
summary: string;
|
||||
timestamp: number;
|
||||
}
|
||||
52
packages/server/src/utils.ts
Normal file
52
packages/server/src/utils.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
|
||||
export async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDir(path: string): Promise<void> {
|
||||
await mkdir(path, { recursive: true });
|
||||
}
|
||||
|
||||
export async function readJsonFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function shortId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function parseList(input: string | undefined): string[] {
|
||||
if (!input) return [];
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return [];
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => String(item)).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
.split(/[,;]/)
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
59
packages/server/src/validators.ts
Normal file
59
packages/server/src/validators.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ApiError } from "./errors.js";
|
||||
|
||||
const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
const COMMAND_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
const MCP_NAME_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function validateSkillName(name: string): void {
|
||||
if (!name || name.length < 1 || name.length > 64 || !SKILL_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_skill_name", "Skill name must be kebab-case (1-64 chars)");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDescription(description: string | undefined): void {
|
||||
if (!description || description.length < 1 || description.length > 1024) {
|
||||
throw new ApiError(422, "invalid_description", "Description must be 1-1024 characters");
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePluginSpec(spec: string): void {
|
||||
if (!spec || spec.trim().length === 0) {
|
||||
throw new ApiError(400, "invalid_plugin_spec", "Plugin spec is required");
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeCommandName(name: string): string {
|
||||
const trimmed = name.trim().replace(/^\/+/, "");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function validateCommandName(name: string): void {
|
||||
if (!name || !COMMAND_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_command_name", "Command name must be alphanumeric with _ or -");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMcpName(name: string): void {
|
||||
if (!name || name.startsWith("-") || !MCP_NAME_REGEX.test(name)) {
|
||||
throw new ApiError(400, "invalid_mcp_name", "MCP name must be alphanumeric and not start with -");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMcpConfig(config: Record<string, unknown>): void {
|
||||
const type = config.type;
|
||||
if (type !== "local" && type !== "remote") {
|
||||
throw new ApiError(400, "invalid_mcp_config", "MCP config type must be local or remote");
|
||||
}
|
||||
if (type === "local") {
|
||||
const command = config.command;
|
||||
if (!Array.isArray(command) || command.length === 0) {
|
||||
throw new ApiError(400, "invalid_mcp_config", "Local MCP requires command array");
|
||||
}
|
||||
}
|
||||
if (type === "remote") {
|
||||
const url = config.url;
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new ApiError(400, "invalid_mcp_config", "Remote MCP requires url");
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/server/src/workspace-files.ts
Normal file
21
packages/server/src/workspace-files.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
export function opencodeConfigPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, "opencode.json");
|
||||
}
|
||||
|
||||
export function openworkConfigPath(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "openwork.json");
|
||||
}
|
||||
|
||||
export function projectSkillsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "skills");
|
||||
}
|
||||
|
||||
export function projectCommandsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "commands");
|
||||
}
|
||||
|
||||
export function projectPluginsDir(workspaceRoot: string): string {
|
||||
return join(workspaceRoot, ".opencode", "plugins");
|
||||
}
|
||||
25
packages/server/src/workspaces.ts
Normal file
25
packages/server/src/workspaces.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { WorkspaceConfig, WorkspaceInfo } from "./types.js";
|
||||
|
||||
export function workspaceIdForPath(path: string): string {
|
||||
const hash = createHash("sha256").update(path).digest("hex");
|
||||
return `ws_${hash.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function buildWorkspaceInfos(
|
||||
workspaces: WorkspaceConfig[],
|
||||
cwd: string,
|
||||
): WorkspaceInfo[] {
|
||||
return workspaces.map((workspace) => {
|
||||
const resolvedPath = resolve(cwd, workspace.path);
|
||||
return {
|
||||
id: workspaceIdForPath(resolvedPath),
|
||||
name: workspace.name ?? basename(resolvedPath),
|
||||
path: resolvedPath,
|
||||
workspaceType: workspace.workspaceType ?? "local",
|
||||
baseUrl: workspace.baseUrl,
|
||||
directory: workspace.directory,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user