feat: add takt reset config with backup restore
This commit is contained in:
parent
6518faf72e
commit
5f4ad753d8
@ -294,6 +294,9 @@ takt prompt [piece]
|
||||
|
||||
# ピースカテゴリをビルトインのデフォルトにリセット
|
||||
takt reset categories
|
||||
|
||||
# グローバル config をテンプレートにリセット(旧設定は .old に退避)
|
||||
takt reset config
|
||||
```
|
||||
|
||||
### おすすめピース
|
||||
|
||||
@ -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不使用の操作のみ)
|
||||
|
||||
48
e2e/specs/cli-reset-config.e2e.ts
Normal file
48
e2e/specs/cli-reset-config.e2e.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
src/__tests__/reset-global-config.test.ts
Normal file
54
src/__tests__/reset-global-config.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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')
|
||||
|
||||
@ -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';
|
||||
|
||||
13
src/features/config/resetConfig.ts
Normal file
13
src/features/config/resetConfig.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,11 @@ export {
|
||||
resetPieceCategories,
|
||||
} from './pieceCategories.js';
|
||||
|
||||
export {
|
||||
resetGlobalConfigToTemplate,
|
||||
type ResetGlobalConfigResult,
|
||||
} from './resetConfig.js';
|
||||
|
||||
export {
|
||||
needsLanguageSetup,
|
||||
promptLanguageSelection,
|
||||
|
||||
71
src/infra/config/global/resetConfig.ts
Normal file
71
src/infra/config/global/resetConfig.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user