diff --git a/docs/README.ja.md b/docs/README.ja.md index eb2bb9b..1d61c42 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -294,6 +294,9 @@ takt prompt [piece] # ピースカテゴリをビルトインのデフォルトにリセット takt reset categories + +# グローバル config をテンプレートにリセット(旧設定は .old に退避) +takt reset config ``` ### おすすめピース diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index f4da4bf..af12867 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -151,6 +151,15 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `takt reset categories` を実行する。 - 出力に `reset` を含むことを確認する。 - `$TAKT_CONFIG_DIR/preferences/piece-categories.yaml` が存在し `piece_categories: {}` を含むことを確認する。 +- Reset config(`e2e/specs/cli-reset-config.e2e.ts`) + - 目的: `takt reset config` でグローバル設定をテンプレートへ戻し、旧設定をバックアップすることを確認。 + - LLM: 呼び出さない(LLM不使用の操作のみ) + - 手順(ユーザー行動/コマンド): + - `$TAKT_CONFIG_DIR/config.yaml` に任意の設定を書き込む(例: `language: ja`, `provider: mock`)。 + - `takt reset config` を実行する。 + - 出力に `reset` と `backup:` を含むことを確認する。 + - `$TAKT_CONFIG_DIR/config.yaml` がテンプレート内容(例: `branch_name_strategy: ai`, `concurrency: 2`)に置き換わっていることを確認する。 + - `$TAKT_CONFIG_DIR/` 直下に `config.yaml.YYYYMMDD-HHmmss.old` 形式のバックアップファイルが1件作成されることを確認する。 - Export Claude Code Skill(`e2e/specs/cli-export-cc.e2e.ts`) - 目的: `takt export-cc` でClaude Code Skillのデプロイを確認。 - LLM: 呼び出さない(LLM不使用の操作のみ) diff --git a/e2e/specs/cli-reset-config.e2e.ts b/e2e/specs/cli-reset-config.e2e.ts new file mode 100644 index 0000000..e7f7807 --- /dev/null +++ b/e2e/specs/cli-reset-config.e2e.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; +import { createLocalRepo, type LocalRepo } from '../helpers/test-repo'; + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Reset config command (takt reset config)', () => { + let isolatedEnv: IsolatedEnv; + let repo: LocalRepo; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should backup current config and replace with builtin template', () => { + const configPath = join(isolatedEnv.taktDir, 'config.yaml'); + writeFileSync(configPath, ['language: ja', 'provider: mock'].join('\n'), 'utf-8'); + + const result = runTakt({ + args: ['reset', 'config'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + expect(result.exitCode).toBe(0); + const output = result.stdout; + expect(output).toMatch(/reset/i); + expect(output).toMatch(/backup:/i); + + const config = readFileSync(configPath, 'utf-8'); + expect(config).toContain('language: ja'); + expect(config).toContain('branch_name_strategy: ai'); + expect(config).toContain('concurrency: 2'); + + const backups = readdirSync(isolatedEnv.taktDir).filter((name) => + /^config\.yaml\.\d{8}-\d{6}\.old(\.\d+)?$/.test(name), + ); + expect(backups.length).toBe(1); + }); +}); diff --git a/src/__tests__/reset-global-config.test.ts b/src/__tests__/reset-global-config.test.ts new file mode 100644 index 0000000..ec19ad3 --- /dev/null +++ b/src/__tests__/reset-global-config.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resetGlobalConfigToTemplate } from '../infra/config/global/resetConfig.js'; + +describe('resetGlobalConfigToTemplate', () => { + const originalEnv = process.env; + let testRoot: string; + let taktDir: string; + let configPath: string; + + beforeEach(() => { + testRoot = mkdtempSync(join(tmpdir(), 'takt-reset-config-')); + taktDir = join(testRoot, '.takt'); + mkdirSync(taktDir, { recursive: true }); + configPath = join(taktDir, 'config.yaml'); + process.env = { ...originalEnv, TAKT_CONFIG_DIR: taktDir }; + }); + + afterEach(() => { + process.env = originalEnv; + rmSync(testRoot, { recursive: true, force: true }); + }); + + it('should backup existing config and replace with language-matched template', () => { + writeFileSync(configPath, ['language: ja', 'provider: mock'].join('\n'), 'utf-8'); + + const result = resetGlobalConfigToTemplate(new Date('2026-02-19T12:00:00Z')); + + expect(result.language).toBe('ja'); + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(readFileSync(result.backupPath!, 'utf-8')).toContain('provider: mock'); + + const newConfig = readFileSync(configPath, 'utf-8'); + expect(newConfig).toContain('language: ja'); + expect(newConfig).toContain('branch_name_strategy: ai'); + expect(newConfig).toContain('concurrency: 2'); + }); + + it('should create config from default language template when config does not exist', () => { + rmSync(configPath, { force: true }); + + const result = resetGlobalConfigToTemplate(new Date('2026-02-19T12:00:00Z')); + + expect(result.backupPath).toBeUndefined(); + expect(result.language).toBe('en'); + expect(existsSync(configPath)).toBe(true); + const newConfig = readFileSync(configPath, 'utf-8'); + expect(newConfig).toContain('language: en'); + expect(newConfig).toContain('branch_name_strategy: ai'); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 428e138..a255935 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -7,7 +7,7 @@ import { clearPersonaSessions, resolveConfigValue } from '../../infra/config/index.js'; import { success } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; +import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, resetConfigToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { showCatalog } from '../../features/catalog/index.js'; import { program, resolvedCwd } from './program.js'; @@ -100,6 +100,13 @@ const reset = program .command('reset') .description('Reset settings to defaults'); +reset + .command('config') + .description('Reset global config to builtin template (with backup)') + .action(async () => { + await resetConfigToDefault(); + }); + reset .command('categories') .description('Reset piece categories to builtin defaults') diff --git a/src/features/config/index.ts b/src/features/config/index.ts index c42df30..db73c75 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -5,4 +5,5 @@ export { switchPiece } from './switchPiece.js'; export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { resetCategoriesToDefault } from './resetCategories.js'; +export { resetConfigToDefault } from './resetConfig.js'; export { deploySkill } from './deploySkill.js'; diff --git a/src/features/config/resetConfig.ts b/src/features/config/resetConfig.ts new file mode 100644 index 0000000..e63b93c --- /dev/null +++ b/src/features/config/resetConfig.ts @@ -0,0 +1,13 @@ +import { resetGlobalConfigToTemplate } from '../../infra/config/global/index.js'; +import { header, info, success } from '../../shared/ui/index.js'; + +export async function resetConfigToDefault(): Promise { + header('Reset Config'); + + const result = resetGlobalConfigToTemplate(); + success('Global config reset from builtin template.'); + info(` config: ${result.configPath}`); + if (result.backupPath) { + info(` backup: ${result.backupPath}`); + } +} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index b51034d..b232603 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -30,6 +30,11 @@ export { resetPieceCategories, } from './pieceCategories.js'; +export { + resetGlobalConfigToTemplate, + type ResetGlobalConfigResult, +} from './resetConfig.js'; + export { needsLanguageSetup, promptLanguageSelection, diff --git a/src/infra/config/global/resetConfig.ts b/src/infra/config/global/resetConfig.ts new file mode 100644 index 0000000..7687884 --- /dev/null +++ b/src/infra/config/global/resetConfig.ts @@ -0,0 +1,71 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import type { Language } from '../../../core/models/index.js'; +import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; +import { getLanguageResourcesDir } from '../../resources/index.js'; +import { getGlobalConfigPath } from '../paths.js'; +import { invalidateGlobalConfigCache } from './globalConfig.js'; + +export interface ResetGlobalConfigResult { + configPath: string; + backupPath?: string; + language: Language; +} + +function detectConfigLanguage(configPath: string): Language { + if (!existsSync(configPath)) return DEFAULT_LANGUAGE; + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYaml(raw) as { language?: unknown } | null; + if (parsed && typeof parsed !== 'object') { + throw new Error(`Invalid config format: ${configPath} must be a YAML object.`); + } + const language = parsed?.language; + if (language === undefined) return DEFAULT_LANGUAGE; + if (language === 'ja' || language === 'en') return language; + throw new Error(`Invalid language in ${configPath}: ${String(language)} (expected: ja | en)`); +} + +function formatTimestamp(date: Date): string { + const y = String(date.getFullYear()); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${y}${m}${d}-${hh}${mm}${ss}`; +} + +function resolveBackupPath(configPath: string, timestamp: string): string { + const base = `${configPath}.${timestamp}.old`; + if (!existsSync(base)) return base; + let index = 1; + while (true) { + const candidate = `${base}.${index}`; + if (!existsSync(candidate)) return candidate; + index += 1; + } +} + +export function resetGlobalConfigToTemplate(now = new Date()): ResetGlobalConfigResult { + const configPath = getGlobalConfigPath(); + const configDir = dirname(configPath); + mkdirSync(configDir, { recursive: true }); + + const language = detectConfigLanguage(configPath); + const templatePath = join(getLanguageResourcesDir(language), 'config.yaml'); + if (!existsSync(templatePath)) { + throw new Error(`Builtin config template not found: ${templatePath}`); + } + + let backupPath: string | undefined; + if (existsSync(configPath)) { + backupPath = resolveBackupPath(configPath, formatTimestamp(now)); + renameSync(configPath, backupPath); + } + + copyFileSync(templatePath, configPath); + invalidateGlobalConfigCache(); + + return { configPath, backupPath, language }; +}