This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// src/config-manager.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { ConfigManager } from './config-manager.js';
|
||||
@@ -85,6 +85,36 @@ describe('ConfigManager', () => {
|
||||
expect(cm.getConfig().provider.workers[0]?.model).toBe('new-model');
|
||||
});
|
||||
|
||||
it('creates config.yaml on first save when the file does not exist (fresh install on defaults)', () => {
|
||||
// Fresh OSS deploy: the server boots on defaults with no config.yaml on
|
||||
// disk. The first Settings-UI save must CREATE the file, not crash with
|
||||
// ENOENT trying to back up a non-existent file.
|
||||
const freshPath = join(tempDir, 'fresh-config.yaml');
|
||||
expect(existsSync(freshPath)).toBe(false);
|
||||
|
||||
const cm = new ConfigManager(freshPath);
|
||||
const result = cm.updateConfig({
|
||||
llm: {
|
||||
workers: [{
|
||||
id: 'w1',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://host:11434/v1',
|
||||
model: 'qwen3:8b',
|
||||
roles: ['auto', 'fast'],
|
||||
maxConcurrency: 1,
|
||||
enabled: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(existsSync(freshPath)).toBe(true);
|
||||
const raw = readFileSync(freshPath, 'utf-8');
|
||||
expect(raw).toContain('config_version: 2');
|
||||
expect(raw).toContain('http://host:11434/v1');
|
||||
expect(cm.getConfig().provider.workers[0]?.model).toBe('qwen3:8b');
|
||||
});
|
||||
|
||||
it('rejects invalid config (unparseable YAML file)', () => {
|
||||
const cm = new ConfigManager(configPath);
|
||||
// Corrupt the file, then try to reload — loadConfig will fall back to defaults
|
||||
|
||||
+15
-5
@@ -1,6 +1,6 @@
|
||||
// src/config-manager.ts
|
||||
import { EventEmitter } from 'events';
|
||||
import { readFileSync, writeFileSync, statSync } from 'fs';
|
||||
import { readFileSync, writeFileSync, statSync, existsSync, unlinkSync } from 'fs';
|
||||
import { stringify } from 'yaml';
|
||||
import { loadConfig, toSnakeKeys, type AppConfig } from './config.js';
|
||||
import { createHash } from 'crypto';
|
||||
@@ -155,15 +155,25 @@ export class ConfigManager {
|
||||
const snakeConfig = toSnakeKeys(merged) as Record<string, unknown>;
|
||||
const yamlStr = stringify(snakeConfig, { lineWidth: 120 });
|
||||
|
||||
// Validate BEFORE writing: backup, write, validate, rollback on failure
|
||||
const backupContent = readFileSync(this.configPath, 'utf-8');
|
||||
// Validate BEFORE writing: backup, write, validate, rollback on failure.
|
||||
// config.yaml may not exist yet — a fresh install boots on defaults with no
|
||||
// file on disk, and the first save must CREATE it rather than ENOENT trying
|
||||
// to back up a missing file. With no prior content, a failed save is rolled
|
||||
// back by removing the file we just created.
|
||||
const backupContent = existsSync(this.configPath)
|
||||
? readFileSync(this.configPath, 'utf-8')
|
||||
: null;
|
||||
try {
|
||||
writeFileSync(this.configPath, yamlStr, 'utf-8');
|
||||
logger.info(`[config-manager] config written to ${this.configPath}`);
|
||||
this.currentConfig = loadConfig(this.configPath);
|
||||
} catch (e) {
|
||||
// Restore original file on validation failure
|
||||
writeFileSync(this.configPath, backupContent, 'utf-8');
|
||||
// Restore original on validation failure (or drop the file we created).
|
||||
if (backupContent !== null) {
|
||||
writeFileSync(this.configPath, backupContent, 'utf-8');
|
||||
} else {
|
||||
try { unlinkSync(this.configPath); } catch { /* nothing to revert */ }
|
||||
}
|
||||
logger.warn(`[config-manager] config update failed, reverted: ${e}`);
|
||||
return { ok: false, errors: e, message: 'Invalid config — changes reverted' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user