Files
openchamber/packages/web/server/lib/opencode/settings-runtime.js
T

440 lines
16 KiB
JavaScript
Raw Normal View History

const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
error: { title: 'Tool error', message: '{last_message}' },
question: { title: 'Input needed', message: '{last_message}' },
subtask: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
};
const ensureNotificationTemplateShape = (templates) => {
const input = templates && typeof templates === 'object' ? templates : {};
let changed = false;
const next = {};
for (const event of Object.keys(DEFAULT_NOTIFICATION_TEMPLATES)) {
const currentEntry = input[event];
const base = DEFAULT_NOTIFICATION_TEMPLATES[event];
const currentTitle = typeof currentEntry?.title === 'string' ? currentEntry.title : base.title;
const currentMessage = typeof currentEntry?.message === 'string' ? currentEntry.message : base.message;
if (!currentEntry || typeof currentEntry.title !== 'string' || typeof currentEntry.message !== 'string') {
changed = true;
}
next[event] = { title: currentTitle, message: currentMessage };
}
return { templates: next, changed };
};
export const createSettingsRuntime = (deps) => {
const {
fsPromises,
path,
crypto,
SETTINGS_FILE_PATH,
sanitizeProjects,
sanitizeSettingsUpdate,
mergePersistedSettings,
normalizeSettingsPaths,
normalizeStringArray,
formatSettingsResponse,
resolveDirectoryCandidate,
normalizeManagedRemoteTunnelHostname,
normalizeManagedRemoteTunnelPresets,
normalizeManagedRemoteTunnelPresetTokens,
syncManagedRemoteTunnelConfigWithPresets,
upsertManagedRemoteTunnelToken,
} = deps;
let persistSettingsLock = Promise.resolve();
const readSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed;
}
return {};
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return {};
}
console.warn('Failed to read settings file:', error);
return {};
}
};
const writeSettingsToDisk = async (settings) => {
try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
await fsPromises.writeFile(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
} catch (error) {
console.warn('Failed to write settings file:', error);
throw error;
}
};
const validateProjectEntries = async (projects) => {
console.log(`[validateProjectEntries] Starting validation for ${projects.length} projects`);
if (!Array.isArray(projects)) {
console.warn('[validateProjectEntries] Input is not an array, returning empty');
return [];
}
const validations = projects.map(async (project) => {
if (!project || typeof project.path !== 'string' || project.path.length === 0) {
console.error('[validateProjectEntries] Invalid project entry: missing or empty path', project);
return null;
}
try {
const stats = await fsPromises.stat(project.path);
if (!stats.isDirectory()) {
console.error(`[validateProjectEntries] Project path is not a directory: ${project.path}`);
return null;
}
return project;
} catch (error) {
const err = error;
console.error(`[validateProjectEntries] Failed to validate project "${project.path}": ${err.code || err.message || err}`);
if (err && typeof err === 'object' && err.code === 'ENOENT') {
console.log(`[validateProjectEntries] Removing project with ENOENT: ${project.path}`);
return null;
}
console.log(`[validateProjectEntries] Keeping project despite non-ENOENT error: ${project.path}`);
return project;
}
});
const results = (await Promise.all(validations)).filter((p) => p !== null);
console.log(`[validateProjectEntries] Validation complete: ${results.length}/${projects.length} projects valid`);
return results;
};
const migrateSettingsFromLegacyLastDirectory = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const now = Date.now();
const sanitizedProjects = sanitizeProjects(settings.projects) || [];
let nextProjects = sanitizedProjects;
let nextActiveProjectId =
typeof settings.activeProjectId === 'string' ? settings.activeProjectId : undefined;
let changed = false;
if (nextProjects.length === 0) {
const legacy = typeof settings.lastDirectory === 'string' ? settings.lastDirectory.trim() : '';
const candidate = legacy ? resolveDirectoryCandidate(legacy) : null;
if (candidate) {
try {
const stats = await fsPromises.stat(candidate);
if (stats.isDirectory()) {
const id = crypto.randomUUID();
nextProjects = [
{
id,
path: candidate,
addedAt: now,
lastOpenedAt: now,
},
];
nextActiveProjectId = id;
changed = true;
}
} catch {
// ignore invalid lastDirectory
}
}
}
if (nextProjects.length > 0) {
const active = nextProjects.find((project) => project.id === nextActiveProjectId) || null;
if (!active) {
nextActiveProjectId = nextProjects[0].id;
changed = true;
}
} else if (nextActiveProjectId) {
nextActiveProjectId = undefined;
changed = true;
}
if (!changed) {
return { settings, changed: false };
}
const merged = mergePersistedSettings(settings, {
...settings,
projects: nextProjects,
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : { activeProjectId: undefined }),
});
return { settings: merged, changed: true };
};
const migrateSettingsFromLegacyThemePreferences = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const themeId = typeof settings.themeId === 'string' ? settings.themeId.trim() : '';
const themeVariant = typeof settings.themeVariant === 'string' ? settings.themeVariant.trim() : '';
const hasLight = typeof settings.lightThemeId === 'string' && settings.lightThemeId.trim().length > 0;
const hasDark = typeof settings.darkThemeId === 'string' && settings.darkThemeId.trim().length > 0;
if (hasLight && hasDark) {
return { settings, changed: false };
}
const defaultLight = 'flexoki-light';
const defaultDark = 'flexoki-dark';
let nextLightThemeId = hasLight ? settings.lightThemeId : undefined;
let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined;
if (!hasLight) {
if (themeId && themeVariant === 'light') {
nextLightThemeId = themeId;
} else {
nextLightThemeId = defaultLight;
}
}
if (!hasDark) {
if (themeId && themeVariant === 'dark') {
nextDarkThemeId = themeId;
} else {
nextDarkThemeId = defaultDark;
}
}
const merged = mergePersistedSettings(settings, {
...settings,
...(nextLightThemeId ? { lightThemeId: nextLightThemeId } : {}),
...(nextDarkThemeId ? { darkThemeId: nextDarkThemeId } : {}),
});
return { settings: merged, changed: true };
};
const migrateSettingsFromLegacyCollapsedProjects = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const collapsed = Array.isArray(settings.collapsedProjects)
? normalizeStringArray(settings.collapsedProjects)
: [];
if (collapsed.length === 0 || !Array.isArray(settings.projects)) {
if (collapsed.length === 0) {
return { settings, changed: false };
}
const next = { ...settings };
delete next.collapsedProjects;
return { settings: next, changed: true };
}
const set = new Set(collapsed);
const projects = sanitizeProjects(settings.projects) || [];
let changed = false;
const nextProjects = projects.map((project) => {
const shouldCollapse = set.has(project.id);
if (project.sidebarCollapsed !== shouldCollapse) {
changed = true;
return { ...project, sidebarCollapsed: shouldCollapse };
}
return project;
});
if (!changed) {
if (Object.prototype.hasOwnProperty.call(settings, 'collapsedProjects')) {
const next = { ...settings };
delete next.collapsedProjects;
return { settings: next, changed: true };
}
return { settings, changed: false };
}
const next = { ...settings, projects: nextProjects };
delete next.collapsedProjects;
return { settings: next, changed: true };
};
const migrateSettingsNotificationDefaults = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
let changed = false;
const next = { ...settings };
if (typeof settings.notifyOnSubtasks !== 'boolean') {
next.notifyOnSubtasks = true;
changed = true;
}
if (typeof settings.notifyOnCompletion !== 'boolean') {
next.notifyOnCompletion = true;
changed = true;
}
if (typeof settings.notifyOnError !== 'boolean') {
next.notifyOnError = true;
changed = true;
}
if (typeof settings.notifyOnQuestion !== 'boolean') {
next.notifyOnQuestion = true;
changed = true;
}
const { templates, changed: templatesChanged } = ensureNotificationTemplateShape(settings.notificationTemplates);
if (templatesChanged || !settings.notificationTemplates || typeof settings.notificationTemplates !== 'object') {
next.notificationTemplates = templates;
changed = true;
}
return { settings: changed ? next : settings, changed };
};
const migrateSettingsFromLegacyNamedTunnelKeys = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const next = { ...settings };
let changed = false;
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelHostname')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelHostname')) {
next.managedRemoteTunnelHostname = normalizeManagedRemoteTunnelHostname(next.namedTunnelHostname);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelToken')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelToken')) {
if (next.namedTunnelToken === null) {
next.managedRemoteTunnelToken = null;
} else if (typeof next.namedTunnelToken === 'string') {
next.managedRemoteTunnelToken = next.namedTunnelToken.trim();
}
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresets')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresets')) {
next.managedRemoteTunnelPresets = normalizeManagedRemoteTunnelPresets(next.namedTunnelPresets);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelPresetTokens')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelPresetTokens')) {
next.managedRemoteTunnelPresetTokens = normalizeManagedRemoteTunnelPresetTokens(next.namedTunnelPresetTokens);
changed = true;
}
if (!Object.prototype.hasOwnProperty.call(next, 'managedRemoteTunnelSelectedPresetId')
&& Object.prototype.hasOwnProperty.call(next, 'namedTunnelSelectedPresetId')) {
const selectedPresetId = typeof next.namedTunnelSelectedPresetId === 'string'
? next.namedTunnelSelectedPresetId.trim()
: '';
if (selectedPresetId) {
next.managedRemoteTunnelSelectedPresetId = selectedPresetId;
}
changed = true;
}
const legacyKeys = [
'namedTunnelHostname',
'namedTunnelToken',
'namedTunnelPresets',
'namedTunnelPresetTokens',
'namedTunnelSelectedPresetId',
];
for (const key of legacyKeys) {
if (Object.prototype.hasOwnProperty.call(next, key)) {
delete next[key];
changed = true;
}
}
return { settings: changed ? next : settings, changed };
};
const readSettingsFromDiskMigrated = async () => {
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
const migration3 = await migrateSettingsFromLegacyCollapsedProjects(migration2.settings);
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
const migration6 = normalizeSettingsPaths(migration5.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) {
await writeSettingsToDisk(migration6.settings);
}
return migration6.settings;
};
const persistSettings = async (changes) => {
persistSettingsLock = persistSettingsLock.then(async () => {
console.log('[persistSettings] Called with changes:', JSON.stringify(changes, null, 2));
const current = await readSettingsFromDisk();
console.log('[persistSettings] Current projects count:', Array.isArray(current.projects) ? current.projects.length : 'N/A');
const sanitized = sanitizeSettingsUpdate(changes);
let next = mergePersistedSettings(current, sanitized);
const normalizedState = normalizeSettingsPaths(next);
if (normalizedState.changed) {
next = normalizedState.settings;
}
if (Array.isArray(next.projects)) {
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
const validated = await validateProjectEntries(next.projects);
console.log(`[persistSettings] After validation: ${validated.length} projects remain`);
next = { ...next, projects: validated };
}
if (Array.isArray(next.projects) && next.projects.length > 0) {
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
const active = next.projects.find((project) => project.id === activeId) || null;
if (!active) {
console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
next = { ...next, activeProjectId: next.projects[0].id };
}
} else if (next.activeProjectId) {
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
next = { ...next, activeProjectId: undefined };
}
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresets')) {
await syncManagedRemoteTunnelConfigWithPresets(next.managedRemoteTunnelPresets);
}
if (Object.prototype.hasOwnProperty.call(sanitized, 'managedRemoteTunnelPresetTokens') && sanitized.managedRemoteTunnelPresetTokens) {
const presetsById = new Map((next.managedRemoteTunnelPresets || []).map((entry) => [entry.id, entry]));
const updates = Object.entries(sanitized.managedRemoteTunnelPresetTokens)
.map(([presetId, token]) => {
const preset = presetsById.get(presetId);
if (!preset || typeof token !== 'string' || token.trim().length === 0) {
return null;
}
return {
id: preset.id,
name: preset.name,
hostname: preset.hostname,
token: token.trim(),
};
})
.filter(Boolean);
for (const update of updates) {
await upsertManagedRemoteTunnelToken(update);
}
}
await writeSettingsToDisk(next);
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
return formatSettingsResponse(next);
});
return persistSettingsLock;
};
return {
readSettingsFromDisk,
readSettingsFromDiskMigrated,
writeSettingsToDisk,
persistSettings,
};
};