feat: add takt reset config with backup restore

This commit is contained in:
nrslib 2026-02-19 11:59:42 +09:00
parent 6518faf72e
commit 5f4ad753d8
9 changed files with 212 additions and 1 deletions

View File

@ -294,6 +294,9 @@ takt prompt [piece]
# ピースカテゴリをビルトインのデフォルトにリセット
takt reset categories
# グローバル config をテンプレートにリセット(旧設定は .old に退避)
takt reset config
```
### おすすめピース

View File

@ -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不使用の操作のみ

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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')

View File

@ -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';

View File

@ -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<void> {
header('Reset Config');
const result = resetGlobalConfigToTemplate();
success('Global config reset from builtin template.');
info(` config: ${result.configPath}`);
if (result.backupPath) {
info(` backup: ${result.backupPath}`);
}
}

View File

@ -30,6 +30,11 @@ export {
resetPieceCategories,
} from './pieceCategories.js';
export {
resetGlobalConfigToTemplate,
type ResetGlobalConfigResult,
} from './resetConfig.js';
export {
needsLanguageSetup,
promptLanguageSelection,

View File

@ -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 };
}