Files
openchamber/packages/web/server/lib/opencode/shared.js
T
Serhii Dziupin 6d6ece6856 fix(config): fail closed when config content yields no JSON value
Treating every undefined parse as empty config let a file that is not JSON
at all (YAML, plain text) read as {}, so a later write would back it up and
replace it - the same data loss this fix is meant to prevent. Only a
comment-only parse, where ValueExpected is the sole error, counts as empty.
2026-08-17 16:44:17 +03:00

674 lines
19 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
// ============== PATH CONSTANTS ==============
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
const SKILL_SCOPE = {
USER: 'user',
PROJECT: 'project'
};
// ============== DIRECTORY OPERATIONS ==============
function ensureDirs() {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) {
fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
}
if (!fs.existsSync(AGENT_DIR)) {
fs.mkdirSync(AGENT_DIR, { recursive: true });
}
if (!fs.existsSync(COMMAND_DIR)) {
fs.mkdirSync(COMMAND_DIR, { recursive: true });
}
if (!fs.existsSync(SKILL_DIR)) {
fs.mkdirSync(SKILL_DIR, { recursive: true });
}
}
// ============== MARKDOWN FILE OPERATIONS ==============
// Mirror of OpenCode's markdown frontmatter sanitizer (packages/opencode/src/
// config/markdown.ts): other coding agents accept unquoted colons in YAML
// values (e.g. `description: Build agent: creates builds`), which strict YAML
// rejects. Rewrite those values as block scalars and retry the parse, so files
// OpenCode accepts are parsed identically here.
function sanitizeFrontmatter(frontmatter) {
return frontmatter
.split(/\r?\n/)
.flatMap((line) => {
if (line.trim().startsWith('#') || line.trim() === '' || /^\s+/.test(line)) return [line];
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
if (!entry) return [line];
const value = entry[2].trim();
if (value === '' || value === '>' || value === '|' || value.startsWith('"') || value.startsWith("'")) return [line];
if (!value.includes(':')) return [line];
return [`${entry[1]}: |-`, ` ${value}`];
})
.join('\n');
}
function parseMdFile(filePath) {
const rawContent = fs.readFileSync(filePath, 'utf8');
// Strip a UTF-8 BOM so frontmatter is recognized regardless of the editor
// that saved the file.
const content = rawContent.charCodeAt(0) === 0xfeff ? rawContent.slice(1) : rawContent;
// The closing `---` may sit at end-of-file without a trailing newline.
// gray-matter (used by OpenCode) accepts that, so we must too: otherwise the
// whole file is treated as the prompt body and a later save rewrites the
// existing YAML block into the body, duplicating the frontmatter.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content.trim() };
}
let frontmatter = {};
try {
frontmatter = yaml.parse(match[1]) || {};
} catch (error) {
// Lenient fallback for frontmatter that strict YAML rejects but OpenCode
// still accepts (unquoted colons in scalar values).
try {
frontmatter = yaml.parse(sanitizeFrontmatter(match[1])) || {};
} catch {
console.warn(`Failed to parse markdown frontmatter ${filePath}, treating as empty:`, error);
frontmatter = {};
}
}
const body = match[2].trim();
return { frontmatter, body };
}
function writeMdFile(filePath, frontmatter, body) {
try {
const cleanedFrontmatter = Object.fromEntries(
Object.entries(frontmatter).filter(([, value]) => value != null)
);
const yamlStr = yaml.stringify(cleanedFrontmatter);
const content = `---\n${yamlStr}---\n\n${body}`;
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Successfully wrote markdown file: ${filePath}`);
} catch (error) {
console.error(`Failed to write markdown file ${filePath}:`, error);
throw new Error('Failed to write agent markdown file');
}
}
// ============== CONFIG FILE OPERATIONS ==============
function getProjectConfigCandidates(workingDirectory) {
if (!workingDirectory) return [];
return [
path.join(workingDirectory, 'opencode.json'),
path.join(workingDirectory, 'opencode.jsonc'),
path.join(workingDirectory, '.opencode', 'opencode.json'),
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
];
}
function getProjectConfigPath(workingDirectory) {
if (!workingDirectory) return null;
const candidates = getProjectConfigCandidates(workingDirectory);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
function getConfigPaths(workingDirectory) {
return {
userPaths: [
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}
function getPrimaryUserConfigPath(userPaths) {
for (const userPath of userPaths) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return CONFIG_FILE;
}
const INVALID_JSONC = 'INVALID_JSONC';
function isInvalidJsoncError(error) {
return Boolean(error && typeof error === 'object' && error.code === INVALID_JSONC);
}
function formatJsoncParseError(filePath, errors) {
const first = Array.isArray(errors) && errors.length > 0 ? errors[0] : null;
const location = first && Number.isFinite(first.offset)
? ` (${printParseErrorCode(first.error)} at offset ${first.offset})`
: '';
return `OpenCode configuration at ${filePath} contains invalid JSONC and cannot be loaded safely${location}`;
}
function isCommentOnlyParse(parsed, errors) {
// Comment-only / whitespace-only files parse to undefined with nothing but
// ValueExpected. Any other error means real content we failed to understand
// (YAML, plain text, a stray leading token), which must not read as empty.
return parsed === undefined
&& errors.every((entry) => printParseErrorCode(entry.error) === 'ValueExpected');
}
function parseConfigObject(content, filePath) {
const errors = [];
const parsed = parseJsonc(content, errors, { allowTrailingComma: true });
if (isCommentOnlyParse(parsed, errors)) {
return {};
}
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
const error = new Error(formatJsoncParseError(filePath, errors));
error.code = INVALID_JSONC;
throw error;
}
return parsed;
}
function readConfigFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return {};
}
try {
const content = fs.readFileSync(filePath, 'utf8');
const normalized = content.trim();
if (!normalized) {
return {};
}
// Refuse partial jsonc-parser trees. Ignoring errors previously let mutations
// rewrite a truncated object (often only `$schema`) over the full config.
return parseConfigObject(normalized, filePath);
} catch (error) {
if (isInvalidJsoncError(error)) {
throw error;
}
console.error(`Failed to read config file: ${filePath}`, error);
throw new Error('Failed to read OpenCode configuration');
}
}
function isPlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}
function mergeConfigs(base, override) {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (key in result) {
const baseValue = result[key];
if (isPlainObject(baseValue) && isPlainObject(value)) {
result[key] = mergeConfigs(baseValue, value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
function readConfigLayer(filePath) {
try {
return { config: readConfigFile(filePath), error: null };
} catch (error) {
if (isInvalidJsoncError(error)) {
console.error(error.message);
return { config: {}, error };
}
throw error;
}
}
function readConfigLayers(workingDirectory) {
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
const userPath = getPrimaryUserConfigPath(userPaths);
const userLayer = readConfigLayer(userPath);
const projectLayer = readConfigLayer(projectPath);
const customLayer = readConfigLayer(customPath);
const mergedConfig = mergeConfigs(
mergeConfigs(userLayer.config, projectLayer.config),
customLayer.config,
);
const layerErrors = [];
if (userLayer.error) {
layerErrors.push({ path: userPath, code: userLayer.error.code, message: userLayer.error.message });
}
if (projectLayer.error && projectPath) {
layerErrors.push({ path: projectPath, code: projectLayer.error.code, message: projectLayer.error.message });
}
if (customLayer.error && customPath) {
layerErrors.push({ path: customPath, code: customLayer.error.code, message: customLayer.error.message });
}
return {
userConfig: userLayer.config,
projectConfig: projectLayer.config,
customConfig: customLayer.config,
mergedConfig,
paths: { userPath, projectPath, customPath },
layerErrors,
};
}
function readConfig(workingDirectory) {
return readConfigLayers(workingDirectory).mergedConfig;
}
function getConfigForPath(layers, targetPath) {
if (!targetPath) {
return layers.userConfig;
}
if (layers.paths.customPath && targetPath === layers.paths.customPath) {
return layers.customConfig;
}
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) {
return layers.projectConfig;
}
return layers.userConfig;
}
function writeConfig(config, filePath = CONFIG_FILE) {
try {
if (fs.existsSync(filePath)) {
// Defense in depth: never overwrite a file we cannot fully parse.
const existing = fs.readFileSync(filePath, 'utf8').trim();
if (existing) {
parseConfigObject(existing, filePath);
}
const backupFile = `${filePath}.openchamber.backup`;
fs.copyFileSync(filePath, backupFile);
console.log(`Created config backup: ${backupFile}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
console.log(`Successfully wrote config file: ${filePath}`);
} catch (error) {
if (isInvalidJsoncError(error)) {
throw error;
}
console.error(`Failed to write config file: ${filePath}`, error);
throw new Error('Failed to write OpenCode configuration');
}
}
function getLayerError(layers, filePath) {
if (!filePath || !Array.isArray(layers?.layerErrors)) {
return null;
}
return layers.layerErrors.find((entry) => entry.path === filePath) || null;
}
function throwIfLayerError(layers, filePath) {
const failed = getLayerError(layers, filePath);
if (!failed) {
return;
}
const error = new Error(failed.message);
error.code = failed.code;
throw error;
}
function getJsonEntrySource(layers, sectionKey, entryName) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
throwIfLayerError(layers, paths.customPath);
const customSection = customConfig?.[sectionKey]?.[entryName];
if (customSection !== undefined) {
return { section: customSection, config: customConfig, path: paths.customPath, exists: true };
}
}
if (paths.projectPath && !getLayerError(layers, paths.projectPath)) {
const projectSection = projectConfig?.[sectionKey]?.[entryName];
if (projectSection !== undefined) {
return { section: projectSection, config: projectConfig, path: paths.projectPath, exists: true };
}
}
throwIfLayerError(layers, paths.userPath);
const userSection = userConfig?.[sectionKey]?.[entryName];
if (userSection !== undefined) {
return { section: userSection, config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
}
function getJsonWriteTarget(layers, preferredScope) {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
throwIfLayerError(layers, paths.customPath);
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
throwIfLayerError(layers, paths.projectPath);
return { config: projectConfig, path: paths.projectPath };
}
throwIfLayerError(layers, paths.userPath);
return { config: userConfig, path: paths.userPath };
}
// ============== GIT/WORKTREE HELPERS ==============
function getAncestors(startDir, stopDir) {
if (!startDir) return [];
const result = [];
let current = path.resolve(startDir);
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
while (true) {
result.push(current);
if (resolvedStop && current === resolvedStop) {
break;
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return result;
}
function findWorktreeRoot(startDir) {
if (!startDir) return null;
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
// ============== PROMPT FILE HELPERS ==============
function isPromptFileReference(value) {
if (typeof value !== 'string') {
return false;
}
return PROMPT_FILE_PATTERN.test(value.trim());
}
function resolvePromptFilePath(reference) {
const match = typeof reference === 'string' ? reference.trim().match(PROMPT_FILE_PATTERN) : null;
if (!match) {
return null;
}
let target = match[1].trim();
if (!target) {
return null;
}
if (target.startsWith('./')) {
target = target.slice(2);
target = path.join(OPENCODE_CONFIG_DIR, target);
} else if (!path.isAbsolute(target)) {
target = path.join(OPENCODE_CONFIG_DIR, target);
}
return target;
}
function writePromptFile(filePath, content) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content ?? '', 'utf8');
console.log(`Updated prompt file: ${filePath}`);
}
// ============== SKILL FILE OPERATIONS ==============
function walkSkillMdFiles(rootDir) {
if (!rootDir || !fs.existsSync(rootDir)) return [];
const results = [];
const walk = (dir) => {
let entries = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'SKILL.md') {
results.push(fullPath);
}
}
};
walk(rootDir);
return results;
}
function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) {
let parsed;
try {
parsed = parseMdFile(skillMdPath);
} catch {
return;
}
const name = typeof parsed.frontmatter?.name === 'string'
? parsed.frontmatter.name.trim()
: '';
const description = typeof parsed.frontmatter?.description === 'string'
? parsed.frontmatter.description
: '';
if (!name) {
return;
}
skillsMap.set(name, {
name,
path: skillMdPath,
scope,
source,
description,
});
}
function resolveSkillSearchDirectories(workingDirectory) {
const directories = [];
const pushDir = (dir) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!directories.includes(resolved)) {
directories.push(resolved);
}
};
pushDir(OPENCODE_CONFIG_DIR);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
.map((dir) => path.join(dir, '.opencode'));
projectDirs.forEach(pushDir);
}
pushDir(path.join(os.homedir(), '.opencode'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
pushDir(customConfigDir);
return directories;
}
function listSkillSupportingFiles(skillDir) {
if (!fs.existsSync(skillDir)) {
return [];
}
const files = [];
function walkDir(dir, relativePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
walkDir(fullPath, relPath);
} else if (entry.name !== 'SKILL.md') {
files.push({
name: entry.name,
path: relPath,
fullPath: fullPath
});
}
}
}
walkDir(skillDir);
return files;
}
function assertPathWithinSkillDir(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const target = path.resolve(root, relativePath);
const relative = path.relative(root, target);
const isWithin = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
if (!isWithin) {
const error = new Error('Access to file denied');
error.code = 'EACCES';
throw error;
}
return target;
}
function readSkillSupportingFile(skillDir, relativePath) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (!fs.existsSync(fullPath)) {
return null;
}
return fs.readFileSync(fullPath, 'utf8');
}
function writeSkillSupportingFile(skillDir, relativePath, content) {
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
const dir = path.dirname(fullPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
}
function deleteSkillSupportingFile(skillDir, relativePath) {
const root = fs.realpathSync(skillDir);
const fullPath = assertPathWithinSkillDir(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
let parentDir = path.dirname(fullPath);
while (parentDir !== root) {
try {
const entries = fs.readdirSync(parentDir);
if (entries.length === 0) {
fs.rmdirSync(parentDir);
parentDir = path.dirname(parentDir);
} else {
break;
}
} catch {
break;
}
}
}
}
export {
OPENCODE_CONFIG_DIR,
AGENT_DIR,
COMMAND_DIR,
SKILL_DIR,
CONFIG_FILE,
AGENT_SCOPE,
COMMAND_SCOPE,
SKILL_SCOPE,
ensureDirs,
parseMdFile,
writeMdFile,
readConfigFile,
readConfigLayer,
isPlainObject,
readConfigLayers,
readConfig,
getConfigForPath,
writeConfig,
getJsonEntrySource,
getJsonWriteTarget,
getAncestors,
findWorktreeRoot,
isPromptFileReference,
resolvePromptFilePath,
writePromptFile,
walkSkillMdFiles,
addSkillFromMdFile,
resolveSkillSearchDirectories,
listSkillSupportingFiles,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
};