Files
openchamber/packages/ui/src/lib/openchamberConfig.ts
T

440 lines
13 KiB
TypeScript
Raw Normal View History

/**
* OpenChamber project-level configuration service.
* Stores per-project settings in ~/.config/openchamber/<projectId>.json.
* Migrates from legacy <project>/.openchamber/openchamber.json.
*/
import type { FilesAPI, RuntimeAPIs } from './api/types';
import { getDesktopHomeDirectory } from './desktop';
import { isVSCodeRuntime } from './desktop';
type ProjectRef = { id: string; path: string };
const CONFIG_FILENAME = 'openchamber.json';
// LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo.
const LEGACY_CONFIG_DIR = '.openchamber';
const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber'];
const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
const SETTINGS_FILENAME = 'settings.json';
const projectIdCache = new Map<string, string>();
const isSafeConfigFileId = (value: string): boolean => /^[A-Za-z0-9._-]+$/.test(value);
const toHex = (bytes: Uint8Array): string => {
let out = '';
for (const b of bytes) {
out += b.toString(16).padStart(2, '0');
}
return out;
};
const sha1Hex = async (value: string): Promise<string | null> => {
try {
if (typeof crypto === 'undefined' || !crypto.subtle) {
return null;
}
const encoder = new TextEncoder();
const data = encoder.encode(value);
const digest = await crypto.subtle.digest('SHA-1', data);
return toHex(new Uint8Array(digest));
} catch {
return null;
}
};
/**
* Get the runtime Files API if available (Desktop/VSCode).
*/
function getRuntimeFilesAPI(): FilesAPI | null {
if (typeof window === 'undefined') return null;
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
if (apis?.files) {
return apis.files;
}
return null;
}
export interface OpenChamberConfig {
'setup-worktree'?: string[];
}
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const joinPath = (base: string, segment: string): string => {
const normalizedBase = normalize(base);
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!normalizedBase || normalizedBase === '/') {
return `/${cleanSegment}`;
}
return `${normalizedBase}/${cleanSegment}`;
};
const getLegacyConfigPath = (projectDirectory: string): string => {
return joinPath(joinPath(projectDirectory, LEGACY_CONFIG_DIR), CONFIG_FILENAME);
};
const getBaseUrl = (): string => {
const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
if (defaultBaseUrl.startsWith('/')) {
return defaultBaseUrl;
}
return defaultBaseUrl;
};
const postJson = async <T>(url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
return { ok: false, data: null };
}
const data = (await response.json().catch(() => null)) as T | null;
return { ok: true, data };
} catch {
return { ok: false, data: null };
}
};
const mkdirp = async (path: string): Promise<boolean> => {
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.createDirectory) {
try {
const result = await runtimeFiles.createDirectory(path);
if (result?.success) {
return true;
}
} catch {
// fall through
}
}
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/mkdir`, { path });
return Boolean(res.ok);
};
const readTextFile = async (path: string): Promise<string | null> => {
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.readFile) {
try {
const result = await runtimeFiles.readFile(path);
const content = typeof result?.content === 'string' ? result.content : '';
return content;
} catch {
return null;
}
}
try {
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`);
if (!response.ok) {
return null;
}
return await response.text();
} catch {
return null;
}
};
const writeTextFile = async (path: string, content: string): Promise<boolean> => {
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.writeFile) {
try {
const result = await runtimeFiles.writeFile(path, content);
if (result?.success) {
return true;
}
} catch {
// fall through
}
}
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/write`, { path, content });
return Boolean(res.ok);
};
const resolveHomeDirectory = async (): Promise<string | null> => {
// VSCode webview sets __OPENCHAMBER_HOME__ to workspace folder (not OS home).
// For user config (~/.config/openchamber), always use /api/fs/home in VSCode.
if (!isVSCodeRuntime()) {
const desktopHome = await getDesktopHomeDirectory().catch(() => null);
if (desktopHome && desktopHome.trim().length > 0) {
return normalize(desktopHome);
}
}
try {
const response = await fetch(`${getBaseUrl()}/fs/home`);
if (!response.ok) {
return null;
}
const payload = await response.json().catch(() => null) as { home?: unknown } | null;
const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
return home ? normalize(home) : null;
} catch {
return null;
}
};
const getUserConfigRootDirectory = async (): Promise<string | null> => {
const home = await resolveHomeDirectory();
if (!home) {
return null;
}
return USER_CONFIG_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
};
const getUserProjectsDirectory = async (): Promise<string | null> => {
const home = await resolveHomeDirectory();
if (!home) {
return null;
}
return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
};
const getSettingsPath = async (): Promise<string | null> => {
const base = await getUserConfigRootDirectory();
if (!base) {
return null;
}
return joinPath(base, SETTINGS_FILENAME);
};
const resolveConfigProjectId = async (project: ProjectRef): Promise<string | null> => {
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
const normalizedProject = projectDirectory ? normalize(projectDirectory) : '';
const explicitId = typeof project?.id === 'string' ? project.id.trim() : '';
if (explicitId && isSafeConfigFileId(explicitId)) {
return explicitId;
}
if (normalizedProject) {
const cached = projectIdCache.get(normalizedProject);
if (cached) {
return cached;
}
}
// Best-effort map project directory -> persisted project id from settings.json.
const settingsPath = await getSettingsPath();
if (settingsPath && normalizedProject) {
const raw = await readTextFile(settingsPath);
if (raw) {
try {
const parsed = JSON.parse(raw) as { projects?: unknown };
const projects = Array.isArray(parsed?.projects) ? parsed.projects : [];
for (const entry of projects) {
if (!entry || typeof entry !== 'object') continue;
const record = entry as { id?: unknown; path?: unknown };
const id = typeof record.id === 'string' ? record.id.trim() : '';
const path = typeof record.path === 'string' ? normalize(record.path.trim()) : '';
if (id && isSafeConfigFileId(id) && path && path === normalizedProject) {
projectIdCache.set(normalizedProject, id);
return id;
}
}
} catch {
// ignore
}
}
}
// Fallback: stable id derived from path (used in VSCode when project isn't registered).
if (normalizedProject) {
const digest = await sha1Hex(normalizedProject);
const fallback = digest ? `path_${digest}` : `path_${normalizedProject.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
projectIdCache.set(normalizedProject, fallback);
return fallback;
}
return null;
};
const getUserConfigPath = async (project: ProjectRef): Promise<string | null> => {
const base = await getUserProjectsDirectory();
if (!base) {
return null;
}
const safeId = await resolveConfigProjectId(project);
if (!safeId) {
return null;
}
return joinPath(base, `${safeId}.json`);
};
/**
* Read the config for a project.
* Returns null if file doesn't exist or is invalid.
*/
export async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberConfig | null> {
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
if (!projectDirectory) {
return null;
}
const configPath = await getUserConfigPath(project);
const readText = async (path: string): Promise<string | null> => {
// Keep behavior consistent with other helpers.
const text = await readTextFile(path);
if (text === null) {
return null;
}
return text;
};
const parseConfig = (text: string | null): OpenChamberConfig | null => {
if (typeof text !== 'string') {
return null;
}
const trimmed = text.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return parsed as OpenChamberConfig;
} catch {
return null;
}
};
// 1) Prefer new per-user config.
if (configPath) {
const existing = parseConfig(await readText(configPath));
if (existing) {
return existing;
}
}
// 2) Migrate legacy <project>/.openchamber/openchamber.json.
// LEGACY_PROJECT_CONFIG: migrate project-local openchamber.json -> ~/.config/openchamber/projects/<projectId>.json
const legacyPath = getLegacyConfigPath(projectDirectory);
const legacyConfig = parseConfig(await readText(legacyPath));
if (!legacyConfig) {
return null;
}
// Best-effort write + delete legacy.
try {
const wrote = await writeOpenChamberConfig(project, legacyConfig);
if (wrote) {
await deleteLegacyOpenChamberConfig(projectDirectory);
}
} catch {
// Ignore migration failures; still return legacy content.
}
return legacyConfig;
}
/**
* Write the per-user config for a project.
*/
export async function writeOpenChamberConfig(
project: ProjectRef,
config: OpenChamberConfig
): Promise<boolean> {
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
if (!projectDirectory) {
return false;
}
const configDir = await getUserProjectsDirectory();
const configPath = await getUserConfigPath(project);
if (!configDir || !configPath) {
return false;
}
try {
// Ensure user config directory exists.
const okDir = await mkdirp(configDir);
if (!okDir) {
return false;
}
const content = JSON.stringify(config, null, 2);
return await writeTextFile(configPath, content);
} catch (error) {
console.error('Failed to write openchamber config:', error);
return false;
}
}
/**
* Update specific keys in the config, preserving other values.
*/
export async function updateOpenChamberConfig(
project: ProjectRef,
updates: Partial<OpenChamberConfig>
): Promise<boolean> {
const existing = await readOpenChamberConfig(project) || {};
const merged = { ...existing, ...updates };
return writeOpenChamberConfig(project, merged);
}
/**
* Get worktree setup commands from config.
*/
export async function getWorktreeSetupCommands(project: ProjectRef): Promise<string[]> {
const config = await readOpenChamberConfig(project);
return config?.['setup-worktree'] ?? [];
}
export async function saveWorktreeSetupCommands(project: ProjectRef, commands: string[]): Promise<boolean> {
const filtered = commands.filter((cmd) => cmd.trim().length > 0);
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
}
/**
* Substitute variables in a command string.
* Supported variables:
* - $ROOT_PROJECT_PATH: The root project directory path
* - $ROOT_WORKTREE_PATH: Legacy alias for $ROOT_PROJECT_PATH
*/
export function substituteCommandVariables(
command: string,
variables: { rootWorktreePath: string }
): string {
return command
// New preferred name
.replace(/\$ROOT_PROJECT_PATH/g, variables.rootWorktreePath)
.replace(/\$\{ROOT_PROJECT_PATH\}/g, variables.rootWorktreePath)
// Legacy
.replace(/\$ROOT_WORKTREE_PATH/g, variables.rootWorktreePath)
.replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath);
}
async function deleteLegacyOpenChamberConfig(projectDirectory: string): Promise<void> {
const legacyPath = getLegacyConfigPath(projectDirectory);
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.delete) {
try {
await runtimeFiles.delete(legacyPath);
return;
} catch {
// fall through
}
}
try {
await postJson(`${getBaseUrl()}/fs/delete`, { path: legacyPath });
} catch {
// ignored
}
}
export type { ProjectRef };