@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.
|
|
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": "
|
|
36
|
-
"@mandujs/ate": "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 {
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -400,9 +400,31 @@ registerCommand({
|
|
|
400
400
|
|
|
401
401
|
registerCommand({
|
|
402
402
|
id: "generate",
|
|
403
|
-
description: "FS Routes
|
|
404
|
-
|
|
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
|
|
24
|
-
check
|
|
25
|
-
routes generate
|
|
26
|
-
routes list
|
|
27
|
-
routes watch
|
|
28
|
-
dev
|
|
29
|
-
build
|
|
30
|
-
start
|
|
31
|
-
guard
|
|
32
|
-
guard arch
|
|
33
|
-
guard legacy
|
|
34
|
-
|
|
35
|
-
generate
|
|
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
|
|