From dd6affca6db7747886493d1a6ee1df074dd2dc88 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Tue, 16 Jun 2026 15:33:59 +0800 Subject: [PATCH] fix(config): store config.json under the persisted data dir (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs, avatars) already lives under DATA_DIR. So on first run the default config was written into the ephemeral image layer (never appearing in the volume), and a manually-placed data/config.json was ignored because the bot read/wrote the root path. - Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits take effect. - Add migrateLegacyConfig(): one-time move of an existing root-level config.json into the data dir, so existing local installs keep their settings (no silent reset). - Tests for first-run persistence + the three migration cases. - README directory tree updated to data/config.json. Fixes #86 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 ++++---- src/data/config.test.ts | 57 +++++++++++++++++++++++++++++++++++++++-- src/data/config.ts | 34 +++++++++++++++++++++++- src/index.ts | 11 ++++++-- 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e9ffdf8..1271a85 100644 --- a/README.md +++ b/README.md @@ -383,11 +383,11 @@ teamspeak-music-bot/ │ └── docker/ # Docker 部署文件 │ ├── Dockerfile │ └── docker-compose.yml -├── data/ # 运行时数据(自动创建,不上传) -│ ├── tsmusicbot.db # SQLite 数据库 -│ ├── cookies/ # 登录 Cookie -│ └── logs/ # 日志文件 -└── config.json # 配置文件(首次运行自动生成,不上传) +└── data/ # 运行时数据(自动创建,不上传) + ├── config.json # 配置文件(首次运行自动生成,可手动编辑) + ├── tsmusicbot.db # SQLite 数据库 + ├── cookies/ # 登录 Cookie + └── logs/ # 日志文件 ``` ## 技术栈 diff --git a/src/data/config.test.ts b/src/data/config.test.ts index b9ad265..a49875f 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, afterEach } from "vitest"; import { join } from "node:path"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { getDefaultConfig, loadConfig, saveConfig } from "./config.js"; +import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig } from "./config.js"; describe("config", () => { const dirs: string[] = []; @@ -51,4 +51,57 @@ describe("config", () => { expect(loaded.commandPrefix).toBe("!"); expect(loaded.autoPauseOnEmpty).toBe(true); }); + + // --- #86: config.json must live under (and be created in) the persisted data dir --- + + it("first run writes config.json into the data dir and reads it back", () => { + const root = makeTmpDir(); + const dataDir = join(root, "data"); + const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH + + // Boot sequence: load (missing -> defaults) then save. + const config = loadConfig(configPath); + saveConfig(configPath, config); + + expect(existsSync(configPath)).toBe(true); + // A subsequent hand-edited file under the SAME persisted path is honored. + writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8"); + expect(loadConfig(configPath).webPort).toBe(9999); + }); + + it("migrates a legacy root config into the data dir, preserving values", () => { + const root = makeTmpDir(); + const legacyPath = join(root, "config.json"); + const newPath = join(root, "data", "config.json"); + writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8"); + + const migrated = migrateLegacyConfig(legacyPath, newPath); + + expect(migrated).toBe(true); + expect(existsSync(newPath)).toBe(true); + expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated + const loaded = loadConfig(newPath); + expect(loaded.webPort).toBe(4242); + expect(loaded.publicUrl).toBe("http://x"); + }); + + it("does NOT overwrite an existing data-dir config during migration", () => { + const root = makeTmpDir(); + const legacyPath = join(root, "config.json"); + const newPath = join(root, "data", "config.json"); + writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8"); + saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 }); + + const migrated = migrateLegacyConfig(legacyPath, newPath); + + expect(migrated).toBe(false); // new location wins, untouched + expect(loadConfig(newPath).webPort).toBe(2222); + expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated + }); + + it("migration is a no-op when there is no legacy config", () => { + const root = makeTmpDir(); + const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json")); + expect(migrated).toBe(false); + }); }); diff --git a/src/data/config.ts b/src/data/config.ts index 12bf70f..301c6ed 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs"; import { dirname } from "node:path"; export interface BotConfig { @@ -58,3 +58,35 @@ export function saveConfig(path: string, config: BotConfig): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify(config, null, 2), "utf-8"); } + +/** + * One-time migration for the config location fix (#86). + * + * Older versions wrote config.json to the app/repo ROOT, which is NOT inside the + * persisted data directory (the Docker volume is mounted at data/). That meant the + * file never landed in the volume on first run and a manually-placed data/config.json + * was ignored. config.json now lives under the data dir alongside the DB/cookies/logs. + * + * If a legacy root-level config exists and the new data-dir config does not yet exist, + * move it so existing local installs keep their customized settings. Best-effort: + * any failure is swallowed and loadConfig falls back to defaults. + * + * @returns true if a legacy config was migrated, false otherwise. + */ +export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean { + try { + if (legacyPath === newPath) return false; + if (existsSync(newPath)) return false; // new location already populated — leave it + if (!existsSync(legacyPath)) return false; // nothing to migrate + mkdirSync(dirname(newPath), { recursive: true }); + copyFileSync(legacyPath, newPath); // copy first (works across filesystems) + try { + rmSync(legacyPath); + } catch { + /* leave the legacy file if it can't be removed; the new one wins */ + } + return true; + } catch { + return false; + } +} diff --git a/src/index.ts b/src/index.ts index f48a557..a281a0b 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { loadConfig, saveConfig } from "./data/config.js"; +import { loadConfig, saveConfig, migrateLegacyConfig } from "./data/config.js"; import { createDatabase } from "./data/database.js"; import { createLogger } from "./logger.js"; import { createApiServerManager } from "./music/api-server.js"; @@ -16,7 +16,11 @@ import { createWebServer } from "./web/server.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT_DIR = path.resolve(__dirname, ".."); const DATA_DIR = path.join(ROOT_DIR, "data"); -const CONFIG_PATH = path.join(ROOT_DIR, "config.json"); +// config.json lives under the persisted data dir (the Docker volume) alongside the +// DB/cookies/logs, so it survives container restarts and manual edits take effect +// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once. +const CONFIG_PATH = path.join(DATA_DIR, "config.json"); +const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json"); const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db"); const LOG_DIR = path.join(DATA_DIR, "logs"); const COOKIE_DIR = path.join(DATA_DIR, "cookies"); @@ -24,6 +28,9 @@ const AVATAR_DIR = path.join(DATA_DIR, "avatars"); const STATIC_DIR = path.join(ROOT_DIR, "web", "dist"); async function main() { + // Migrate a pre-#86 root-level config.json into the data dir so existing + // installs keep their settings; no-op if already migrated or none exists. + migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH); const config = loadConfig(CONFIG_PATH); saveConfig(CONFIG_PATH, config);