sonamu 0.10.13 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/dist/ai/agents/agent.d.ts.map +1 -1
  2. package/dist/ai/providers/rtzr/error.d.ts.map +1 -1
  3. package/dist/ai/providers/rtzr/model.d.ts.map +1 -1
  4. package/dist/ai/providers/rtzr/utils.d.ts.map +1 -1
  5. package/dist/api/base-frame.d.ts.map +1 -1
  6. package/dist/api/decorators.d.ts.map +1 -1
  7. package/dist/api/sonamu.d.ts.map +1 -1
  8. package/dist/api/sonamu.js +3 -2
  9. package/dist/api/validator.d.ts.map +1 -1
  10. package/dist/auth/audit-log/events.d.ts.map +1 -1
  11. package/dist/auth/index.d.ts +1 -1
  12. package/dist/auth/index.d.ts.map +1 -1
  13. package/dist/auth/index.js +3 -3
  14. package/dist/auth/knex-adapter.d.ts.map +1 -1
  15. package/dist/auth/plugins/wrappers/admin.d.ts +4 -4
  16. package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
  17. package/dist/auth/plugins/wrappers/anonymous.d.ts.map +1 -1
  18. package/dist/auth/plugins/wrappers/api-key.d.ts.map +1 -1
  19. package/dist/auth/plugins/wrappers/jwt.d.ts.map +1 -1
  20. package/dist/auth/plugins/wrappers/organization.d.ts.map +1 -1
  21. package/dist/auth/plugins/wrappers/passkey.d.ts.map +1 -1
  22. package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
  23. package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -1
  24. package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
  25. package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
  26. package/dist/bin/build-config.d.ts +10 -0
  27. package/dist/bin/build-config.d.ts.map +1 -1
  28. package/dist/bin/build-config.js +130 -6
  29. package/dist/bin/fixture.d.ts +7 -4
  30. package/dist/bin/fixture.d.ts.map +1 -1
  31. package/dist/bin/fixture.js +161 -13
  32. package/dist/bin/test-command.d.ts +7 -0
  33. package/dist/bin/test-command.d.ts.map +1 -1
  34. package/dist/bin/test-command.js +18 -4
  35. package/dist/bin/typecheck-web.d.ts +2 -0
  36. package/dist/bin/typecheck-web.d.ts.map +1 -0
  37. package/dist/bin/typecheck-web.js +36 -0
  38. package/dist/cache/decorator.d.ts.map +1 -1
  39. package/dist/cache/drivers.d.ts.map +1 -1
  40. package/dist/cache-control/cache-control.d.ts.map +1 -1
  41. package/dist/compress/compress.d.ts.map +1 -1
  42. package/dist/cone/cone-generator.js +1 -1
  43. package/dist/database/base-model.d.ts.map +1 -1
  44. package/dist/database/base-model.types.d.ts +0 -1
  45. package/dist/database/base-model.types.d.ts.map +1 -1
  46. package/dist/database/db.d.ts.map +1 -1
  47. package/dist/database/puri-subset.types.d.ts +0 -1
  48. package/dist/database/puri-subset.types.d.ts.map +1 -1
  49. package/dist/database/puri-wrapper.d.ts +0 -1
  50. package/dist/database/puri-wrapper.d.ts.map +1 -1
  51. package/dist/database/puri.d.ts +0 -1
  52. package/dist/database/puri.d.ts.map +1 -1
  53. package/dist/database/puri.types.d.ts +0 -1
  54. package/dist/database/puri.types.d.ts.map +1 -1
  55. package/dist/database/transaction-context.d.ts.map +1 -1
  56. package/dist/database/upsert-builder.d.ts.map +1 -1
  57. package/dist/dict/en.d.ts +58 -58
  58. package/dist/dict/en.d.ts.map +1 -1
  59. package/dist/dict/ko.d.ts +58 -58
  60. package/dist/dict/ko.d.ts.map +1 -1
  61. package/dist/dict/rc-keys.d.ts.map +1 -1
  62. package/dist/dict/sonamu-dictionary.d.ts.map +1 -1
  63. package/dist/dict/sonamu-dictionary.js +2 -2
  64. package/dist/dict/utils.d.ts.map +1 -1
  65. package/dist/entity/entity-manager.d.ts +115 -113
  66. package/dist/entity/entity-manager.d.ts.map +1 -1
  67. package/dist/entity/entity-manager.js +25 -19
  68. package/dist/entity/entity-template-cone.d.ts +4 -4
  69. package/dist/entity/entity.d.ts +6 -1
  70. package/dist/entity/entity.d.ts.map +1 -1
  71. package/dist/entity/entity.js +8 -3
  72. package/dist/env.d.ts.map +1 -1
  73. package/dist/exceptions/so-exceptions.d.ts.map +1 -1
  74. package/dist/filter/types.d.ts.map +1 -1
  75. package/dist/index.js +6 -6
  76. package/dist/logger/configure.d.ts +10 -1
  77. package/dist/logger/configure.d.ts.map +1 -1
  78. package/dist/logger/configure.js +30 -5
  79. package/dist/migration/migrator.d.ts +12 -1
  80. package/dist/migration/migrator.d.ts.map +1 -1
  81. package/dist/migration/migrator.js +77 -30
  82. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  83. package/dist/migration/slack-confirm.d.ts.map +1 -1
  84. package/dist/naite/messaging-types.d.ts +0 -1
  85. package/dist/naite/messaging-types.d.ts.map +1 -1
  86. package/dist/naite/naite-reporter.d.ts +0 -1
  87. package/dist/naite/naite-reporter.d.ts.map +1 -1
  88. package/dist/naite/naite.d.ts.map +1 -1
  89. package/dist/storage/base-file.d.ts.map +1 -1
  90. package/dist/storage/buffered-file.d.ts.map +1 -1
  91. package/dist/storage/drivers.d.ts.map +1 -1
  92. package/dist/storage/s3-driver.d.ts.map +1 -1
  93. package/dist/storage/storage-manager.d.ts.map +1 -1
  94. package/dist/storage/uploaded-file.d.ts.map +1 -1
  95. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  96. package/dist/stream/ws-audience.d.ts.map +1 -1
  97. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  98. package/dist/stream/ws-delivery.d.ts.map +1 -1
  99. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  100. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  101. package/dist/stream/ws-registry.d.ts.map +1 -1
  102. package/dist/stream/ws-telemetry-memory.d.ts.map +1 -1
  103. package/dist/stream/ws-telemetry.d.ts.map +1 -1
  104. package/dist/stream/ws.d.ts.map +1 -1
  105. package/dist/syncer/api-parser.js +2 -2
  106. package/dist/syncer/file-patterns.d.ts +17 -17
  107. package/dist/syncer/filesystem-dependencies.js +1 -1
  108. package/dist/syncer/syncer.d.ts.map +1 -1
  109. package/dist/tasks/step-wrapper.d.ts.map +1 -1
  110. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  111. package/dist/template/implementations/entity.template.d.ts +2 -2
  112. package/dist/template/implementations/entity.template.d.ts.map +1 -1
  113. package/dist/template/implementations/entry-server.template.d.ts +2 -2
  114. package/dist/template/implementations/entry-server.template.d.ts.map +1 -1
  115. package/dist/template/implementations/generated.template.d.ts +2 -2
  116. package/dist/template/implementations/generated.template.d.ts.map +1 -1
  117. package/dist/template/implementations/generated_http.template.d.ts +2 -2
  118. package/dist/template/implementations/generated_http.template.d.ts.map +1 -1
  119. package/dist/template/implementations/generated_sso.template.d.ts +2 -2
  120. package/dist/template/implementations/generated_sso.template.d.ts.map +1 -1
  121. package/dist/template/implementations/http_validators.template.d.ts +2 -2
  122. package/dist/template/implementations/http_validators.template.d.ts.map +1 -1
  123. package/dist/template/implementations/init_types.template.d.ts +2 -2
  124. package/dist/template/implementations/init_types.template.d.ts.map +1 -1
  125. package/dist/template/implementations/model.template.d.ts +2 -2
  126. package/dist/template/implementations/model.template.d.ts.map +1 -1
  127. package/dist/template/implementations/model_test.template.d.ts +2 -2
  128. package/dist/template/implementations/model_test.template.d.ts.map +1 -1
  129. package/dist/template/implementations/queries.template.d.ts +2 -2
  130. package/dist/template/implementations/queries.template.d.ts.map +1 -1
  131. package/dist/template/implementations/sd.template.d.ts +2 -2
  132. package/dist/template/implementations/sd.template.d.ts.map +1 -1
  133. package/dist/template/implementations/sd.template.js +1 -1
  134. package/dist/template/implementations/services.template.d.ts +2 -2
  135. package/dist/template/implementations/services.template.d.ts.map +1 -1
  136. package/dist/template/implementations/view_form.template.d.ts +3 -3
  137. package/dist/template/implementations/view_form.template.d.ts.map +1 -1
  138. package/dist/template/implementations/view_id_all_select.template.d.ts +2 -2
  139. package/dist/template/implementations/view_id_all_select.template.d.ts.map +1 -1
  140. package/dist/template/implementations/view_list.template.d.ts +2 -2
  141. package/dist/template/implementations/view_list.template.d.ts.map +1 -1
  142. package/dist/template/implementations/view_search_input.template.d.ts +2 -2
  143. package/dist/template/implementations/view_search_input.template.d.ts.map +1 -1
  144. package/dist/template/template-manager.d.ts.map +1 -1
  145. package/dist/template/template.d.ts.map +1 -1
  146. package/dist/template/zod-converter.d.ts.map +1 -1
  147. package/dist/testing/_relation-graph.d.ts.map +1 -1
  148. package/dist/testing/data-explorer.d.ts +2 -2
  149. package/dist/testing/data-explorer.d.ts.map +1 -1
  150. package/dist/testing/dev-vitest-manager.d.ts.map +1 -1
  151. package/dist/testing/faker-mappings.d.ts.map +1 -1
  152. package/dist/testing/fixture-generator.d.ts.map +1 -1
  153. package/dist/testing/fixture-generator.js +1 -1
  154. package/dist/testing/fixture-loader.d.ts.map +1 -1
  155. package/dist/testing/fixture-manager.d.ts +4 -0
  156. package/dist/testing/fixture-manager.d.ts.map +1 -1
  157. package/dist/testing/fixture-manager.js +99 -25
  158. package/dist/testing/naite-vitest-reporter.d.ts.map +1 -1
  159. package/dist/testing/parallel-db-manager.d.ts.map +1 -1
  160. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts +10 -0
  161. package/dist/tooling/__tests__/helpers/sonamu-test-root.d.ts.map +1 -0
  162. package/dist/tooling/__tests__/helpers/sonamu-test-root.js +42 -0
  163. package/dist/tooling/cdd-service.d.ts +87 -0
  164. package/dist/tooling/cdd-service.d.ts.map +1 -0
  165. package/dist/tooling/cdd-service.js +215 -0
  166. package/dist/tooling/cli-tooling.d.ts +11 -0
  167. package/dist/tooling/cli-tooling.d.ts.map +1 -0
  168. package/dist/tooling/cli-tooling.js +901 -0
  169. package/dist/tooling/fixture-service.d.ts +29 -0
  170. package/dist/tooling/fixture-service.d.ts.map +1 -0
  171. package/dist/tooling/fixture-service.js +53 -0
  172. package/dist/tooling/index.d.ts +2 -0
  173. package/dist/tooling/index.d.ts.map +1 -0
  174. package/dist/tooling/index.js +2 -0
  175. package/dist/tooling/test-run-client.d.ts +27 -0
  176. package/dist/tooling/test-run-client.d.ts.map +1 -0
  177. package/dist/tooling/test-run-client.js +120 -0
  178. package/dist/tsdown.api.config.js +48 -0
  179. package/dist/types/types.d.ts +527 -527
  180. package/dist/types/types.d.ts.map +1 -1
  181. package/dist/ui/ai-client.d.ts.map +1 -1
  182. package/dist/utils/formatter.d.ts +6 -0
  183. package/dist/utils/formatter.d.ts.map +1 -1
  184. package/dist/utils/formatter.js +42 -11
  185. package/dist/utils/model.d.ts +1 -1
  186. package/dist/vector/chunking.d.ts.map +1 -1
  187. package/dist/vector/embedding.d.ts.map +1 -1
  188. package/package.json +13 -12
  189. package/src/api/sonamu.ts +2 -1
  190. package/src/auth/index.ts +1 -1
  191. package/src/bin/__tests__/zod-compiler-build.test.ts +67 -0
  192. package/src/bin/build-config.ts +151 -5
  193. package/src/bin/fixture.ts +204 -21
  194. package/src/bin/test-command.ts +27 -4
  195. package/src/bin/typecheck-web.ts +56 -0
  196. package/src/dict/sonamu-dictionary.ts +1 -1
  197. package/src/entity/__tests__/entity-manager-autoload.contract.test.ts +55 -0
  198. package/src/entity/__tests__/entity-manager-explicit-root-registration.contract.test.ts +107 -0
  199. package/src/entity/entity-manager.ts +31 -19
  200. package/src/entity/entity.ts +7 -2
  201. package/src/logger/__tests__/configure.test.ts +33 -0
  202. package/src/logger/configure.ts +45 -3
  203. package/src/migration/__tests__/migrator.test.ts +51 -0
  204. package/src/migration/migrator.ts +103 -34
  205. package/src/syncer/api-parser.ts +1 -1
  206. package/src/testing/fixture-manager.ts +124 -32
  207. package/src/tooling/__tests__/cdd-rule-bootstrap.contract.test.ts +118 -0
  208. package/src/tooling/__tests__/cli-tooling-auth.contract.test.ts +72 -0
  209. package/src/tooling/__tests__/cli-tooling-cone-locale.contract.test.ts +150 -0
  210. package/src/tooling/__tests__/cli-tooling-entity-patch.contract.test.ts +131 -0
  211. package/src/tooling/__tests__/cli-tooling-fixture-transfer.contract.test.ts +255 -0
  212. package/src/tooling/__tests__/cli-tooling-i18n-update.contract.test.ts +104 -0
  213. package/src/tooling/__tests__/cli-tooling-scaffold.contract.test.ts +86 -0
  214. package/src/tooling/__tests__/cli-tooling-stub-practice.contract.test.ts +147 -0
  215. package/src/tooling/__tests__/default-cdd-adapter.contract.test.ts +73 -0
  216. package/src/tooling/__tests__/helpers/sonamu-test-root.ts +42 -0
  217. package/src/tooling/__tests__/test-run-client-config.contract.test.ts +154 -0
  218. package/src/tooling/cdd-service.ts +256 -0
  219. package/src/tooling/cli-tooling.ts +937 -0
  220. package/src/tooling/fixture-service.ts +72 -0
  221. package/src/tooling/index.ts +1 -0
  222. package/src/tooling/test-run-client.ts +196 -0
  223. package/src/utils/__tests__/formatter-bin.test.ts +99 -0
  224. package/src/utils/formatter.ts +74 -10
  225. package/tsdown.api.config.ts +8 -10
  226. package/bin/cli.js +0 -75
  227. package/dist/bin/cli.d.ts +0 -2
  228. package/dist/bin/cli.d.ts.map +0 -1
  229. package/dist/bin/cli.js +0 -956
  230. package/src/bin/cli.ts +0 -1166
@@ -0,0 +1,937 @@
1
+ import { z } from "zod";
2
+
3
+ import { type FixtureCommandOptions } from "../bin/fixture";
4
+
5
+ const jsonValueSchema = z.json();
6
+ type CommandValue = z.infer<typeof jsonValueSchema> | undefined;
7
+ type CommandInput = Record<string, CommandValue>;
8
+ type ToolingResult = object | string | number | boolean | null | void | AsyncIterable<object>;
9
+ type ToolingOperation = (input: CommandInput) => ToolingResult | Promise<ToolingResult>;
10
+ type ToolingGroup = Record<string, ToolingOperation>;
11
+ export type CliToolingOperations = Record<string, ToolingGroup>;
12
+
13
+ type MigrationTarget = "development" | "staging" | "production" | "fixture" | "test";
14
+
15
+ const MIGRATION_TARGETS = new Set<string>([
16
+ "development",
17
+ "staging",
18
+ "production",
19
+ "fixture",
20
+ "test",
21
+ ]);
22
+
23
+ function assertMigrationTarget(target: string): asserts target is MigrationTarget {
24
+ // DB 상태를 조회하거나 변경하기 전에 모든 외부 대상을 검증합니다.
25
+ if (MIGRATION_TARGETS.has(target)) return;
26
+ throw Object.assign(new Error(`Unsupported migration target: ${target}`), {
27
+ code: "INVALID_MIGRATION_TARGET",
28
+ exitCode: 2,
29
+ });
30
+ }
31
+
32
+ function assertShadowMigrationTarget(
33
+ target: MigrationTarget,
34
+ ): asserts target is Extract<MigrationTarget, "fixture" | "test"> {
35
+ if (target === "fixture" || target === "test") return;
36
+
37
+ // Shadow DB는 운영 계열 연결에 접근하기 전에 허용 대상을 제한합니다.
38
+ throw Object.assign(new Error(`Invalid shadow migration target: ${target}`), {
39
+ code: "INVALID_SHADOW_MIGRATION_TARGET",
40
+ target,
41
+ exitCode: 2,
42
+ });
43
+ }
44
+
45
+ async function migrationPlan(
46
+ target: MigrationTarget,
47
+ migrations: string[],
48
+ action: "apply" | "rollback" = "apply",
49
+ ) {
50
+ const digest = await crypto.subtle.digest(
51
+ "SHA-256",
52
+ new TextEncoder().encode(JSON.stringify({ action, target, migrations })),
53
+ );
54
+ return {
55
+ kind: "migration-plan" as const,
56
+ action,
57
+ target,
58
+ migrations,
59
+ fingerprint: Array.from(new Uint8Array(digest), (byte) =>
60
+ byte.toString(16).padStart(2, "0"),
61
+ ).join(""),
62
+ };
63
+ }
64
+
65
+ const REQUIRED_METHODS = {
66
+ core: ["sync"],
67
+ entity: ["list", "show", "search", "applyPatch", "create", "cones"],
68
+ scaffold: ["model", "model_test", "view_list", "view_form", "status", "preview", "batch"],
69
+ migration: ["run", "generate", "status", "connections", "code", "preview", "shadow", "rollback"],
70
+ fixture: ["init", "import", "sync", "gen", "fetch", "explore"],
71
+ build: ["all", "api", "web"],
72
+ test: ["run", "status"],
73
+ auth: ["generate", "addCompanions"],
74
+ i18n: ["list", "check", "import", "export", "create", "update", "remove"],
75
+ task: ["definitions", "runs", "show", "steps", "watch", "pause", "resume", "cancel"],
76
+ cdd: ["tree", "read", "rules", "showRule", "addRule", "addAcceptanceCriterion"],
77
+ stub: ["practice"],
78
+ } as const;
79
+
80
+ let initialization: Promise<void> | undefined;
81
+
82
+ async function ensureSonamu(): Promise<void> {
83
+ const { Sonamu } = await import("../api/sonamu");
84
+ if (Sonamu.isInitialized) return;
85
+ initialization ??= Sonamu.init(true, false);
86
+ await initialization;
87
+ }
88
+
89
+ async function syncOperation(input: CommandInput): Promise<ToolingResult> {
90
+ await ensureSonamu();
91
+ const { Sonamu } = await import("../api/sonamu");
92
+ return input.force === true ? Sonamu.syncer.forceSync() : Sonamu.syncer.sync();
93
+ }
94
+
95
+ function text(input: CommandInput, key: string): string | undefined {
96
+ const value = z.string().safeParse(input[key]);
97
+ return value.success ? value.data : undefined;
98
+ }
99
+
100
+ function strings(input: CommandInput, key: string): string[] {
101
+ const value = z.array(z.string()).safeParse(input[key]);
102
+ return value.success ? value.data : [];
103
+ }
104
+
105
+ function requiredText(input: CommandInput, key: string): string {
106
+ const value = text(input, key);
107
+ if (value !== undefined && value.length > 0) return value;
108
+ throw Object.assign(new Error(`Missing ${key}.`), { code: "MISSING_ARGUMENT", exitCode: 2 });
109
+ }
110
+
111
+ /**
112
+ * 생략한 값은 `undefined`로 통과시키되, 값이 있는데 문자열이 아니면 인자 오류로 거절합니다.
113
+ *
114
+ * `text()`처럼 조용히 무시하면 잘못된 타입의 입력이 기본값으로 둔갑합니다.
115
+ */
116
+ function optionalText(input: CommandInput, key: string): string | undefined {
117
+ const value = input[key];
118
+ if (value === undefined || value === null) return undefined;
119
+ const parsed = z.string().safeParse(value);
120
+ if (parsed.success) return parsed.data;
121
+ throw Object.assign(new Error(`Invalid ${key}.`), { code: "INVALID_ARGUMENT", exitCode: 2 });
122
+ }
123
+
124
+ function parseJson<Schema extends z.ZodType>(
125
+ contents: string,
126
+ code: string,
127
+ schema: Schema,
128
+ ): z.infer<Schema> {
129
+ try {
130
+ return schema.parse(JSON.parse(contents));
131
+ } catch (cause) {
132
+ throw Object.assign(new Error("Invalid JSON input."), { code, exitCode: 2, cause });
133
+ }
134
+ }
135
+
136
+ function mutationRequested(input: CommandInput): boolean {
137
+ return input.execute === true || input.dryRun === false;
138
+ }
139
+
140
+ function explicitlyDryRun(input: CommandInput): boolean {
141
+ return input.dryRun === true || input.execute === false;
142
+ }
143
+
144
+ const entityPatchSchema = z.object({
145
+ entityId: z.string().min(1),
146
+ operations: z.array(
147
+ z.object({
148
+ op: z.literal("add"),
149
+ path: z.literal("/props/-"),
150
+ value: z
151
+ .object({ name: z.string().min(1), type: z.string().min(1) })
152
+ .catchall(jsonValueSchema),
153
+ }),
154
+ ),
155
+ });
156
+ const i18nEntriesSchema = z.array(
157
+ z.object({ key: z.string(), values: z.record(z.string(), z.string()) }),
158
+ );
159
+ const dictionaryValuesSchema = z.record(z.string(), z.string());
160
+ const dictionarySourceSchema = z.enum(["entity", "project", "sonamu"]);
161
+ const coneLocaleSchema = z.enum(["ko", "en", "ja"]);
162
+
163
+ /**
164
+ * cone 생성에 사용할 locale을 결정합니다.
165
+ *
166
+ * 명시한 locale이 최우선이고, 없으면 프로젝트 i18n 기본 locale, 그마저 없으면 "ko"를 사용합니다.
167
+ */
168
+ async function resolveConeLocale(input: CommandInput): Promise<z.infer<typeof coneLocaleSchema>> {
169
+ const explicit = coneLocaleSchema.safeParse(input.locale);
170
+ if (explicit.success) return explicit.data;
171
+
172
+ const { Sonamu } = await import("../api/sonamu");
173
+ let configuredLocale: unknown;
174
+ try {
175
+ configuredLocale = Sonamu.config.i18n?.defaultLocale;
176
+ } catch {
177
+ // 설정을 아직 읽을 수 없는 실행 경로에서는 기본 locale로 폴백합니다.
178
+ }
179
+ const configured = coneLocaleSchema.safeParse(configuredLocale);
180
+ return configured.success ? configured.data : "ko";
181
+ }
182
+
183
+ async function entityOperation(method: string, input: CommandInput): Promise<ToolingResult> {
184
+ await ensureSonamu();
185
+ const { EntityManager } = await import("../entity/entity-manager");
186
+ const entities = EntityManager.getAllEntities();
187
+
188
+ if (method === "list") return entities.map((entity) => entity.toJson());
189
+ if (method === "show") {
190
+ const entityId = requiredText(input, "entityId");
191
+ if (!EntityManager.exists(entityId)) {
192
+ throw Object.assign(new Error(`Entity not found: ${entityId}`), {
193
+ code: "ENTITY_NOT_FOUND",
194
+ });
195
+ }
196
+ return EntityManager.get(entityId).toJson();
197
+ }
198
+ if (method === "search") {
199
+ const query = requiredText(input, "query").toLocaleLowerCase();
200
+ return entities
201
+ .map((entity) => entity.toJson())
202
+ .filter((entity) => JSON.stringify(entity).toLocaleLowerCase().includes(query));
203
+ }
204
+ if (method === "create") {
205
+ const entityId = requiredText(input, "name");
206
+ const { Sonamu } = await import("../api/sonamu");
207
+ await Sonamu.syncer.createEntity({ entityId, title: entityId });
208
+ if (input.noCones === true) return { entityId, cones: "skipped" };
209
+ const entity = EntityManager.get(entityId);
210
+ if (input.ai === true) {
211
+ return entity.generateCones({
212
+ preserveExisting: false,
213
+ onlyEmpty: false,
214
+ locale: await resolveConeLocale(input),
215
+ });
216
+ }
217
+ await entity.generateTemplateCones();
218
+ return { entityId, cones: "template" };
219
+ }
220
+ if (method === "cones") {
221
+ const entityId = requiredText(input, "entityId");
222
+ const targets =
223
+ input.all === true || entityId === "all"
224
+ ? EntityManager.getAllEntities()
225
+ : EntityManager.exists(entityId)
226
+ ? [EntityManager.get(entityId)]
227
+ : [];
228
+ if (targets.length === 0) {
229
+ throw Object.assign(new Error(`Entity not found: ${entityId}`), {
230
+ code: "ENTITY_NOT_FOUND",
231
+ });
232
+ }
233
+ const regenerate = input.regenerate === true;
234
+ const selectedLocale = await resolveConeLocale(input);
235
+ // 한 엔티티의 실패가 나머지 엔티티의 cone 생성을 취소하지 않도록 전부 끝까지 실행합니다.
236
+ const settled = await Promise.allSettled(
237
+ targets.map(async (entity) => ({
238
+ entityId: entity.id,
239
+ ...(await entity.generateCones({
240
+ preserveExisting: !regenerate,
241
+ onlyEmpty: !regenerate,
242
+ locale: selectedLocale,
243
+ })),
244
+ })),
245
+ );
246
+ const results = [];
247
+ const failures = [];
248
+ for (const [index, outcome] of settled.entries()) {
249
+ if (outcome.status === "fulfilled") {
250
+ results.push(outcome.value);
251
+ continue;
252
+ }
253
+ failures.push({
254
+ entityId: targets[index].id,
255
+ message: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
256
+ });
257
+ }
258
+ if (failures.length === 0) return results;
259
+ throw Object.assign(new Error(`Cone generation failed for ${failures.length} entity(s).`), {
260
+ code: "CONE_GENERATION_FAILED",
261
+ details: { results, failures },
262
+ });
263
+ }
264
+
265
+ const file = requiredText(input, "file");
266
+ const { readFile } = await import("node:fs/promises");
267
+ const contents =
268
+ file === "-"
269
+ ? await (async () => {
270
+ const chunks = [];
271
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
272
+ return Buffer.concat(chunks).toString("utf8");
273
+ })()
274
+ : await readFile(file, "utf8");
275
+ const patch = parseJson(contents, "INVALID_ENTITY_PATCH", entityPatchSchema);
276
+ if (!EntityManager.exists(patch.entityId)) {
277
+ throw Object.assign(new Error(`Entity not found: ${patch.entityId}`), {
278
+ code: "ENTITY_NOT_FOUND",
279
+ });
280
+ }
281
+ const target = EntityManager.get(patch.entityId);
282
+ const before = target.toJson();
283
+ const after = structuredClone(before);
284
+ const nextProps = [
285
+ ...after.props,
286
+ ...patch.operations.map(({ value }) => structuredClone(value)),
287
+ ];
288
+ // 파일이나 등록된 엔티티를 건드리기 전에 patch 적용 결과 전체를 entity 스키마로 검증합니다.
289
+ const { EntityJsonSchema } = await import("../types/types");
290
+ const validation = EntityJsonSchema.safeParse({ ...after, props: nextProps });
291
+ if (!validation.success) {
292
+ throw Object.assign(new Error("Invalid entity patch."), {
293
+ code: "INVALID_ENTITY_PATCH",
294
+ exitCode: 2,
295
+ cause: validation.error,
296
+ });
297
+ }
298
+ // SAFETY: EntityJsonSchema가 patch 적용 결과 전체를 검증했습니다.
299
+ after.props = nextProps as typeof after.props;
300
+ if (mutationRequested(input)) {
301
+ // SAFETY: after.props originates from the current entity plus schema-validated prop additions.
302
+ target.props = after.props as typeof target.props;
303
+ await target.save();
304
+ }
305
+ return { changed: patch.operations.length > 0, before, after };
306
+ }
307
+
308
+ async function renderScaffolds(input: CommandInput) {
309
+ await ensureSonamu();
310
+ const { renderTemplate } = await import("../syncer/code-generator");
311
+ const entities = strings(input, "entities");
312
+ const templates = strings(input, "templates");
313
+ const rendered = [];
314
+ for (const entityId of entities) {
315
+ for (const template of templates) {
316
+ const templateKey = z
317
+ .enum(["model", "model_test", "view_list", "view_form", "view_search_input"])
318
+ .parse(template);
319
+ const files = await renderTemplate(templateKey, { entityId });
320
+ rendered.push({ entityId, template: templateKey, files });
321
+ }
322
+ }
323
+ return rendered;
324
+ }
325
+
326
+ async function scaffoldOperation(method: string, input: CommandInput): Promise<ToolingResult> {
327
+ const directTemplate = z
328
+ .enum(["model", "model_test", "view_list", "view_form"])
329
+ .safeParse(method);
330
+ if (directTemplate.success) {
331
+ await ensureSonamu();
332
+ const { Sonamu } = await import("../api/sonamu");
333
+ const entityId = requiredText(input, "entityId");
334
+ return directTemplate.data === "view_list"
335
+ ? Sonamu.syncer.generateTemplate("view_list", { entityId, extra: input.extra })
336
+ : Sonamu.syncer.generateTemplate(directTemplate.data, { entityId });
337
+ }
338
+ const rendered = await renderScaffolds(input);
339
+ const { access } = await import("node:fs/promises");
340
+ const path = await import("node:path");
341
+ const { Sonamu } = await import("../api/sonamu");
342
+ const items = await Promise.all(
343
+ rendered.flatMap(({ entityId, template, files }) =>
344
+ files.map(async (file) => {
345
+ const target = path.default.join(
346
+ Sonamu.appRootPath,
347
+ file.path.replace("/:target/", "/web/"),
348
+ );
349
+ let exists = true;
350
+ try {
351
+ await access(target);
352
+ } catch {
353
+ exists = false;
354
+ }
355
+ return { entityId, template, target, exists, content: file.code };
356
+ }),
357
+ ),
358
+ );
359
+ if (method === "status") return items.map(({ content: _content, ...item }) => item);
360
+ if (method === "preview" || (method === "batch" && explicitlyDryRun(input))) return items;
361
+
362
+ const results = [];
363
+ for (const item of items) {
364
+ const { content: _content, ...summary } = item;
365
+ if (item.exists && input.overwrite !== true) {
366
+ results.push({ ...summary, status: "skipped" });
367
+ continue;
368
+ }
369
+ await Sonamu.syncer.generateTemplate(
370
+ item.template,
371
+ { entityId: item.entityId },
372
+ { overwrite: input.overwrite === true },
373
+ );
374
+ results.push({ ...summary, status: "written" });
375
+ }
376
+ return { items: results };
377
+ }
378
+
379
+ async function migrationOperation(method: string, input: CommandInput): Promise<ToolingResult> {
380
+ let shadowTarget: Extract<MigrationTarget, "fixture" | "test"> | undefined;
381
+ if (method === "shadow") {
382
+ const requestedTarget = requiredText(input, "target");
383
+ assertMigrationTarget(requestedTarget);
384
+ assertShadowMigrationTarget(requestedTarget);
385
+ shadowTarget = requestedTarget;
386
+ }
387
+
388
+ await ensureSonamu();
389
+ const { Migrator } = await import("../migration/migrator");
390
+ const migrator = new Migrator();
391
+ if (method === "status") return migrator.getStatus();
392
+ if (method === "generate") {
393
+ const status = await migrator.getStatus();
394
+ if (!status.conns.some((connection) => connection.status === 0)) {
395
+ throw Object.assign(new Error("Pending migrations must be applied before generation."), {
396
+ code: "MIGRATION_NOT_READY",
397
+ });
398
+ }
399
+ return { count: await migrator.generatePreparedCodes() };
400
+ }
401
+ if (method === "run") {
402
+ if (!mutationRequested(input)) return { dryRun: true };
403
+ const { getSonamuEnvironment } = await import("../env");
404
+ const environment = getSonamuEnvironment();
405
+ return migrator.apply(environment === "test" ? ["test", "fixture"] : [environment]);
406
+ }
407
+ if (method === "connections") return migrator.getConnections();
408
+ if (method === "apply") {
409
+ const rawTargets = strings(input, "targets");
410
+ if (rawTargets.length === 0) {
411
+ throw Object.assign(new Error("Missing targets."), { code: "MISSING_ARGUMENT", exitCode: 2 });
412
+ }
413
+ const targets = rawTargets.map((target) => {
414
+ assertMigrationTarget(target);
415
+ return target;
416
+ });
417
+ if (mutationRequested(input)) return migrator.apply(targets);
418
+ return Promise.all(
419
+ targets.map(async (target) => {
420
+ const status = await migrator.getConnectionStatus(target);
421
+ return migrationPlan(target, status.pending);
422
+ }),
423
+ );
424
+ }
425
+ if (method === "shadow") {
426
+ if (shadowTarget === undefined) throw new Error("Shadow target validation was skipped.");
427
+ if (mutationRequested(input)) {
428
+ const result = await migrator.runShadowTest({ target: shadowTarget });
429
+ return Array.isArray(result)
430
+ ? {
431
+ ok: true,
432
+ target: shadowTarget,
433
+ migrations: result.flatMap(({ applied }) => applied),
434
+ }
435
+ : result;
436
+ }
437
+
438
+ const status = await migrator.getConnectionStatus(shadowTarget);
439
+ return migrationPlan(shadowTarget, status.pending);
440
+ }
441
+
442
+ const target = requiredText(input, "target");
443
+ assertMigrationTarget(target);
444
+ if (method === "code") return migrator.getPreparedCodes(target);
445
+
446
+ const action =
447
+ method === "rollback" || text(input, "action") === "rollback" ? "rollback" : "apply";
448
+ const migrations =
449
+ action === "apply"
450
+ ? (await migrator.getConnectionStatus(target)).pending
451
+ : (await migrator.getLatestAppliedBatch(target)).files;
452
+ const plan = await migrationPlan(target, migrations, action);
453
+ if (method !== "rollback" || !mutationRequested(input)) return plan;
454
+ return migrator.rollback([target]);
455
+ }
456
+
457
+ async function fixtureOperation(method: string, input: CommandInput): Promise<ToolingResult> {
458
+ await ensureSonamu();
459
+ const [fixtureCommands, { FixtureManager }, fixtureService, { Sonamu }] = await Promise.all([
460
+ import("../bin/fixture"),
461
+ import("../testing/fixture-manager"),
462
+ import("./fixture-service"),
463
+ import("../api/sonamu"),
464
+ ]);
465
+ if (method === "init") {
466
+ return mutationRequested(input) ? fixtureCommands.fixtureInitCommand() : { dryRun: true };
467
+ }
468
+ if (method === "import") {
469
+ if (!mutationRequested(input)) return { dryRun: true };
470
+ FixtureManager.init();
471
+ await FixtureManager.importFixture(
472
+ requiredText(input, "entityId"),
473
+ z.array(z.number()).parse(input.recordIds),
474
+ );
475
+ await FixtureManager.sync();
476
+ return { imported: true };
477
+ }
478
+ if (method === "sync") {
479
+ if (!mutationRequested(input)) return { dryRun: true };
480
+ FixtureManager.init();
481
+ await FixtureManager.sync();
482
+ return { synced: true };
483
+ }
484
+ const legacyCommand = z.enum(["gen", "fetch", "explore"]).parse(method);
485
+ const isTransfer = ["entityId", "source", "target", "field", "values", "relations", "depth"].some(
486
+ (key) => input[key] !== undefined,
487
+ );
488
+ if (!isTransfer) {
489
+ const options = fixtureService.normalizeLegacyFixtureCommandOptions(legacyCommand, input);
490
+ const strategy = z
491
+ .enum(["sample", "ids", "query", "file", "recent", "random"])
492
+ .optional()
493
+ .parse(options.strategy);
494
+ const fixtureOptions = { ...options, strategy } satisfies FixtureCommandOptions;
495
+ if (legacyCommand !== "explore" && !mutationRequested(input)) {
496
+ return { dryRun: true, request: fixtureOptions };
497
+ }
498
+ if (legacyCommand === "gen") return fixtureCommands.fixtureGenCommand(fixtureOptions);
499
+ if (legacyCommand === "explore") return fixtureCommands.fixtureExploreCommand(fixtureOptions);
500
+ return fixtureCommands.fixtureFetchCommand(fixtureOptions);
501
+ }
502
+
503
+ const entityId = requiredText(input, "entityId");
504
+ const source = requiredText(input, "source");
505
+ const target = requiredText(input, "target");
506
+ const field = requiredText(input, "field");
507
+ const values = strings(input, "values");
508
+ const relations = requiredText(input, "relations");
509
+ // CLI 문법이 integer({ min: 0 })이므로 관계 순회를 끄는 depth 0을 허용합니다.
510
+ const depth = z.number().int().nonnegative().safeParse(input.depth);
511
+ if (
512
+ !Object.hasOwn(Sonamu.dbConfig, source) ||
513
+ !Object.hasOwn(Sonamu.dbConfig, target) ||
514
+ values.length === 0 ||
515
+ !["include", "exclude", "none"].includes(relations) ||
516
+ !depth.success
517
+ ) {
518
+ throw Object.assign(new Error("Invalid fixture transfer selector."), {
519
+ code: "INVALID_FIXTURE_TRANSFER",
520
+ exitCode: 2,
521
+ });
522
+ }
523
+ // SAFETY: Object.hasOwn checks above establish both keys as configured DB presets.
524
+ const sourceKey = source as keyof typeof Sonamu.dbConfig;
525
+ // SAFETY: Object.hasOwn checks above establish both keys as configured DB presets.
526
+ const targetKey = target as keyof typeof Sonamu.dbConfig;
527
+ const records = (
528
+ await Promise.all(
529
+ values.map((value) =>
530
+ FixtureManager.getFixtures(
531
+ sourceKey,
532
+ targetKey,
533
+ { entityId, field, value, searchType: "equals" },
534
+ { includeRelations: relations === "include", maxDepth: depth.data },
535
+ ),
536
+ ),
537
+ )
538
+ ).flat();
539
+ if (explicitlyDryRun(input)) return { dryRun: true, records };
540
+ if (records.length > 0) await FixtureManager.insertFixtures(targetKey, records);
541
+ return { records };
542
+ }
543
+
544
+ async function buildOperation(method: string): Promise<ToolingResult> {
545
+ const { buildApiCommand, buildWebCommand } = await import("../bin/build-config");
546
+ if (method === "api") return buildApiCommand();
547
+ if (method === "web") return buildWebCommand();
548
+ await buildApiCommand();
549
+ return buildWebCommand({ skipIfMissing: true });
550
+ }
551
+
552
+ async function testOperation(method: string, input: CommandInput): Promise<ToolingResult> {
553
+ const { createDefaultTestRunClient } = await import("./test-run-client");
554
+ const client = createDefaultTestRunClient();
555
+ if (method === "status") return client.status();
556
+ const request = {
557
+ files: z.array(z.string()).catch([]).parse(input.files),
558
+ pattern: z.string().optional().parse(input.pattern),
559
+ traces: input.traces === true,
560
+ };
561
+ return { request, result: await client.run(request) };
562
+ }
563
+
564
+ const betterAuthPluginSchema = z.enum([
565
+ "2fa",
566
+ "admin",
567
+ "anonymous",
568
+ "api-key",
569
+ "audit-log",
570
+ "jwt",
571
+ "organization",
572
+ "passkey",
573
+ "phone-number",
574
+ "sso",
575
+ "username",
576
+ ]);
577
+
578
+ async function authOperation(method: string, input: CommandInput): Promise<ToolingResult> {
579
+ const { addCompanionsToEntities, generateBetterAuthEntities } = await import("../auth");
580
+ if (method === "addCompanions") return addCompanionsToEntities();
581
+ // plugins는 선택 옵션이므로 생략하면 core 엔티티만 생성합니다.
582
+ const plugins = z.array(betterAuthPluginSchema).parse(
583
+ (optionalText(input, "plugins") ?? "")
584
+ .split(",")
585
+ .map((plugin) => plugin.trim())
586
+ .filter(Boolean),
587
+ );
588
+ return generateBetterAuthEntities({ plugins });
589
+ }
590
+
591
+ async function i18nOperation(method: string, input: CommandInput): Promise<ToolingResult> {
592
+ await ensureSonamu();
593
+ const { sonamuDictionary } = await import("../dict/sonamu-dictionary");
594
+ if (method === "list") {
595
+ const dictionary = await sonamuDictionary.getDictionary();
596
+ const locale = text(input, "locale");
597
+ return locale === undefined
598
+ ? dictionary
599
+ : {
600
+ ...dictionary,
601
+ locales: [locale],
602
+ rows: dictionary.rows.map((row) => ({
603
+ key: row.key,
604
+ source: row.source,
605
+ [locale]: row[locale],
606
+ })),
607
+ };
608
+ }
609
+ if (method === "check") {
610
+ const dictionary = await sonamuDictionary.getDictionary();
611
+ return sonamuDictionary.checkUsage(dictionary.rows.map((row) => row.key));
612
+ }
613
+ if (method === "export" || method === "import") {
614
+ const file = requiredText(input, "file");
615
+ if (explicitlyDryRun(input)) return { operation: method, dryRun: true };
616
+ const { readFile, writeFile } = await import("node:fs/promises");
617
+ const format = text(input, "format") === "json" ? "json" : "workbook";
618
+ if (method === "export" && format === "workbook") {
619
+ const { buffer } = await sonamuDictionary.exportToExcel();
620
+ await writeFile(file, buffer);
621
+ return { exported: 1 };
622
+ }
623
+ if (method === "import" && format === "workbook") {
624
+ return sonamuDictionary.importFromExcel(await readFile(file));
625
+ }
626
+
627
+ if (method === "export") {
628
+ const dictionary = await sonamuDictionary.getDictionary();
629
+ const locale = text(input, "locale");
630
+ const rows = dictionary.rows.map(
631
+ ({ key, source: _source, isFunction: _isFunction, ...values }) => ({
632
+ key,
633
+ values:
634
+ locale === undefined
635
+ ? values
636
+ : { [locale]: z.string().catch("").parse(values[locale]) },
637
+ }),
638
+ );
639
+ await writeFile(file, JSON.stringify(rows, null, 2));
640
+ return { exported: rows.length };
641
+ }
642
+
643
+ const entries = parseJson(
644
+ (await readFile(file)).toString("utf8"),
645
+ "I18N_INVALID_JSON",
646
+ i18nEntriesSchema,
647
+ );
648
+ const current = await sonamuDictionary.getDictionary();
649
+ const existingKeys = new Set(current.rows.map(({ key }) => key));
650
+ for (const entry of entries) {
651
+ if (existingKeys.has(entry.key)) {
652
+ await sonamuDictionary.updateEntry({
653
+ oldKey: entry.key,
654
+ newKey: entry.key,
655
+ source: "project",
656
+ values: entry.values,
657
+ });
658
+ } else {
659
+ await sonamuDictionary.createEntry(entry);
660
+ }
661
+ }
662
+ return { imported: entries.length };
663
+ }
664
+ const key = requiredText(input, "key");
665
+ if (method === "create") {
666
+ if (explicitlyDryRun(input)) return { operation: "create", input, dryRun: true };
667
+ return sonamuDictionary.createEntry({
668
+ key,
669
+ values: dictionaryValuesSchema.parse(input.values),
670
+ });
671
+ }
672
+ if (method === "update") {
673
+ if (explicitlyDryRun(input)) return { operation: "update", input, dryRun: true };
674
+ const explicitSource = text(input, "source");
675
+ // source를 생략하면 사전에 등록된 기존 source를 유지해 entity 항목이 project override로 덮이지 않게 합니다.
676
+ const resolvedSource =
677
+ explicitSource === undefined
678
+ ? ((await sonamuDictionary.getDictionary()).rows.find((row) => row.key === key)?.source ??
679
+ "project")
680
+ : explicitSource;
681
+ return sonamuDictionary.updateEntry({
682
+ oldKey: key,
683
+ newKey: text(input, "newKey") ?? key,
684
+ source: dictionarySourceSchema.parse(resolvedSource),
685
+ values: dictionaryValuesSchema.parse(input.values),
686
+ });
687
+ }
688
+ if (!mutationRequested(input)) return { key, operation: "remove", dryRun: true };
689
+ return sonamuDictionary.deleteEntry(key);
690
+ }
691
+
692
+ async function taskOperation(method: string, input: CommandInput): Promise<ToolingResult> {
693
+ await ensureSonamu();
694
+ const { Sonamu } = await import("../api/sonamu");
695
+ const workflows = Sonamu.workflows;
696
+ const backend = workflows.backend;
697
+ if (method === "definitions") return { definitions: workflows.workflowDefinitions };
698
+ if (method === "runs") return backend.listWorkflowRuns({ limit: 50 });
699
+ const workflowRunId = requiredText(input, "runId");
700
+ if (method === "show") return backend.getWorkflowRun({ workflowRunId });
701
+ if (method === "watch") {
702
+ const { isDeepStrictEqual } = await import("node:util");
703
+ return (async function* watch() {
704
+ let previous: object | undefined;
705
+ while (true) {
706
+ const snapshot = await backend.getWorkflowRun({ workflowRunId });
707
+ if (snapshot === null) {
708
+ throw Object.assign(new Error(`Workflow run not found: ${workflowRunId}`), {
709
+ code: "TASK_RUN_NOT_FOUND",
710
+ });
711
+ }
712
+ const rawState = snapshot.status === "succeeded" ? "completed" : snapshot.status;
713
+ const state = rawState === "canceled" ? "cancelled" : rawState;
714
+ const current = { ...snapshot, state };
715
+ if (!isDeepStrictEqual(current, previous)) {
716
+ previous = structuredClone(current);
717
+ yield current;
718
+ }
719
+ if (["completed", "failed", "cancelled"].includes(state)) return;
720
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
721
+ }
722
+ })();
723
+ }
724
+ if (method === "steps") return backend.listStepAttempts({ workflowRunId, limit: 100 });
725
+ if (!mutationRequested(input)) return { operation: method, workflowRunId, dryRun: true };
726
+ if (method === "pause") return backend.pauseWorkflowRun({ workflowRunId });
727
+ if (method === "resume") return backend.resumeWorkflowRun({ workflowRunId });
728
+ return backend.cancelWorkflowRun({ workflowRunId });
729
+ }
730
+
731
+ async function cddRootPath(): Promise<string> {
732
+ const [{ Sonamu }, path] = await Promise.all([import("../api/sonamu"), import("node:path")]);
733
+ return path.join(Sonamu.appRootPath, "contract");
734
+ }
735
+
736
+ const practiceSequencePattern = /^p([0-9]+)-/;
737
+
738
+ // 이름은 파일명과 스텁 본문의 문자열 리터럴에 그대로 들어가므로,
739
+ // 경로 구분자/상위 경로 참조/따옴표/역슬래시를 원천 차단하는 문자만 허용합니다.
740
+ const practiceNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
741
+
742
+ function invalidPracticeName(name: string): Error {
743
+ return Object.assign(
744
+ new Error(
745
+ `Invalid practice name: ${name}. Use letters, digits, ".", "-", and "_" only, starting with a letter or digit.`,
746
+ ),
747
+ { code: "INVALID_PRACTICE_NAME", exitCode: 2 },
748
+ );
749
+ }
750
+
751
+ function invalidPracticeDirectory(directory: string): Error {
752
+ return Object.assign(new Error(`Practice directory escapes the API root: ${directory}`), {
753
+ code: "INVALID_PRACTICE_PATH",
754
+ exitCode: 2,
755
+ });
756
+ }
757
+
758
+ /**
759
+ * `src/practices` 아래에 다음 시퀀스 번호를 가진 practice 스텁 파일을 만듭니다.
760
+ *
761
+ * 구 CLI(`stub practice`)의 파일명 규칙과 스텁 본문을 그대로 유지합니다.
762
+ */
763
+ async function stubPracticeOperation(input: CommandInput): Promise<ToolingResult> {
764
+ const name = requiredText(input, "name");
765
+ if (!practiceNamePattern.test(name)) throw invalidPracticeName(name);
766
+ await ensureSonamu();
767
+ const [{ Sonamu }, { constants }, { mkdir, open, readdir, realpath }, path] = await Promise.all([
768
+ import("../api/sonamu"),
769
+ import("node:fs"),
770
+ import("node:fs/promises"),
771
+ import("node:path"),
772
+ ]);
773
+ const practiceDirectory = path.default.join(Sonamu.apiRootPath, "src", "practices");
774
+ await mkdir(practiceDirectory, { recursive: true });
775
+ // cdd-service와 같이 실제 경로까지 확인해, symlink로 연결된 practices 디렉터리가
776
+ // api root 밖을 가리키는 경우를 차단합니다.
777
+ const [canonicalApiRoot, canonicalDirectory] = await Promise.all([
778
+ realpath(Sonamu.apiRootPath),
779
+ realpath(practiceDirectory),
780
+ ]);
781
+ const directoryRelative = path.default.relative(canonicalApiRoot, canonicalDirectory);
782
+ if (directoryRelative.startsWith("..") || path.default.isAbsolute(directoryRelative)) {
783
+ throw invalidPracticeDirectory(practiceDirectory);
784
+ }
785
+ const fileNames = await readdir(practiceDirectory);
786
+ const maxSequence = fileNames
787
+ .filter((fileName) => fileName.startsWith("p") && fileName.endsWith(".ts"))
788
+ .reduce((largest, fileName) => {
789
+ const matched = practiceSequencePattern.exec(fileName);
790
+ return matched === null ? largest : Math.max(largest, Number.parseInt(matched[1], 10));
791
+ }, 0);
792
+
793
+ const fileName = `p${maxSequence + 1}-${name}.ts`;
794
+ // 이름 패턴이 경로 구분자를 차단하므로 파일명은 항상 이 디렉터리의 직계 자식입니다.
795
+ // 경로는 설정된 값 그대로 돌려주고, 탈출 여부는 위의 canonical 검사로 판단합니다.
796
+ const filePath = path.default.resolve(practiceDirectory, fileName);
797
+ const stubCode = [
798
+ `import { Sonamu } from "sonamu";`,
799
+ "",
800
+ `console.clear();`,
801
+ `console.log("${fileName}");`,
802
+ "",
803
+ `Sonamu.runScript(async () => {`,
804
+ ` // TODO`,
805
+ `});`,
806
+ "",
807
+ ].join("\n");
808
+ let handle;
809
+ try {
810
+ // 기존 파일을 truncate하지 않도록 배타 생성하고, 최종 경로의 symlink는 따라가지 않습니다.
811
+ handle = await open(
812
+ filePath,
813
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
814
+ 0o666,
815
+ );
816
+ await handle.writeFile(stubCode, "utf8");
817
+ } catch (error) {
818
+ // SAFETY: node:fs open 실패의 code만 읽어 기존 파일/symlink 충돌을 정규화합니다.
819
+ const errorCode = (error as NodeJS.ErrnoException).code;
820
+ if (errorCode === "EEXIST" || errorCode === "ELOOP") {
821
+ throw Object.assign(new Error(`Practice file already exists: ${filePath}`), {
822
+ code: "PRACTICE_FILE_EXISTS",
823
+ exitCode: 2,
824
+ cause: error,
825
+ });
826
+ }
827
+ throw error;
828
+ } finally {
829
+ await handle?.close();
830
+ }
831
+ // 에디터 열기/클립보드 복사 같은 대화형 부수효과는 비대화형 실행을 깨뜨리므로 호출자에게 맡깁니다.
832
+ return { fileName, path: filePath };
833
+ }
834
+
835
+ async function readCddTree(directory: string, root: string): Promise<object[]> {
836
+ const [{ readdir }, path] = await Promise.all([import("node:fs/promises"), import("node:path")]);
837
+ const entries = await readdir(directory, { withFileTypes: true });
838
+ const nodes = [];
839
+ for (const entry of entries) {
840
+ if (entry.name === "rules") continue;
841
+ const absolutePath = path.join(directory, entry.name);
842
+ const relativePath = path.relative(root, absolutePath);
843
+ if (entry.isDirectory()) {
844
+ const children = await readCddTree(absolutePath, root);
845
+ if (children.length > 0) {
846
+ nodes.push({ name: entry.name, path: relativePath, type: "directory", children });
847
+ }
848
+ } else if (entry.isFile() && entry.name.endsWith(".contract.md")) {
849
+ nodes.push({ name: entry.name, path: relativePath, type: "file", fileType: "contract" });
850
+ }
851
+ }
852
+ return nodes;
853
+ }
854
+
855
+ async function cddOperation(method: string, input: CommandInput): Promise<ToolingResult> {
856
+ await ensureSonamu();
857
+ const [{ access }, { createDefaultCddToolingAdapter }] = await Promise.all([
858
+ import("node:fs/promises"),
859
+ import("./cdd-service"),
860
+ ]);
861
+ const adapter = createDefaultCddToolingAdapter({ contractRoot: await cddRootPath() });
862
+ if (method === "tree") {
863
+ const root = await cddRootPath();
864
+ try {
865
+ await access(root);
866
+ } catch {
867
+ return { exists: false, tree: [] };
868
+ }
869
+ return { exists: true, tree: await readCddTree(root, root) };
870
+ }
871
+ if (method === "read") {
872
+ const relativePath = requiredText(input, "path");
873
+ return adapter.read({ path: relativePath });
874
+ }
875
+ if (method === "rules") return adapter.rules();
876
+ if (method === "showRule") {
877
+ const ruleId = requiredText(input, "ruleId");
878
+ return adapter.showRule({ ruleId });
879
+ }
880
+ if (method === "addRule") {
881
+ const ruleKey = requiredText(input, "ruleKey");
882
+ const id = requiredText(input, "id");
883
+ const when = requiredText(input, "when");
884
+ const ruleText = text(input, "instruction") ?? requiredText(input, "text");
885
+ return adapter.addRule({
886
+ ruleKey,
887
+ id,
888
+ when,
889
+ text: ruleText,
890
+ examples: strings(input, "examples"),
891
+ dryRun: explicitlyDryRun(input),
892
+ });
893
+ }
894
+ if (explicitlyDryRun(input)) {
895
+ return { operation: "addAcceptanceCriterion", input, dryRun: true };
896
+ }
897
+ const document = requiredText(input, "document");
898
+ const criterion = requiredText(input, "text");
899
+ return adapter.addAcceptanceCriterion({ document, text: criterion, dryRun: false });
900
+ }
901
+
902
+ async function invokeDefault(
903
+ group: string,
904
+ method: string,
905
+ input: CommandInput,
906
+ ): Promise<ToolingResult> {
907
+ if (group === "core") return syncOperation(input);
908
+ if (group === "entity") return entityOperation(method, input);
909
+ if (group === "scaffold") return scaffoldOperation(method, input);
910
+ if (group === "migration") return migrationOperation(method, input);
911
+ if (group === "fixture") return fixtureOperation(method, input);
912
+ if (group === "build") return buildOperation(method);
913
+ if (group === "test") return testOperation(method, input);
914
+ if (group === "auth") return authOperation(method, input);
915
+ if (group === "i18n") return i18nOperation(method, input);
916
+ if (group === "task") return taskOperation(method, input);
917
+ if (group === "stub") return stubPracticeOperation(input);
918
+ return cddOperation(method, input);
919
+ }
920
+
921
+ // 기본 registry는 호출 시점까지 Sonamu 초기화와 외부 작업을 모두 미룹니다.
922
+ export const tooling: CliToolingOperations = Object.fromEntries(
923
+ Object.entries(REQUIRED_METHODS).map(([group, methods]) => [
924
+ group,
925
+ Object.fromEntries(
926
+ methods.map((method) => [
927
+ method,
928
+ (input: CommandInput) => invokeDefault(group, method, input),
929
+ ]),
930
+ ),
931
+ ]),
932
+ );
933
+
934
+ Object.defineProperty(tooling.migration, "apply", {
935
+ enumerable: false,
936
+ value: (input: CommandInput) => invokeDefault("migration", "apply", input),
937
+ });