Files
openchamber/packages/ui/src/lib/openchamberConfig.ts
T
00821700de chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* chore: remove dead/unreferenced files across ui, vscode

Remove 59 unused source files (components, hooks, lib utils, stores,
barrels, and orphaned vscode github modules) that are not imported by
any entry-reachable code. Also drop a stale test mock for the removed
execCommands module.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* refactor: remove unused exported symbols (types, functions, consts, hooks)

Remove exported symbols whose identifier is referenced nowhere in the
repository (verified via repo-wide search), across ui types/contracts,
lib utilities, sync layer, stores, and components. Also drop the few
imports/private helpers orphaned by these removals.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* refactor: remove more unused exports (desktop, shortcuts, worktree, vscode)

Continue removing repo-wide unreferenced exported functions, consts and
types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and
vscode gitService, with cascading orphaned helpers/imports cleaned up.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: add dead-code cleanup tooling

* refactor: checkpoint dead-code cleanup

* refactor: remove dead-code suppressions

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-26 19:27:53 +03:00

952 lines
28 KiB
TypeScript

/**
* OpenChamber project-level configuration service.
* Stores per-project settings in ~/.config/openchamber/<projectId>.json.
* Migrates from legacy <project>/.openchamber/openchamber.json.
*/
import type { FilesAPI } from './api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDesktopHomeDirectory } from './desktop';
import { isVSCodeRuntime } from './desktop';
import { sanitizeStarterRefs, type DraftStarterRef } from './draftStarters';
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
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_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
/**
* Get the runtime Files API if available (Desktop/VSCode).
*/
function getRuntimeFilesAPI(): FilesAPI | null {
const apis = getRegisteredRuntimeAPIs();
if (apis?.files) {
return apis.files;
}
return null;
}
interface OpenChamberConfig {
projectPath?: string;
'setup-worktree'?: string[];
'setup-worktree-wait'?: boolean;
projectNotes?: string;
projectTodos?: OpenChamberProjectTodoItem[];
projectPlanFiles?: OpenChamberProjectPlanFileLink[];
projectActions?: OpenChamberProjectAction[];
projectActionsPrimaryId?: string;
draftStarters?: DraftStarterRef[];
}
type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
export interface OpenChamberProjectAction {
id: string;
name: string;
command: string;
icon?: string | null;
platforms?: OpenChamberProjectActionPlatform[];
autoOpenUrl?: boolean;
openUrl?: string;
desktopOpenSshForward?: string;
}
export interface OpenChamberProjectActionsState {
actions: OpenChamberProjectAction[];
primaryActionId: string | null;
}
export interface OpenChamberProjectTodoItem {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
export interface OpenChamberProjectPlanFileLink {
id: string;
path: string;
createdAt: number;
}
export interface OpenChamberProjectPlanFile {
title: string;
body: string;
raw: string;
path: string;
}
export interface OpenChamberProjectNotesTodos {
notes: string;
todos: OpenChamberProjectTodoItem[];
}
export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTodos {
plans: OpenChamberProjectPlanFileLink[];
}
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 3000;
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160;
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
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 runtimeFetch(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 runtimeFetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`,
{
// Avoid conditional requests (304 + empty body).
cache: 'no-store',
}
);
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> => {
// Use server-reported home as the source of truth for user config paths.
// In some runtimes, window.__OPENCHAMBER_HOME__ can be workspace/project-root
// scoped, which would incorrectly route writes into the project directory.
try {
const response = await runtimeFetch(`${getBaseUrl()}/fs/home`, {
// Avoid conditional requests (304 + empty body).
cache: 'no-store',
});
if (!response.ok) {
throw new Error('Failed to resolve home directory from API');
}
const payload = await response.json().catch(() => null) as { home?: unknown } | null;
const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
if (home) {
return normalize(home);
}
} catch {
// fall through
}
// Fallback for environments where /api/fs/home is unavailable.
// VSCode intentionally avoids this because embedded home equals workspace path.
if (!isVSCodeRuntime()) {
const desktopHome = await getDesktopHomeDirectory().catch(() => null);
if (desktopHome && desktopHome.trim().length > 0) {
return normalize(desktopHome);
}
}
return null;
};
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 resolveConfigProjectId = (project: ProjectRef): string | null => {
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
const normalizedProject = projectDirectory ? normalize(projectDirectory) : '';
if (!normalizedProject) return null;
return createProjectIdFromPath(normalizedProject) || null;
};
const getUserConfigPath = async (project: ProjectRef): Promise<string | null> => {
const base = await getUserProjectsDirectory();
if (!base) {
return null;
}
const safeId = resolveConfigProjectId(project);
if (!safeId) {
return null;
}
return joinPath(base, `${safeId}.json`);
};
const trimToMaxLength = (value: string, maxLength: number): string => {
if (value.length <= maxLength) {
return value;
}
return value.slice(0, maxLength);
};
const sanitizeProjectNotes = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
return trimToMaxLength(value, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH);
};
const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectTodoItem[] = [];
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
text?: unknown;
completed?: unknown;
createdAt?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const textRaw = typeof record.text === 'string' ? record.text : '';
const text = trimToMaxLength(textRaw.trim(), OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH);
if (!id || !text) {
continue;
}
const completed = Boolean(record.completed);
const createdAt =
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
? record.createdAt
: Date.now();
sanitized.push({
id,
text,
completed,
createdAt,
});
}
return sanitized;
};
const sanitizeProjectPlanFileLinks = (value: unknown): OpenChamberProjectPlanFileLink[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectPlanFileLink[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
path?: unknown;
createdAt?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const path = typeof record.path === 'string' ? record.path.trim() : '';
const createdAt =
typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0
? record.createdAt
: Date.now();
if (!id || !path || seenIds.has(id)) {
continue;
}
seenIds.add(id);
sanitized.push({ id, path, createdAt });
}
return sanitized.sort((a, b) => b.createdAt - a.createdAt);
};
const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
if (!Array.isArray(value)) {
return [];
}
const unique: OpenChamberProjectActionPlatform[] = [];
const seen = new Set<OpenChamberProjectActionPlatform>();
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const normalized = entry.trim().toLowerCase() as OpenChamberProjectActionPlatform;
if (!OPENCHAMBER_ACTION_PLATFORM_SET.has(normalized) || seen.has(normalized)) {
continue;
}
seen.add(normalized);
unique.push(normalized);
}
return unique;
};
const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectAction[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
name?: unknown;
command?: unknown;
icon?: unknown;
platforms?: unknown;
autoOpenUrl?: unknown;
openUrl?: unknown;
desktopOpenSshForward?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const name = trimToMaxLength(typeof record.name === 'string' ? record.name.trim() : '', OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH);
const command = trimToMaxLength(typeof record.command === 'string' ? record.command.trim() : '', OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH);
if (!id || !name || !command || seenIds.has(id)) {
continue;
}
seenIds.add(id);
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
const platforms = sanitizeProjectActionPlatforms(record.platforms);
const autoOpenUrl = record.autoOpenUrl === true;
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
const openUrl = trimToMaxLength(openUrlRaw, OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH);
const desktopOpenSshForwardRaw = typeof record.desktopOpenSshForward === 'string'
? record.desktopOpenSshForward.trim()
: '';
const desktopOpenSshForward = trimToMaxLength(
desktopOpenSshForwardRaw,
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
);
sanitized.push({
id,
name,
command,
icon: iconRaw || null,
...(autoOpenUrl ? { autoOpenUrl: true } : {}),
...(openUrl ? { openUrl } : {}),
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
...(platforms.length > 0 ? { platforms } : {}),
});
}
return sanitized;
};
const sanitizeProjectActionsState = (value: {
actions?: unknown;
primaryActionId?: unknown;
} | null | undefined): OpenChamberProjectActionsState => {
const actions = sanitizeProjectActions(value?.actions);
const primaryRaw = typeof value?.primaryActionId === 'string' ? value.primaryActionId.trim() : '';
const primaryActionId = primaryRaw && actions.some((entry) => entry.id === primaryRaw)
? primaryRaw
: null;
return {
actions,
primaryActionId,
};
};
const sanitizeProjectNotesAndTodos = (value: {
notes?: unknown;
todos?: unknown;
} | null | undefined): OpenChamberProjectNotesTodos => {
return {
notes: sanitizeProjectNotes(value?.notes),
todos: sanitizeProjectTodoItems(value?.todos),
};
};
const sanitizeProjectContextData = (value: {
notes?: unknown;
todos?: unknown;
plans?: unknown;
} | null | undefined): OpenChamberProjectContextData => {
const notesAndTodos = sanitizeProjectNotesAndTodos(value);
return {
...notesAndTodos,
plans: sanitizeProjectPlanFileLinks(value?.plans),
};
};
const slugifyPlanTitle = (value: string): string => {
const normalized = value
.trim()
.toLowerCase()
.replace(/[`*_#>[\](){}.!?,:;"']/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'plan';
};
const sanitizePlanTitle = (value: string): string => {
return trimToMaxLength(value.trim(), OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH);
};
const createProjectPlanId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const getProjectStorageDirectory = async (project: ProjectRef): Promise<string | null> => {
const base = await getUserProjectsDirectory();
const safeId = resolveConfigProjectId(project);
if (!base || !safeId) {
return null;
}
return joinPath(base, safeId);
};
const getProjectPlansDirectory = async (project: ProjectRef): Promise<string | null> => {
const projectDirectory = await getProjectStorageDirectory(project);
if (!projectDirectory) {
return null;
}
return joinPath(projectDirectory, 'plans');
};
const formatProjectPlanMarkdown = (title: string, body: string): string => {
const normalizedTitle = sanitizePlanTitle(title) || 'Plan';
const normalizedBody = body.trim();
return normalizedBody
? `# ${normalizedTitle}\n\n${normalizedBody}`
: `# ${normalizedTitle}\n`;
};
export const parseProjectPlanMarkdown = (raw: string): { title: string; body: string } => {
const text = typeof raw === 'string' ? raw : '';
const normalized = text.replace(/\r\n?/g, '\n');
const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
if (match) {
const title = sanitizePlanTitle(match[1]);
const body = normalized.slice(match[0].length).replace(/^\n+/, '');
return {
title: title || 'Plan',
body,
};
}
const firstNonEmptyLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan';
return {
title: sanitizePlanTitle(firstNonEmptyLine.replace(/^#+\s*/, '')) || 'Plan',
body: normalized.trim(),
};
};
/**
* Read the config for a project.
* Returns null if file doesn't exist or is invalid.
*/
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.
*
* Server owns `version` and `scheduledTasks` keys; client reads them via their
* dedicated route and never round-trips them through this config write path to
* avoid a read-then-write race clobbering a concurrent server update.
*/
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 {
const okDir = await mkdirp(configDir);
if (!okDir) {
return false;
}
const existingRaw = await readTextFile(configPath);
let existing: Record<string, unknown> = {};
if (typeof existingRaw === 'string' && existingRaw.trim()) {
try {
const parsed = JSON.parse(existingRaw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>;
}
} catch {
existing = {};
}
}
const serverOwned: Record<string, unknown> = {};
if (existing.version !== undefined) serverOwned.version = existing.version;
if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks;
const content = JSON.stringify({
...existing,
...config,
...serverOwned,
projectPath: normalize(projectDirectory),
}, 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.
*/
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 });
}
export async function getWorktreeSetupWaitEnabled(project: ProjectRef): Promise<boolean> {
const config = await readOpenChamberConfig(project);
return config?.['setup-worktree-wait'] === true;
}
export async function saveWorktreeSetupWaitEnabled(project: ProjectRef, enabled: boolean): Promise<boolean> {
return updateOpenChamberConfig(project, { 'setup-worktree-wait': enabled });
}
/**
* Get this project's pinned draft welcome starters.
*/
export async function getProjectDraftStarters(project: ProjectRef): Promise<DraftStarterRef[]> {
const config = await readOpenChamberConfig(project);
return sanitizeStarterRefs(config?.draftStarters);
}
export async function saveProjectDraftStarters(project: ProjectRef, starters: DraftStarterRef[]): Promise<boolean> {
return updateOpenChamberConfig(project, { draftStarters: sanitizeStarterRefs(starters) });
}
export async function getProjectNotesAndTodos(project: ProjectRef): Promise<OpenChamberProjectNotesTodos> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectNotesAndTodos({
notes: config?.projectNotes,
todos: config?.projectTodos,
});
}
export async function saveProjectNotesAndTodos(
project: ProjectRef,
value: OpenChamberProjectNotesTodos
): Promise<boolean> {
const sanitized = sanitizeProjectNotesAndTodos({
notes: value.notes,
todos: value.todos,
});
return updateOpenChamberConfig(project, {
projectNotes: sanitized.notes,
projectTodos: sanitized.todos,
});
}
export async function getProjectContextData(project: ProjectRef): Promise<OpenChamberProjectContextData> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectContextData({
notes: config?.projectNotes,
todos: config?.projectTodos,
plans: config?.projectPlanFiles,
});
}
async function getProjectPlanFiles(project: ProjectRef): Promise<OpenChamberProjectPlanFileLink[]> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectPlanFileLinks(config?.projectPlanFiles);
}
async function saveProjectPlanFiles(
project: ProjectRef,
value: OpenChamberProjectPlanFileLink[]
): Promise<boolean> {
const sanitized = sanitizeProjectPlanFileLinks(value);
return updateOpenChamberConfig(project, {
projectPlanFiles: sanitized,
});
}
export async function readProjectPlanFile(path: string): Promise<OpenChamberProjectPlanFile | null> {
const trimmedPath = typeof path === 'string' ? path.trim() : '';
if (!trimmedPath) {
return null;
}
const raw = await readTextFile(trimmedPath);
if (raw === null) {
return null;
}
const parsed = parseProjectPlanMarkdown(raw);
return {
title: parsed.title,
body: parsed.body,
raw,
path: trimmedPath,
};
}
const deleteFile = async (path: string): Promise<boolean> => {
const runtimeFiles = getRuntimeFilesAPI();
if (runtimeFiles?.delete) {
try {
const result = await runtimeFiles.delete(path);
if (result?.success !== false) {
return true;
}
} catch {
// fall through
}
}
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/delete`, { path });
return Boolean(res.ok);
};
export async function deleteProjectPlanFile(
project: ProjectRef,
planId: string
): Promise<boolean> {
const trimmedId = typeof planId === 'string' ? planId.trim() : '';
if (!trimmedId) {
return false;
}
const existing = await getProjectPlanFiles(project);
const target = existing.find((entry) => entry.id === trimmedId);
if (!target) {
return false;
}
const next = existing.filter((entry) => entry.id !== trimmedId);
const saved = await saveProjectPlanFiles(project, next);
if (!saved) {
return false;
}
// Best-effort: remove underlying markdown file, ignore failure.
await deleteFile(target.path).catch(() => false);
return true;
}
export async function importProjectPlanFileFromContent(
project: ProjectRef,
content: string,
fallbackTitle?: string
): Promise<OpenChamberProjectPlanFileLink | null> {
const raw = typeof content === 'string' ? content : '';
if (!raw.trim()) {
return null;
}
const parsed = parseProjectPlanMarkdown(raw);
const title = parsed.title || sanitizePlanTitle(fallbackTitle ?? '') || 'Plan';
return createProjectPlanFile(project, { title, body: parsed.body });
}
export async function createProjectPlanFile(
project: ProjectRef,
value: { title: string; body: string }
): Promise<OpenChamberProjectPlanFileLink | null> {
const plansDirectory = await getProjectPlansDirectory(project);
if (!plansDirectory) {
return null;
}
const title = sanitizePlanTitle(value.title) || 'Plan';
const createdAt = Date.now();
const id = createProjectPlanId();
const filePath = joinPath(plansDirectory, `${createdAt}-${slugifyPlanTitle(title)}.md`);
const projectDirectory = await getProjectStorageDirectory(project);
if (!projectDirectory) {
return null;
}
const createdProjectDir = await mkdirp(projectDirectory);
const createdPlansDir = createdProjectDir ? await mkdirp(plansDirectory) : false;
if (!createdProjectDir || !createdPlansDir) {
return null;
}
const wrote = await writeTextFile(filePath, formatProjectPlanMarkdown(title, value.body));
if (!wrote) {
return null;
}
const existing = await getProjectPlanFiles(project);
const nextEntry = { id, path: filePath, createdAt };
const saved = await saveProjectPlanFiles(project, [nextEntry, ...existing]);
if (!saved) {
return null;
}
return nextEntry;
}
export async function getProjectActionsState(project: ProjectRef): Promise<OpenChamberProjectActionsState> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectActionsState({
actions: config?.projectActions,
primaryActionId: config?.projectActionsPrimaryId,
});
}
export async function saveProjectActionsState(
project: ProjectRef,
value: OpenChamberProjectActionsState
): Promise<boolean> {
const sanitized = sanitizeProjectActionsState({
actions: value.actions,
primaryActionId: value.primaryActionId,
});
return updateOpenChamberConfig(project, {
projectActions: sanitized.actions,
projectActionsPrimaryId: sanitized.primaryActionId ?? undefined,
});
}
/**
* 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 };