feat(config): hot-reload safe config updates and document behavior

This commit is contained in:
2026-02-18 01:04:56 -08:00
parent fd49e73762
commit 4703b995da
18 changed files with 850 additions and 85 deletions

View File

@@ -258,6 +258,55 @@ test('parses jsonc and warns/falls back on invalid value', () => {
assert.ok(service.getWarnings().some((w) => w.path === 'websocket.port'));
});
test('reloadConfigStrict rejects invalid jsonc and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync(
configPath,
`{
"logging": {
"level": "warn"
}
}`,
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'warn');
fs.writeFileSync(
configPath,
`{
"logging":`,
);
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSONC.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'warn');
});
test('reloadConfigStrict rejects invalid json and preserves previous config', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.json');
fs.writeFileSync(configPath, JSON.stringify({ logging: { level: 'error' } }, null, 2));
const service = new ConfigService(dir);
assert.equal(service.getConfig().logging.level, 'error');
fs.writeFileSync(configPath, '{"logging":');
const result = service.reloadConfigStrict();
assert.equal(result.ok, false);
if (result.ok) {
throw new Error('Expected strict reload to fail on invalid JSON.');
}
assert.equal(result.path, configPath);
assert.equal(service.getConfig().logging.level, 'error');
});
test('accepts valid logging.level', () => {
const dir = makeTempDir();
fs.writeFileSync(

View File

@@ -715,11 +715,16 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts',
},
{
@@ -737,11 +742,15 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Extra keybindings that are merged with built-in defaults.',
'Set command to null to disable a default keybinding.',
],
notes: [
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
],
key: 'keybindings',
},
{
title: 'Subtitle Appearance',
description: ['Primary and secondary subtitle styling.'],
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle',
},
{
@@ -750,6 +759,7 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
'Dual subtitle track options.',
'Used by subminer YouTube subtitle generation as secondary language preferences.',
],
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
key: 'secondarySub',
},
{

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { parse as parseJsonc, type ParseError } from 'jsonc-parser';
import { Config, ConfigValidationWarning, RawConfig, ResolvedConfig } from '../types';
import { DEFAULT_CONFIG, deepCloneConfig, deepMergeRawConfig } from './definitions';
@@ -9,6 +9,19 @@ interface LoadResult {
path: string;
}
export type ReloadConfigStrictResult =
| {
ok: true;
config: ResolvedConfig;
warnings: ConfigValidationWarning[];
path: string;
}
| {
ok: false;
error: string;
path: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
@@ -91,6 +104,26 @@ export class ConfigService {
return this.getConfig();
}
reloadConfigStrict(): ReloadConfigStrictResult {
const loadResult = this.loadRawConfigStrict();
if (!loadResult.ok) {
return loadResult;
}
const { config, path: configPath } = loadResult;
this.rawConfig = config;
this.configPathInUse = configPath;
const { resolved, warnings } = this.resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
return {
ok: true,
config: this.getConfig(),
warnings: [...warnings],
path: configPath,
};
}
saveRawConfig(config: RawConfig): void {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
@@ -112,6 +145,20 @@ export class ConfigService {
}
private loadRawConfig(): LoadResult {
const strictResult = this.loadRawConfigStrict();
if (strictResult.ok) {
return strictResult;
}
return { config: {}, path: strictResult.path };
}
private loadRawConfigStrict():
| (LoadResult & { ok: true })
| {
ok: false;
error: string;
path: string;
} {
const configPath = fs.existsSync(this.configFileJsonc)
? this.configFileJsonc
: fs.existsSync(this.configFileJson)
@@ -119,18 +166,29 @@ export class ConfigService {
: this.configFileJsonc;
if (!fs.existsSync(configPath)) {
return { config: {}, path: configPath };
return { ok: true, config: {}, path: configPath };
}
try {
const data = fs.readFileSync(configPath, 'utf-8');
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
const parsed = configPath.endsWith('.jsonc')
? (() => {
const errors: ParseError[] = [];
const result = parseJsonc(data, errors);
if (errors.length > 0) {
throw new Error(`Invalid JSONC (${errors[0]?.error ?? 'unknown'})`);
}
return result;
})()
: JSON.parse(data);
return {
ok: true,
config: isObject(parsed) ? (parsed as Config) : {},
path: configPath,
};
} catch {
return { config: {}, path: configPath };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown parse error';
return { ok: false, error: message, path: configPath };
}
}