440 lines
16 KiB
JavaScript
440 lines
16 KiB
JavaScript
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,
|
||
|
|
};
|
||
|
|
};
|