import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import zlib from "node:zlib"; const ZIP_LOCAL_FILE_HEADER = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_HEADER = 0x02014b50; const ZIP_END_OF_CENTRAL_DIRECTORY = 0x06054b50; const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024; const MAX_ARCHIVE_ENTRIES = 1024; const MAX_ENTRY_UNCOMPRESSED_BYTES = 16 * 1024 * 1024; const MAX_TOTAL_UNCOMPRESSED_BYTES = 64 * 1024 * 1024; function nowMs() { return Date.now(); } function isSecretName(name) { const lower = String(name ?? "").toLowerCase(); if (lower === ".env" || lower.startsWith(".env.")) return true; if (lower === "credentials.json" || lower === "credentials.yml" || lower === "credentials.yaml") return true; return lower.endsWith(".key") || lower.endsWith(".pem") || lower.endsWith(".p12") || lower.endsWith(".pfx"); } function normalizeZipPath(filePath) { return filePath.replace(/\\/g, "/"); } async function pathExists(targetPath) { try { await stat(targetPath); return true; } catch { return false; } } async function isDirectoryEmpty(targetPath) { try { const entries = await readdir(targetPath); return entries.length === 0; } catch { return true; } } async function collectFiles(root, current = root) { const out = []; const entries = await readdir(current, { withFileTypes: true }).catch(() => []); for (const entry of entries) { const absolute = path.join(current, entry.name); if (entry.isDirectory()) { out.push(...(await collectFiles(root, absolute))); } else if (entry.isFile()) { const rel = normalizeZipPath(path.relative(root, absolute)); out.push({ absolute, rel }); } } return out; } async function collectWorkspaceEntries(workspaceRoot) { const entries = []; const excluded = []; const configPath = path.join(workspaceRoot, "opencode.json"); if (await pathExists(configPath)) { if (isSecretName(path.basename(configPath))) { excluded.push("opencode.json"); } else { entries.push({ absolute: configPath, rel: "opencode.json" }); } } const opencodeDir = path.join(workspaceRoot, ".opencode"); if (await pathExists(opencodeDir)) { for (const file of await collectFiles(workspaceRoot, opencodeDir)) { if (isSecretName(path.basename(file.absolute))) { if (!excluded.includes(file.rel)) excluded.push(file.rel); continue; } entries.push(file); } } return { entries, excluded }; } let crcTable = null; function crc32(buffer) { if (!crcTable) { crcTable = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } crcTable[n] = c >>> 0; } } let crc = 0xffffffff; for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function localFileHeader(nameBuffer, data, checksum) { const header = Buffer.alloc(30); header.writeUInt32LE(ZIP_LOCAL_FILE_HEADER, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(0, 6); header.writeUInt16LE(0, 8); header.writeUInt16LE(0, 10); header.writeUInt16LE(0, 12); header.writeUInt32LE(checksum, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(nameBuffer.length, 26); header.writeUInt16LE(0, 28); return header; } function centralDirectoryHeader(nameBuffer, data, checksum, offset) { const header = Buffer.alloc(46); header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_HEADER, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(20, 6); header.writeUInt16LE(0, 8); header.writeUInt16LE(0, 10); header.writeUInt16LE(0, 12); header.writeUInt16LE(0, 14); header.writeUInt32LE(checksum, 16); header.writeUInt32LE(data.length, 20); header.writeUInt32LE(data.length, 24); header.writeUInt16LE(nameBuffer.length, 28); header.writeUInt16LE(0, 30); header.writeUInt16LE(0, 32); header.writeUInt16LE(0, 34); header.writeUInt16LE(0, 36); header.writeUInt32LE(0, 38); header.writeUInt32LE(offset, 42); return header; } function endOfCentralDirectory(entryCount, centralSize, centralOffset) { const header = Buffer.alloc(22); header.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY, 0); header.writeUInt16LE(0, 4); header.writeUInt16LE(0, 6); header.writeUInt16LE(entryCount, 8); header.writeUInt16LE(entryCount, 10); header.writeUInt32LE(centralSize, 12); header.writeUInt32LE(centralOffset, 16); header.writeUInt16LE(0, 20); return header; } async function writeZip(outputPath, files) { const chunks = []; const central = []; let offset = 0; for (const file of files) { const nameBuffer = Buffer.from(file.name, "utf8"); const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data); const checksum = crc32(data); const local = localFileHeader(nameBuffer, data, checksum); chunks.push(local, nameBuffer, data); central.push(centralDirectoryHeader(nameBuffer, data, checksum, offset), nameBuffer); offset += local.length + nameBuffer.length + data.length; } const centralOffset = offset; const centralSize = central.reduce((sum, chunk) => sum + chunk.length, 0); chunks.push(...central, endOfCentralDirectory(files.length, centralSize, centralOffset)); await mkdir(path.dirname(outputPath), { recursive: true }); await writeFile(outputPath, Buffer.concat(chunks)); } function findEndOfCentralDirectory(buffer) { const start = Math.max(0, buffer.length - 0xffff - 22); for (let offset = buffer.length - 22; offset >= start; offset -= 1) { if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY) return offset; } throw new Error("Failed to find ZIP central directory."); } function listZipEntries(buffer) { if (buffer.byteLength > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive is too large."); const eocd = findEndOfCentralDirectory(buffer); const count = buffer.readUInt16LE(eocd + 10); if (count > MAX_ARCHIVE_ENTRIES) throw new Error("Workspace archive contains too many entries."); const centralOffset = buffer.readUInt32LE(eocd + 16); const entries = []; let totalUncompressed = 0; let cursor = centralOffset; for (let i = 0; i < count; i += 1) { if (buffer.readUInt32LE(cursor) !== ZIP_CENTRAL_DIRECTORY_HEADER) { throw new Error("Invalid ZIP central directory entry."); } const method = buffer.readUInt16LE(cursor + 10); const compressedSize = buffer.readUInt32LE(cursor + 20); const uncompressedSize = buffer.readUInt32LE(cursor + 24); if (uncompressedSize > MAX_ENTRY_UNCOMPRESSED_BYTES) throw new Error(`Archive entry exceeds the ${MAX_ENTRY_UNCOMPRESSED_BYTES}-byte limit.`); totalUncompressed += uncompressedSize; if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES) throw new Error("Workspace archive contains too much uncompressed data."); const nameLength = buffer.readUInt16LE(cursor + 28); const extraLength = buffer.readUInt16LE(cursor + 30); const commentLength = buffer.readUInt16LE(cursor + 32); const localOffset = buffer.readUInt32LE(cursor + 42); const name = buffer.toString("utf8", cursor + 46, cursor + 46 + nameLength); entries.push({ name, method, compressedSize, uncompressedSize, localOffset }); cursor += 46 + nameLength + extraLength + commentLength; } return entries; } function readZipEntryData(buffer, entry) { if (entry.uncompressedSize > MAX_ENTRY_UNCOMPRESSED_BYTES) throw new Error(`Archive entry exceeds the ${MAX_ENTRY_UNCOMPRESSED_BYTES}-byte limit.`); const cursor = entry.localOffset; if (buffer.readUInt32LE(cursor) !== ZIP_LOCAL_FILE_HEADER) { throw new Error(`Invalid ZIP local header for ${entry.name}.`); } const nameLength = buffer.readUInt16LE(cursor + 26); const extraLength = buffer.readUInt16LE(cursor + 28); const dataStart = cursor + 30 + nameLength + extraLength; const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize); if (entry.method === 0) return compressed; if (entry.method === 8) { return zlib.inflateRawSync(compressed, { finishFlush: zlib.constants.Z_SYNC_FLUSH, maxOutputLength: MAX_ENTRY_UNCOMPRESSED_BYTES, }); } throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}.`); } function isSafeArchivePath(name) { if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name)) return false; const normalized = normalizeZipPath(name); return !normalized.split("/").some((part) => part === ".." || part === ""); } function defaultOpenworkConfig(targetDir, preset = "starter") { return { version: 1, workspace: { name: path.basename(targetDir) || "Workspace", createdAt: nowMs(), preset, }, authorizedRoots: [targetDir], reload: null, }; } export async function exportWorkspaceConfig({ workspace, outputPath }) { if (!workspace?.path || workspace.workspaceType === "remote") { throw new Error("Workspace export is only supported for local workspaces"); } const workspaceRoot = workspace.path; if (!(await pathExists(workspaceRoot))) { throw new Error(`Workspace path not found: ${workspaceRoot}`); } const { entries, excluded } = await collectWorkspaceEntries(workspaceRoot); if (entries.length === 0) throw new Error("No workspace config files found to export"); const files = []; const included = []; for (const entry of entries) { files.push({ name: entry.rel, data: await readFile(entry.absolute) }); included.push(entry.rel); } files.push({ name: "manifest.json", data: `${JSON.stringify({ version: 1, createdAtMs: nowMs(), workspace: { id: workspace.id, name: workspace.name, path: workspace.path }, included, excluded, }, null, 2)}\n`, }); await writeZip(outputPath, files); return { outputPath, included: included.length, excluded }; } export async function importWorkspaceConfig({ archivePath, targetDir, name }) { if (await pathExists(targetDir)) { if (!(await isDirectoryEmpty(targetDir))) throw new Error("Target folder must be empty"); } await mkdir(targetDir, { recursive: true }); const archiveStats = await stat(archivePath); if (archiveStats.size > MAX_ARCHIVE_BYTES) throw new Error("Workspace archive is too large."); const buffer = await readFile(archivePath); for (const entry of listZipEntries(buffer)) { if (entry.name === "manifest.json" || entry.name.endsWith("/")) continue; if (!isSafeArchivePath(entry.name)) throw new Error("Archive contains an unsafe path"); if (!(entry.name === "opencode.json" || entry.name.startsWith(".opencode/"))) continue; if (isSecretName(path.basename(entry.name))) continue; const outPath = path.join(targetDir, ...entry.name.split("/")); await mkdir(path.dirname(outPath), { recursive: true }); await writeFile(outPath, readZipEntryData(buffer, entry)); } const opencodeDir = path.join(targetDir, ".opencode"); if (!(await pathExists(opencodeDir))) throw new Error("Archive is missing .opencode config"); const openworkPath = path.join(opencodeDir, "openwork.json"); let preset = "starter"; let workspaceName = typeof name === "string" && name.trim() ? name.trim() : null; if (await pathExists(openworkPath)) { const raw = await readFile(openworkPath, "utf8"); try { const config = JSON.parse(raw); config.authorizedRoots = [targetDir]; if (!workspaceName && typeof config.workspace?.name === "string" && config.workspace.name.trim()) { workspaceName = config.workspace.name.trim(); } if (typeof config.workspace?.preset === "string" && config.workspace.preset.trim()) { preset = config.workspace.preset.trim(); } await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } catch { const config = defaultOpenworkConfig(targetDir, preset); await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } } else { const config = defaultOpenworkConfig(targetDir, preset); await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } return { workspaceName: workspaceName || path.basename(targetDir) || "Workspace", preset, }; }