diff --git a/package.json b/package.json index 8a78477c..2474221e 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "scripts": { "lint": "npx oxlint src/", "test": "npx vitest run", - "test:e2e": "node scripts/e2e.mjs", + "test:e2e": "npx tsx scripts/e2e.ts", "build": "npx tsc --noEmit --pretty || true", "build:hooks-pre-tool-validate": "cd .claude/hooks-pre-tool-validate && cargo build --release && cp target/release/hooks-pre-tool-validate.exe ../hooks-pre-tool-validate.exe", "build:hooks-post-tool-linter": "cd .claude/hooks-post-tool-linter && cargo build --release && cp target/release/hooks-post-tool-linter.exe ../hooks-post-tool-linter.exe", "build:hooks-stop-quality": "cd .claude/hooks-stop-quality && cargo build --release && cp target/release/hooks-stop-quality.exe ../hooks-stop-quality.exe", "build:hooks-settings": "node -e \"const fs=require('fs');const t=fs.readFileSync('.claude/settings.local.json.template','utf8');const p=process.cwd().replace(/\\\\/g,'\\\\\\\\');fs.writeFileSync('.claude/settings.local.json',t.replace(/\\{\\{PROJECT_DIR\\}\\}/g,p))\" && echo settings.local.json generated", "build:hooks": "pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-stop-quality && pnpm build:hooks-settings", - "deploy:hooks": "node scripts/deploy-hooks.js", + "deploy:hooks": "npx tsx scripts/deploy-hooks.ts", "py-lint": "ruff check src/", "py-test": "python -m pytest tests/", "py-typecheck": "mypy src/", diff --git a/scripts/deploy-hooks.js b/scripts/deploy-hooks.js deleted file mode 100644 index e29f5571..00000000 --- a/scripts/deploy-hooks.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * hooks exe を派生プロジェクトに一括配布するスクリプト - * - * 動作: - * 1. deploy-targets.json からターゲットプロジェクト一覧を読み込み - * 2. 各ターゲットの .claude/ ディレクトリに exe をコピー - * 3. settings.local.json.template を解決して settings.local.json を生成 - * 4. hooks-config.toml が存在しない場合は作成を促すメッセージを表示 - * - * 使い方: pnpm deploy:hooks - */ - -const fs = require("fs"); -const path = require("path"); - -const ROOT = path.resolve(__dirname, ".."); -const CLAUDE_DIR = path.join(ROOT, ".claude"); - -const EXE_FILES = [ - "hooks-pre-tool-validate.exe", - "hooks-post-tool-linter.exe", - "hooks-stop-quality.exe", -]; - -const SETTINGS_TEMPLATE = "settings.local.json.template"; - -function loadTargets() { - const targetsPath = path.join(__dirname, "deploy-targets.json"); - if (!fs.existsSync(targetsPath)) { - console.error("Error: deploy-targets.json not found."); - console.error("Copy deploy-targets.template.json and fill in your local paths:"); - console.error(" cp scripts/deploy-targets.template.json scripts/deploy-targets.json"); - process.exit(1); - } - try { - const data = JSON.parse(fs.readFileSync(targetsPath, "utf8")); - return data.targets || []; - } catch (e) { - console.error("Error: Failed to parse deploy-targets.json:", e.message); - process.exit(1); - } -} - -function copyFile(src, dest) { - fs.copyFileSync(src, dest); - console.log(` copied: ${path.basename(src)}`); -} - -function deployTo(targetDir) { - const targetClaude = path.join(targetDir, ".claude"); - - if (!fs.existsSync(targetDir)) { - console.log(` SKIP: project directory does not exist: ${targetDir}`); - return false; - } - - // .claude ディレクトリが無ければ作成 - if (!fs.existsSync(targetClaude)) { - fs.mkdirSync(targetClaude, { recursive: true }); - } - - // exe をコピー - for (const exe of EXE_FILES) { - const src = path.join(CLAUDE_DIR, exe); - if (!fs.existsSync(src)) { - console.log(` WARN: ${exe} not found (run pnpm build:hooks first)`); - continue; - } - copyFile(src, path.join(targetClaude, exe)); - } - - // hooks-config.toml: 存在しない場合のみコピー (既存設定を上書きしない) - const configDest = path.join(targetClaude, "hooks-config.toml"); - if (!fs.existsSync(configDest)) { - console.log( - " NOTE: hooks-config.toml not found — please create one for this project" - ); - console.log( - " See templates/ directory for language-specific examples" - ); - } - - // settings.local.json を生成 (hooks のみ更新、既存 permissions 等は保持) - const templateSrc = path.join(CLAUDE_DIR, SETTINGS_TEMPLATE); - if (fs.existsSync(templateSrc)) { - const template = fs.readFileSync(templateSrc, "utf8"); - const resolved = template.replace( - /\{\{PROJECT_DIR\}\}/g, - targetDir.replace(/\\/g, "\\\\") - ); - let newSettings; - try { - newSettings = JSON.parse(resolved); - } catch (e) { - console.log(` WARN: Failed to parse ${SETTINGS_TEMPLATE}: ${e.message}`); - return true; - } - if ( - !newSettings || - typeof newSettings !== "object" || - Array.isArray(newSettings) || - !newSettings.hooks || - typeof newSettings.hooks !== "object" || - Array.isArray(newSettings.hooks) - ) { - throw new Error("Invalid settings template: `hooks` object is required"); - } - const settingsDest = path.join(targetClaude, "settings.local.json"); - - if (fs.existsSync(settingsDest)) { - try { - const parsed = JSON.parse(fs.readFileSync(settingsDest, "utf8")); - const existing = - parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed - : {}; - existing.hooks = newSettings.hooks; - fs.writeFileSync(settingsDest, JSON.stringify(existing, null, 2) + "\n"); - console.log(" updated: settings.local.json (hooks merged, permissions preserved)"); - } catch (e) { - fs.writeFileSync(settingsDest, JSON.stringify(newSettings, null, 2) + "\n"); - console.log(` WARN: existing settings.local.json was invalid (${e.message}), regenerated`); - } - } else { - fs.writeFileSync(settingsDest, JSON.stringify(newSettings, null, 2) + "\n"); - console.log(" generated: settings.local.json"); - } - } - - return true; -} - -function main() { - const targets = loadTargets(); - - if (targets.length === 0) { - console.log("No deploy targets configured in deploy-targets.json"); - return; - } - - console.log(`Deploying hooks to ${targets.length} target(s)...\n`); - - let success = 0; - for (const target of targets) { - console.log(`[${path.basename(target)}] ${target}`); - if (deployTo(target)) { - success++; - } - console.log(); - } - - console.log(`Done: ${success}/${targets.length} targets deployed.`); -} - -main(); diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts new file mode 100644 index 00000000..4c5e04ce --- /dev/null +++ b/scripts/deploy-hooks.ts @@ -0,0 +1,169 @@ +/** + * hooks exe を派生プロジェクトに一括配布するスクリプト + * + * 動作: + * 1. deploy-targets.json からターゲットプロジェクト一覧を読み込み + * 2. 各ターゲットの .claude/ ディレクトリに exe をコピー + * 3. settings.local.json.template を解決して settings.local.json を生成 + * 4. hooks-config.toml が存在しない場合は作成を促すメッセージを表示 + * + * 使い方: pnpm deploy:hooks + */ + +import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs"; +import { resolve, join, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { logger } from "../src/logger"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const CLAUDE_DIR = join(ROOT, ".claude"); + +const EXE_FILES = [ + "hooks-pre-tool-validate.exe", + "hooks-post-tool-linter.exe", + "hooks-stop-quality.exe", +]; + +const SETTINGS_TEMPLATE = "settings.local.json.template"; + +interface DeployTargets { + targets?: string[]; +} + +function loadTargets(): string[] { + const targetsPath = join(__dirname, "deploy-targets.json"); + if (!existsSync(targetsPath)) { + logger.error("deploy-targets.json not found."); + logger.error("Copy deploy-targets.template.json and fill in your local paths:"); + logger.error(" cp scripts/deploy-targets.template.json scripts/deploy-targets.json"); + process.exit(1); + } + try { + const parsed: unknown = JSON.parse(readFileSync(targetsPath, "utf8")); + const targets = + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as DeployTargets).targets) && + (parsed as DeployTargets).targets!.every((t) => typeof t === "string") + ? (parsed as DeployTargets).targets! + : null; + if (!targets) { + throw new Error("`targets` must be string[]"); + } + return targets; + } catch (e) { + logger.error("Failed to parse deploy-targets.json:", (e as Error).message); + process.exit(1); + } +} + +function copyFile(src: string, dest: string): void { + copyFileSync(src, dest); + logger.info(` copied: ${basename(src)}`); +} + +function deployTo(targetDir: string): boolean { + const targetClaude = join(targetDir, ".claude"); + + if (!existsSync(targetDir)) { + logger.warn(` SKIP: project directory does not exist: ${targetDir}`); + return false; + } + + if (!existsSync(targetClaude)) { + mkdirSync(targetClaude, { recursive: true }); + } + + for (const exe of EXE_FILES) { + const src = join(CLAUDE_DIR, exe); + if (!existsSync(src)) { + logger.warn(` ${exe} not found (run pnpm build:hooks first)`); + continue; + } + copyFile(src, join(targetClaude, exe)); + } + + const configDest = join(targetClaude, "hooks-config.toml"); + if (!existsSync(configDest)) { + logger.info( + " NOTE: hooks-config.toml not found — please create one for this project" + ); + logger.info( + " See templates/ directory for language-specific examples" + ); + } + + const templateSrc = join(CLAUDE_DIR, SETTINGS_TEMPLATE); + if (existsSync(templateSrc)) { + const template = readFileSync(templateSrc, "utf8"); + const resolved = template.replace( + /\{\{PROJECT_DIR\}\}/g, + targetDir.replace(/\\/g, "\\\\") + ); + let newSettings: Record; + try { + newSettings = JSON.parse(resolved); + } catch (e) { + logger.warn(` Failed to parse ${SETTINGS_TEMPLATE}: ${(e as Error).message}`); + return false; + } + if ( + !newSettings || + typeof newSettings !== "object" || + Array.isArray(newSettings) || + !newSettings.hooks || + typeof newSettings.hooks !== "object" || + Array.isArray(newSettings.hooks) + ) { + throw new Error("Invalid settings template: `hooks` object is required"); + } + const settingsDest = join(targetClaude, "settings.local.json"); + + if (existsSync(settingsDest)) { + try { + const parsed: unknown = JSON.parse(readFileSync(settingsDest, "utf8")); + const existing: Record = + parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + existing.hooks = newSettings.hooks; + writeFileSync(settingsDest, JSON.stringify(existing, null, 2) + "\n"); + logger.info(" updated: settings.local.json (hooks merged, permissions preserved)"); + } catch (e) { + writeFileSync(settingsDest, JSON.stringify(newSettings, null, 2) + "\n"); + logger.warn(` existing settings.local.json was invalid (${(e as Error).message}), regenerated`); + } + } else { + writeFileSync(settingsDest, JSON.stringify(newSettings, null, 2) + "\n"); + logger.info(" generated: settings.local.json"); + } + } + + return true; +} + +function main(): void { + const targets = loadTargets(); + + if (targets.length === 0) { + logger.info("No deploy targets configured in deploy-targets.json"); + return; + } + + logger.info(`Deploying hooks to ${targets.length} target(s)...\n`); + + let success = 0; + for (const target of targets) { + logger.info(`[${basename(target)}] ${target}`); + if (deployTo(target)) { + success++; + } + logger.info(""); + } + + logger.info(`Done: ${success}/${targets.length} targets deployed.`); +} + +main(); diff --git a/scripts/e2e.mjs b/scripts/e2e.ts similarity index 58% rename from scripts/e2e.mjs rename to scripts/e2e.ts index 1e6d3e15..e82b7f57 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.ts @@ -1,10 +1,14 @@ -// E2E test runner -// .env.e2e が存在しない場合は自動スキップ +/** + * E2E test runner + * .env.e2e が存在しない場合は自動スキップ + */ + import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; +import { logger } from "../src/logger"; if (!existsSync(".env.e2e")) { - console.log("Skipped: .env.e2e not found"); + logger.info("Skipped: .env.e2e not found"); process.exit(0); } diff --git a/src/logger.ts b/src/logger.ts index 94acb829..79a498b6 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,10 +1,8 @@ /** - * 最小限の Logger 実装 + * Logger — プロジェクト共通のログ出力 * * カスタムリンター (no-console-log) で console.log を禁止しているため、 - * 内部実装は console.info / console.warn / console.error を使用する。 - * - * フル実装(ログレベル制御・フォーマット・出力先切替)は別PRで対応予定。 + * 内部実装は console.info / console.warn / console.error を使用する (ADR-007)。 */ type LogLevel = "debug" | "info" | "warn" | "error"; @@ -16,6 +14,19 @@ const LEVEL_PRIORITY: Record = { error: 3, }; +function jstTimestamp(): string { + return new Date().toLocaleString("ja-JP", { + timeZone: "Asia/Tokyo", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +} + class Logger { private level: LogLevel; @@ -29,26 +40,25 @@ class Logger { debug(...args: unknown[]): void { if (this.shouldLog("debug")) { - // console.info を使用: console.log はカスタムリンターで禁止 (ADR-007) - console.info("[DEBUG]", ...args); + console.info(`${jstTimestamp()} [DEBUG]`, ...args); } } info(...args: unknown[]): void { if (this.shouldLog("info")) { - console.info("[INFO]", ...args); + console.info(`${jstTimestamp()} [INFO]`, ...args); } } warn(...args: unknown[]): void { if (this.shouldLog("warn")) { - console.warn("[WARN]", ...args); + console.warn(`${jstTimestamp()} [WARN]`, ...args); } } error(...args: unknown[]): void { if (this.shouldLog("error")) { - console.error("[ERROR]", ...args); + console.error(`${jstTimestamp()} [ERROR]`, ...args); } } }