410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
|
import { createHash } from 'crypto';
|
|
import { promises as fs } from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
export interface OpenCodeMcpServerConfig {
|
|
type: 'local' | 'remote';
|
|
command?: string;
|
|
args?: string[];
|
|
url?: string;
|
|
enabled: boolean;
|
|
environment?: Record<string, string>;
|
|
timeout?: number;
|
|
}
|
|
|
|
export type OpenCodeBehaviorSourceKind =
|
|
| 'global_config'
|
|
| 'project_config'
|
|
| 'global_plugin_dir'
|
|
| 'project_plugin_dir'
|
|
| 'project_opencode_dir';
|
|
|
|
export interface OpenCodeBehaviorSource {
|
|
kind: OpenCodeBehaviorSourceKind;
|
|
pathHash: string;
|
|
exists: boolean;
|
|
fingerprint: string | null;
|
|
fileCount: number;
|
|
}
|
|
|
|
export interface OpenCodeManagedOverlay {
|
|
launchMode: 'project_root_with_inline_overlay';
|
|
projectPath: string;
|
|
env: {
|
|
OPENCODE_CONFIG_CONTENT: string;
|
|
OPENCODE_DISABLE_AUTOUPDATE?: '1';
|
|
};
|
|
appMcpServerName: string;
|
|
appMcpConfig: OpenCodeMcpServerConfig;
|
|
preservedSources: OpenCodeBehaviorSource[];
|
|
diagnostics: string[];
|
|
}
|
|
|
|
export interface OpenCodeManagedOverlayBuilderInput {
|
|
projectPath: string;
|
|
preferredMcpName: string;
|
|
appMcpCommand: string;
|
|
appMcpArgs: string[];
|
|
appMcpEnv: Record<string, string>;
|
|
mcpTimeoutMs?: number;
|
|
env?: NodeJS.ProcessEnv;
|
|
}
|
|
|
|
export interface OpenCodeBehaviorSourceScannerOptions {
|
|
homePath?: string;
|
|
maxDirectoryFiles?: number;
|
|
}
|
|
|
|
const FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS = [
|
|
'plugin',
|
|
'plugins',
|
|
'agent',
|
|
'command',
|
|
'instructions',
|
|
'formatter',
|
|
'lsp',
|
|
'theme',
|
|
'keybinds',
|
|
'model',
|
|
'mode',
|
|
'provider',
|
|
'tools',
|
|
'skills',
|
|
] as const;
|
|
|
|
export class OpenCodeManagedOverlayBuilder {
|
|
constructor(
|
|
private readonly behaviorSourceScanner = new OpenCodeBehaviorSourceScanner(),
|
|
private readonly clock: () => Date = () => new Date()
|
|
) {}
|
|
|
|
async build(input: OpenCodeManagedOverlayBuilderInput): Promise<OpenCodeManagedOverlay> {
|
|
const preservedSources = await this.behaviorSourceScanner.scan(input.projectPath);
|
|
const existingMcpNames = await this.behaviorSourceScanner.readDeclaredMcpNames(
|
|
input.projectPath
|
|
);
|
|
const appMcpServerName = pickAppOwnedMcpServerName(input.preferredMcpName, existingMcpNames);
|
|
const overlayConfig = buildManagedOverlayConfig({
|
|
serverName: appMcpServerName,
|
|
command: input.appMcpCommand,
|
|
args: input.appMcpArgs,
|
|
environment: input.appMcpEnv,
|
|
timeout: input.mcpTimeoutMs ?? 10_000,
|
|
});
|
|
|
|
assertManagedOverlayDoesNotShadowUserConfig(overlayConfig);
|
|
|
|
return {
|
|
launchMode: 'project_root_with_inline_overlay',
|
|
projectPath: input.projectPath,
|
|
env: applyOpenCodeAutoUpdatePolicy(
|
|
{
|
|
OPENCODE_CONFIG_CONTENT: JSON.stringify(overlayConfig),
|
|
},
|
|
input.env ?? process.env
|
|
),
|
|
appMcpServerName,
|
|
appMcpConfig: overlayConfig.mcp[appMcpServerName],
|
|
preservedSources,
|
|
diagnostics: buildOverlayDiagnostics({
|
|
preferredMcpName: input.preferredMcpName,
|
|
appMcpServerName,
|
|
existingMcpNames,
|
|
preservedSources,
|
|
checkedAt: this.clock().toISOString(),
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class OpenCodeBehaviorSourceScanner {
|
|
private readonly homePath: string;
|
|
private readonly maxDirectoryFiles: number;
|
|
|
|
constructor(options: OpenCodeBehaviorSourceScannerOptions = {}) {
|
|
this.homePath = options.homePath ?? os.homedir();
|
|
this.maxDirectoryFiles = options.maxDirectoryFiles ?? 200;
|
|
}
|
|
|
|
async scan(projectPath: string): Promise<OpenCodeBehaviorSource[]> {
|
|
const sourceSpecs: { kind: OpenCodeBehaviorSourceKind; targetPath: string }[] = [
|
|
{
|
|
kind: 'global_config',
|
|
targetPath: path.join(this.homePath, '.config/opencode/opencode.json'),
|
|
},
|
|
{
|
|
kind: 'global_config',
|
|
targetPath: path.join(this.homePath, '.config/opencode/opencode.jsonc'),
|
|
},
|
|
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.json') },
|
|
{ kind: 'project_config', targetPath: path.join(projectPath, 'opencode.jsonc') },
|
|
{
|
|
kind: 'global_plugin_dir',
|
|
targetPath: path.join(this.homePath, '.config/opencode/plugins'),
|
|
},
|
|
{ kind: 'project_plugin_dir', targetPath: path.join(projectPath, '.opencode/plugins') },
|
|
{ kind: 'project_opencode_dir', targetPath: path.join(projectPath, '.opencode') },
|
|
];
|
|
|
|
return Promise.all(sourceSpecs.map((source) => this.fingerprintSource(source)));
|
|
}
|
|
|
|
async readDeclaredMcpNames(projectPath: string): Promise<Set<string>> {
|
|
const configPaths = [
|
|
path.join(this.homePath, '.config/opencode/opencode.json'),
|
|
path.join(this.homePath, '.config/opencode/opencode.jsonc'),
|
|
path.join(projectPath, 'opencode.json'),
|
|
path.join(projectPath, 'opencode.jsonc'),
|
|
path.join(projectPath, '.opencode/opencode.json'),
|
|
path.join(projectPath, '.opencode/opencode.jsonc'),
|
|
];
|
|
const names = new Set<string>();
|
|
|
|
for (const configPath of configPaths) {
|
|
const config = await this.readConfig(configPath);
|
|
const mcp = asRecord(config?.mcp);
|
|
for (const name of Object.keys(mcp ?? {})) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
private async fingerprintSource(input: {
|
|
kind: OpenCodeBehaviorSourceKind;
|
|
targetPath: string;
|
|
}): Promise<OpenCodeBehaviorSource> {
|
|
const pathHash = hashText(input.targetPath);
|
|
let stat;
|
|
try {
|
|
stat = await fs.stat(input.targetPath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return {
|
|
kind: input.kind,
|
|
pathHash,
|
|
exists: false,
|
|
fingerprint: null,
|
|
fileCount: 0,
|
|
};
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (stat.isFile()) {
|
|
const content = await fs.readFile(input.targetPath);
|
|
return {
|
|
kind: input.kind,
|
|
pathHash,
|
|
exists: true,
|
|
fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:${hashBuffer(content)}`),
|
|
fileCount: 1,
|
|
};
|
|
}
|
|
|
|
if (stat.isDirectory()) {
|
|
const entries = await this.listDirectoryFiles(input.targetPath);
|
|
return {
|
|
kind: input.kind,
|
|
pathHash,
|
|
exists: true,
|
|
fingerprint: hashJson(
|
|
entries.map((entry) => ({
|
|
relativePath: entry.relativePath,
|
|
size: entry.size,
|
|
mtimeMs: entry.mtimeMs,
|
|
contentHash: entry.contentHash,
|
|
}))
|
|
),
|
|
fileCount: entries.length,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: input.kind,
|
|
pathHash,
|
|
exists: true,
|
|
fingerprint: hashText(`${stat.size}:${stat.mtimeMs}:unsupported`),
|
|
fileCount: 0,
|
|
};
|
|
}
|
|
|
|
private async listDirectoryFiles(rootPath: string): Promise<
|
|
{
|
|
relativePath: string;
|
|
size: number;
|
|
mtimeMs: number;
|
|
contentHash: string;
|
|
}[]
|
|
> {
|
|
const results: {
|
|
relativePath: string;
|
|
size: number;
|
|
mtimeMs: number;
|
|
contentHash: string;
|
|
}[] = [];
|
|
|
|
const visit = async (directoryPath: string): Promise<void> => {
|
|
if (results.length >= this.maxDirectoryFiles) {
|
|
return;
|
|
}
|
|
|
|
const entries = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (results.length >= this.maxDirectoryFiles) {
|
|
return;
|
|
}
|
|
|
|
const absolutePath = path.join(directoryPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(absolutePath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const stat = await fs.stat(absolutePath);
|
|
const content = await fs.readFile(absolutePath);
|
|
results.push({
|
|
relativePath: path.relative(rootPath, absolutePath),
|
|
size: stat.size,
|
|
mtimeMs: stat.mtimeMs,
|
|
contentHash: hashBuffer(content),
|
|
});
|
|
}
|
|
};
|
|
|
|
await visit(rootPath);
|
|
return results.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
}
|
|
|
|
private async readConfig(configPath: string): Promise<Record<string, unknown> | null> {
|
|
let text: string;
|
|
try {
|
|
text = await fs.readFile(configPath, 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(stripJsonComments(text)) as unknown;
|
|
return asRecord(parsed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function buildManagedOverlayConfig(input: {
|
|
serverName: string;
|
|
command: string;
|
|
args: string[];
|
|
environment: Record<string, string>;
|
|
timeout: number;
|
|
}): { mcp: Record<string, OpenCodeMcpServerConfig> } {
|
|
return {
|
|
mcp: {
|
|
[input.serverName]: {
|
|
type: 'local',
|
|
command: input.command,
|
|
args: input.args,
|
|
enabled: true,
|
|
environment: input.environment,
|
|
timeout: input.timeout,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export function assertManagedOverlayDoesNotShadowUserConfig(config: Record<string, unknown>): void {
|
|
const usedForbiddenKeys = FORBIDDEN_MANAGED_OVERLAY_TOP_LEVEL_KEYS.filter((key) => key in config);
|
|
if (usedForbiddenKeys.length > 0) {
|
|
throw new Error(
|
|
`Managed OpenCode overlay must not set user behavior keys: ${usedForbiddenKeys.join(', ')}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export function pickAppOwnedMcpServerName(preferred: string, existingNames: Set<string>): string {
|
|
if (!existingNames.has(preferred)) {
|
|
return preferred;
|
|
}
|
|
|
|
let index = 1;
|
|
while (existingNames.has(`${preferred}-runtime-${index}`)) {
|
|
index += 1;
|
|
}
|
|
|
|
return `${preferred}-runtime-${index}`;
|
|
}
|
|
|
|
function buildOverlayDiagnostics(input: {
|
|
preferredMcpName: string;
|
|
appMcpServerName: string;
|
|
existingMcpNames: Set<string>;
|
|
preservedSources: OpenCodeBehaviorSource[];
|
|
checkedAt: string;
|
|
}): string[] {
|
|
const diagnostics = [
|
|
`OpenCode managed overlay checked at ${input.checkedAt}`,
|
|
`OpenCode preserved behavior sources: ${input.preservedSources.filter((source) => source.exists).length}`,
|
|
];
|
|
|
|
if (input.appMcpServerName !== input.preferredMcpName) {
|
|
diagnostics.push(
|
|
`User OpenCode config already declares MCP server "${input.preferredMcpName}"; managed runtime will use "${input.appMcpServerName}"`
|
|
);
|
|
}
|
|
|
|
if (input.existingMcpNames.size > 0) {
|
|
diagnostics.push(
|
|
`OpenCode existing MCP server names observed: ${[...input.existingMcpNames].sort().join(', ')}`
|
|
);
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
function stripJsonComments(text: string): string {
|
|
return text
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/(^|[^:])\/\/.*$/gm, (_match, prefix: string) => prefix);
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
function hashJson(value: unknown): string {
|
|
return hashText(stableJsonStringify(value));
|
|
}
|
|
|
|
function hashText(value: string): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function hashBuffer(value: Buffer): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function stableJsonStringify(value: unknown): string {
|
|
if (value === null || typeof value !== 'object') {
|
|
return JSON.stringify(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map(stableJsonStringify).join(',')}]`;
|
|
}
|
|
return `{${Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableJsonStringify(item)}`)
|
|
.join(',')}}`;
|
|
}
|