@mandujs/cli 0.9.21 → 0.9.23
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/README.ko.md +57 -4
- package/README.md +62 -15
- package/package.json +2 -2
- package/src/commands/check.ts +41 -5
- package/src/commands/contract.ts +135 -9
- package/src/commands/dev.ts +155 -95
- package/src/commands/guard-arch.ts +39 -9
- package/src/commands/guard-check.ts +3 -3
- package/src/commands/init.ts +264 -9
- package/src/commands/monitor.ts +301 -0
- package/src/main.ts +421 -361
- package/templates/default/app/globals.css +37 -0
- package/templates/default/app/layout.tsx +27 -0
- package/templates/default/app/page.tsx +27 -49
- package/templates/default/package.json +20 -11
- package/templates/default/postcss.config.js +6 -0
- package/templates/default/src/client/app/index.ts +1 -0
- package/templates/default/src/client/entities/index.ts +1 -0
- package/templates/default/src/client/features/index.ts +1 -0
- package/templates/default/src/client/pages/index.ts +1 -0
- package/templates/default/src/client/shared/index.ts +1 -0
- package/templates/default/src/client/shared/lib/utils.ts +16 -0
- package/templates/default/src/client/shared/ui/button.tsx +57 -0
- package/templates/default/src/client/shared/ui/card.tsx +78 -0
- package/templates/default/src/client/shared/ui/index.ts +21 -0
- package/templates/default/src/client/shared/ui/input.tsx +24 -0
- package/templates/default/src/client/widgets/index.ts +1 -0
- package/templates/default/src/server/api/index.ts +1 -0
- package/templates/default/src/server/application/index.ts +1 -0
- package/templates/default/src/server/core/index.ts +1 -0
- package/templates/default/src/server/domain/index.ts +1 -0
- package/templates/default/src/server/infra/index.ts +1 -0
- package/templates/default/src/shared/contracts/index.ts +1 -0
- package/templates/default/src/shared/env/index.ts +1 -0
- package/templates/default/src/shared/schema/index.ts +1 -0
- package/templates/default/src/shared/types/index.ts +1 -0
- package/templates/default/src/shared/utils/client/index.ts +1 -0
- package/templates/default/src/shared/utils/server/index.ts +1 -0
- package/templates/default/tailwind.config.ts +64 -0
- package/templates/default/tsconfig.json +14 -3
package/src/commands/init.ts
CHANGED
|
@@ -1,30 +1,137 @@
|
|
|
1
1
|
import path from "path";
|
|
2
2
|
import fs from "fs/promises";
|
|
3
3
|
|
|
4
|
+
export type CSSFramework = "tailwind" | "panda" | "none";
|
|
5
|
+
export type UILibrary = "shadcn" | "ark" | "none";
|
|
6
|
+
|
|
4
7
|
export interface InitOptions {
|
|
5
8
|
name?: string;
|
|
6
9
|
template?: string;
|
|
10
|
+
css?: CSSFramework;
|
|
11
|
+
ui?: UILibrary;
|
|
12
|
+
theme?: boolean;
|
|
13
|
+
minimal?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Files to skip based on CSS/UI options
|
|
17
|
+
const CSS_FILES = [
|
|
18
|
+
"tailwind.config.ts",
|
|
19
|
+
"postcss.config.js",
|
|
20
|
+
"app/globals.css",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const UI_FILES = [
|
|
24
|
+
"src/client/shared/ui/button.tsx",
|
|
25
|
+
"src/client/shared/ui/card.tsx",
|
|
26
|
+
"src/client/shared/ui/input.tsx",
|
|
27
|
+
"src/client/shared/ui/index.ts",
|
|
28
|
+
"src/client/shared/lib/utils.ts",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
interface CopyOptions {
|
|
32
|
+
projectName: string;
|
|
33
|
+
css: CSSFramework;
|
|
34
|
+
ui: UILibrary;
|
|
35
|
+
theme: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shouldSkipFile(relativePath: string, options: CopyOptions): boolean {
|
|
39
|
+
const normalizedPath = relativePath.replace(/\\/g, "/");
|
|
40
|
+
|
|
41
|
+
// Skip CSS files if css is none
|
|
42
|
+
if (options.css === "none") {
|
|
43
|
+
if (CSS_FILES.some((f) => normalizedPath.endsWith(f))) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip UI files if ui is none
|
|
49
|
+
if (options.ui === "none") {
|
|
50
|
+
if (UI_FILES.some((f) => normalizedPath.endsWith(f))) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// Skip UI/shared directories
|
|
54
|
+
if (normalizedPath.includes("src/client/shared/ui/")) return true;
|
|
55
|
+
if (normalizedPath.includes("src/client/shared/lib/")) return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return false;
|
|
7
59
|
}
|
|
8
60
|
|
|
9
|
-
async function copyDir(
|
|
61
|
+
async function copyDir(
|
|
62
|
+
src: string,
|
|
63
|
+
dest: string,
|
|
64
|
+
options: CopyOptions,
|
|
65
|
+
relativePath = ""
|
|
66
|
+
): Promise<void> {
|
|
10
67
|
await fs.mkdir(dest, { recursive: true });
|
|
11
68
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
12
69
|
|
|
13
70
|
for (const entry of entries) {
|
|
14
71
|
const srcPath = path.join(src, entry.name);
|
|
15
72
|
const destPath = path.join(dest, entry.name);
|
|
73
|
+
const currentRelativePath = relativePath
|
|
74
|
+
? `${relativePath}/${entry.name}`
|
|
75
|
+
: entry.name;
|
|
16
76
|
|
|
17
77
|
if (entry.isDirectory()) {
|
|
18
|
-
|
|
19
|
-
|
|
78
|
+
// Skip directories that would be empty when ui=none
|
|
79
|
+
if (options.ui === "none") {
|
|
80
|
+
if (entry.name === "ui" && relativePath === "src/client/shared") continue;
|
|
81
|
+
if (entry.name === "lib" && relativePath === "src/client/shared") continue;
|
|
82
|
+
}
|
|
83
|
+
await copyDir(srcPath, destPath, options, currentRelativePath);
|
|
84
|
+
} else {
|
|
85
|
+
// Check if file should be skipped
|
|
86
|
+
if (shouldSkipFile(currentRelativePath, options)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
20
90
|
let content = await fs.readFile(srcPath, "utf-8");
|
|
21
91
|
// Replace template variables
|
|
22
|
-
content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
|
|
92
|
+
content = content.replace(/\{\{PROJECT_NAME\}\}/g, options.projectName);
|
|
93
|
+
|
|
94
|
+
// Add dark mode CSS variables if theme is enabled
|
|
95
|
+
if (options.theme && currentRelativePath === "app/globals.css") {
|
|
96
|
+
content = addDarkModeCSS(content);
|
|
97
|
+
}
|
|
98
|
+
|
|
23
99
|
await fs.writeFile(destPath, content);
|
|
24
100
|
}
|
|
25
101
|
}
|
|
26
102
|
}
|
|
27
103
|
|
|
104
|
+
function addDarkModeCSS(content: string): string {
|
|
105
|
+
const darkModeCSS = `
|
|
106
|
+
.dark {
|
|
107
|
+
--background: 222.2 84% 4.9%;
|
|
108
|
+
--foreground: 210 40% 98%;
|
|
109
|
+
--card: 222.2 84% 4.9%;
|
|
110
|
+
--card-foreground: 210 40% 98%;
|
|
111
|
+
--popover: 222.2 84% 4.9%;
|
|
112
|
+
--popover-foreground: 210 40% 98%;
|
|
113
|
+
--primary: 210 40% 98%;
|
|
114
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
115
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
116
|
+
--secondary-foreground: 210 40% 98%;
|
|
117
|
+
--muted: 217.2 32.6% 17.5%;
|
|
118
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
119
|
+
--accent: 217.2 32.6% 17.5%;
|
|
120
|
+
--accent-foreground: 210 40% 98%;
|
|
121
|
+
--destructive: 0 62.8% 30.6%;
|
|
122
|
+
--destructive-foreground: 210 40% 98%;
|
|
123
|
+
--border: 217.2 32.6% 17.5%;
|
|
124
|
+
--input: 217.2 32.6% 17.5%;
|
|
125
|
+
--ring: 212.7 26.8% 83.9%;
|
|
126
|
+
}`;
|
|
127
|
+
|
|
128
|
+
// Insert dark mode after :root block
|
|
129
|
+
return content.replace(
|
|
130
|
+
/(:root\s*\{[^}]+\})/,
|
|
131
|
+
`$1\n${darkModeCSS}`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
28
135
|
function getTemplatesDir(): string {
|
|
29
136
|
// When installed via npm, templates are in the CLI package
|
|
30
137
|
const commandsDir = import.meta.dir;
|
|
@@ -37,9 +144,20 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
|
|
|
37
144
|
const template = options.template || "default";
|
|
38
145
|
const targetDir = path.resolve(process.cwd(), projectName);
|
|
39
146
|
|
|
147
|
+
// Handle minimal flag (shortcut for --css none --ui none)
|
|
148
|
+
const css: CSSFramework = options.minimal ? "none" : (options.css || "tailwind");
|
|
149
|
+
const ui: UILibrary = options.minimal ? "none" : (options.ui || "shadcn");
|
|
150
|
+
const theme = options.theme || false;
|
|
151
|
+
|
|
40
152
|
console.log(`🥟 Mandu Init`);
|
|
41
153
|
console.log(`📁 프로젝트: ${projectName}`);
|
|
42
|
-
console.log(`📦 템플릿: ${template}
|
|
154
|
+
console.log(`📦 템플릿: ${template}`);
|
|
155
|
+
console.log(`🎨 CSS: ${css}${css !== "none" ? " (Tailwind CSS)" : ""}`);
|
|
156
|
+
console.log(`🧩 UI: ${ui}${ui !== "none" ? " (shadcn/ui)" : ""}`);
|
|
157
|
+
if (theme) {
|
|
158
|
+
console.log(`🌙 테마: Dark mode 지원`);
|
|
159
|
+
}
|
|
160
|
+
console.log();
|
|
43
161
|
|
|
44
162
|
// Check if target directory exists
|
|
45
163
|
try {
|
|
@@ -64,8 +182,15 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
|
|
|
64
182
|
|
|
65
183
|
console.log(`📋 템플릿 복사 중...`);
|
|
66
184
|
|
|
185
|
+
const copyOptions: CopyOptions = {
|
|
186
|
+
projectName,
|
|
187
|
+
css,
|
|
188
|
+
ui,
|
|
189
|
+
theme,
|
|
190
|
+
};
|
|
191
|
+
|
|
67
192
|
try {
|
|
68
|
-
await copyDir(templateDir, targetDir,
|
|
193
|
+
await copyDir(templateDir, targetDir, copyOptions);
|
|
69
194
|
} catch (error) {
|
|
70
195
|
console.error(`❌ 템플릿 복사 실패:`, error);
|
|
71
196
|
return false;
|
|
@@ -74,15 +199,145 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
|
|
|
74
199
|
// Create .mandu directory for build output
|
|
75
200
|
await fs.mkdir(path.join(targetDir, ".mandu/client"), { recursive: true });
|
|
76
201
|
|
|
202
|
+
// Create minimal layout.tsx if css=none (without globals.css import)
|
|
203
|
+
if (css === "none") {
|
|
204
|
+
await createMinimalLayout(targetDir, projectName);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Create minimal page.tsx if ui=none (without UI components)
|
|
208
|
+
if (ui === "none") {
|
|
209
|
+
await createMinimalPage(targetDir);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Update package.json to remove unused dependencies
|
|
213
|
+
if (css === "none" || ui === "none") {
|
|
214
|
+
await updatePackageJson(targetDir, css, ui);
|
|
215
|
+
}
|
|
216
|
+
|
|
77
217
|
console.log(`\n✅ 프로젝트 생성 완료!\n`);
|
|
78
218
|
console.log(`📍 위치: ${targetDir}`);
|
|
79
219
|
console.log(`\n🚀 시작하기:`);
|
|
80
220
|
console.log(` cd ${projectName}`);
|
|
81
221
|
console.log(` bun install`);
|
|
82
222
|
console.log(` bun run dev`);
|
|
83
|
-
console.log(`\n📂 파일 구조:`);
|
|
84
|
-
console.log(` app/
|
|
85
|
-
console.log(` app/
|
|
223
|
+
console.log(`\n📂 파일 구조:`);
|
|
224
|
+
console.log(` app/layout.tsx → 루트 레이아웃`);
|
|
225
|
+
console.log(` app/page.tsx → http://localhost:3000/`);
|
|
226
|
+
console.log(` app/api/*/route.ts → API endpoints`);
|
|
227
|
+
console.log(` src/client/* → 클라이언트 레이어`);
|
|
228
|
+
console.log(` src/server/* → 서버 레이어`);
|
|
229
|
+
console.log(` src/shared/contracts → 계약 (client-safe)`);
|
|
230
|
+
console.log(` src/shared/types → 공용 타입`);
|
|
231
|
+
console.log(` src/shared/utils/client → 클라이언트 safe 유틸`);
|
|
232
|
+
console.log(` src/shared/utils/server → 서버 전용 유틸`);
|
|
233
|
+
console.log(` src/shared/schema → 서버 전용 스키마`);
|
|
234
|
+
console.log(` src/shared/env → 서버 전용 환경`);
|
|
235
|
+
if (css !== "none") {
|
|
236
|
+
console.log(` app/globals.css → 전역 CSS (Tailwind)`);
|
|
237
|
+
console.log(` tailwind.config.ts → Tailwind 설정`);
|
|
238
|
+
}
|
|
239
|
+
if (ui !== "none") {
|
|
240
|
+
console.log(` src/client/shared/ui/ → UI 컴포넌트 (shadcn)`);
|
|
241
|
+
console.log(` src/client/shared/lib/utils.ts → 유틸리티 (cn 함수)`);
|
|
242
|
+
}
|
|
86
243
|
|
|
87
244
|
return true;
|
|
88
245
|
}
|
|
246
|
+
|
|
247
|
+
async function createMinimalLayout(targetDir: string, projectName: string): Promise<void> {
|
|
248
|
+
const layoutContent = `/**
|
|
249
|
+
* Root Layout (Minimal)
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
interface RootLayoutProps {
|
|
253
|
+
children: React.ReactNode;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function RootLayout({ children }: RootLayoutProps) {
|
|
257
|
+
return (
|
|
258
|
+
<html lang="ko">
|
|
259
|
+
<head>
|
|
260
|
+
<meta charSet="UTF-8" />
|
|
261
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
262
|
+
<title>${projectName}</title>
|
|
263
|
+
</head>
|
|
264
|
+
<body>
|
|
265
|
+
{children}
|
|
266
|
+
</body>
|
|
267
|
+
</html>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
await fs.writeFile(path.join(targetDir, "app/layout.tsx"), layoutContent);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function createMinimalPage(targetDir: string): Promise<void> {
|
|
275
|
+
const pageContent = `/**
|
|
276
|
+
* Home Page (Minimal)
|
|
277
|
+
*
|
|
278
|
+
* Edit this file and see changes at http://localhost:3000
|
|
279
|
+
*/
|
|
280
|
+
|
|
281
|
+
export default function HomePage() {
|
|
282
|
+
return (
|
|
283
|
+
<main style={{
|
|
284
|
+
display: "flex",
|
|
285
|
+
minHeight: "100vh",
|
|
286
|
+
flexDirection: "column",
|
|
287
|
+
alignItems: "center",
|
|
288
|
+
justifyContent: "center",
|
|
289
|
+
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
290
|
+
padding: "2rem",
|
|
291
|
+
}}>
|
|
292
|
+
<div style={{
|
|
293
|
+
textAlign: "center",
|
|
294
|
+
color: "white",
|
|
295
|
+
}}>
|
|
296
|
+
<h1 style={{ fontSize: "3rem", marginBottom: "1rem" }}>🥟 Mandu</h1>
|
|
297
|
+
<p style={{ fontSize: "1.2rem", opacity: 0.9 }}>
|
|
298
|
+
Welcome to your new Mandu project!
|
|
299
|
+
</p>
|
|
300
|
+
<p style={{ fontSize: "1rem", opacity: 0.8, marginTop: "0.5rem" }}>
|
|
301
|
+
Edit <code style={{
|
|
302
|
+
background: "rgba(255,255,255,0.2)",
|
|
303
|
+
padding: "0.2rem 0.5rem",
|
|
304
|
+
borderRadius: "4px",
|
|
305
|
+
}}>app/page.tsx</code> to get started.
|
|
306
|
+
</p>
|
|
307
|
+
<p style={{ marginTop: "1rem" }}>
|
|
308
|
+
<a href="/api/health" style={{ color: "white" }}>API Health →</a>
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
</main>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
await fs.writeFile(path.join(targetDir, "app/page.tsx"), pageContent);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function updatePackageJson(
|
|
319
|
+
targetDir: string,
|
|
320
|
+
css: CSSFramework,
|
|
321
|
+
ui: UILibrary
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const pkgPath = path.join(targetDir, "package.json");
|
|
324
|
+
const content = await fs.readFile(pkgPath, "utf-8");
|
|
325
|
+
const pkg = JSON.parse(content);
|
|
326
|
+
|
|
327
|
+
if (css === "none") {
|
|
328
|
+
// Remove Tailwind dependencies
|
|
329
|
+
delete pkg.devDependencies?.tailwindcss;
|
|
330
|
+
delete pkg.devDependencies?.postcss;
|
|
331
|
+
delete pkg.devDependencies?.autoprefixer;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (ui === "none") {
|
|
335
|
+
// Remove UI library dependencies
|
|
336
|
+
delete pkg.dependencies?.["@radix-ui/react-slot"];
|
|
337
|
+
delete pkg.dependencies?.["class-variance-authority"];
|
|
338
|
+
delete pkg.dependencies?.clsx;
|
|
339
|
+
delete pkg.dependencies?.["tailwind-merge"];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
343
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import fsSync from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { resolveFromCwd, pathExists } from "../util/fs";
|
|
5
|
+
import { resolveOutputFormat, type OutputFormat } from "../util/output";
|
|
6
|
+
|
|
7
|
+
type MonitorOutput = "console" | "json";
|
|
8
|
+
|
|
9
|
+
export interface MonitorOptions {
|
|
10
|
+
format?: OutputFormat;
|
|
11
|
+
follow?: boolean;
|
|
12
|
+
summary?: boolean;
|
|
13
|
+
since?: string;
|
|
14
|
+
file?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MonitorEvent {
|
|
18
|
+
ts?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
severity?: "info" | "warn" | "error";
|
|
21
|
+
source?: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
data?: Record<string, unknown>;
|
|
24
|
+
count?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseDuration(value?: string): number | undefined {
|
|
28
|
+
if (!value) return undefined;
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
const match = trimmed.match(/^(\d+)(ms|s|m|h|d)?$/);
|
|
31
|
+
if (!match) return undefined;
|
|
32
|
+
const amount = Number(match[1]);
|
|
33
|
+
const unit = match[2] ?? "m";
|
|
34
|
+
switch (unit) {
|
|
35
|
+
case "ms":
|
|
36
|
+
return amount;
|
|
37
|
+
case "s":
|
|
38
|
+
return amount * 1000;
|
|
39
|
+
case "m":
|
|
40
|
+
return amount * 60 * 1000;
|
|
41
|
+
case "h":
|
|
42
|
+
return amount * 60 * 60 * 1000;
|
|
43
|
+
case "d":
|
|
44
|
+
return amount * 24 * 60 * 60 * 1000;
|
|
45
|
+
default:
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatTime(ts?: string): string {
|
|
51
|
+
const date = ts ? new Date(ts) : new Date();
|
|
52
|
+
return date.toLocaleTimeString("ko-KR", { hour12: false });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatEventForConsole(event: MonitorEvent): string {
|
|
56
|
+
const time = formatTime(event.ts);
|
|
57
|
+
const countSuffix = event.count && event.count > 1 ? ` x${event.count}` : "";
|
|
58
|
+
const type = event.type ?? "event";
|
|
59
|
+
|
|
60
|
+
if (type === "tool.call") {
|
|
61
|
+
const tag = (event.data?.tag as string | undefined) ?? "TOOL";
|
|
62
|
+
const argsSummary = event.data?.argsSummary as string | undefined;
|
|
63
|
+
return `${time} → [${tag}]${argsSummary ?? ""}${countSuffix}`;
|
|
64
|
+
}
|
|
65
|
+
if (type === "tool.error") {
|
|
66
|
+
const tag = (event.data?.tag as string | undefined) ?? "TOOL";
|
|
67
|
+
const argsSummary = event.data?.argsSummary as string | undefined;
|
|
68
|
+
const message = event.message ?? "ERROR";
|
|
69
|
+
return `${time} ✗ [${tag}]${argsSummary ?? ""}${countSuffix}\n ${message}`;
|
|
70
|
+
}
|
|
71
|
+
if (type === "tool.result") {
|
|
72
|
+
const tag = (event.data?.tag as string | undefined) ?? "TOOL";
|
|
73
|
+
const summary = event.data?.summary as string | undefined;
|
|
74
|
+
return `${time} ✓ [${tag}]${summary ?? ""}${countSuffix}`;
|
|
75
|
+
}
|
|
76
|
+
if (type === "watch.warning") {
|
|
77
|
+
const ruleId = event.data?.ruleId as string | undefined;
|
|
78
|
+
const file = event.data?.file as string | undefined;
|
|
79
|
+
const message = event.message ?? "";
|
|
80
|
+
const icon = event.severity === "info" ? "ℹ" : "⚠";
|
|
81
|
+
return `${time} ${icon} [WATCH:${ruleId ?? "UNKNOWN"}] ${file ?? ""}${countSuffix}\n ${message}`;
|
|
82
|
+
}
|
|
83
|
+
if (type === "guard.violation") {
|
|
84
|
+
const ruleId = event.data?.ruleId as string | undefined;
|
|
85
|
+
const file = event.data?.file as string | undefined;
|
|
86
|
+
const line = event.data?.line as number | undefined;
|
|
87
|
+
const message = event.message ?? (event.data?.message as string | undefined) ?? "";
|
|
88
|
+
const location = line ? `${file}:${line}` : file ?? "";
|
|
89
|
+
return `${time} 🚨 [GUARD:${ruleId ?? "UNKNOWN"}] ${location}${countSuffix}\n ${message}`;
|
|
90
|
+
}
|
|
91
|
+
if (type === "guard.summary") {
|
|
92
|
+
const count = event.data?.count as number | undefined;
|
|
93
|
+
const passed = event.data?.passed as boolean | undefined;
|
|
94
|
+
return `${time} 🧱 [GUARD] ${passed ? "PASSED" : "FAILED"} (${count ?? 0} violations)`;
|
|
95
|
+
}
|
|
96
|
+
if (type === "routes.change") {
|
|
97
|
+
const action = event.data?.action as string | undefined;
|
|
98
|
+
const routeId = event.data?.routeId as string | undefined;
|
|
99
|
+
const pattern = event.data?.pattern as string | undefined;
|
|
100
|
+
const kind = event.data?.kind as string | undefined;
|
|
101
|
+
const detail = [routeId, pattern, kind].filter(Boolean).join(" ");
|
|
102
|
+
return `${time} 🛣️ [ROUTES:${action ?? "change"}] ${detail}${countSuffix}`;
|
|
103
|
+
}
|
|
104
|
+
if (type === "monitor.summary") {
|
|
105
|
+
return `${time} · SUMMARY ${event.message ?? ""}`;
|
|
106
|
+
}
|
|
107
|
+
if (type === "system.event") {
|
|
108
|
+
const category = event.data?.category as string | undefined;
|
|
109
|
+
return `${time} [${category ?? "SYSTEM"}] ${event.message ?? ""}${countSuffix}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `${time} [${type}] ${event.message ?? ""}${countSuffix}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function resolveLogFile(
|
|
116
|
+
rootDir: string,
|
|
117
|
+
output: MonitorOutput,
|
|
118
|
+
explicit?: string
|
|
119
|
+
): Promise<string | null> {
|
|
120
|
+
if (explicit) return explicit;
|
|
121
|
+
|
|
122
|
+
const manduDir = path.join(rootDir, ".mandu");
|
|
123
|
+
const jsonPath = path.join(manduDir, "activity.jsonl");
|
|
124
|
+
const logPath = path.join(manduDir, "activity.log");
|
|
125
|
+
|
|
126
|
+
const hasJson = await pathExists(jsonPath);
|
|
127
|
+
const hasLog = await pathExists(logPath);
|
|
128
|
+
|
|
129
|
+
if (output === "json") {
|
|
130
|
+
if (hasJson) return jsonPath;
|
|
131
|
+
if (hasLog) return logPath;
|
|
132
|
+
} else {
|
|
133
|
+
if (hasLog) return logPath;
|
|
134
|
+
if (hasJson) return jsonPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readSummary(
|
|
141
|
+
filePath: string,
|
|
142
|
+
sinceMs: number
|
|
143
|
+
): Promise<{
|
|
144
|
+
windowMs: number;
|
|
145
|
+
total: number;
|
|
146
|
+
bySeverity: { info: number; warn: number; error: number };
|
|
147
|
+
byType: Record<string, number>;
|
|
148
|
+
}> {
|
|
149
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
150
|
+
const lines = content.split("\n").filter(Boolean);
|
|
151
|
+
const cutoff = Date.now() - sinceMs;
|
|
152
|
+
const counts = { total: 0, info: 0, warn: 0, error: 0 };
|
|
153
|
+
const byType: Record<string, number> = {};
|
|
154
|
+
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
try {
|
|
157
|
+
const event = JSON.parse(line) as MonitorEvent;
|
|
158
|
+
if (!event.ts) continue;
|
|
159
|
+
const ts = new Date(event.ts).getTime();
|
|
160
|
+
if (Number.isNaN(ts) || ts < cutoff) continue;
|
|
161
|
+
const count = event.count ?? 1;
|
|
162
|
+
counts.total += count;
|
|
163
|
+
if (event.severity) {
|
|
164
|
+
counts[event.severity] += count;
|
|
165
|
+
}
|
|
166
|
+
const type = event.type ?? "event";
|
|
167
|
+
byType[type] = (byType[type] ?? 0) + count;
|
|
168
|
+
} catch {
|
|
169
|
+
// ignore parse errors
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { windowMs: sinceMs, total: counts.total, bySeverity: counts, byType };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function printSummaryConsole(summary: {
|
|
177
|
+
windowMs: number;
|
|
178
|
+
total: number;
|
|
179
|
+
bySeverity: { info: number; warn: number; error: number };
|
|
180
|
+
byType: Record<string, number>;
|
|
181
|
+
}): void {
|
|
182
|
+
const seconds = Math.round(summary.windowMs / 1000);
|
|
183
|
+
const topTypes = Object.entries(summary.byType)
|
|
184
|
+
.sort((a, b) => b[1] - a[1])
|
|
185
|
+
.slice(0, 5)
|
|
186
|
+
.map(([type, count]) => `${type}=${count}`)
|
|
187
|
+
.join(", ");
|
|
188
|
+
|
|
189
|
+
console.log(`Summary (last ${seconds}s)`);
|
|
190
|
+
console.log(` total=${summary.total}`);
|
|
191
|
+
console.log(` error=${summary.bySeverity.error} warn=${summary.bySeverity.warn} info=${summary.bySeverity.info}`);
|
|
192
|
+
if (topTypes) {
|
|
193
|
+
console.log(` top=${topTypes}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function outputChunk(
|
|
198
|
+
chunk: string,
|
|
199
|
+
isJson: boolean,
|
|
200
|
+
output: MonitorOutput
|
|
201
|
+
): void {
|
|
202
|
+
if (!isJson || output === "json") {
|
|
203
|
+
process.stdout.write(chunk);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const lines = chunk.split("\n").filter(Boolean);
|
|
208
|
+
for (const line of lines) {
|
|
209
|
+
try {
|
|
210
|
+
const event = JSON.parse(line) as MonitorEvent;
|
|
211
|
+
const formatted = formatEventForConsole(event);
|
|
212
|
+
process.stdout.write(`${formatted}\n`);
|
|
213
|
+
} catch {
|
|
214
|
+
process.stdout.write(`${line}\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function followFile(
|
|
220
|
+
filePath: string,
|
|
221
|
+
isJson: boolean,
|
|
222
|
+
output: MonitorOutput,
|
|
223
|
+
startAtEnd: boolean
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
let position = 0;
|
|
226
|
+
let buffer = "";
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const stat = await fs.stat(filePath);
|
|
230
|
+
position = startAtEnd ? stat.size : 0;
|
|
231
|
+
} catch {
|
|
232
|
+
position = 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fd = await fs.open(filePath, "r");
|
|
236
|
+
|
|
237
|
+
fsSync.watchFile(
|
|
238
|
+
filePath,
|
|
239
|
+
{ interval: 500 },
|
|
240
|
+
async (curr) => {
|
|
241
|
+
if (curr.size < position) {
|
|
242
|
+
position = 0;
|
|
243
|
+
buffer = "";
|
|
244
|
+
}
|
|
245
|
+
if (curr.size === position) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const length = curr.size - position;
|
|
250
|
+
const chunk = Buffer.alloc(length);
|
|
251
|
+
await fd.read(chunk, 0, length, position);
|
|
252
|
+
position = curr.size;
|
|
253
|
+
buffer += chunk.toString("utf-8");
|
|
254
|
+
|
|
255
|
+
const lines = buffer.split("\n");
|
|
256
|
+
buffer = lines.pop() ?? "";
|
|
257
|
+
if (lines.length > 0) {
|
|
258
|
+
outputChunk(lines.join("\n"), isJson, output);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function monitor(options: MonitorOptions = {}): Promise<boolean> {
|
|
265
|
+
const rootDir = resolveFromCwd(".");
|
|
266
|
+
const resolved = resolveOutputFormat(options.format);
|
|
267
|
+
const output: MonitorOutput = resolved === "json" || resolved === "agent" ? "json" : "console";
|
|
268
|
+
const filePath = await resolveLogFile(rootDir, output, options.file);
|
|
269
|
+
|
|
270
|
+
if (!filePath) {
|
|
271
|
+
console.error("❌ activity log 파일을 찾을 수 없습니다. (.mandu/activity.log 또는 activity.jsonl)");
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const isJson = filePath.endsWith(".jsonl");
|
|
276
|
+
const follow = options.follow !== false;
|
|
277
|
+
|
|
278
|
+
if (options.summary) {
|
|
279
|
+
if (!isJson) {
|
|
280
|
+
console.error("⚠️ summary는 JSON 로그(activity.jsonl)에서만 가능합니다.");
|
|
281
|
+
} else {
|
|
282
|
+
const windowMs = parseDuration(options.since) ?? 5 * 60 * 1000;
|
|
283
|
+
const summary = await readSummary(filePath, windowMs);
|
|
284
|
+
if (output === "json") {
|
|
285
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
286
|
+
} else {
|
|
287
|
+
printSummaryConsole(summary);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!follow) return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!follow) {
|
|
294
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
295
|
+
outputChunk(content, isJson, output);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await followFile(filePath, isJson, output, true);
|
|
300
|
+
return new Promise(() => {});
|
|
301
|
+
}
|