diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b135864..7ce9f02d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,6 +70,8 @@ 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`) +- workspace template export/import, including shareable `.opencode/**` files and `opencode.json` state +- share-bundle publish/fetch flows used by OpenWork template links - reload event generation after config or capability changes - other filesystem-backed capability changes that must work across desktop host mode and remote clients diff --git a/apps/app/pr/openwork-template-import-empty-state.png b/apps/app/pr/openwork-template-import-empty-state.png new file mode 100644 index 00000000..1c19e0f5 Binary files /dev/null and b/apps/app/pr/openwork-template-import-empty-state.png differ diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index 0826dac3..f026e362 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -224,6 +224,7 @@ import { type OpenworkServerDiagnostics, type OpenworkServerStatus, type OpenworkServerSettings, + type OpenworkServerClient, type OpenworkWorkspaceExport, OpenworkServerError, } from "./lib/openwork-server"; @@ -382,6 +383,15 @@ function readSkillItem(value: unknown): SharedSkillItem | null { }; } +function readTemplateFileItem(value: unknown): { path: string; content: string } | null { + const record = readRecord(value); + if (!record) return null; + const path = typeof record.path === "string" ? record.path.trim() : ""; + const content = typeof record.content === "string" ? record.content : ""; + if (!path) return null; + return { path, content }; +} + function parseSharedBundle(value: unknown): SharedBundleV1 { const record = readRecord(value); if (!record) { @@ -432,19 +442,25 @@ function parseSharedBundle(value: unknown): SharedBundleV1 { if (!workspace) { throw new Error("Workspace profile bundle is missing workspace payload."); } + const files = Array.isArray(workspace.files) + ? workspace.files.map(readTemplateFileItem).filter((item): item is { path: string; content: string } => Boolean(item)) + : []; return { schemaVersion: 1, type: "workspace-profile", name: name || "Shared workspace profile", description: typeof record.description === "string" ? record.description : undefined, - workspace: workspace as OpenworkWorkspaceExport, + workspace: { + ...(workspace as OpenworkWorkspaceExport), + ...(files.length ? { files } : {}), + }, }; } throw new Error(`Unsupported bundle type: ${type || "unknown"}`); } -async function fetchSharedBundle(bundleUrl: string): Promise { +async function fetchSharedBundle(bundleUrl: string, serverClient?: OpenworkServerClient | null): Promise { let targetUrl: URL; try { targetUrl = new URL(bundleUrl); @@ -460,6 +476,10 @@ async function fetchSharedBundle(bundleUrl: string): Promise { targetUrl.searchParams.set("format", "json"); } + if (serverClient) { + return parseSharedBundle(await serverClient.fetchBundle(targetUrl.toString())); + } + const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 15_000); @@ -541,6 +561,7 @@ function buildImportPayloadFromBundle(bundle: SharedBundleV1): { if (workspace.openwork && typeof workspace.openwork === "object") payload.openwork = workspace.openwork; if (Array.isArray(workspace.skills) && workspace.skills.length) payload.skills = workspace.skills; if (Array.isArray(workspace.commands) && workspace.commands.length) payload.commands = workspace.commands; + if (Array.isArray(workspace.files) && workspace.files.length) payload.files = workspace.files; const importedSkillsCount = Array.isArray(workspace.skills) ? workspace.skills.length : 0; return { payload, importedSkillsCount }; @@ -3885,7 +3906,7 @@ export default function App() { bundleOverride?: SharedBundleV1, ) => { try { - const bundle = bundleOverride ?? (await fetchSharedBundle(request.bundleUrl)); + const bundle = bundleOverride ?? (await fetchSharedBundle(request.bundleUrl, openworkServerClient())); await importSharedBundlePayload(bundle, target); setError(null); return true; @@ -3959,7 +3980,7 @@ export default function App() { }; const processSharedBundleInvite = async (request: SharedBundleDeepLink) => { - const bundle = await fetchSharedBundle(request.bundleUrl); + const bundle = await fetchSharedBundle(request.bundleUrl, openworkServerClient()); if (bundle.type === "skill") { setView("dashboard"); diff --git a/apps/app/src/app/context/workspace.ts b/apps/app/src/app/context/workspace.ts index 78d14279..426ccb73 100644 --- a/apps/app/src/app/context/workspace.ts +++ b/apps/app/src/app/context/workspace.ts @@ -3552,6 +3552,7 @@ export function createWorkspaceStore(options: { importingWorkspaceConfig, migrationRepairBusy, migrationRepairResult, + activeWorkspaceInfo, activeWorkspaceDisplay, activeWorkspacePath, activeWorkspaceRoot, diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index b02d79ca..265f77f6 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -450,6 +450,7 @@ export type OpenworkWorkspaceExport = { openwork?: Record; skills?: Array<{ name: string; description?: string; trigger?: string; content: string }>; commands?: Array<{ name: string; description?: string; template?: string }>; + files?: Array<{ path: string; content: string }>; }; export type OpenworkArtifactItem = { @@ -1188,6 +1189,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s opencodeRouter: 10_000, workspaceExport: 30_000, workspaceImport: 30_000, + shareBundle: 20_000, binary: 60_000, }; @@ -1271,6 +1273,31 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s body: payload, timeoutMs: timeouts.workspaceImport, }), + publishBundle: (payload: unknown, bundleType: "skill" | "workspace-profile" | "skills-set", options?: { name?: string; baseUrl?: string | null; timeoutMs?: number }) => + requestJson<{ url: string }>(baseUrl, "/share/bundles/publish", { + token, + hostToken, + method: "POST", + body: { + payload, + bundleType, + name: options?.name, + baseUrl: options?.baseUrl ?? undefined, + timeoutMs: options?.timeoutMs, + }, + timeoutMs: options?.timeoutMs ?? timeouts.shareBundle, + }), + fetchBundle: (bundleUrl: string, options?: { timeoutMs?: number }) => + requestJson>(baseUrl, "/share/bundles/fetch", { + token, + hostToken, + method: "POST", + body: { + bundleUrl, + timeoutMs: options?.timeoutMs, + }, + timeoutMs: options?.timeoutMs ?? timeouts.shareBundle, + }), getConfig: (workspaceId: string) => requestJson<{ opencode: Record; openwork: Record; updatedAt?: number | null }>( baseUrl, diff --git a/apps/app/src/app/pages/dashboard.tsx b/apps/app/src/app/pages/dashboard.tsx index a2a364e8..4f8694c4 100644 --- a/apps/app/src/app/pages/dashboard.tsx +++ b/apps/app/src/app/pages/dashboard.tsx @@ -43,7 +43,7 @@ import type { OpenworkServerStatus, } from "../lib/openwork-server"; import type { EngineInfo, OrchestratorStatus, OpenworkServerInfo, OpenCodeRouterInfo, WorkspaceInfo } from "../lib/tauri"; -import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL, publishOpenworkBundleJson } from "../lib/publisher"; +import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL } from "../lib/publisher"; import Button from "../components/button"; import ExtensionsView from "./extensions"; @@ -889,15 +889,14 @@ export default function DashboardView(props: DashboardViewProps) { const payload: WorkspaceProfileBundleV1 = { schemaVersion: 1, type: "workspace-profile", - name: `${workspaceLabel(workspace)} profile`, - description: "Full OpenWork workspace profile with config, MCP setup, commands, and skills.", + name: `${workspaceLabel(workspace)} template`, + description: "Full OpenWork workspace template with config, commands, skills, and extra .opencode files.", workspace: exported, }; - const result = await publishOpenworkBundleJson({ - payload, - bundleType: "workspace-profile", + const result = await client.publishBundle(payload, "workspace-profile", { name: payload.name, + baseUrl: DEFAULT_OPENWORK_PUBLISHER_BASE_URL, }); setShareWorkspaceProfileUrl(result.url); @@ -944,10 +943,9 @@ export default function DashboardView(props: DashboardViewProps) { }, }; - const result = await publishOpenworkBundleJson({ - payload, - bundleType: "skills-set", + const result = await client.publishBundle(payload, "skills-set", { name: payload.name, + baseUrl: DEFAULT_OPENWORK_PUBLISHER_BASE_URL, }); setShareSkillsSetUrl(result.url); diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index ea447698..8c78c17e 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -85,10 +85,7 @@ import type { OpenworkServerStatus, OpenworkWorkspaceExport, } from "../lib/openwork-server"; -import { - DEFAULT_OPENWORK_PUBLISHER_BASE_URL, - publishOpenworkBundleJson, -} from "../lib/publisher"; +import { DEFAULT_OPENWORK_PUBLISHER_BASE_URL } from "../lib/publisher"; import { join } from "@tauri-apps/api/path"; import { isUserVisiblePart, @@ -3283,16 +3280,15 @@ export default function SessionView(props: SessionViewProps) { const payload: WorkspaceProfileBundleV1 = { schemaVersion: 1, type: "workspace-profile", - name: `${workspaceLabel(workspace)} profile`, + name: `${workspaceLabel(workspace)} template`, description: - "Full OpenWork workspace profile with config, MCP setup, commands, and skills.", + "Full OpenWork workspace template with config, commands, skills, and extra .opencode files.", workspace: exported, }; - const result = await publishOpenworkBundleJson({ - payload, - bundleType: "workspace-profile", + const result = await client.publishBundle(payload, "workspace-profile", { name: payload.name, + baseUrl: DEFAULT_OPENWORK_PUBLISHER_BASE_URL, }); setShareWorkspaceProfileUrl(result.url); @@ -3344,10 +3340,9 @@ export default function SessionView(props: SessionViewProps) { }, }; - const result = await publishOpenworkBundleJson({ - payload, - bundleType: "skills-set", + const result = await client.publishBundle(payload, "skills-set", { name: payload.name, + baseUrl: DEFAULT_OPENWORK_PUBLISHER_BASE_URL, }); setShareSkillsSetUrl(result.url); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5814e064..d0ad4f2c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,8 @@ import { sanitizeCommandName, validateMcpName } from "./validators.js"; import { TokenService } from "./tokens.js"; import { TOY_UI_CSS, TOY_UI_FAVICON_SVG, TOY_UI_HTML, TOY_UI_JS, cssResponse, htmlResponse, jsResponse, svgResponse } from "./toy-ui.js"; import { FileSessionStore } from "./file-sessions.js"; +import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js"; +import { listTemplateFiles, planTemplateFiles, writeTemplateFiles } from "./template-files.js"; import pkg from "../package.json" with { type: "json" }; const SERVER_VERSION = pkg.version; @@ -3902,11 +3904,16 @@ function createRoutes( requireClientScope(ctx, "collaborator"); const workspace = await resolveWorkspace(config, ctx.params.id); const body = await readJsonBody(ctx.request); + const templateFiles = planTemplateFiles(workspace.path, body.files); await requireApproval(ctx, { workspaceId: workspace.id, action: "config.import", summary: "Import workspace config", - paths: [opencodeConfigPath(workspace.path), openworkConfigPath(workspace.path)], + paths: [ + opencodeConfigPath(workspace.path), + openworkConfigPath(workspace.path), + ...templateFiles.map((file) => file.absolutePath), + ], }); await importWorkspace(workspace, body); await recordAudit(workspace.path, { @@ -3922,6 +3929,28 @@ function createRoutes( return jsonResponse({ ok: true }); }); + addRoute(routes, "POST", "/share/bundles/publish", "client", async (ctx) => { + requireClientScope(ctx, "viewer"); + const body = await readJsonBody(ctx.request); + const result = await publishSharedBundle({ + payload: body.payload, + bundleType: String(body.bundleType ?? "").trim(), + name: typeof body.name === "string" ? body.name : undefined, + baseUrl: typeof body.baseUrl === "string" ? body.baseUrl : undefined, + timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined, + }); + return jsonResponse(result); + }); + + addRoute(routes, "POST", "/share/bundles/fetch", "client", async (ctx) => { + requireClientScope(ctx, "viewer"); + const body = await readJsonBody(ctx.request); + const bundle = await fetchSharedBundle(body.bundleUrl, { + timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined, + }); + return jsonResponse(bundle); + }); + addRoute(routes, "GET", "/approvals", "host", async (ctx) => { return jsonResponse({ items: ctx.approvals.list() }); }); @@ -5086,6 +5115,7 @@ async function exportWorkspace(workspace: WorkspaceInfo) { const openwork = await readOpenworkConfig(workspace.path); const skills = await listSkills(workspace.path, false); const commands = await listCommands(workspace.path, "workspace"); + const files = await listTemplateFiles(workspace.path); const skillContents = await Promise.all( skills.map(async (skill) => ({ name: skill.name, @@ -5108,6 +5138,7 @@ async function exportWorkspace(workspace: WorkspaceInfo) { openwork, skills: skillContents, commands: commandContents, + ...(files.length ? { files } : {}), }; } @@ -5117,6 +5148,7 @@ async function importWorkspace(workspace: WorkspaceInfo, payload: Record | 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) ?? []; + const files = payload.files; if (opencode) { if (modes.opencode === "replace") { @@ -5178,4 +5210,8 @@ async function importWorkspace(workspace: WorkspaceInfo, payload: Record 0) { + await writeTemplateFiles(workspace.path, files, { replace: modes.files === "replace" }); + } } diff --git a/apps/server/src/share-bundles.ts b/apps/server/src/share-bundles.ts new file mode 100644 index 00000000..c9543384 --- /dev/null +++ b/apps/server/src/share-bundles.ts @@ -0,0 +1,129 @@ +import { ApiError } from "./errors.js"; + +type PublishBundleInput = { + payload: unknown; + bundleType: string; + name?: string; + baseUrl?: string; + timeoutMs?: number; +}; + +const DEFAULT_PUBLISHER_BASE_URL = String(process.env.OPENWORK_PUBLISHER_BASE_URL ?? "").trim() || "https://share.openwork.software"; +const DEFAULT_PUBLISHER_ORIGIN = String(process.env.OPENWORK_PUBLISHER_REQUEST_ORIGIN ?? "").trim() || "https://app.openwork.software"; +const ALLOWED_BUNDLE_TYPES = new Set(["skill", "skills-set", "workspace-profile"]); + +function normalizeBaseUrl(input: unknown): string { + const trimmed = String(input ?? "").trim(); + if (!trimmed) { + throw new ApiError(500, "publisher_base_url_missing", "Publisher base URL is required"); + } + return trimmed.replace(/\/+$/, ""); +} + +async function readErrorMessage(response: Response): Promise { + try { + const text = await response.text(); + if (!text.trim()) return ""; + try { + const json = JSON.parse(text) as Record; + if (typeof json.message === "string" && json.message.trim()) { + return json.message.trim(); + } + } catch { + // ignore + } + return text.trim(); + } catch { + return ""; + } +} + +export async function publishSharedBundle(input: PublishBundleInput): Promise<{ url: string }> { + const bundleType = String(input.bundleType ?? "").trim(); + if (!ALLOWED_BUNDLE_TYPES.has(bundleType)) { + throw new ApiError(400, "invalid_bundle_type", `Unsupported bundle type: ${bundleType || "unknown"}`); + } + + const baseUrl = normalizeBaseUrl(input.baseUrl ?? DEFAULT_PUBLISHER_BASE_URL); + const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 15_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs)); + + try { + const response = await fetch(`${baseUrl}/v1/bundles`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Origin: DEFAULT_PUBLISHER_ORIGIN, + "X-OpenWork-Bundle-Type": bundleType, + "X-OpenWork-Schema-Version": "v1", + ...(input.name?.trim() ? { "X-OpenWork-Name": input.name.trim() } : {}), + }, + body: JSON.stringify(input.payload), + signal: controller.signal, + }); + + if (!response.ok) { + const details = await readErrorMessage(response); + const suffix = details ? `: ${details}` : ""; + throw new ApiError(502, "bundle_publish_failed", `Publish failed (${response.status})${suffix}`); + } + + const json = (await response.json()) as Record; + const url = typeof json.url === "string" ? json.url.trim() : ""; + if (!url) { + throw new ApiError(502, "bundle_publish_failed", "Publisher response missing url"); + } + return { url }; + } catch (error) { + if (error instanceof ApiError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new ApiError(502, "bundle_publish_failed", `Failed to publish bundle: ${message}`); + } finally { + clearTimeout(timer); + } +} + +export async function fetchSharedBundle(bundleUrl: unknown, options?: { timeoutMs?: number }): Promise { + let url: URL; + try { + url = new URL(String(bundleUrl ?? "").trim()); + } catch { + throw new ApiError(400, "invalid_bundle_url", "Invalid shared bundle URL"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new ApiError(400, "invalid_bundle_url", "Shared bundle URL must use http(s)"); + } + + if (!url.searchParams.has("format")) { + url.searchParams.set("format", "json"); + } + + const timeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) ? options.timeoutMs : 15_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(1_000, timeoutMs)); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + + if (!response.ok) { + const details = await readErrorMessage(response); + const suffix = details ? `: ${details}` : ""; + throw new ApiError(502, "bundle_fetch_failed", `Failed to fetch bundle (${response.status})${suffix}`); + } + + return await response.json(); + } catch (error) { + if (error instanceof ApiError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new ApiError(502, "bundle_fetch_failed", `Failed to fetch bundle: ${message}`); + } finally { + clearTimeout(timer); + } +} diff --git a/apps/server/src/template-files.test.ts b/apps/server/src/template-files.test.ts new file mode 100644 index 00000000..3621a61a --- /dev/null +++ b/apps/server/src/template-files.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { listTemplateFiles, planTemplateFiles, writeTemplateFiles } from "./template-files.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }); + } +}); + +async function makeWorkspace(): Promise { + const dir = await mkdtemp(join(tmpdir(), "openwork-template-files-")); + tempDirs.push(dir); + await mkdir(join(dir, ".opencode"), { recursive: true }); + return dir; +} + +describe("template files", () => { + test("lists only extra shareable .opencode files", async () => { + const workspaceRoot = await makeWorkspace(); + await mkdir(join(workspaceRoot, ".opencode", "agents"), { recursive: true }); + await mkdir(join(workspaceRoot, ".opencode", "plugins"), { recursive: true }); + await mkdir(join(workspaceRoot, ".opencode", "skills", "demo"), { recursive: true }); + await mkdir(join(workspaceRoot, ".opencode", "commands"), { recursive: true }); + + await writeFile(join(workspaceRoot, ".opencode", "agents", "openwork.md"), "# agent\n", "utf8"); + await writeFile(join(workspaceRoot, ".opencode", "plugins", "router.json"), '{"enabled":true}\n', "utf8"); + await writeFile(join(workspaceRoot, ".opencode", "skills", "demo", "SKILL.md"), "# skill\n", "utf8"); + await writeFile(join(workspaceRoot, ".opencode", "commands", "demo.md"), "# command\n", "utf8"); + await writeFile(join(workspaceRoot, ".opencode", "openwork.json"), '{"version":1}\n', "utf8"); + await writeFile(join(workspaceRoot, ".opencode", "opencode.db"), "sqlite-bytes", "utf8"); + await writeFile(join(workspaceRoot, ".opencode", ".env"), "SECRET=value\n", "utf8"); + + const files = await listTemplateFiles(workspaceRoot); + + expect(files).toEqual([ + { path: ".opencode/agents/openwork.md", content: "# agent\n" }, + { path: ".opencode/plugins/router.json", content: '{"enabled":true}\n' }, + ]); + }); + + test("plans and writes validated template files", async () => { + const workspaceRoot = await makeWorkspace(); + const planned = planTemplateFiles(workspaceRoot, [ + { path: ".opencode/agents/demo.md", content: "hello\n" }, + ]); + + expect(planned[0]?.absolutePath.endsWith("/.opencode/agents/demo.md")).toBe(true); + + await writeTemplateFiles(workspaceRoot, [ + { path: ".opencode/agents/demo.md", content: "hello\n" }, + ]); + + const contents = await readFile(join(workspaceRoot, ".opencode", "agents", "demo.md"), "utf8"); + expect(contents).toBe("hello\n"); + }); + + test("rejects env files and path traversal", async () => { + const workspaceRoot = await makeWorkspace(); + + expect(() => + planTemplateFiles(workspaceRoot, [{ path: ".opencode/.env", content: "SECRET=value" }]), + ).toThrow(/not allowed/i); + + expect(() => + planTemplateFiles(workspaceRoot, [{ path: "../outside.md", content: "oops" }]), + ).toThrow(/invalid/i); + }); +}); diff --git a/apps/server/src/template-files.ts b/apps/server/src/template-files.ts new file mode 100644 index 00000000..7c56561e --- /dev/null +++ b/apps/server/src/template-files.ts @@ -0,0 +1,156 @@ +import { readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +import { ApiError } from "./errors.js"; +import { ensureDir, exists } from "./utils.js"; + +export type TemplateFile = { + path: string; + content: string; +}; + +export type PlannedTemplateFile = TemplateFile & { + absolutePath: string; +}; + +const RESERVED_TEMPLATE_EXACT_PATHS = new Set([ + ".opencode/openwork.json", + ".opencode/openwork.jsonc", + ".opencode/opencode.db", +]); + +const RESERVED_TEMPLATE_PREFIXES = [ + ".opencode/commands/", + ".opencode/skills/", +]; + +const RESERVED_TEMPLATE_SEGMENTS = new Set([".DS_Store", "Thumbs.db"]); + +function normalizeTemplatePath(input: unknown): string { + const normalized = String(input ?? "") + .replaceAll("\\", "/") + .replace(/\/+/g, "/") + .replace(/^\.\//, "") + .replace(/^\/+/, "") + .trim(); + + if (!normalized) { + throw new ApiError(400, "invalid_template_file_path", "Template file path is required"); + } + + if (normalized.includes("\0")) { + throw new ApiError(400, "invalid_template_file_path", `Template file path contains an invalid byte: ${normalized}`); + } + + const segments = normalized.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new ApiError(400, "invalid_template_file_path", `Template file path is invalid: ${normalized}`); + } + + return normalized; +} + +function isEnvFilePath(path: string): boolean { + return path + .split("/") + .some((segment) => /^\.env(?:\..+)?$/i.test(segment)); +} + +function isReservedTemplatePath(path: string): boolean { + if (RESERVED_TEMPLATE_EXACT_PATHS.has(path)) return true; + if (RESERVED_TEMPLATE_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; + + const segments = path.split("/"); + if (segments.some((segment) => RESERVED_TEMPLATE_SEGMENTS.has(segment))) return true; + + const name = segments[segments.length - 1] ?? ""; + return /(?:\.db(?:-shm|-wal)?|\.sqlite(?:-shm|-wal)?)$/i.test(name); +} + +export function isAllowedTemplateFilePath(input: unknown): boolean { + const path = normalizeTemplatePath(input); + if (!path.startsWith(".opencode/")) return false; + if (isEnvFilePath(path)) return false; + if (isReservedTemplatePath(path)) return false; + return true; +} + +function normalizeTemplateFile(value: unknown): TemplateFile { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ApiError(400, "invalid_template_file", "Template files must be objects with path and content"); + } + + const record = value as Record; + const path = normalizeTemplatePath(record.path); + if (!isAllowedTemplateFilePath(path)) { + throw new ApiError(400, "invalid_template_file_path", `Template file path is not allowed: ${path}`); + } + + return { + path, + content: typeof record.content === "string" ? record.content : String(record.content ?? ""), + }; +} + +export function planTemplateFiles(workspaceRoot: string, value: unknown): PlannedTemplateFile[] { + if (!Array.isArray(value) || !value.length) return []; + + const root = resolve(workspaceRoot); + return value.map((entry) => { + const file = normalizeTemplateFile(entry); + return { + ...file, + absolutePath: join(root, file.path), + }; + }); +} + +async function walkTemplateFiles(root: string, currentPath: string, output: TemplateFile[]): Promise { + const entries = await readdir(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const absolutePath = join(currentPath, entry.name); + if (entry.isDirectory()) { + await walkTemplateFiles(root, absolutePath, output); + continue; + } + if (!entry.isFile()) continue; + + const relativePath = normalizeTemplatePath(absolutePath.slice(root.length + 1)); + if (!isAllowedTemplateFilePath(relativePath)) continue; + output.push({ + path: relativePath, + content: await readFile(absolutePath, "utf8"), + }); + } +} + +export async function listTemplateFiles(workspaceRoot: string): Promise { + const root = resolve(workspaceRoot); + const templateRoot = join(root, ".opencode"); + if (!(await exists(templateRoot))) return []; + + const output: TemplateFile[] = []; + await walkTemplateFiles(root, templateRoot, output); + output.sort((a, b) => a.path.localeCompare(b.path)); + return output; +} + +export async function writeTemplateFiles(workspaceRoot: string, value: unknown, options?: { replace?: boolean }): Promise { + const files = planTemplateFiles(workspaceRoot, value); + if (!files.length) return []; + + if (options?.replace) { + const existing = await listTemplateFiles(workspaceRoot); + for (const file of existing) { + await rm(join(resolve(workspaceRoot), file.path), { force: true }); + } + } + + for (const file of files) { + await ensureDir(dirname(file.absolutePath)); + await writeFile(file.absolutePath, file.content, "utf8"); + } + + return files; +} diff --git a/apps/share/README.md b/apps/share/README.md index 9e7a7199..9ccda3d4 100644 --- a/apps/share/README.md +++ b/apps/share/README.md @@ -42,7 +42,7 @@ It keeps the existing bundle APIs, but the public share surface now runs as a si - `skills-set` - A full skills pack (multiple skills) exported from a worker. - `workspace-profile` - - Full workspace profile payload (config, MCP/OpenCode settings, commands, skills, and agent config). + - Full workspace template payload (config, MCP/OpenCode settings, commands, skills, and extra shareable `.opencode/**` files, excluding env/runtime files). ## Packager input support diff --git a/apps/share/pr/openwork-template-files-share-page.png b/apps/share/pr/openwork-template-files-share-page.png new file mode 100644 index 00000000..2ba1622f Binary files /dev/null and b/apps/share/pr/openwork-template-files-share-page.png differ diff --git a/apps/share/server/_lib/share-utils.test.ts b/apps/share/server/_lib/share-utils.test.ts index 13f7f1dc..7cf12590 100644 --- a/apps/share/server/_lib/share-utils.test.ts +++ b/apps/share/server/_lib/share-utils.test.ts @@ -41,6 +41,7 @@ test("buildBundlePreviewSelections exposes workspace configs alongside skills", config: { "team-rules.json": { strict: true }, }, + files: [{ path: ".opencode/agents/openwork.md", content: "# OpenWork\n" }], }, skills: [], commands: [], @@ -50,10 +51,20 @@ test("buildBundlePreviewSelections exposes workspace configs alongside skills", assert.deepEqual( selections.map((selection) => selection.filename), - ["workspace-guide.md", "daily-sync.md", "concierge.json", "github.json", "opencode.json", "openwork.json", "team-rules.json"], + [ + "workspace-guide.md", + "daily-sync.md", + "concierge.json", + "github.json", + "opencode.json", + "openwork.json", + "team-rules.json", + "openwork.md", + ], ); assert.equal(selections[4]?.label, "OpenCode settings"); assert.equal(selections[5]?.label, "Workspace settings"); + assert.match(selections[7]?.label ?? "", /Agent file/); }); test("buildOgImageUrls returns typed platform variants", () => { diff --git a/apps/share/server/_lib/share-utils.ts b/apps/share/server/_lib/share-utils.ts index 6dd8c470..c42bdd39 100644 --- a/apps/share/server/_lib/share-utils.ts +++ b/apps/share/server/_lib/share-utils.ts @@ -6,9 +6,10 @@ import type { BundleUrls, Frontmatter, NormalizedBundle, - OgImageUrlSet, NormalizedCommandItem, NormalizedSkillItem, + NormalizedTemplateFileItem, + OgImageUrlSet, OpenInAppUrls, RequestLike, ValidationResult, @@ -92,6 +93,9 @@ export function escapeJsonForScript(rawJson: string): string { export function humanizeType(type: unknown): string { if (!type) return "Bundle"; + if (String(type).trim().toLowerCase() === "workspace-profile") { + return "Workspace Template"; + } return String(type) .split("-") .filter(Boolean) @@ -253,6 +257,43 @@ function normalizeCommandItem(value: unknown): NormalizedCommandItem | null { }; } +function normalizeTemplateFileItem(value: unknown): NormalizedTemplateFileItem | null { + const record = maybeObject(value); + if (!record) return null; + const path = maybeString(record.path).trim(); + if (!path) return null; + return { + path, + content: maybeString(record.content), + }; +} + +function workspaceTemplateFiles(bundle: NormalizedBundle): NormalizedTemplateFileItem[] { + return maybeArray(bundle.workspace?.files) + .map(normalizeTemplateFileItem) + .filter((file): file is NormalizedTemplateFileItem => file !== null); +} + +function basename(path: string): string { + const normalized = String(path ?? "").replaceAll("\\", "/"); + const segments = normalized.split("/").filter(Boolean); + return segments[segments.length - 1] ?? normalized; +} + +function templateFilePreviewMeta(path: string): { kind: PreviewItem["kind"]; tone: PreviewItem["tone"]; label: string } { + const normalized = String(path ?? "").toLowerCase(); + if (normalized.startsWith(".opencode/agents/")) { + return { kind: "Agent", tone: "agent", label: "Agent file" }; + } + if (normalized.startsWith(".opencode/commands/")) { + return { kind: "Command", tone: "command", label: "Command file" }; + } + if (normalized.startsWith(".opencode/skills/")) { + return { kind: "Skill", tone: "skill", label: "Skill file" }; + } + return { kind: "Config", tone: "config", label: "Template file" }; +} + export function parseBundle(rawJson: string): NormalizedBundle { try { return normalizeBundleRecord(JSON.parse(rawJson)); @@ -322,6 +363,7 @@ export function getBundleCounts(bundle: NormalizedBundle): BundleCounts { const openwork = maybeObject(bundle.workspace?.openwork); const genericConfig = maybeObject(bundle.workspace?.config); const commands = maybeArray(bundle.workspace?.commands).map(normalizeCommandItem).filter((c): c is NormalizedCommandItem => c !== null); + const files = workspaceTemplateFiles(bundle); const agentEntries = Object.entries(maybeObject(opencode?.agent) ?? {}); const mcpEntries = Object.entries(maybeObject(opencode?.mcp) ?? {}); const opencodeConfigKeys = Object.keys(opencode ?? {}).filter((key) => !["agent", "mcp"].includes(key)); @@ -339,6 +381,7 @@ export function getBundleCounts(bundle: NormalizedBundle): BundleCounts { agentCount: agentEntries.length, mcpCount: mcpEntries.length, configCount: (openwork ? 1 : 0) + (opencodeConfigKeys.length ? 1 : 0) + Object.keys(genericConfig ?? {}).length, + fileCount: files.length, hasConfig: Boolean(openwork || opencodeConfigKeys.length || genericConfig), }; } @@ -445,6 +488,16 @@ export function collectBundleItems(bundle: NormalizedBundle, limit = 8): Preview tone: "config", }); } + + for (const file of workspaceTemplateFiles(bundle)) { + const preview = templateFilePreviewMeta(file.path); + items.push({ + name: basename(file.path), + kind: preview.kind, + meta: file.path, + tone: preview.tone, + }); + } } return items.slice(0, limit); @@ -587,6 +640,18 @@ export function buildBundlePreview(bundle: NormalizedBundle): { }); } + const files = workspaceTemplateFiles(bundle); + if (files.length) { + const firstFile = files[0]!; + const preview = templateFilePreviewMeta(firstFile.path); + return buildBundlePreviewSelection({ + filename: basename(firstFile.path), + text: buildTextPreview(firstFile.content, `# ${basename(firstFile.path) || "OpenWork template file"}`), + tone: preview.tone, + label: `${preview.label} · ${firstFile.path}`, + }); + } + return buildBundlePreviewSelection({ filename: "bundle.json", text: buildJsonPreview(bundle, '{\n "bundle": true\n}'), @@ -738,6 +803,21 @@ export function buildBundlePreviewSelections(bundle: NormalizedBundle): { })); } + const files = workspaceTemplateFiles(bundle); + if (files.length) { + selections.push(...files.map((file, index) => { + const preview = templateFilePreviewMeta(file.path); + return { + id: `workspace-file-${index}`, + name: basename(file.path), + filename: basename(file.path), + text: buildTextPreview(file.content, `# ${basename(file.path) || `File ${index + 1}`}`), + tone: preview.tone, + label: `${preview.label} · ${file.path}`, + }; + })); + } + if (selections.length) return selections; const preview = buildBundlePreview(bundle); @@ -776,6 +856,7 @@ export function buildBundleNarrative(bundle: NormalizedBundle): string { if (counts.mcpCount) parts.push(`${counts.mcpCount} MCP${counts.mcpCount === 1 ? "" : "s"}`); if (counts.commandCount) parts.push(`${counts.commandCount} command${counts.commandCount === 1 ? "" : "s"}`); if (counts.configCount) parts.push(`${counts.configCount} config${counts.configCount === 1 ? "" : "s"}`); + if (counts.fileCount) parts.push(`${counts.fileCount} template file${counts.fileCount === 1 ? "" : "s"}`); return parts.length ? `${parts.join(", ")} bundled into a worker package that imports through OpenWork with one step.` : "Worker configuration bundle prepared for OpenWork import."; diff --git a/apps/share/server/_lib/types.ts b/apps/share/server/_lib/types.ts index 3795c1cf..dd6c1965 100644 --- a/apps/share/server/_lib/types.ts +++ b/apps/share/server/_lib/types.ts @@ -40,6 +40,11 @@ export interface NormalizedCommandItem { subtask: boolean; } +export interface NormalizedTemplateFileItem { + path: string; + content: string; +} + export interface NormalizedBundle { schemaVersion: number | null; type: string; @@ -58,6 +63,7 @@ export interface BundleCounts { agentCount: number; mcpCount: number; configCount: number; + fileCount: number; hasConfig: boolean; } diff --git a/apps/share/server/b/get-bundle-page-props.ts b/apps/share/server/b/get-bundle-page-props.ts index 7751d70d..3a0973fe 100644 --- a/apps/share/server/b/get-bundle-page-props.ts +++ b/apps/share/server/b/get-bundle-page-props.ts @@ -28,7 +28,8 @@ function buildMetadataRows( ...(counts.agentCount ? [{ label: "Agents", value: String(counts.agentCount) }] : []), ...(counts.mcpCount ? [{ label: "MCPs", value: String(counts.mcpCount) }] : []), ...(counts.commandCount ? [{ label: "Commands", value: String(counts.commandCount) }] : []), - ...(counts.configCount ? [{ label: "Configs", value: String(counts.configCount) }] : []) + ...(counts.configCount ? [{ label: "Configs", value: String(counts.configCount) }] : []), + ...(counts.fileCount ? [{ label: "Files", value: String(counts.fileCount) }] : []) ]; } @@ -68,7 +69,7 @@ export async function getBundlePageProps({ id, requestLike }: { id: string; requ ? "Open in app to choose where to add this skill." : bundle.type === "skills-set" ? "Open in app to add this full skills set to an existing worker or create a new worker with it attached." - : "Open in app to create a new worker with these skills, agents, MCPs, and config already bundled."; + : "Open in app to create a new worker with these skills, commands, config, and extra template files already bundled."; return { missing: false,