@mandujs/cli 0.17.0 → 0.18.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Agent-Native Fullstack Framework - 에이전트가 코딩해도 아키텍처가 무너지지 않는 개발 OS",
5
5
  "type": "module",
6
6
  "main": "./src/main.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@mandujs/core": "^0.16.0",
36
- "@mandujs/ate": "0.16.0",
35
+ "@mandujs/core": "0.18.0",
36
+ "@mandujs/ate": "0.17.0",
37
37
  "cfonts": "^3.3.0"
38
38
  },
39
39
  "engines": {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * CLI Integration Tests - generate-resource command
3
+ *
4
+ * QA Engineer: Integration testing for CLI resource generation
5
+ */
6
+
7
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
8
+ import { parseFieldsFlag, parseMethodsFlag, formatSchemaFile } from "../generate-resource";
9
+ import type { ResourceDefinition } from "@mandujs/core";
10
+
11
+ describe("CLI - Field Parsing", () => {
12
+ test("should parse simple fields string", () => {
13
+ const result = parseFieldsFlag("name:string,email:email,age:number");
14
+
15
+ expect(result.name).toBeDefined();
16
+ expect(result.name.type).toBe("string");
17
+ expect(result.email.type).toBe("email");
18
+ expect(result.age.type).toBe("number");
19
+ });
20
+
21
+ test("should handle optional fields with ?", () => {
22
+ const result = parseFieldsFlag("name:string,bio:string?");
23
+
24
+ expect(result.name.required).toBe(true);
25
+ expect(result.bio.required).toBe(false);
26
+ });
27
+
28
+ test("should handle required fields with !", () => {
29
+ const result = parseFieldsFlag("email:email!");
30
+
31
+ expect(result.email.required).toBe(true);
32
+ });
33
+
34
+ test("should handle all field types", () => {
35
+ const fields = parseFieldsFlag(
36
+ "str:string,num:number,bool:boolean,dt:date,id:uuid,mail:email,link:url,data:json"
37
+ );
38
+
39
+ expect(fields.str.type).toBe("string");
40
+ expect(fields.num.type).toBe("number");
41
+ expect(fields.bool.type).toBe("boolean");
42
+ expect(fields.dt.type).toBe("date");
43
+ expect(fields.id.type).toBe("uuid");
44
+ expect(fields.mail.type).toBe("email");
45
+ expect(fields.link.type).toBe("url");
46
+ expect(fields.data.type).toBe("json");
47
+ });
48
+
49
+ test("should throw on invalid field format", () => {
50
+ expect(() => parseFieldsFlag("invalid")).toThrow(/Invalid field format/);
51
+ expect(() => parseFieldsFlag("name:")).toThrow(/Invalid field format/);
52
+ expect(() => parseFieldsFlag(":string")).toThrow(/Invalid field format/);
53
+ });
54
+
55
+ test("should throw on invalid field type", () => {
56
+ expect(() => parseFieldsFlag("field:invalidtype")).toThrow(/Invalid field type/);
57
+ });
58
+
59
+ test("should handle whitespace gracefully", () => {
60
+ const result = parseFieldsFlag(" name:string , email:email ");
61
+
62
+ expect(result.name).toBeDefined();
63
+ expect(result.email).toBeDefined();
64
+ });
65
+ });
66
+
67
+ describe("CLI - Methods Parsing", () => {
68
+ test("should parse GET,POST,PUT,DELETE", () => {
69
+ const endpoints = parseMethodsFlag("GET,POST,PUT,DELETE");
70
+
71
+ expect(endpoints.list).toBe(true);
72
+ expect(endpoints.get).toBe(true);
73
+ expect(endpoints.create).toBe(true);
74
+ expect(endpoints.update).toBe(true);
75
+ expect(endpoints.delete).toBe(true);
76
+ });
77
+
78
+ test("should parse partial methods", () => {
79
+ const endpoints = parseMethodsFlag("GET,POST");
80
+
81
+ expect(endpoints.list).toBe(true);
82
+ expect(endpoints.get).toBe(true);
83
+ expect(endpoints.create).toBe(true);
84
+ expect(endpoints.update).toBe(false);
85
+ expect(endpoints.delete).toBe(false);
86
+ });
87
+
88
+ test("should handle lowercase methods", () => {
89
+ const endpoints = parseMethodsFlag("get,post");
90
+
91
+ expect(endpoints.list).toBe(true);
92
+ expect(endpoints.create).toBe(true);
93
+ });
94
+
95
+ test("should handle single method", () => {
96
+ const endpoints = parseMethodsFlag("GET");
97
+
98
+ expect(endpoints.list).toBe(true);
99
+ expect(endpoints.get).toBe(true);
100
+ expect(endpoints.create).toBe(false);
101
+ });
102
+
103
+ test("should handle whitespace", () => {
104
+ const endpoints = parseMethodsFlag(" GET , POST ");
105
+
106
+ expect(endpoints.list).toBe(true);
107
+ expect(endpoints.create).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("CLI - Schema File Formatting", () => {
112
+ test("should generate valid TypeScript schema file", () => {
113
+ const definition: ResourceDefinition = {
114
+ name: "user",
115
+ fields: {
116
+ id: { type: "uuid", required: true },
117
+ name: { type: "string", required: true },
118
+ email: { type: "email", required: true },
119
+ age: { type: "number", required: false },
120
+ },
121
+ options: {
122
+ description: "User management API",
123
+ tags: ["user"],
124
+ endpoints: {
125
+ list: true,
126
+ get: true,
127
+ create: true,
128
+ update: true,
129
+ delete: true,
130
+ },
131
+ },
132
+ };
133
+
134
+ const schemaFile = formatSchemaFile(definition);
135
+
136
+ // Verify structure
137
+ expect(schemaFile).toContain('import { defineResource } from "@mandujs/core"');
138
+ expect(schemaFile).toContain("export const UserResource = defineResource({");
139
+ expect(schemaFile).toContain('name: "user"');
140
+
141
+ // Verify fields
142
+ expect(schemaFile).toContain('id: { type: "uuid", required: true }');
143
+ expect(schemaFile).toContain('name: { type: "string", required: true }');
144
+ expect(schemaFile).toContain('email: { type: "email", required: true }');
145
+ expect(schemaFile).toContain('age: { type: "number", required: false }');
146
+
147
+ // Verify options
148
+ expect(schemaFile).toContain('description: "User management API"');
149
+ expect(schemaFile).toContain('tags: ["user"]');
150
+ expect(schemaFile).toContain("list: true");
151
+ expect(schemaFile).toContain("get: true");
152
+ expect(schemaFile).toContain("create: true");
153
+ });
154
+
155
+ test("should handle minimal definition", () => {
156
+ const definition: ResourceDefinition = {
157
+ name: "item",
158
+ fields: {
159
+ id: { type: "uuid", required: true },
160
+ },
161
+ };
162
+
163
+ const schemaFile = formatSchemaFile(definition);
164
+
165
+ expect(schemaFile).toContain('name: "item"');
166
+ expect(schemaFile).toContain('id: { type: "uuid", required: true }');
167
+ });
168
+
169
+ test("should capitalize resource name in export", () => {
170
+ const definition: ResourceDefinition = {
171
+ name: "product",
172
+ fields: {
173
+ id: { type: "uuid", required: true },
174
+ },
175
+ };
176
+
177
+ const schemaFile = formatSchemaFile(definition);
178
+
179
+ expect(schemaFile).toContain("export const ProductResource = defineResource({");
180
+ });
181
+ });
182
+
183
+ describe("CLI - Error Messages", () => {
184
+ test("should provide helpful error for invalid field format", () => {
185
+ try {
186
+ parseFieldsFlag("name-string");
187
+ expect(false).toBe(true); // Should not reach here
188
+ } catch (error) {
189
+ expect(error).toBeInstanceOf(Error);
190
+ expect((error as Error).message).toContain("Invalid field format");
191
+ expect((error as Error).message).toContain("name-string");
192
+ expect((error as Error).message).toContain("Expected format: fieldName:fieldType");
193
+ }
194
+ });
195
+
196
+ test("should provide helpful error for invalid type", () => {
197
+ try {
198
+ parseFieldsFlag("name:text");
199
+ expect(false).toBe(true); // Should not reach here
200
+ } catch (error) {
201
+ expect(error).toBeInstanceOf(Error);
202
+ expect((error as Error).message).toContain("Invalid field type");
203
+ expect((error as Error).message).toContain("text");
204
+ expect((error as Error).message).toContain("Valid types:");
205
+ }
206
+ });
207
+ });
208
+
209
+ describe("CLI - Edge Cases", () => {
210
+ test("should handle empty string gracefully", () => {
211
+ const result = parseFieldsFlag("");
212
+ expect(Object.keys(result).length).toBe(0);
213
+ });
214
+
215
+ test("should skip empty segments", () => {
216
+ const result = parseFieldsFlag("name:string,,email:email");
217
+ expect(Object.keys(result).length).toBe(2);
218
+ });
219
+
220
+ test("should handle very long field names", () => {
221
+ const longName = "a".repeat(50);
222
+ const result = parseFieldsFlag(`${longName}:string`);
223
+ expect(result[longName]).toBeDefined();
224
+ });
225
+
226
+ test("should handle camelCase and snake_case", () => {
227
+ const result = parseFieldsFlag("firstName:string,last_name:string");
228
+ expect(result.firstName).toBeDefined();
229
+ expect(result.last_name).toBeDefined();
230
+ });
231
+ });
@@ -1,12 +1,62 @@
1
- import { loadManifest, generateManifest, generateRoutes, buildGenerateReport, printReportSummary, writeReport } from "@mandujs/core";
1
+ import {
2
+ loadManifest,
3
+ generateManifest,
4
+ generateRoutes,
5
+ buildGenerateReport,
6
+ printReportSummary,
7
+ writeReport,
8
+ parseResourceSchemas,
9
+ generateResourcesArtifacts,
10
+ logGeneratorResult,
11
+ } from "@mandujs/core";
2
12
  import { resolveFromCwd, getRootDir } from "../util/fs";
13
+ import path from "path";
14
+ import fs from "fs/promises";
3
15
 
4
- export async function generateApply(): Promise<boolean> {
16
+ /**
17
+ * Discover resource schema files in spec/resources/
18
+ */
19
+ async function discoverResourceSchemas(rootDir: string): Promise<string[]> {
20
+ const resourcesDir = path.join(rootDir, "spec/resources");
21
+
22
+ try {
23
+ await fs.access(resourcesDir);
24
+ } catch {
25
+ // spec/resources doesn't exist, no resources to discover
26
+ return [];
27
+ }
28
+
29
+ const schemaPaths: string[] = [];
30
+
31
+ async function scanDir(dir: string): Promise<void> {
32
+ const entries = await fs.readdir(dir, { withFileTypes: true });
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(dir, entry.name);
36
+
37
+ if (entry.isDirectory()) {
38
+ // Recursively scan subdirectories
39
+ await scanDir(fullPath);
40
+ } else if (entry.isFile() && entry.name.endsWith(".resource.ts")) {
41
+ schemaPaths.push(fullPath);
42
+ }
43
+ }
44
+ }
45
+
46
+ await scanDir(resourcesDir);
47
+ return schemaPaths;
48
+ }
49
+
50
+ export async function generateApply(options?: { force?: boolean }): Promise<boolean> {
5
51
  const rootDir = getRootDir();
6
52
  const manifestPath = resolveFromCwd(".mandu/routes.manifest.json");
7
53
 
8
54
  console.log(`🥟 Mandu Generate`);
9
- console.log(`📄 FS Routes 기반 코드 생성\n`);
55
+ console.log(`📄 FS Routes + Resources 코드 생성\n`);
56
+
57
+ // ============================================
58
+ // 1. Generate FS Routes artifacts
59
+ // ============================================
10
60
 
11
61
  // Regenerate manifest from FS Routes
12
62
  const fsResult = await generateManifest(rootDir);
@@ -20,7 +70,7 @@ export async function generateApply(): Promise<boolean> {
20
70
  return false;
21
71
  }
22
72
 
23
- console.log(`🔄 코드 생성 중...\n`);
73
+ console.log(`🔄 FS Routes 코드 생성 중...\n`);
24
74
 
25
75
  const generateResult = await generateRoutes(result.data, rootDir);
26
76
 
@@ -32,10 +82,60 @@ export async function generateApply(): Promise<boolean> {
32
82
  console.log(`📋 Report 저장: ${reportPath}`);
33
83
 
34
84
  if (!generateResult.success) {
35
- console.log(`\n❌ generate 실패`);
85
+ console.log(`\n❌ FS Routes generate 실패`);
36
86
  return false;
37
87
  }
38
88
 
89
+ console.log(`\n✅ FS Routes generate 완료`);
90
+
91
+ // ============================================
92
+ // 2. Generate Resource artifacts
93
+ // ============================================
94
+
95
+ console.log(`\n🔍 리소스 스키마 검색 중...\n`);
96
+
97
+ const schemaPaths = await discoverResourceSchemas(rootDir);
98
+
99
+ if (schemaPaths.length === 0) {
100
+ console.log(`📋 리소스 스키마 없음 (spec/resources/*.resource.ts)`);
101
+ console.log(`💡 리소스 생성: bunx mandu generate resource`);
102
+ } else {
103
+ console.log(`📋 ${schemaPaths.length}개 리소스 스키마 발견`);
104
+ schemaPaths.forEach((p) =>
105
+ console.log(` - ${path.relative(rootDir, p)}`)
106
+ );
107
+
108
+ try {
109
+ console.log(`\n🔄 리소스 아티팩트 생성 중...\n`);
110
+
111
+ const resources = await parseResourceSchemas(schemaPaths);
112
+ const resourceResult = await generateResourcesArtifacts(resources, {
113
+ rootDir,
114
+ force: options?.force ?? false,
115
+ });
116
+
117
+ logGeneratorResult(resourceResult);
118
+
119
+ if (!resourceResult.success) {
120
+ console.log(`\n❌ 리소스 generate 실패`);
121
+ return false;
122
+ }
123
+
124
+ console.log(`\n✅ 리소스 generate 완료`);
125
+ } catch (error) {
126
+ console.error(
127
+ `\n❌ 리소스 generate 오류: ${
128
+ error instanceof Error ? error.message : String(error)
129
+ }`
130
+ );
131
+ return false;
132
+ }
133
+ }
134
+
135
+ // ============================================
136
+ // Final Summary
137
+ // ============================================
138
+
39
139
  console.log(`\n✅ generate 완료`);
40
140
  console.log(`💡 다음 단계: bunx mandu guard`);
41
141
 
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Mandu CLI - Generate Resource Command
3
+ * Interactive and flag-based resource creation
4
+ */
5
+
6
+ import {
7
+ defineResource,
8
+ type ResourceDefinition,
9
+ type ResourceField,
10
+ type FieldType,
11
+ FieldTypes,
12
+ generateResourceArtifacts,
13
+ parseResourceSchema,
14
+ logGeneratorResult,
15
+ } from "@mandujs/core";
16
+ import path from "path";
17
+ import fs from "fs/promises";
18
+ import { createInterface } from "readline/promises";
19
+
20
+ // ============================================
21
+ // Types
22
+ // ============================================
23
+
24
+ export interface GenerateResourceOptions {
25
+ name?: string;
26
+ fields?: string;
27
+ timestamps?: boolean;
28
+ methods?: string;
29
+ force?: boolean;
30
+ }
31
+
32
+ interface InteractiveAnswers {
33
+ name: string;
34
+ fields: Record<string, ResourceField>;
35
+ timestamps: boolean;
36
+ endpoints: string[];
37
+ }
38
+
39
+ // ============================================
40
+ // Field Parsing
41
+ // ============================================
42
+
43
+ /**
44
+ * Parse fields flag string to ResourceField objects
45
+ *
46
+ * @example
47
+ * "name:string,email:email,age:number" → { name: { type: "string" }, ... }
48
+ */
49
+ export function parseFieldsFlag(input: string): Record<string, ResourceField> {
50
+ const fields: Record<string, ResourceField> = {};
51
+
52
+ for (const fieldStr of input.split(",")) {
53
+ const trimmed = fieldStr.trim();
54
+ if (!trimmed) continue;
55
+
56
+ // Parse: "name:string?" or "email:email!"
57
+ const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*):([a-z]+)([?!])?$/);
58
+
59
+ if (!match) {
60
+ throw new Error(
61
+ `Invalid field format: "${fieldStr}". Expected format: fieldName:fieldType (e.g., name:string)`
62
+ );
63
+ }
64
+
65
+ const [, name, type, modifier] = match;
66
+
67
+ // Validate type
68
+ if (!FieldTypes.includes(type as FieldType)) {
69
+ throw new Error(
70
+ `Invalid field type: "${type}". Valid types: ${FieldTypes.join(", ")}`
71
+ );
72
+ }
73
+
74
+ fields[name] = {
75
+ type: type as FieldType,
76
+ required: modifier !== "?",
77
+ description: undefined,
78
+ };
79
+ }
80
+
81
+ return fields;
82
+ }
83
+
84
+ /**
85
+ * Parse methods flag to endpoints configuration
86
+ *
87
+ * @example
88
+ * "GET,POST,PUT,DELETE" → { list: true, get: true, create: true, update: true, delete: true }
89
+ */
90
+ export function parseMethodsFlag(input: string): Record<string, boolean> {
91
+ const methods = input.split(",").map((m) => m.trim().toUpperCase());
92
+ const endpoints: Record<string, boolean> = {
93
+ list: false,
94
+ get: false,
95
+ create: false,
96
+ update: false,
97
+ delete: false,
98
+ };
99
+
100
+ for (const method of methods) {
101
+ switch (method) {
102
+ case "GET":
103
+ endpoints.list = true;
104
+ endpoints.get = true;
105
+ break;
106
+ case "POST":
107
+ endpoints.create = true;
108
+ break;
109
+ case "PUT":
110
+ case "PATCH":
111
+ endpoints.update = true;
112
+ break;
113
+ case "DELETE":
114
+ endpoints.delete = true;
115
+ break;
116
+ default:
117
+ throw new Error(
118
+ `Invalid HTTP method: "${method}". Valid: GET, POST, PUT, PATCH, DELETE`
119
+ );
120
+ }
121
+ }
122
+
123
+ return endpoints;
124
+ }
125
+
126
+ // ============================================
127
+ // Interactive Mode
128
+ // ============================================
129
+
130
+ /**
131
+ * Run interactive prompts to gather resource information
132
+ */
133
+ async function runInteractiveMode(): Promise<InteractiveAnswers> {
134
+ const rl = createInterface({
135
+ input: process.stdin,
136
+ output: process.stdout,
137
+ });
138
+
139
+ console.log("\n🥟 Create a new resource\n");
140
+
141
+ // Resource name
142
+ const name = await rl.question("Resource name (singular, e.g., 'user'): ");
143
+ if (!name || !/^[a-z][a-z0-9_]*$/i.test(name)) {
144
+ rl.close();
145
+ throw new Error(
146
+ "Invalid resource name. Must start with a letter and contain only letters, numbers, and underscores."
147
+ );
148
+ }
149
+
150
+ // Fields
151
+ console.log("\nAdd fields (press Enter with empty input to finish):");
152
+ const fields: Record<string, ResourceField> = {};
153
+ let fieldIndex = 0;
154
+
155
+ while (true) {
156
+ fieldIndex++;
157
+ const fieldInput = await rl.question(
158
+ ` Field ${fieldIndex} (format: name:type, or empty to finish): `
159
+ );
160
+
161
+ if (!fieldInput.trim()) {
162
+ if (fieldIndex === 1) {
163
+ rl.close();
164
+ throw new Error("Resource must have at least one field");
165
+ }
166
+ break;
167
+ }
168
+
169
+ try {
170
+ const parsed = parseFieldsFlag(fieldInput);
171
+ Object.assign(fields, parsed);
172
+ } catch (error) {
173
+ console.error(
174
+ ` ❌ ${error instanceof Error ? error.message : String(error)}`
175
+ );
176
+ fieldIndex--; // Retry this field
177
+ }
178
+ }
179
+
180
+ // Timestamps
181
+ const timestampsAnswer = await rl.question(
182
+ "\nAdd timestamp fields (createdAt, updatedAt)? (y/N): "
183
+ );
184
+ const timestamps = timestampsAnswer.toLowerCase() === "y";
185
+
186
+ // Endpoints
187
+ console.log("\nSelect endpoints to generate:");
188
+ const listAnswer = await rl.question(" - List all (GET /resources)? (Y/n): ");
189
+ const getAnswer = await rl.question(" - Get one (GET /resources/:id)? (Y/n): ");
190
+ const createAnswer = await rl.question(" - Create (POST /resources)? (Y/n): ");
191
+ const updateAnswer = await rl.question(
192
+ " - Update (PUT /resources/:id)? (Y/n): "
193
+ );
194
+ const deleteAnswer = await rl.question(
195
+ " - Delete (DELETE /resources/:id)? (Y/n): "
196
+ );
197
+
198
+ const endpoints: string[] = [];
199
+ if (listAnswer.toLowerCase() !== "n") endpoints.push("list");
200
+ if (getAnswer.toLowerCase() !== "n") endpoints.push("get");
201
+ if (createAnswer.toLowerCase() !== "n") endpoints.push("create");
202
+ if (updateAnswer.toLowerCase() !== "n") endpoints.push("update");
203
+ if (deleteAnswer.toLowerCase() !== "n") endpoints.push("delete");
204
+
205
+ rl.close();
206
+
207
+ return { name, fields, timestamps, endpoints };
208
+ }
209
+
210
+ // ============================================
211
+ // Schema File Generation
212
+ // ============================================
213
+
214
+ /**
215
+ * Format resource definition as TypeScript code
216
+ */
217
+ export function formatSchemaFile(definition: ResourceDefinition): string {
218
+ const { name, fields, options } = definition;
219
+
220
+ const fieldsCode = Object.entries(fields)
221
+ .map(([fieldName, field]) => {
222
+ const parts: string[] = [`type: "${field.type}"`];
223
+ if (field.required !== undefined) parts.push(`required: ${field.required}`);
224
+ if (field.description) parts.push(`description: "${field.description}"`);
225
+ return ` ${fieldName}: { ${parts.join(", ")} },`;
226
+ })
227
+ .join("\n");
228
+
229
+ const endpointsCode = options?.endpoints
230
+ ? Object.entries(options.endpoints)
231
+ .map(([endpoint, enabled]) => ` ${endpoint}: ${enabled},`)
232
+ .join("\n")
233
+ : "";
234
+
235
+ return `import { defineResource } from "@mandujs/core";
236
+
237
+ /**
238
+ * ${name.charAt(0).toUpperCase() + name.slice(1)} Resource
239
+ * Auto-generated by Mandu CLI
240
+ */
241
+ export const ${name.charAt(0).toUpperCase() + name.slice(1)}Resource = defineResource({
242
+ name: "${name}",
243
+ fields: {
244
+ ${fieldsCode}
245
+ },
246
+ options: {
247
+ description: "${name.charAt(0).toUpperCase() + name.slice(1)} management API",
248
+ tags: ["${name}"],
249
+ endpoints: {
250
+ ${endpointsCode}
251
+ },
252
+ },
253
+ });
254
+ `;
255
+ }
256
+
257
+ /**
258
+ * Create schema file in spec/resources/{name}/schema.ts
259
+ */
260
+ async function createSchemaFile(
261
+ rootDir: string,
262
+ definition: ResourceDefinition
263
+ ): Promise<string> {
264
+ const resourceDir = path.join(rootDir, "spec/resources", definition.name);
265
+ const schemaPath = path.join(resourceDir, "schema.ts");
266
+
267
+ // Check if already exists
268
+ try {
269
+ await fs.access(schemaPath);
270
+ throw new Error(
271
+ `Resource schema already exists: ${path.relative(rootDir, schemaPath)}\nUse --force to overwrite.`
272
+ );
273
+ } catch (error) {
274
+ if (
275
+ error instanceof Error &&
276
+ error.message.includes("Resource schema already exists")
277
+ ) {
278
+ throw error;
279
+ }
280
+ // File doesn't exist, we can create it
281
+ }
282
+
283
+ // Create directory
284
+ await fs.mkdir(resourceDir, { recursive: true });
285
+
286
+ // Write schema file
287
+ const content = formatSchemaFile(definition);
288
+ await Bun.write(schemaPath, content);
289
+
290
+ return schemaPath;
291
+ }
292
+
293
+ // ============================================
294
+ // Main Command
295
+ // ============================================
296
+
297
+ /**
298
+ * Generate resource command
299
+ */
300
+ export async function generateResource(
301
+ options: GenerateResourceOptions
302
+ ): Promise<boolean> {
303
+ const rootDir = process.cwd();
304
+
305
+ try {
306
+ let definition: ResourceDefinition;
307
+
308
+ // Interactive mode or flag-based mode
309
+ if (!options.name || !options.fields) {
310
+ // Interactive mode
311
+ const answers = await runInteractiveMode();
312
+
313
+ // Add timestamps if requested
314
+ if (answers.timestamps) {
315
+ answers.fields.createdAt = { type: "date", required: true };
316
+ answers.fields.updatedAt = { type: "date", required: true };
317
+ }
318
+
319
+ // Build endpoints config
320
+ const endpoints: Record<string, boolean> = {
321
+ list: answers.endpoints.includes("list"),
322
+ get: answers.endpoints.includes("get"),
323
+ create: answers.endpoints.includes("create"),
324
+ update: answers.endpoints.includes("update"),
325
+ delete: answers.endpoints.includes("delete"),
326
+ };
327
+
328
+ definition = defineResource({
329
+ name: answers.name,
330
+ fields: answers.fields,
331
+ options: { endpoints },
332
+ });
333
+ } else {
334
+ // Flag-based mode
335
+ const fields = parseFieldsFlag(options.fields);
336
+
337
+ // Add timestamps if requested
338
+ if (options.timestamps) {
339
+ fields.createdAt = { type: "date", required: true };
340
+ fields.updatedAt = { type: "date", required: true };
341
+ }
342
+
343
+ // Parse methods if provided
344
+ const endpoints = options.methods
345
+ ? parseMethodsFlag(options.methods)
346
+ : {
347
+ list: true,
348
+ get: true,
349
+ create: true,
350
+ update: true,
351
+ delete: true,
352
+ };
353
+
354
+ definition = defineResource({
355
+ name: options.name,
356
+ fields,
357
+ options: { endpoints },
358
+ });
359
+ }
360
+
361
+ console.log(`\n🥟 Generating resource: ${definition.name}\n`);
362
+
363
+ // 1. Create schema file
364
+ const schemaPath = await createSchemaFile(rootDir, definition);
365
+ console.log(`✅ Created schema: ${path.relative(rootDir, schemaPath)}`);
366
+
367
+ // 2. Generate artifacts
368
+ const parsed = await parseResourceSchema(schemaPath);
369
+ const result = await generateResourceArtifacts(parsed, {
370
+ rootDir,
371
+ force: options.force ?? false,
372
+ });
373
+
374
+ // 3. Log results
375
+ logGeneratorResult(result);
376
+
377
+ if (!result.success) {
378
+ console.log("\n❌ Resource generation failed");
379
+ return false;
380
+ }
381
+
382
+ // 4. Success guidance
383
+ console.log("\n✅ Resource generated successfully!");
384
+ console.log("\n💡 Next steps:");
385
+ console.log(` 1. Edit slot implementation: spec/slots/${definition.name}.slot.ts`);
386
+ console.log(` 2. Run \`mandu dev\` to start development server`);
387
+ console.log(` 3. Test API endpoints with your resource\n`);
388
+
389
+ return true;
390
+ } catch (error) {
391
+ console.error(
392
+ `\n❌ Error: ${error instanceof Error ? error.message : String(error)}\n`
393
+ );
394
+ return false;
395
+ }
396
+ }
@@ -400,9 +400,31 @@ registerCommand({
400
400
 
401
401
  registerCommand({
402
402
  id: "generate",
403
- description: "FS Routes 기반 코드 생성",
404
- async run() {
403
+ description: "코드 생성 (FS Routes + Resources)",
404
+ subcommands: ["resource"],
405
+ async run(ctx) {
406
+ const subCommand = ctx.args[1];
407
+
408
+ if (subCommand === "resource") {
409
+ // generate resource subcommand
410
+ const { generateResource } = await import("./generate-resource");
411
+ return generateResource({
412
+ name: ctx.args[2] || ctx.options._positional,
413
+ fields: ctx.options.fields,
414
+ timestamps: ctx.options.timestamps === "true",
415
+ methods: ctx.options.methods,
416
+ force: ctx.options.force === "true",
417
+ });
418
+ }
419
+
420
+ // Default: generate all (FS Routes + Resources)
421
+ if (subCommand && !subCommand.startsWith("--")) {
422
+ return false; // Unknown subcommand
423
+ }
424
+
405
425
  const { generateApply } = await import("./generate-apply");
406
- return generateApply();
426
+ return generateApply({
427
+ force: ctx.options.force === "true",
428
+ });
407
429
  },
408
430
  });
package/src/main.ts CHANGED
@@ -20,19 +20,20 @@ ${theme.heading("🥟 Mandu CLI")} ${theme.muted(`v${VERSION}`)} - Agent-Native
20
20
  ${theme.heading("Usage:")} ${theme.command("bunx mandu")} ${theme.option("<command>")} [options]
21
21
 
22
22
  Commands:
23
- init 새 프로젝트 생성 (Tailwind + shadcn/ui 기본 포함)
24
- check FS Routes + Guard 통합 검사
25
- routes generate FS Routes 스캔 및 매니페스트 생성
26
- routes list 현재 라우트 목록 출력
27
- routes watch 실시간 라우트 감시
28
- dev 개발 서버 실행 (FS Routes + Guard 기본)
29
- build 클라이언트 번들 빌드 (Hydration)
30
- start 프로덕션 서버 실행 (build 후)
31
- guard 아키텍처 위반 검사 (기본)
32
- guard arch 아키텍처 위반 검사 (FSD/Clean/Hexagonal)
33
- guard legacy 레거시 Spec Guard 검사
34
- spec-upsert Spec 파일 검증 lock 갱신 (레거시)
35
- generate Spec에서 코드 생성 (레거시)
23
+ init 새 프로젝트 생성 (Tailwind + shadcn/ui 기본 포함)
24
+ check FS Routes + Guard 통합 검사
25
+ routes generate FS Routes 스캔 및 매니페스트 생성
26
+ routes list 현재 라우트 목록 출력
27
+ routes watch 실시간 라우트 감시
28
+ dev 개발 서버 실행 (FS Routes + Guard 기본)
29
+ build 클라이언트 번들 빌드 (Hydration)
30
+ start 프로덕션 서버 실행 (build 후)
31
+ guard 아키텍처 위반 검사 (기본)
32
+ guard arch 아키텍처 위반 검사 (FSD/Clean/Hexagonal)
33
+ guard legacy 레거시 Spec Guard 검사
34
+ generate FS Routes + Resources 코드 생성
35
+ generate resource 리소스 생성 (Interactive 또는 Flag 기반)
36
+ spec-upsert Spec 파일 검증 및 lock 갱신 (레거시)
36
37
 
37
38
  doctor Guard 실패 분석 + 패치 제안 (Brain)
38
39
  watch 실시간 파일 감시 - 경고만 (Brain)
@@ -101,6 +102,10 @@ Options:
101
102
  --model <name> brain setup 시 모델 이름 (기본: llama3.2)
102
103
  --url <url> brain setup 시 Ollama URL
103
104
  --skip-check brain setup 시 모델/서버 체크 건너뜀
105
+ --fields <fields> generate resource 시 필드 정의 (예: name:string,email:email)
106
+ --timestamps generate resource 시 createdAt/updatedAt 자동 추가
107
+ --methods <methods> generate resource 시 HTTP 메서드 (예: GET,POST,PUT,DELETE)
108
+ --force generate/generate resource 시 기존 슬롯 덮어쓰기
104
109
  --help, -h 도움말 표시
105
110
 
106
111
  Notes:
@@ -135,10 +140,18 @@ Examples:
135
140
  bunx mandu lock # Lockfile 생성/갱신
136
141
  bunx mandu lock --verify # 설정 무결성 검증
137
142
  bunx mandu lock --diff --show-secrets # 변경사항 상세 비교
143
+ bunx mandu generate resource # Interactive 리소스 생성
144
+ bunx mandu generate resource user --fields name:string,email:email --timestamps
145
+ bunx mandu generate resource product --fields name:string,price:number --methods GET,POST,PUT
146
+ bunx mandu generate # FS Routes + Resources 코드 생성
147
+ bunx mandu generate --force # 기존 슬롯 덮어쓰기
138
148
 
139
149
  FS Routes Workflow (권장):
140
150
  1. init → 2. app/ 폴더에 page.tsx 생성 → 3. dev → 4. build → 5. start
141
151
 
152
+ Resource-Centric Workflow (새로운 방식):
153
+ 1. init → 2. generate resource → 3. Edit slot → 4. generate → 5. dev
154
+
142
155
  Legacy Workflow:
143
156
  1. init → 2. spec-upsert → 3. generate → 4. build → 5. guard → 6. dev
144
157