refactor(config): unify config path resolution across app and launcher

Share config discovery logic between main and launcher so XDG/home and SubMiner/subminer precedence stay consistent. Add regression tests for resolution order and keep config path/show behavior stable.
This commit is contained in:
2026-02-19 01:06:26 -08:00
parent 9384d67b8e
commit 58f28b7b55
9 changed files with 250 additions and 72 deletions

View File

@@ -1,10 +1,11 @@
--- ---
id: TASK-70 id: TASK-70
title: Unify config path resolution across main and launcher title: Unify config path resolution across main and launcher
status: To Do status: Done
assignee: [] assignee:
- codex-main
created_date: '2026-02-18 11:35' created_date: '2026-02-18 11:35'
updated_date: '2026-02-18 11:35' updated_date: '2026-02-19 09:05'
labels: labels:
- config - config
- launcher - launcher
@@ -32,14 +33,46 @@ Config discovery logic is duplicated and inconsistent between app main process a
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Single canonical path-resolution logic used by app and launcher - [x] #1 Single canonical path-resolution logic used by app and launcher
- [ ] #2 `XDG_CONFIG_HOME` and `SubMiner|subminer` precedence covered by tests - [x] #2 `XDG_CONFIG_HOME` and `SubMiner|subminer` precedence covered by tests
- [ ] #3 No behavior drift for existing config-path CLI commands - [x] #3 No behavior drift for existing config-path CLI commands
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused precedence tests in `src/config/path-resolution.test.ts` for XDG/home base-dir order, SubMiner/subminer fallbacks, config.jsonc/config.json preference, and fallback path behavior.
2. Create canonical helper module `src/config/path-resolution.ts` exporting shared discovery functions for config dir and config file resolution.
3. Replace duplicated path-resolution logic in `src/main.ts`, `launcher/main.ts`, and launcher config loaders in `launcher/config.ts` to use the canonical helper.
4. Verify no behavior drift with `bun run build`, config/path tests, and launcher bundle build; then update backlog acceptance/DoD checks and execution notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-02-19T08:52:23Z: Started task execution; gathering code context for unified config-path resolution across main and launcher.
Plan captured in docs/plans/2026-02-19-task-70-unify-config-path-resolution.md and recorded in task. User requested immediate execution after planning.
Implemented canonical config path discovery in `src/config/path-resolution.ts` and switched `src/main.ts`, `launcher/main.ts`, and launcher config loaders in `launcher/config.ts` to use it.
Added precedence regression coverage in `src/config/path-resolution.test.ts` and wired into `test:config:dist`.
Verification passed: `bun run build`, `node --test dist/config/path-resolution.test.js dist/config/config.test.js`, and `bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=/tmp/subminer-task70`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Unified config discovery behind a single canonical utility at `src/config/path-resolution.ts` and replaced duplicated resolvers in `src/main.ts`, `launcher/main.ts`, and launcher config loaders in `launcher/config.ts`. This keeps `XDG_CONFIG_HOME` + `~/.config` base-dir handling, `SubMiner|subminer` case variants, and `config.jsonc` > `config.json` file preference consistent across app and launcher while preserving `subminer config path|show` fallback output behavior.
Added regression tests in `src/config/path-resolution.test.ts` for base-dir trimming/dedup, candidate precedence, lowercase fallback, directory fallback for main config dir resolution, and fallback file-path behavior; wired the new suite into `test:config:dist` in `package.json`.
Verification run: `bun run build`, `node --test dist/config/path-resolution.test.js dist/config/config.test.js`, `bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=/tmp/subminer-task70` (all pass).
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 Launcher and config tests pass - [x] #1 Launcher and config tests pass
- [ ] #2 Code no longer duplicates config path candidate logic - [x] #2 Code no longer duplicates config path candidate logic
<!-- DOD:END --> <!-- DOD:END -->

View File

@@ -2,6 +2,6 @@
Read first. Keep concise. Read first. Keep concise.
| agent_id | alias | mission | status | file | last_update_utc | | agent_id | alias | mission | status | file | last_update_utc |
| ------------ | -------------- | -------------------------------------------- | --------- | ------------------------------------- | ---------------------- | | ------------ | -------------- | ---------------------------------------------------- | --------- | ------------------------------------- | ---------------------- |
| `codex-main` | `planner-exec` | `Track config-gated keybinding task request` | `handoff` | `docs/subagents/agents/codex-main.md` | `2026-02-19T08:41:44Z` | | `codex-main` | `planner-exec` | `Unify config path resolution across app + launcher` | `handoff` | `docs/subagents/agents/codex-main.md` | `2026-02-19T09:05:26Z` |

View File

@@ -1,7 +1,7 @@
# Agent: codex-main # Agent: codex-main
- alias: planner-exec - alias: planner-exec
- mission: Track config-gated keybinding task request - mission: Unify config path resolution across app + launcher
- status: handoff - status: handoff
- branch: main - branch: main
- started_at: 2026-02-19T08:06:28Z - started_at: 2026-02-19T08:06:28Z
@@ -9,6 +9,10 @@
## Current Work (newest first) ## Current Work (newest first)
- [2026-02-19T09:05:26Z] handoff: completed TASK-70 (Done); unified config path resolution into shared `src/config/path-resolution.ts`, wired app+launcher call sites, added precedence tests, verified build/tests/launcher bundle, and checked AC/DoD in Backlog.
- [2026-02-19T09:05:26Z] test: `bun run build && node --test dist/config/path-resolution.test.js dist/config/config.test.js && bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=/tmp/subminer-task70` -> pass.
- [2026-02-19T08:57:36Z] progress: plan saved to `docs/plans/2026-02-19-task-70-unify-config-path-resolution.md`, mirrored to backlog TASK-70 `planSet`; moving to edit/test execution.
- [2026-02-19T08:52:23Z] intent: execute TASK-70 by reading full backlog context, writing implementation plan via writing-plans skill, then executing via executing-plans skill with parallel subagents where possible.
- [2026-02-19T08:41:44Z] handoff: created backlog TASK-84 for config-gated keybindings + disabled-feature integration non-loading (Jellyfin example) with tests/docs acceptance criteria. - [2026-02-19T08:41:44Z] handoff: created backlog TASK-84 for config-gated keybindings + disabled-feature integration non-loading (Jellyfin example) with tests/docs acceptance criteria.
- [2026-02-19T08:41:22Z] intent: create backlog task for config-gated keybindings so disabled features become no-ops and feature modules (example: Jellyfin) are not loaded when disabled. - [2026-02-19T08:41:22Z] intent: create backlog task for config-gated keybindings so disabled features become no-ops and feature modules (example: Jellyfin) are not loaded when disabled.
- [2026-02-19T08:40:53Z] handoff: completed TASK-83 simplification; removed configurable sentence/audio field overrides for Lapis sentence cards and verified. - [2026-02-19T08:40:53Z] handoff: completed TASK-83 simplification; removed configurable sentence/audio field overrides for Lapis sentence cards and verified.
@@ -31,6 +35,14 @@
- `docs/subagents/INDEX.md` - `docs/subagents/INDEX.md`
- `docs/subagents/agents/codex-main.md` - `docs/subagents/agents/codex-main.md`
- `docs/plans/2026-02-19-task-70-unify-config-path-resolution.md`
- `src/config/path-resolution.ts`
- `src/config/path-resolution.test.ts`
- `launcher/main.ts`
- `launcher/config.ts`
- `src/main.ts`
- `package.json`
- `backlog/tasks/task-70 - Unify-config-path-resolution-across-main-and-launcher.md`
- `backlog/tasks/task-84 - Gate-feature-dependent-keybindings-behind-config-flags.md` - `backlog/tasks/task-84 - Gate-feature-dependent-keybindings-behind-config-flags.md`
- `src/config/service.ts` - `src/config/service.ts`
- `src/config/config.test.ts` - `src/config/config.test.ts`
@@ -61,4 +73,4 @@
## Next Step ## Next Step
- Await user prioritization / implementation request for TASK-84. - Await user review; optional next step is commit for TASK-70 changes.

View File

@@ -3,6 +3,7 @@ import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { Command } from 'commander'; import { Command } from 'commander';
import { parse as parseJsonc } from 'jsonc-parser'; import { parse as parseJsonc } from 'jsonc-parser';
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
import type { import type {
LogLevel, LogLevel,
YoutubeSubgenMode, YoutubeSubgenMode,
@@ -27,12 +28,17 @@ import {
inferWhisperLanguage, inferWhisperLanguage,
} from './util.js'; } from './util.js';
function resolveLauncherMainConfigPath(): string {
return resolveConfigFilePath({
xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(),
existsSync: fs.existsSync,
});
}
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
const configDir = path.join(os.homedir(), '.config', 'SubMiner'); const configPath = resolveLauncherMainConfigPath();
const jsoncPath = path.join(configDir, 'config.jsonc'); if (!fs.existsSync(configPath)) return {};
const jsonPath = path.join(configDir, 'config.json');
const configPath = fs.existsSync(jsoncPath) ? jsoncPath : fs.existsSync(jsonPath) ? jsonPath : '';
if (!configPath) return {};
try { try {
const data = fs.readFileSync(configPath, 'utf8'); const data = fs.readFileSync(configPath, 'utf8');
@@ -118,11 +124,8 @@ export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
} }
export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
const configDir = path.join(os.homedir(), '.config', 'SubMiner'); const configPath = resolveLauncherMainConfigPath();
const jsoncPath = path.join(configDir, 'config.jsonc'); if (!fs.existsSync(configPath)) return {};
const jsonPath = path.join(configDir, 'config.json');
const configPath = fs.existsSync(jsoncPath) ? jsoncPath : fs.existsSync(jsonPath) ? jsonPath : '';
if (!configPath) return {};
try { try {
const data = fs.readFileSync(configPath, 'utf8'); const data = fs.readFileSync(configPath, 'utf8');

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
import type { Args } from './types.js'; import type { Args } from './types.js';
import { log, fail } from './log.js'; import { log, fail } from './log.js';
import { commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe } from './util.js'; import { commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe } from './util.js';
@@ -97,18 +98,11 @@ function registerCleanup(args: Args): void {
} }
function resolveMainConfigPath(): string { function resolveMainConfigPath(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); return resolveConfigFilePath({
const baseDirs = Array.from(new Set([xdgConfigHome, path.join(os.homedir(), '.config')])); xdgConfigHome: process.env.XDG_CONFIG_HOME,
const appNames = ['SubMiner', 'subminer']; homeDir: os.homedir(),
for (const baseDir of baseDirs) { existsSync: fs.existsSync,
for (const appName of appNames) { });
const jsoncPath = path.join(baseDir, appName, 'config.jsonc');
if (fs.existsSync(jsoncPath)) return jsoncPath;
const jsonPath = path.join(baseDir, appName, 'config.json');
if (fs.existsSync(jsonPath)) return jsonPath;
}
}
return path.join(baseDirs[0], 'SubMiner', 'config.jsonc');
} }
function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never { function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never {

View File

@@ -16,7 +16,7 @@
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test:config:dist": "node --test dist/config/config.test.js", "test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js",
"test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test": "bun run test:config && bun run test:core", "test": "bun run test:config && bun run test:core",

View File

@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveConfigBaseDirs, resolveConfigDir, resolveConfigFilePath } from './path-resolution';
function existsSyncFrom(paths: string[]): (candidate: string) => boolean {
const normalized = new Set(paths.map((entry) => path.normalize(entry)));
return (candidate: string): boolean => normalized.has(path.normalize(candidate));
}
test('resolveConfigBaseDirs trims xdg value and deduplicates fallback dir', () => {
const homeDir = '/home/tester';
const baseDirs = resolveConfigBaseDirs(' /home/tester/.config ', homeDir);
assert.deepEqual(baseDirs, [path.join(homeDir, '.config')]);
});
test('resolveConfigDir prefers xdg SubMiner config when present', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const configDir = path.join(xdgConfigHome, 'SubMiner');
const existsSync = existsSyncFrom([path.join(configDir, 'config.jsonc')]);
const resolved = resolveConfigDir({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigDir falls back to lowercase subminer candidate', () => {
const homeDir = '/home/tester';
const configDir = path.join(homeDir, '.config', 'subminer');
const existsSync = existsSyncFrom([path.join(configDir, 'config.json')]);
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigDir falls back to existing directory when file is missing', () => {
const homeDir = '/home/tester';
const configDir = path.join(homeDir, '.config', 'subminer');
const existsSync = existsSyncFrom([configDir]);
const resolved = resolveConfigDir({
xdgConfigHome: '/tmp/missing-xdg',
homeDir,
existsSync,
});
assert.equal(resolved, configDir);
});
test('resolveConfigFilePath prefers jsonc before json', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
path.join(xdgConfigHome, 'SubMiner', 'config.json'),
]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});
test('resolveConfigFilePath keeps legacy fallback output path', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const existsSync = existsSyncFrom([]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
existsSync,
});
assert.equal(resolved, path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});

View File

@@ -0,0 +1,76 @@
import path from 'node:path';
type ExistsSync = (candidate: string) => boolean;
type ConfigPathOptions = {
xdgConfigHome?: string;
homeDir: string;
existsSync: ExistsSync;
appNames?: readonly string[];
defaultAppName?: string;
};
const DEFAULT_APP_NAMES = ['SubMiner', 'subminer'] as const;
const DEFAULT_FILE_NAMES = ['config.jsonc', 'config.json'] as const;
export function resolveConfigBaseDirs(
xdgConfigHome: string | undefined,
homeDir: string,
): string[] {
const fallbackBaseDir = path.join(homeDir, '.config');
const primaryBaseDir = xdgConfigHome?.trim() || fallbackBaseDir;
return Array.from(new Set([primaryBaseDir, fallbackBaseDir]));
}
function getAppNames(options: ConfigPathOptions): readonly string[] {
return options.appNames ?? DEFAULT_APP_NAMES;
}
function getDefaultAppName(options: ConfigPathOptions): string {
return options.defaultAppName ?? DEFAULT_APP_NAMES[0];
}
export function resolveConfigDir(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
for (const fileName of DEFAULT_FILE_NAMES) {
if (options.existsSync(path.join(dir, fileName))) {
return dir;
}
}
}
}
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
if (options.existsSync(dir)) {
return dir;
}
}
}
return path.join(baseDirs[0], getDefaultAppName(options));
}
export function resolveConfigFilePath(options: ConfigPathOptions): string {
const baseDirs = resolveConfigBaseDirs(options.xdgConfigHome, options.homeDir);
const appNames = getAppNames(options);
for (const baseDir of baseDirs) {
for (const appName of appNames) {
for (const fileName of DEFAULT_FILE_NAMES) {
const candidate = path.join(baseDir, appName, fileName);
if (options.existsSync(candidate)) {
return candidate;
}
}
}
}
return path.join(baseDirs[0], getDefaultAppName(options), DEFAULT_FILE_NAMES[0]);
}

View File

@@ -184,6 +184,7 @@ import {
DEFAULT_KEYBINDINGS, DEFAULT_KEYBINDINGS,
generateConfigTemplate, generateConfigTemplate,
} from './config'; } from './config';
import { resolveConfigDir } from './config/path-resolution';
if (process.platform === 'linux') { if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -252,41 +253,11 @@ function applyJellyfinMpvDefaults(client: MpvIpcClient): void {
sendMpvCommandRuntime(client, ['set_property', 'slang', JELLYFIN_LANG_PREF]); sendMpvCommandRuntime(client, ['set_property', 'slang', JELLYFIN_LANG_PREF]);
} }
function resolveConfigDir(): string { const CONFIG_DIR = resolveConfigDir({
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); xdgConfigHome: process.env.XDG_CONFIG_HOME,
const baseDirs = Array.from( homeDir: os.homedir(),
new Set([ existsSync: fs.existsSync,
xdgConfigHome || path.join(os.homedir(), '.config'), });
path.join(os.homedir(), '.config'),
]),
);
const appNames = ['SubMiner', 'subminer'];
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
if (
fs.existsSync(path.join(dir, 'config.jsonc')) ||
fs.existsSync(path.join(dir, 'config.json'))
) {
return dir;
}
}
}
for (const baseDir of baseDirs) {
for (const appName of appNames) {
const dir = path.join(baseDir, appName);
if (fs.existsSync(dir)) {
return dir;
}
}
}
return path.join(baseDirs[0], 'SubMiner');
}
const CONFIG_DIR = resolveConfigDir();
const USER_DATA_PATH = CONFIG_DIR; const USER_DATA_PATH = CONFIG_DIR;
const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE;
const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, 'immersion.sqlite'); const DEFAULT_IMMERSION_DB_PATH = path.join(USER_DATA_PATH, 'immersion.sqlite');