import chalk from "chalk"; import dotenv from "dotenv"; dotenv.config(); import assert from "assert"; import { execSync, spawn } from "child_process"; import { cp, mkdir, readdir, readFile, rm, symlink, writeFile } from "fs/promises"; import { createRequire } from "module"; import os from "os"; import path from "path"; import process from "process"; import knex from "knex"; import { type Knex } from "knex"; import { tsicli } from "tsicli"; import { Sonamu } from "../api/sonamu"; import { addCompanionsToEntities, generateBetterAuthEntities } from "../auth/auth-generator"; import { isValidPluginId, SUPPORTED_PLUGIN_IDS } from "../auth/plugins/entity-definitions"; import { type BetterAuthPluginId } from "../auth/plugins/entity-definitions"; import { type SonamuDBConfig } from "../database/db"; import { EntityManager } from "../entity/entity-manager"; import { Migrator } from "../migration/migrator"; import { FixtureManager } from "../testing/fixture-manager"; import { execWithLinePrefix, printBuildSummary, printTaskFailed, printTaskHeader, printTaskStart, printTaskSuccess, } from "../utils/console-util"; import { exists } from "../utils/fs-utils"; import { findApiRootPath, findAppRootPath } from "../utils/utils"; import { API_ARTIFACTS, WEB_ARTIFACTS } from "./build-config"; import { type BuildArtifact } from "./build-config"; import { fixtureExploreCommand, fixtureFetchCommand, fixtureGenCommand } from "./fixture"; import { testCommand } from "./test-command"; let migrator: Migrator; /** * CLI 옵션을 파싱하는 헬퍼 함수 */ function parseCliOptions(argv: string[] = process.argv): { flags: Set; // --regenerate, --ai, --no-cones 등 options: Record; // --locale ko 등 } { const flags = new Set(); const options: Record = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg.startsWith("--")) continue; // --option=value 형식 if (arg.includes("=")) { const [key, value] = arg.slice(2).split("="); options[key] = value; continue; } // --option value 형식인지 확인 const nextArg = argv[i + 1]; if (nextArg && !nextArg.startsWith("--") && !nextArg.startsWith("-")) { options[arg.slice(2)] = nextArg; i++; // 다음 arg는 값이므로 스킵 } else { // --flag 형식 flags.add(arg.slice(2)); } } return { flags, options }; } async function bootstrap() { const notToInit = ["dev", "build", "start", "skills", "test"].includes(process.argv[2] ?? ""); if (!notToInit) { await Sonamu.init(false, false); } try { // tsicli는 정확한 명령어 매칭만 지원하므로, --로 시작하는 옵션과 그 값을 필터링합니다. // 옵션 파싱은 각 runner 함수에서 원본 process.argv를 사용하여 수행합니다. // "--"(bare double dash)는 passthrough 구분자이므로, 그 뒤의 모든 인자도 제외합니다. const filteredArgv: string[] = []; let skipNext = false; let afterDoubleDash = false; for (let i = 0; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg === "--") { afterDoubleDash = true; continue; } if (afterDoubleDash) continue; if (skipNext) { skipNext = false; continue; } if (arg.startsWith("--")) { // --option=value 형식은 이 arg만 스킵 if (arg.includes("=")) { continue; } // --option value 형식인지 확인: 다음 arg가 --로 시작하지 않으면 값이 있는 것 const nextArg = process.argv[i + 1]; if (nextArg && !nextArg.startsWith("--") && !nextArg.startsWith("-")) { skipNext = true; } continue; } filteredArgv.push(arg); } // build/dev 명령어가 서브커맨드 없이 호출될 때 "all"을 기본값으로 추가합니다. // 예: `sonamu build` → `sonamu build all`, `sonamu dev` → `sonamu dev all` const cmd = filteredArgv[2]; if ((cmd === "build" || cmd === "dev") && filteredArgv.length === 3) { filteredArgv.push("all"); } // test 커맨드는 가변 인자(파일명, --pattern 등)를 받으므로 tsicli를 우회합니다. if (cmd === "test") { return testCommand(); } await tsicli(filteredArgv, { types: { "#entityId": { type: "autocomplete", name: "#entityId", message: "Please input #entityId", choices: EntityManager.getAllParentIds().map((entityId) => ({ title: entityId, value: entityId, })), }, "#recordIds": "number[]", "#name": "string", "#targets": { type: "multiselect", name: "#targets", message: "Please input #targets", choices: [ { title: "Development", value: "development_master" }, { title: "Production", value: "production_master" }, { title: "Fixture", value: "fixture" }, { title: "Test", value: "test" }, ], }, }, args: [ ["fixture", "init"], ["fixture", "import", "#entityId", "#recordIds"], ["fixture", "sync"], ["fixture", "gen"], ["fixture", "fetch"], ["fixture", "explore"], ["migrate", "run"], ["migrate", "apply", "#targets"], ["migrate", "generate"], ["migrate", "status"], ["stub", "practice", "#name"], ["stub", "entity", "#name"], ["scaffold", "model", "#entityId"], ["scaffold", "model_test", "#entityId"], ["scaffold", "view_list", "#entityId"], ["scaffold", "view_form", "#entityId"], ["cone", "gen", "#entityId"], ["sync"], ["build", "all"], ["build", "api"], ["build", "web"], ["dev", "all"], ["dev", "api"], ["dev", "web"], ["start"], ["skills", "sync"], ["skills", "create", "#name"], ["test"], ["auth", "generate"], ["auth", "add-companions"], ], runners: { migrate_status, migrate_run, migrate_apply, migrate_generate, fixture_init, fixture_import, fixture_sync, fixture_gen, fixture_fetch, fixture_explore, stub_practice, stub_entity, scaffold_model, scaffold_model_test, // scaffold_view_list, // scaffold_view_form, cone_gen, sync, build_all, build_api, build_web, dev_all, dev_api, dev_web, start, skills_sync, skills_create, test: testCommand, auth_generate, "auth_add-companions": auth_add_companions, }, }); } finally { await Sonamu.destroy(); } } bootstrap().finally(async () => { await FixtureManager.destroy(); }); /** * pnpm sync 하면 실행되는 함수입니다. * 프로젝트를 싱크합니다. * * `--force` 옵션이 주어지면 lock을 무시하고 풀-싱크를 수행합니다. * git post-merge hook이나 CI에서 매 pull 후 자동 실행할 수 있도록 노출. */ async function sync() { const { flags } = parseCliOptions(); if (flags.has("force")) { await Sonamu.syncer.forceSync(); } else { await Sonamu.syncer.sync(); } } /** * API 개발 서버를 실행하는 공통 로직입니다. * dev_all과 dev_api에서 공유합니다. * * TypeScript를 바로 실행할 수 있도록 @sonamu-kit/ts-loader를, * HMR을 지원하기 위해 @sonamu-kit/hmr-hook을 import하며, * 소스맵 지원을 위해 --enable-source-maps 플래그를 포함하여 실행합니다. * * 이때 @sonamu-kit/ts-loader와 @sonamu-kit/hmr-hook는 sonamu가 자체적으로 가지고 있는 dependency입니다. * 또한 실행에 사용하는 @sonamu-kit/hmr-runner도 마찬가지로 sonamu가 자체적으로 가지고 있는 dependency입니다. * 따라서 사용자 프로젝트에서는 이 세 패키지를 직접 설치할 필요가 없습니다. */ function spawnApiDevServer(options?: { extraEnv?: Record }) { const apiRoot = findApiRootPath(); const entryPoint = "src/index.ts"; // 이 sonamu 패키지가 dependencies로 가지고 있는 @sonamu-kit/hmr-runner의 bin/run.js를 사용합니다. // 이 경로(/bin/run.js)는 @sonamu-kit/hmr-runner의 package.json의 bin 필드에 명시되어 있는 그것과 같습니다. const hotRunnerBinPath = createRequire(import.meta.url).resolve( "@sonamu-kit/hmr-runner/bin/run.js", ); const serverProcess = spawn( process.execPath, // node [ hotRunnerBinPath, // 이렇게 해서 hot-runner를 실행하구요 "--clear-screen=false", // 이하 hot-runner에게 넘겨줄 인자들입니다. "--node-args=--import=sonamu/ts-loader-register", // TypeScript 서포트를 위한 로더, "--node-args=--import=sonamu/hmr-hook-register", // HMR을 지원하기 위한 hook, "--node-args=--enable-source-maps", // 그리고 소스맵 지원을 위한 플래그입니다. "--on-key=r:restart:Restart server", // r 누르면 서버 재시작하게 해줘요. "--on-key=c:clear:Clear screen", // c 누르면 터미널 화면을 지워줘요. `--on-key=f:shell(rm ${path.join(apiRoot, "sonamu.lock")}):restart:Force restart`, // f 누르면 lock 제거 후 재시작 → 새 프로세스가 부트스트랩에서 풀-싱크. force sync CLI를 shell로 부르면 살아있는 서버의 watcher 폭풍이 :restart와 충돌해 상태 이상. "--on-key=enter:shell(echo hi):Key binding test", // enter를 key로 쓸 수 있음을 보이기 위한 테스트입니다. "--on-key=ctrl+f ctrl+f:shell(git pull && pnpm install && pnpm --filter sonamu build && echo 'Sonamu is now up-to-date!'):restart:Pull & install & build & restart", // modifier와의 조합, 그리고 두 개의 chord를 사용할 수 있음을 보이기 위한 테스트입니다. entryPoint, // 마지막으로 실제 실행할 스크립트의 경로를 넘겨줍니다. ], { cwd: apiRoot, stdio: "inherit", env: { ...process.env, NODE_ENV: "development", HOT: "yes", // 얘가 있어야 HMR이 활성화됩니다. API_ROOT_PATH: apiRoot, // 이 경로가 hmr-hook의 루트 디렉토리가 됩니다. ...options?.extraEnv, }, }, ); // 종료 처리 const cleanup = () => { console.log(chalk.yellow("\n\n👋 Shutting down...")); serverProcess.kill("SIGTERM"); process.exit(0); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); serverProcess.on("exit", (code) => { if (code !== 0) { console.error(chalk.red(`Server exited with code ${code}`)); process.exit(code || 1); } }); } /** * pnpm dev / pnpm dev all 하면 실행되는 함수입니다. * 프로젝트에 대해 HMR 지원하는 개발 서버를 띄워줍니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ function dev_all() { const require = createRequire(import.meta.url); const { version } = require("../../package.json"); console.log(`🌲 Sonamu v${version}\n`); spawnApiDevServer(); } /** * pnpm dev api 하면 실행되는 함수입니다. * API 전용 개발 서버를 띄웁니다. * dev_all과 거의 동일하되, 통합 웹 서버를 비활성화합니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ function dev_api() { console.log(chalk.yellow.bold("Starting Sonamu API-only dev server...\n")); spawnApiDevServer({ extraEnv: { SONAMU_DISABLE_INTEGRATED_WEB: "yes" }, }); } /** * pnpm dev web 하면 실행되는 함수입니다. * Vite 개발 서버를 단독으로 실행합니다. * -- 뒤의 인자는 Vite에 그대로 전달됩니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ async function dev_web() { const appRoot = findAppRootPath(); const webPath = path.join(appRoot, "web"); if (!(await exists(webPath))) { console.error(`web 디렉토리를 찾을 수 없습니다: ${webPath}`); process.exit(1); } // -- 뒤의 인자 추출 const doubleDashIndex = process.argv.indexOf("--"); const passthroughArgs = doubleDashIndex !== -1 ? process.argv.slice(doubleDashIndex + 1) : []; const viteArgs = ["exec", "vite", ...passthroughArgs]; console.log(chalk.yellow.bold("Starting Vite dev server...\n")); const viteProcess = spawn("pnpm", viteArgs, { cwd: webPath, stdio: "inherit", }); viteProcess.on("exit", (code) => { process.exit(code ?? 0); }); // SIGINT/SIGTERM 시 Vite 프로세스를 gracefully 종료합니다. for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { viteProcess.kill(signal); }); } } /** * API 빌드 설정 파일 경로를 결정합니다. * 프로젝트 루트에 `tsdown.config.ts`가 있으면 그것을, 없으면 sonamu 기본 설정을 사용합니다. */ async function resolveApiBuildConfigPath(): Promise { const localConfigPath = path.join(process.cwd(), "tsdown.config.ts"); try { if (await exists(localConfigPath)) { console.log(chalk.dim("Using tsdown.config.ts from project root...")); return localConfigPath; } console.log(chalk.dim("Using default tsdown API config from sonamu package...")); return path.join(import.meta.dirname, "..", "..", "tsdown.api.config.ts"); } catch (error) { console.error(chalk.red("Setting up API build config failed."), error); process.exit(1); } } /** * sonamu build / sonamu build all 하면 실행되는 함수입니다. * build_api + build_web의 합성입니다. Web 디렉토리가 없으면 Web 빌드를 스킵합니다. */ async function build_all() { await build_api(); await build_web({ skipIfMissing: true }); } /** * pnpm build api 하면 실행되는 함수입니다. * API 프로젝트만 빌드합니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ async function build_api() { const appRoot = findAppRootPath(); const configFilePath = await resolveApiBuildConfigPath(); const apiStartedAt = Date.now(); try { for (const artifact of API_ARTIFACTS) { const cwd = path.join(appRoot, artifact.projectPath); printTaskHeader(artifact.name, artifact.description, cwd); await runBuildSteps(artifact, { cwd, buildCommandArgs: { configFilePath } }); } printBuildSummary("API", true, Date.now() - apiStartedAt); } catch (e) { printBuildSummary("API", false, Date.now() - apiStartedAt); console.error(e); process.exit(1); } } /** * pnpm build web 하면 실행되는 함수입니다. * Web 프로젝트만 빌드합니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ async function build_web({ skipIfMissing = false } = {}) { const appRoot = findAppRootPath(); const webPath = path.join(appRoot, "web"); if (!(await exists(webPath))) { if (skipIfMissing) { console.log(chalk.gray("Web 디렉토리가 없으므로 Web 빌드를 건너뜁니다.")); return; } console.error(`web 디렉토리를 찾을 수 없습니다: ${webPath}`); process.exit(1); } const webStartedAt = Date.now(); try { for (const artifact of WEB_ARTIFACTS) { const cwd = path.join(appRoot, artifact.projectPath); printTaskHeader(artifact.name, artifact.description, cwd); await runBuildSteps(artifact, { cwd, buildCommandArgs: {} }); } printBuildSummary("Web", true, Date.now() - webStartedAt); } catch (e) { printBuildSummary("Web", false, Date.now() - webStartedAt); console.error(e); process.exit(1); } } /** * pre-build, build, post-build 단계를 순차적으로 실행합니다. */ async function runBuildSteps( artifact: BuildArtifact, options: { cwd: string; buildCommandArgs: T }, ) { const steps = [ { name: "pre-build", cmd: artifact.preBuildCommand?.() }, { name: "build", cmd: artifact.buildCommand(options.buildCommandArgs) }, { name: "post-build", cmd: artifact.postBuildCommand?.() }, ].filter((step) => step.cmd); for (let i = 0; i < steps.length; i++) { const step = steps[i]; const isLast = i === steps.length - 1; try { assert(step.cmd); printTaskStart(step.name, step.cmd, isLast); await execWithLinePrefix(step.cmd, { cwd: options.cwd }); printTaskSuccess(step.name, isLast); } catch (e) { printTaskFailed(step.name, isLast); throw new Error(`${step.name} failed`, { cause: e }); } } } /** * pnpm start 하면 실행되는 함수입니다. * 빌드된 프로젝트를 실행합니다. * * 빌드된 결과물(dist 디렉토리의 index.js 엔트리포인트)이 없다면 실행을 중단합니다. * 소스맵 지원과 dotenv 지원을 포함하여 실행합니다. * * Sonamu.init 없이 호출될 것을 상정하여 구현되었습니다. */ async function start() { const require = createRequire(import.meta.url); const { version } = require("../../package.json"); console.log(`🌲 Sonamu v${version}\n`); const apiRoot = findApiRootPath(); const entryPoint = "dist/index.js"; if (!(await exists(entryPoint))) { console.log(chalk.red(`${entryPoint} not found. Please build your project first.`)); console.log(chalk.blue("Run: pnpm sonamu build")); return; } const { spawn } = await import("child_process"); const serverProcess = spawn( process.execPath, ["--enable-source-maps", "-r", "dotenv/config", entryPoint], { cwd: apiRoot, stdio: "inherit", }, ); process.on("SIGINT", () => { serverProcess.kill("SIGTERM"); process.exit(0); }); } async function setupMigrator() { // migrator migrator = new Migrator(); } async function setupFixtureManager() { FixtureManager.init(); } async function migrate_apply(targets: (keyof SonamuDBConfig)[]) { await setupMigrator(); await migrator.runAction("apply", targets); } async function migrate_run() { await setupMigrator(); const localHosts = ["localhost", "127.0.0.1", "0.0.0.0", "::1"]; const targets = Object.keys(Sonamu.dbConfig).filter((target) => { const targetConfig = Sonamu.dbConfig[target as keyof SonamuDBConfig]; const host = (targetConfig?.connection as { host?: string })?.host ?? "localhost"; return localHosts.includes(host.toLowerCase()); }); // 로컬 데이터베이스에 대해서만 전체 마이그레이션에서 동작 await migrator.runAction("apply", targets as (keyof SonamuDBConfig)[]); } async function migrate_generate() { await setupMigrator(); const { conns } = await migrator.getStatus(); const hasStatus0 = conns.some((conn) => conn.status === 0); if (!hasStatus0) { console.log( chalk.red( "마이그레이션 파일을 생성하려면 기존 마이그레이션이 최소 하나의 DB에 모두 적용되어 있어야 합니다.", ), ); for (const conn of conns) { if (conn.pending.length > 0) { console.log(chalk.yellow(` ${conn.name}: pending ${conn.pending.length}개`)); } } process.exit(1); } const count = await migrator.generatePreparedCodes(); if (count > 0) { console.log(chalk.green(`${count}개의 마이그레이션 파일이 생성되었습니다.`)); } } async function migrate_status() { await setupMigrator(); const status = await migrator.getStatus(); // status; console.log(status); } async function fixture_init() { const srcConfig = Sonamu.dbConfig.development_master; const targets = [ { label: "(REMOTE) Fixture DB", config: Sonamu.dbConfig.fixture, }, { label: "(LOCAL) Testing DB", config: Sonamu.dbConfig.test, toSkip: (() => { const remoteConn = Sonamu.dbConfig.fixture.connection as Knex.ConnectionConfig; const localConn = Sonamu.dbConfig.test.connection as Knex.ConnectionConfig; return remoteConn.host === localConn.host && remoteConn.database === localConn.database; })(), }, ] as { label: string; config: Knex.Config; toSkip?: boolean; }[]; // 1. 기준DB 스키마를 덤프 console.log("DUMP..."); const dumpFilename = `/tmp/sonamu-fixture-init-${Date.now()}.sql`; const srcConn = srcConfig.connection as Knex.ConnectionConfig; const migrationsDump = `/tmp/sonamu-fixture-init-migrations-${Date.now()}.sql`; execSync( `mysqldump -h${srcConn.host} -u${srcConn.user} -p${srcConn.password} --single-transaction -d --no-create-db --triggers ${srcConn.database} > ${dumpFilename}`, ); const _db = knex(srcConfig); const [[migrations]] = await _db.raw( "SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = ? AND table_name = 'knex_migrations'", [srcConn.database], ); if (migrations.count > 0) { execSync( `mysqldump -h${srcConn.host} -u${srcConn.user} -p${srcConn.password} --single-transaction --no-create-db --triggers ${srcConn.database} knex_migrations knex_migrations_lock > ${migrationsDump}`, ); } // 2. 대상DB 각각에 대하여 존재여부 확인 후 붓기 for (const { label, config, toSkip } of targets) { const conn = config.connection as Knex.ConnectionConfig; if (toSkip === true) { console.log(chalk.red(`${label}: Skipped!`)); continue; } const db = knex({ ...config, connection: { ...((config.connection ?? {}) as Knex.ConnectionConfig), database: undefined, }, }); const [[row]] = await db.raw(`SHOW DATABASES LIKE "${conn.database}"`); if (row) { console.log(chalk.yellow(`${label}: Database "${conn.database}" Already exists`)); await db.destroy(); continue; } console.log(`SYNC to ${label}...`); const mysqlCmd = `mysql -h${conn.host} -u${conn.user} -p${conn.password}`; execSync(`${mysqlCmd} -e 'DROP DATABASE IF EXISTS \`${conn.database}\`'`); execSync(`${mysqlCmd} -e 'CREATE DATABASE \`${conn.database}\`'`); execSync(`${mysqlCmd} ${conn.database} < ${dumpFilename}`); if (await exists(migrationsDump)) { execSync(`${mysqlCmd} ${conn.database} < ${migrationsDump}`); } await db.destroy(); } await _db.destroy(); } async function fixture_import(entityId: string, recordIds: number[]) { await setupFixtureManager(); await FixtureManager.importFixture(entityId, recordIds); await FixtureManager.sync(); } async function fixture_sync() { await setupFixtureManager(); await FixtureManager.sync(); } /** * fixture gen 명령어 * 옵션을 process.argv에서 파싱 */ async function fixture_gen() { const options = parseOptions(process.argv); await fixtureGenCommand(options); } /** * fixture fetch 명령어 * 옵션을 process.argv에서 파싱 */ async function fixture_fetch() { const options = parseOptions(process.argv); await fixtureFetchCommand(options); } /** * fixture explore 명령어 * 옵션을 process.argv에서 파싱 */ async function fixture_explore() { const options = parseOptions(process.argv); await fixtureExploreCommand(options); } /** * 간단한 옵션 파서 */ function parseOptions( argv: string[], ): Record & { _: string[] } { const options: Record & { _: string[] } = { _: [] }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg.startsWith("--")) { const key = arg.slice(2); if (key.includes("=")) { const [k, v] = key.split("="); options[k] = v; } else { const next = argv[i + 1]; if (next && !next.startsWith("--")) { options[key] = next; i++; } else { options[key] = true; } } } else if (arg.startsWith("-")) { const key = arg.slice(1); const next = argv[i + 1]; if (next && !next.startsWith("-")) { options[key] = next; i++; } else { options[key] = true; } } else { options._.push(arg); } } return options; } async function stub_practice(name: string) { const practiceDir = path.join(Sonamu.apiRootPath, "src", "practices"); const fileNames = await readdir(practiceDir); const maxSeqNo = await (async () => { if (!(await exists(practiceDir))) { await mkdir(practiceDir, { recursive: true }); } const filteredSeqs = fileNames .filter((fileName) => fileName.startsWith("p") && fileName.endsWith(".ts")) .map((fileName) => { const [, seqNo] = fileName.match(/^p([0-9]+)-/) ?? ["0", "0"]; return parseInt(seqNo); }) .toSorted((a, b) => b - a); if (filteredSeqs.length > 0) { return filteredSeqs[0]; } return 0; })(); const currentSeqNo = maxSeqNo + 1; const fileName = `p${currentSeqNo}-${name}.ts`; const dstPath = path.join(practiceDir, fileName); const code = [ `import { Sonamu } from "sonamu";`, "", `console.clear();`, `console.log("${fileName}");`, "", `Sonamu.runScript(async () => {`, ` // TODO`, `});`, "", ].join("\n"); await writeFile(dstPath, code); execSync(`code ${dstPath}`); const runCode = `yarn node -r dotenv/config --enable-source-maps dist/practices/${fileName.replace( ".ts", ".js", )}`; console.log(`${chalk.blue(runCode)} copied to clipboard.`); execSync(`echo "${runCode}" | pbcopy`); } async function stub_entity(entityId: string) { await Sonamu.syncer.createEntity({ entityId, title: entityId }); const { flags } = parseCliOptions(); const useAI = flags.has("ai"); const noCones = flags.has("no-cones"); // --no-cones: cone 생성 스킵 if (noCones) { console.log(`✓ Entity '${entityId}' created without cones`); return; } const { EntityManager } = await import("../entity/entity-manager"); const entity = EntityManager.get(entityId); if (!entity) { console.error(`Entity not found: ${entityId}`); return; } // --ai: LLM으로 cone 생성 if (useAI) { console.log(`✓ Entity '${entityId}' created`); console.log(`🌟 Generating AI-powered cones...`); try { const configLocale = Sonamu.config.i18n?.defaultLocale; const locale = configLocale === "ko" || configLocale === "en" || configLocale === "ja" ? configLocale : "ko"; const result = await entity.generateCones({ preserveExisting: false, onlyEmpty: false, locale, }); console.log(`✅ Done (${result.tokensUsed} tokens)`); } catch (error) { if (error instanceof Error && error.message.includes("ANTHROPIC_API_KEY")) { console.error(`\n❌ ${error.message}`); console.error(`\n💡 Remove --ai flag to use template cones instead`); } else { throw error; } } return; } // 기본: 템플릿 cone 자동 생성 // LLM 없이 faker-mappings.ts를 활용하여 기본 cone 메타데이터를 생성합니다. // 이를 통해 ANTHROPIC_API_KEY가 없어도 Sonamu를 사용할 수 있으며, // 생성된 템플릿 cone은 나중에 'cone gen' 명령어로 AI를 통해 업그레이드할 수 있습니다. console.log(`🌟 Generating template cones...`); await entity.generateTemplateCones(); console.log(`✓ Entity '${entityId}' created with template cones`); console.log(`💡 Tip: Run 'pnpm sonamu cone gen ${entityId}' to improve with AI`); } /** * AI를 사용하여 entity의 cone 메타데이터를 생성하거나 업그레이드합니다. * * 옵션: * - --regenerate: 전체 재생성 (기존 cone 덮어쓰기) * - --locale : 생성 언어 지정 * - 기본: onlyEmpty 모드 (기존 fixtureHint 보존) * * ANTHROPIC_API_KEY 필요 (sonamu.secret.ts 또는 환경변수) */ async function cone_gen(entityId: string) { const { EntityManager } = await import("../entity/entity-manager"); const { flags, options } = parseCliOptions(); // --all 옵션: 모든 entity cone 생성 if (flags.has("all") || entityId === "all") { const allEntities = EntityManager.getAllEntities(); console.log(`🌟 Generating AI-powered cones for ${allEntities.length} entities...\n`); let totalTokens = 0; const errors: string[] = []; for (const entity of allEntities) { try { console.log(`Processing ${entity.id}...`); const configLocale = options.locale || Sonamu.config.i18n?.defaultLocale; const locale = configLocale === "ko" || configLocale === "en" || configLocale === "ja" ? configLocale : "ko"; const result = await entity.generateCones({ preserveExisting: !flags.has("regenerate"), onlyEmpty: !flags.has("regenerate"), locale, }); totalTokens += result.tokensUsed; console.log(` ✓ ${entity.id} (${result.tokensUsed} tokens)\n`); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; errors.push(`${entity.id}: ${message}`); console.error(` ✗ ${entity.id}: ${message}\n`); } } console.log(`\n✅ Done! Total: ${totalTokens} tokens used`); const estimatedCost = (totalTokens * 9) / 1_000_000; console.log(`💰 Estimated cost: ~$${estimatedCost.toFixed(4)}`); if (errors.length > 0) { console.error(`\n❌ Failed entities (${errors.length}):`); for (const err of errors) { console.error(` - ${err}`); } } return; } // 단일 entity cone 생성 const entity = EntityManager.get(entityId); if (!entity) { console.error(`Entity not found: ${entityId}`); return; } const mode = flags.has("regenerate") ? "regenerating" : "generating"; console.log( `🌟 ${mode === "regenerating" ? "Regenerating" : "Generating"} AI-powered cones for ${entityId}...`, ); try { const configLocale = options.locale || Sonamu.config.i18n?.defaultLocale; const locale = configLocale === "ko" || configLocale === "en" || configLocale === "ja" ? configLocale : "ko"; const result = await entity.generateCones({ preserveExisting: !flags.has("regenerate"), onlyEmpty: !flags.has("regenerate"), locale, }); console.log(`✅ Done! (${result.tokensUsed} tokens used)`); // 토큰 비용 계산 (대략적인 추정) // Claude Sonnet 4.5: input $3/M tokens, output $15/M tokens // 간단하게 평균 $9/M tokens로 계산 const estimatedCost = (result.tokensUsed * 9) / 1_000_000; console.log(`💰 Estimated cost: ~$${estimatedCost.toFixed(4)}`); } catch (error) { if (error instanceof Error) { if (error.message.includes("ANTHROPIC_API_KEY")) { console.error(`\n❌ ${error.message}`); console.error(`\n💡 To use AI-powered cone generation:`); console.error(` 1. Get an API key from https://console.anthropic.com/`); console.error( ` 2. Add it to sonamu.secret.ts or set ANTHROPIC_API_KEY environment variable`, ); } else if (error.message.includes("Rate limit")) { console.error(`\n❌ ${error.message}`); console.error(`\n💡 Please wait a moment and try again.`); } else { console.error(`\n❌ Failed to generate cones: ${error.message}`); } } else { console.error(`\n❌ Failed to generate cones: Unknown error`); } } } async function scaffold_model(entityId: string) { await Sonamu.syncer.generateTemplate("model", { entityId, }); } async function scaffold_model_test(entityId: string) { await Sonamu.syncer.generateTemplate("model_test", { entityId, }); } /** * pnpm sonamu skills sync 하면 실행되는 함수입니다. * 공식 Skills를 로컬 프로젝트 또는 글로벌 ~/.claude/로 동기화합니다. * * --global 플래그: ~/.claude/에 동기화 (프로젝트 생성 전 사용 가능) */ async function skills_sync() { const { flags } = parseCliOptions(); const isGlobal = flags.has("global"); // 개발 환경 - cli.ts: sonamu/modules/sonamu/src/bin/cli.ts // 빌드 후 - cli.js: node_modules/sonamu/dist/bin/cli.js (실제 실행) // skills 위치: node_modules/sonamu/src/skills (npm 배포 시) const sourceBase = path.resolve(import.meta.dirname, "..", "..", "src", "skills"); const sourceSkillsDir = path.join(sourceBase, "sonamu"); const sourceClaudeMd = path.join(sourceBase, "CLAUDE.md"); if (!(await exists(sourceSkillsDir))) { console.log(chalk.yellow("Skills source not found in sonamu package.")); return; } if (isGlobal) { const homeClaudeDir = path.join(os.homedir(), ".claude"); await skills_sync_to(homeClaudeDir, sourceSkillsDir, sourceClaudeMd, { useSymlink: false, copyProjectTemplates: false, isGlobal: true, }); // ~/.claude/commands/sonamu-skills.md 설치 const sourceCommandsDir = path.join(sourceBase, "commands"); const sourceCommand = path.join(sourceCommandsDir, "sonamu-skills.md"); if (await exists(sourceCommand)) { const targetCommandsDir = path.join(homeClaudeDir, "commands"); await mkdir(targetCommandsDir, { recursive: true }); await cp(sourceCommand, path.join(targetCommandsDir, "sonamu-skills.md")); console.log(chalk.green(`✓ /sonamu-skills command installed → ~/.claude/commands/`)); } console.log(chalk.cyan(`\n Global sync complete → ~/.claude/skills/sonamu/`)); console.log(chalk.dim(` These skills are available in all Claude Code sessions.`)); console.log( chalk.dim( ` Once a project is created, run 'pnpm sonamu skills sync' for project-local sync.`, ), ); } else { const workspaceRoot = await findWorkspaceRoot(); const claudeDir = path.join(workspaceRoot, ".claude"); await skills_sync_to(claudeDir, sourceSkillsDir, sourceClaudeMd, { useSymlink: true, copyProjectTemplates: true, sourceBase, }); } } /** * claudeDir로 skills를 동기화하는 공통 로직입니다. */ async function skills_sync_to( claudeDir: string, sourceSkillsDir: string, sourceClaudeMd: string, options: { useSymlink: boolean; copyProjectTemplates: boolean; sourceBase?: string; isGlobal?: boolean; }, ) { const targetSkillsDir = path.join(claudeDir, "skills", "sonamu"); // 기존 디렉토리/symlink 삭제 후 재생성 // exists()는 broken symlink를 감지하지 못하므로 rm을 무조건 시도합니다 try { await rm(targetSkillsDir, { recursive: true, force: true }); } catch { // 파일이 없으면 무시 } await mkdir(path.dirname(targetSkillsDir), { recursive: true }); if (options.useSymlink) { try { await symlink(sourceSkillsDir, targetSkillsDir, "dir"); console.log(chalk.green(`✓ Skills linked (symlink)`)); } catch (error) { console.log( chalk.yellow(`⚠ Symlink failed: ${error instanceof Error ? error.message : String(error)}`), ); console.log(chalk.yellow(` Falling back to copy...`)); await skillsCopy(sourceSkillsDir, targetSkillsDir); } } else { await skillsCopy(sourceSkillsDir, targetSkillsDir); } // project 디렉토리 초기화 (없으면 생성, 있으면 유지) if (options.copyProjectTemplates && options.sourceBase) { const sourceProjectDir = path.join(options.sourceBase, "project"); const targetProjectDir = path.join(claudeDir, "skills", "project"); if (await exists(sourceProjectDir)) { if (!(await exists(targetProjectDir))) { try { await cp(sourceProjectDir, targetProjectDir, { recursive: true }); console.log(chalk.green(`✓ Project templates initialized`)); } catch (error) { console.error( chalk.red( `✗ Failed to initialize project templates: ${error instanceof Error ? error.message : String(error)}`, ), ); } } else { console.log(chalk.dim(`⏭ Project templates already exist (preserved)`)); } } } // settings.local.json — project-local 모드에서만, 없을 때만 생성 if (options.copyProjectTemplates) { const settingsLocalPath = path.join(claudeDir, "settings.local.json"); if (!(await exists(settingsLocalPath))) { try { const settingsContent = { hooks: { PostToolUse: [ { matcher: "Edit|Write|MultiEdit", hooks: [ { type: "command", command: "pnpm check 2>&1 | head -60", }, ], }, ], }, }; await writeFile(settingsLocalPath, `${JSON.stringify(settingsContent, null, 2)}\n`); console.log(chalk.green(`✓ .claude/settings.local.json created`)); } catch (error) { console.error( chalk.red( `✗ Failed to create settings.local.json: ${error instanceof Error ? error.message : String(error)}`, ), ); } } else { console.log(chalk.dim(`⏭ .claude/settings.local.json already exists (preserved)`)); } } // CLAUDE.md 복사/업데이트 if (await exists(sourceClaudeMd)) { try { const targetClaudeMd = path.join(claudeDir, "CLAUDE.md"); const rawContent = await readFile(sourceClaudeMd, "utf-8"); // 글로벌 모드에서는 상대 경로를 절대 경로로 변환합니다 const sourceContent = options.isGlobal ? rawContent.replaceAll(".claude/skills/sonamu/", "~/.claude/skills/sonamu/") : rawContent; if (await exists(targetClaudeMd)) { const targetContent = await readFile(targetClaudeMd, "utf-8"); const startMarker = ""; const endMarker = ""; if (targetContent.includes(startMarker) && targetContent.includes(endMarker)) { const startIdx = targetContent.indexOf(startMarker); const endIdx = targetContent.indexOf(endMarker); if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) { const before = targetContent.substring(0, startIdx); const after = targetContent.substring(endIdx + endMarker.length); const newContent = `${before}${startMarker}\n${sourceContent}\n${endMarker}${after}`; await writeFile(targetClaudeMd, newContent); console.log(chalk.green(`✓ CLAUDE.md updated (marker region)`)); } else { console.log(chalk.yellow(`⏭ CLAUDE.md marker positions invalid, skipped`)); } } else { // 마커가 없는 기존 CLAUDE.md에 Sonamu 섹션을 추가합니다 const appended = `${targetContent.trimEnd()}\n\n\n${sourceContent}\n\n`; await writeFile(targetClaudeMd, appended); console.log(chalk.green(`✓ CLAUDE.md updated (appended Sonamu section)`)); } } else { const withMarkers = `\n${sourceContent}\n\n`; await writeFile(targetClaudeMd, withMarkers); console.log(chalk.green(`✓ CLAUDE.md created`)); } } catch (error) { console.error( chalk.red( `✗ Failed to update CLAUDE.md: ${error instanceof Error ? error.message : String(error)}`, ), ); } } } async function skillsCopy(src: string, dest: string) { try { await cp(src, dest, { recursive: true }); console.log(chalk.green(`✓ Skills copied`)); } catch (copyError) { console.error( chalk.red( `✗ Failed to copy skills: ${copyError instanceof Error ? copyError.message : String(copyError)}`, ), ); throw copyError; } } /** * pnpm sonamu skills create 하면 실행되는 함수입니다. * 로컬 skill 초안을 생성합니다. */ async function skills_create(name: string) { const workspaceRoot = await findWorkspaceRoot(); const localDir = path.join(workspaceRoot, ".claude", "skills", "local"); // === 파일명 검증 및 Sanitize === if (!name || name.trim() === "") { console.error(chalk.red("✗ Skill name is required")); return; } let sanitized = name // 공백을 하이픈으로 .replace(/\s+/g, "-") // 경로 구분자 제거 .replace(/[/\\]/g, "-") // Path traversal 방지 .replace(/\.\./g, "") // Windows 금지 문자 제거 .replace(/[<>:"|?*]/g, "") // 시작/끝 점, 하이픈, 언더스코어 제거 .replace(/^[.\-_]+|[.\-_]+$/g, "") // 연속된 하이픈을 하나로 .replace(/-+/g, "-") // 알파벳, 숫자, 하이픈, 언더스코어, 한글만 허용 .replace(/[^a-zA-Z0-9-_가-힣]/g, ""); // 길이 제한 const MAX_LENGTH = 100; if (sanitized.length > MAX_LENGTH) { sanitized = sanitized.substring(0, MAX_LENGTH); console.log(chalk.yellow(`⚠ Name truncated to ${MAX_LENGTH} characters`)); } // Windows 예약어 확인 const RESERVED_NAMES = [ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; if (RESERVED_NAMES.includes(sanitized.toUpperCase())) { sanitized = `skill-${sanitized}`; console.log(chalk.yellow(`⚠ Reserved name detected, prefixed with "skill-"`)); } // 빈 문자열 체크 if (sanitized === "") { console.error(chalk.red("✗ Invalid skill name after sanitization")); console.log(chalk.dim(` Original: "${name}"`)); return; } // 변경 알림 if (sanitized !== name) { console.log(chalk.yellow(`⚠ Name sanitized: "${name}" → "${sanitized}"`)); } const filePath = path.join(localDir, `${sanitized}.md`); if (await exists(filePath)) { console.log(chalk.yellow(`Skill "${sanitized}" already exists.`)); return; } await mkdir(localDir, { recursive: true }); const template = `--- name: ${sanitized} category: other created_at: ${new Date().toISOString().split("T")[0]} status: draft --- # ${sanitized} ## 상황 [어떤 문제였는지] ## 해결 방법 [어떻게 해결했는지] ## 코드 예시 \`\`\`typescript // 예시 코드 \`\`\` `; await writeFile(filePath, template); console.log(chalk.green(`✓ Created .claude/skills/local/${sanitized}.md`)); } /** * pnpm sonamu auth generate 하면 실행되는 함수입니다. * better-auth 엔티티들(User, Session, Account, Verification)을 생성합니다. * * 옵션: * --plugins phone-number,2fa 플러그인 엔티티도 함께 생성 */ async function auth_generate() { // --plugins 옵션 파싱 const pluginsArg = process.argv.find((arg) => arg.startsWith("--plugins")); const plugins: BetterAuthPluginId[] = []; if (pluginsArg) { const pluginValue = pluginsArg.includes("=") ? pluginsArg.split("=")[1] : process.argv[process.argv.indexOf(pluginsArg) + 1]; if (pluginValue) { const pluginIds = pluginValue.split(",").map((p) => p.trim()); for (const id of pluginIds) { if (isValidPluginId(id)) { plugins.push(id); } else { console.log(chalk.yellow(`⚠ Unknown plugin: ${id}`)); console.log(chalk.dim(` Supported plugins: ${SUPPORTED_PLUGIN_IDS.join(", ")}`)); } } } } console.log(chalk.yellow.bold("🔐 Generating better-auth entities...\n")); if (plugins.length > 0) { console.log(chalk.dim(` Plugins: ${plugins.join(", ")}`)); } await generateBetterAuthEntities({ plugins }); } /** * pnpm sonamu auth add-companions 하면 실행되는 함수입니다. * 기존 프로젝트의 entity.json에 fixtureCompanions를 소급 추가합니다. * * 이미 fixtureCompanions가 있는 entity는 스킵합니다 (덮어쓰기 없음). */ async function auth_add_companions() { console.log(chalk.yellow.bold("🔐 Adding fixtureCompanions to better-auth entities...\n")); await addCompanionsToEntities(); console.log(chalk.bold("\n✅ Done!")); } /** * 워크스페이스 루트를 찾습니다. * 우선순위: pnpm-workspace.yaml > package.json(workspaces) > .agents/ * * CLAUDE.md는 서브패키지에도 존재할 수 있으므로 사용하지 않습니다. * .agents/는 agents init이 생성하는 디렉토리로, 워크스페이스 루트에만 존재합니다. */ async function findWorkspaceRoot() { let dir = process.cwd(); while (dir !== path.dirname(dir)) { // 1. pnpm-workspace.yaml: 확실한 monorepo 루트. if (await exists(path.join(dir, "pnpm-workspace.yaml"))) { return dir; } // 2. package.json에 workspaces 필드가 있으면 monorepo 루트. const packagePath = path.join(dir, "package.json"); if (await exists(packagePath)) { try { const packageJson = JSON.parse(await readFile(packagePath, "utf-8")); if (packageJson.workspaces) { return dir; } } catch { // 파싱 실패시 무시 } } // 3. .agents/: agents init이 생성한 디렉토리. 서브패키지에는 존재하지 않음. if (await exists(path.join(dir, ".agents"))) { return dir; } dir = path.dirname(dir); } // 찾지 못하면 api 폴더의 부모 사용 return findAppRootPath(); }