@mandujs/cli 0.9.24 β†’ 0.9.43

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.
@@ -19,6 +19,7 @@ import {
19
19
  calculateLayerStatistics,
20
20
  generateGuardMarkdownReport,
21
21
  generateHTMLReport,
22
+ validateAndReport,
22
23
  type GuardConfig,
23
24
  type GuardPreset,
24
25
  } from "@mandujs/core";
@@ -52,22 +53,28 @@ export interface GuardArchOptions {
52
53
  showTrend?: boolean;
53
54
  }
54
55
 
56
+ function inferReportFormat(output?: string): "json" | "markdown" | "html" | undefined {
57
+ if (!output) return undefined;
58
+ const ext = path.extname(output).toLowerCase();
59
+ if (ext === ".json") return "json";
60
+ if (ext === ".html" || ext === ".htm") return "html";
61
+ if (ext === ".md" || ext === ".markdown") return "markdown";
62
+ return undefined;
63
+ }
64
+
55
65
  export async function guardArch(options: GuardArchOptions = {}): Promise<boolean> {
66
+ const rootDir = resolveFromCwd(".");
56
67
  const {
57
- preset = "mandu",
58
68
  watch = false,
59
- ci = false,
69
+ ci = process.env.CI === "true",
60
70
  format,
61
71
  quiet = false,
62
- srcDir = "src",
63
72
  listPresets: showPresets = false,
64
73
  output,
65
- reportFormat = "markdown",
74
+ reportFormat = inferReportFormat(options.output) ?? "markdown",
66
75
  saveStats = false,
67
76
  showTrend = false,
68
77
  } = options;
69
-
70
- const rootDir = resolveFromCwd(".");
71
78
  const resolvedFormat = resolveOutputFormat(format);
72
79
  const enableFsRoutes = await isDirectory(path.resolve(rootDir, "app"));
73
80
 
@@ -86,10 +93,17 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
86
93
  console.log("");
87
94
  }
88
95
 
89
- console.log("Usage: bunx mandu guard arch --preset <name>");
96
+ console.log("Usage: set guard.preset in mandu.config to choose a preset");
90
97
  return true;
91
98
  }
92
99
 
100
+ const fileConfig = await validateAndReport(rootDir);
101
+ if (!fileConfig) return false;
102
+ const guardConfigFromFile = fileConfig.guard ?? {};
103
+
104
+ const preset = options.preset ?? guardConfigFromFile.preset ?? "mandu";
105
+ const srcDir = options.srcDir ?? guardConfigFromFile.srcDir ?? "src";
106
+
93
107
  if (resolvedFormat === "console") {
94
108
  console.log("");
95
109
  console.log("πŸ›‘οΈ Mandu Guard - Architecture Checker");
@@ -101,11 +115,12 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
101
115
  }
102
116
 
103
117
  // Guard μ„€μ •
104
- const config: GuardConfig = {
118
+ const guardConfig: GuardConfig = {
105
119
  preset,
106
120
  srcDir,
107
121
  realtime: watch,
108
122
  realtimeOutput: resolvedFormat,
123
+ exclude: guardConfigFromFile.exclude,
109
124
  fsRoutes: enableFsRoutes
110
125
  ? {
111
126
  noPageToPage: true,
@@ -152,7 +167,7 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
152
167
  }
153
168
 
154
169
  const watcher = createGuardWatcher({
155
- config,
170
+ config: guardConfig,
156
171
  rootDir,
157
172
  onViolation: (violation) => {
158
173
  // μ‹€μ‹œκ°„ μœ„λ°˜ 좜λ ₯은 watcher λ‚΄λΆ€μ—μ„œ 처리됨
@@ -185,7 +200,7 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
185
200
  console.log("πŸ” Scanning for architecture violations...\n");
186
201
  }
187
202
 
188
- const report = await checkDirectory(config, rootDir);
203
+ const report = await checkDirectory(guardConfig, rootDir);
189
204
  const presetDef = getPreset(preset);
190
205
 
191
206
  // 좜λ ₯ ν˜•μ‹μ— λ”°λ₯Έ 리포트 좜λ ₯
@@ -1,5 +1,6 @@
1
- import path from "path";
2
- import fs from "fs/promises";
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { CLI_ERROR_CODES, printCLIError } from "../errors";
3
4
 
4
5
  export type CSSFramework = "tailwind" | "panda" | "none";
5
6
  export type UILibrary = "shadcn" | "ark" | "none";
@@ -162,8 +163,8 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
162
163
  // Check if target directory exists
163
164
  try {
164
165
  await fs.access(targetDir);
165
- console.error(`❌ 디렉토리가 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€: ${targetDir}`);
166
- return false;
166
+ printCLIError(CLI_ERROR_CODES.INIT_DIR_EXISTS, { path: targetDir });
167
+ return false;
167
168
  } catch {
168
169
  // Directory doesn't exist, good to proceed
169
170
  }
@@ -175,9 +176,9 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
175
176
  try {
176
177
  await fs.access(templateDir);
177
178
  } catch {
178
- console.error(`❌ ν…œν”Œλ¦Ώμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: ${template}`);
179
- console.error(` μ‚¬μš© κ°€λŠ₯ν•œ ν…œν”Œλ¦Ώ: default`);
180
- return false;
179
+ printCLIError(CLI_ERROR_CODES.INIT_TEMPLATE_NOT_FOUND, { template });
180
+ console.error(` μ‚¬μš© κ°€λŠ₯ν•œ ν…œν”Œλ¦Ώ: default`);
181
+ return false;
181
182
  }
182
183
 
183
184
  console.log(`πŸ“‹ ν…œν”Œλ¦Ώ 볡사 쀑...`);
@@ -2,12 +2,11 @@ import fs from "fs/promises";
2
2
  import fsSync from "fs";
3
3
  import path from "path";
4
4
  import { resolveFromCwd, pathExists } from "../util/fs";
5
- import { resolveOutputFormat, type OutputFormat } from "../util/output";
5
+ import { resolveOutputFormat } from "../util/output";
6
6
 
7
7
  type MonitorOutput = "console" | "json";
8
8
 
9
9
  export interface MonitorOptions {
10
- format?: OutputFormat;
11
10
  follow?: boolean;
12
11
  summary?: boolean;
13
12
  since?: string;
@@ -263,7 +262,7 @@ async function followFile(
263
262
 
264
263
  export async function monitor(options: MonitorOptions = {}): Promise<boolean> {
265
264
  const rootDir = resolveFromCwd(".");
266
- const resolved = resolveOutputFormat(options.format);
265
+ const resolved = resolveOutputFormat();
267
266
  const output: MonitorOutput = resolved === "json" || resolved === "agent" ? "json" : "console";
268
267
  const filePath = await resolveLogFile(rootDir, output, options.file);
269
268
 
@@ -3,9 +3,10 @@
3
3
  * OpenAPI μŠ€νŽ™ 생성 λͺ…λ Ήμ–΄
4
4
  */
5
5
 
6
- import { loadManifest, generateOpenAPIDocument, openAPIToJSON } from "@mandujs/core";
7
- import path from "path";
8
- import fs from "fs/promises";
6
+ import { generateOpenAPIDocument, openAPIToJSON, validateAndReport } from "@mandujs/core";
7
+ import path from "path";
8
+ import fs from "fs/promises";
9
+ import { resolveManifest } from "../util/manifest";
9
10
 
10
11
  interface OpenAPIGenerateOptions {
11
12
  output?: string;
@@ -13,27 +14,78 @@ interface OpenAPIGenerateOptions {
13
14
  version?: string;
14
15
  }
15
16
 
16
- interface OpenAPIServeOptions {
17
- port?: number;
18
- }
19
-
20
- /**
21
- * Generate OpenAPI specification from contracts
22
- */
23
- export async function openAPIGenerate(options: OpenAPIGenerateOptions = {}): Promise<boolean> {
24
- const rootDir = process.cwd();
25
- const manifestPath = path.join(rootDir, "spec/routes.manifest.json");
26
-
27
- console.log(`\nπŸ“„ Generating OpenAPI specification...\n`);
28
-
29
- // Load manifest
30
- const manifestResult = await loadManifest(manifestPath);
31
- if (!manifestResult.success) {
32
- console.error("❌ Failed to load manifest:", manifestResult.errors);
33
- return false;
34
- }
35
-
36
- const manifest = manifestResult.data!;
17
+ interface OpenAPIServeOptions {
18
+ port?: number;
19
+ }
20
+
21
+ function normalizePort(value: string | number | undefined, label: string): number | undefined {
22
+ if (value === undefined || value === null || value === "") {
23
+ return undefined;
24
+ }
25
+ const port = typeof value === "string" ? Number(value) : value;
26
+ if (!Number.isFinite(port) || !Number.isInteger(port)) {
27
+ console.warn(`⚠️ Invalid ${label} value: "${value}" (using default)`);
28
+ return undefined;
29
+ }
30
+ if (port < 1 || port > 65535) {
31
+ console.warn(`⚠️ Invalid ${label} range: ${port} (must be 1-65535, using default)`);
32
+ return undefined;
33
+ }
34
+ return port;
35
+ }
36
+
37
+ function isPortInUse(error: unknown): boolean {
38
+ if (!error || typeof error !== "object") return false;
39
+ const code = (error as { code?: string }).code;
40
+ const message = (error as { message?: string }).message ?? "";
41
+ return code === "EADDRINUSE" || message.includes("EADDRINUSE") || message.includes("address already in use");
42
+ }
43
+
44
+ function serveWithAutoPort(
45
+ startPort: number,
46
+ fetch: (req: Request) => Response
47
+ ): { server: ReturnType<typeof Bun.serve>; port: number; attempts: number } {
48
+ const maxAttempts = 10;
49
+ let lastError: unknown = null;
50
+
51
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
52
+ const candidate = startPort + attempt;
53
+ if (candidate < 1 || candidate > 65535) {
54
+ continue;
55
+ }
56
+ try {
57
+ const server = Bun.serve({ port: candidate, fetch });
58
+ return { server, port: server.port ?? candidate, attempts: attempt };
59
+ } catch (error) {
60
+ if (!isPortInUse(error)) {
61
+ throw error;
62
+ }
63
+ lastError = error;
64
+ }
65
+ }
66
+
67
+ throw lastError ?? new Error(`No available port found starting at ${startPort}`);
68
+ }
69
+
70
+ /**
71
+ * Generate OpenAPI specification from contracts
72
+ */
73
+ export async function openAPIGenerate(options: OpenAPIGenerateOptions = {}): Promise<boolean> {
74
+ const rootDir = process.cwd();
75
+
76
+ console.log(`\nπŸ“„ Generating OpenAPI specification...\n`);
77
+
78
+ // Load manifest (FS Routes μš°μ„ )
79
+ let manifest: Awaited<ReturnType<typeof resolveManifest>>["manifest"];
80
+ try {
81
+ const config = await validateAndReport(rootDir);
82
+ if (!config) return false;
83
+ const resolved = await resolveManifest(rootDir, { fsRoutes: config.fsRoutes });
84
+ manifest = resolved.manifest;
85
+ } catch (error) {
86
+ console.error("❌ Failed to load manifest:", error instanceof Error ? error.message : error);
87
+ return false;
88
+ }
37
89
 
38
90
  // Count routes with contracts
39
91
  const contractRoutes = manifest.routes.filter((r) => r.contractModule);
@@ -96,10 +148,16 @@ export async function openAPIGenerate(options: OpenAPIGenerateOptions = {}): Pro
96
148
  /**
97
149
  * Serve Swagger UI for OpenAPI documentation
98
150
  */
99
- export async function openAPIServe(options: OpenAPIServeOptions = {}): Promise<boolean> {
100
- const rootDir = process.cwd();
101
- const port = options.port || 8080;
102
- const openAPIPath = path.join(rootDir, "openapi.json");
151
+ export async function openAPIServe(options: OpenAPIServeOptions = {}): Promise<boolean> {
152
+ const rootDir = process.cwd();
153
+ const config = await validateAndReport(rootDir);
154
+ if (!config) return false;
155
+
156
+ const optionPort = normalizePort(options.port, "openapi.port");
157
+ const envPort = normalizePort(process.env.PORT, "PORT");
158
+ const configPort = normalizePort(config.server?.port, "mandu.config server.port");
159
+ const desiredPort = optionPort ?? envPort ?? configPort ?? 8080;
160
+ const openAPIPath = path.join(rootDir, "openapi.json");
103
161
 
104
162
  console.log(`\n🌐 Starting OpenAPI documentation server...\n`);
105
163
 
@@ -154,26 +212,27 @@ export async function openAPIServe(options: OpenAPIServeOptions = {}): Promise<b
154
212
  </html>
155
213
  `.trim();
156
214
 
157
- // Start server
158
- const server = Bun.serve({
159
- port,
160
- fetch(req) {
161
- const url = new URL(req.url);
162
-
163
- if (url.pathname === "/openapi.json") {
164
- return new Response(specContent, {
165
- headers: { "Content-Type": "application/json" },
166
- });
167
- }
168
-
169
- return new Response(swaggerHTML, {
170
- headers: { "Content-Type": "text/html" },
171
- });
172
- },
173
- });
174
-
175
- console.log(`βœ… Swagger UI is running at http://localhost:${port}`);
176
- console.log(` OpenAPI spec: http://localhost:${port}/openapi.json`);
215
+ // Start server (auto port fallback)
216
+ const { port, attempts } = serveWithAutoPort(desiredPort, (req) => {
217
+ const url = new URL(req.url);
218
+
219
+ if (url.pathname === "/openapi.json") {
220
+ return new Response(specContent, {
221
+ headers: { "Content-Type": "application/json" },
222
+ });
223
+ }
224
+
225
+ return new Response(swaggerHTML, {
226
+ headers: { "Content-Type": "text/html" },
227
+ });
228
+ });
229
+
230
+ if (attempts > 0) {
231
+ console.warn(`⚠️ Port ${desiredPort} is in use. Using ${port} instead.`);
232
+ }
233
+
234
+ console.log(`βœ… Swagger UI is running at http://localhost:${port}`);
235
+ console.log(` OpenAPI spec: http://localhost:${port}/openapi.json`);
177
236
  console.log(`\nPress Ctrl+C to stop.\n`);
178
237
 
179
238
  // Keep server running
@@ -9,6 +9,7 @@ import {
9
9
  generateManifest,
10
10
  formatRoutesForCLI,
11
11
  watchFSRoutes,
12
+ validateAndReport,
12
13
  type GenerateOptions,
13
14
  type FSScannerConfig,
14
15
  } from "@mandujs/core";
@@ -46,11 +47,14 @@ export interface RoutesWatchOptions {
46
47
  */
47
48
  export async function routesGenerate(options: RoutesGenerateOptions = {}): Promise<boolean> {
48
49
  const rootDir = resolveFromCwd(".");
50
+ const config = await validateAndReport(rootDir);
51
+ if (!config) return false;
49
52
 
50
53
  console.log("πŸ₯Ÿ Mandu FS Routes Generate\n");
51
54
 
52
55
  try {
53
56
  const generateOptions: GenerateOptions = {
57
+ scanner: config.fsRoutes,
54
58
  outputPath: options.output ?? ".mandu/routes.manifest.json",
55
59
  skipLegacy: true, // λ ˆκ±°μ‹œ 병합 λΉ„ν™œμ„±ν™”
56
60
  };
@@ -93,11 +97,13 @@ export async function routesGenerate(options: RoutesGenerateOptions = {}): Promi
93
97
  */
94
98
  export async function routesList(options: RoutesListOptions = {}): Promise<boolean> {
95
99
  const rootDir = resolveFromCwd(".");
100
+ const config = await validateAndReport(rootDir);
101
+ if (!config) return false;
96
102
 
97
103
  console.log("πŸ₯Ÿ Mandu Routes List\n");
98
104
 
99
105
  try {
100
- const result = await scanRoutes(rootDir);
106
+ const result = await scanRoutes(rootDir, config.fsRoutes);
101
107
 
102
108
  if (result.errors.length > 0) {
103
109
  console.log("⚠️ μŠ€μΊ” κ²½κ³ :");
@@ -164,6 +170,8 @@ export async function routesList(options: RoutesListOptions = {}): Promise<boole
164
170
  */
165
171
  export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boolean> {
166
172
  const rootDir = resolveFromCwd(".");
173
+ const config = await validateAndReport(rootDir);
174
+ if (!config) return false;
167
175
 
168
176
  console.log("πŸ₯Ÿ Mandu FS Routes Watch\n");
169
177
  console.log("πŸ‘€ 라우트 λ³€κ²½ κ°μ‹œ 쀑... (Ctrl+C둜 μ’…λ£Œ)\n");
@@ -171,6 +179,7 @@ export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boo
171
179
  try {
172
180
  // 초기 μŠ€μΊ”
173
181
  const initialResult = await generateManifest(rootDir, {
182
+ scanner: config.fsRoutes,
174
183
  outputPath: options.output ?? ".mandu/routes.manifest.json",
175
184
  });
176
185
 
@@ -178,6 +187,7 @@ export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boo
178
187
 
179
188
  // κ°μ‹œ μ‹œμž‘
180
189
  const watcher = await watchFSRoutes(rootDir, {
190
+ scanner: config.fsRoutes,
181
191
  outputPath: options.output ?? ".mandu/routes.manifest.json",
182
192
  onChange: (result) => {
183
193
  const timestamp = new Date().toLocaleTimeString();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CLI error codes
3
+ */
4
+ export const CLI_ERROR_CODES = {
5
+ // Init errors (E001-E009)
6
+ INIT_DIR_EXISTS: "CLI_E001",
7
+ INIT_BUN_NOT_FOUND: "CLI_E002",
8
+ INIT_TEMPLATE_NOT_FOUND: "CLI_E003",
9
+
10
+ // Dev errors (E010-E019)
11
+ DEV_PORT_IN_USE: "CLI_E010",
12
+ DEV_MANIFEST_NOT_FOUND: "CLI_E011",
13
+ DEV_NO_ROUTES: "CLI_E012",
14
+
15
+ // Guard errors (E020-E029)
16
+ GUARD_CONFIG_INVALID: "CLI_E020",
17
+ GUARD_PRESET_NOT_FOUND: "CLI_E021",
18
+ GUARD_VIOLATION_FOUND: "CLI_E022",
19
+
20
+ // Build errors (E030-E039)
21
+ BUILD_ENTRY_NOT_FOUND: "CLI_E030",
22
+ BUILD_BUNDLE_FAILED: "CLI_E031",
23
+ BUILD_OUTDIR_NOT_WRITABLE: "CLI_E032",
24
+
25
+ // Config errors (E040-E049)
26
+ CONFIG_PARSE_FAILED: "CLI_E040",
27
+ CONFIG_VALIDATION_FAILED: "CLI_E041",
28
+
29
+ // CLI usage errors (E100+)
30
+ UNKNOWN_COMMAND: "CLI_E100",
31
+ UNKNOWN_SUBCOMMAND: "CLI_E101",
32
+ MISSING_ARGUMENT: "CLI_E102",
33
+ } as const;
34
+
35
+ export type CLIErrorCode = typeof CLI_ERROR_CODES[keyof typeof CLI_ERROR_CODES];
@@ -0,0 +1,2 @@
1
+ export { CLI_ERROR_CODES, type CLIErrorCode } from "./codes";
2
+ export { CLIError, formatCLIError, handleCLIError, printCLIError } from "./messages";
@@ -0,0 +1,143 @@
1
+ import { CLI_ERROR_CODES, type CLIErrorCode } from "./codes";
2
+
3
+ interface ErrorInfo {
4
+ message: string;
5
+ suggestion?: string;
6
+ docLink?: string;
7
+ }
8
+
9
+ export const ERROR_MESSAGES: Record<CLIErrorCode, ErrorInfo> = {
10
+ [CLI_ERROR_CODES.INIT_DIR_EXISTS]: {
11
+ message: "Directory already exists: {path}",
12
+ suggestion: "Choose a different project name or remove the existing directory.",
13
+ },
14
+ [CLI_ERROR_CODES.INIT_BUN_NOT_FOUND]: {
15
+ message: "Bun runtime not found.",
16
+ suggestion: "Install Bun and ensure it is available in your PATH.",
17
+ },
18
+ [CLI_ERROR_CODES.INIT_TEMPLATE_NOT_FOUND]: {
19
+ message: "Template not found: {template}",
20
+ suggestion: "Use a valid template name (default).",
21
+ },
22
+ [CLI_ERROR_CODES.DEV_PORT_IN_USE]: {
23
+ message: "Port {port} is already in use.",
24
+ suggestion: "Set PORT or mandu.config server.port to pick a different port, or stop the process using this port.",
25
+ },
26
+ [CLI_ERROR_CODES.DEV_MANIFEST_NOT_FOUND]: {
27
+ message: "Routes manifest not found.",
28
+ suggestion: "Run `mandu routes generate` or create app/ routes before dev.",
29
+ },
30
+ [CLI_ERROR_CODES.DEV_NO_ROUTES]: {
31
+ message: "No routes were found in app/.",
32
+ suggestion: "Create app/page.tsx or app/api/*/route.ts to get started.",
33
+ },
34
+ [CLI_ERROR_CODES.GUARD_CONFIG_INVALID]: {
35
+ message: "Invalid guard configuration.",
36
+ suggestion: "Check your mandu.config and guard settings.",
37
+ },
38
+ [CLI_ERROR_CODES.GUARD_PRESET_NOT_FOUND]: {
39
+ message: "Unknown architecture preset: {preset}",
40
+ suggestion: "Available presets: mandu, fsd, clean, hexagonal, atomic.",
41
+ },
42
+ [CLI_ERROR_CODES.GUARD_VIOLATION_FOUND]: {
43
+ message: "{count} architecture violation(s) found.",
44
+ suggestion: "Fix violations above or set MANDU_OUTPUT=agent for AI-friendly output.",
45
+ },
46
+ [CLI_ERROR_CODES.BUILD_ENTRY_NOT_FOUND]: {
47
+ message: "Build entry not found: {entry}",
48
+ suggestion: "Check your routes manifest or build inputs.",
49
+ },
50
+ [CLI_ERROR_CODES.BUILD_BUNDLE_FAILED]: {
51
+ message: "Bundle build failed for '{target}'.",
52
+ suggestion: "Review build errors above for missing deps or syntax errors.",
53
+ },
54
+ [CLI_ERROR_CODES.BUILD_OUTDIR_NOT_WRITABLE]: {
55
+ message: "Output directory is not writable: {path}",
56
+ suggestion: "Ensure the directory exists and you have write permissions.",
57
+ },
58
+ [CLI_ERROR_CODES.CONFIG_PARSE_FAILED]: {
59
+ message: "Failed to parse mandu.config.",
60
+ suggestion: "Fix syntax errors in the config file.",
61
+ },
62
+ [CLI_ERROR_CODES.CONFIG_VALIDATION_FAILED]: {
63
+ message: "Configuration validation failed.",
64
+ suggestion: "Review validation errors above and fix your config.",
65
+ },
66
+ [CLI_ERROR_CODES.UNKNOWN_COMMAND]: {
67
+ message: "Unknown command: {command}",
68
+ suggestion: "Run with --help to see available commands.",
69
+ },
70
+ [CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND]: {
71
+ message: "Unknown subcommand '{subcommand}' for {command}.",
72
+ suggestion: "Run the command with --help to see available subcommands.",
73
+ },
74
+ [CLI_ERROR_CODES.MISSING_ARGUMENT]: {
75
+ message: "Missing required argument: {argument}",
76
+ suggestion: "Provide the required argument and try again.",
77
+ },
78
+ };
79
+
80
+ function interpolate(text: string, context?: Record<string, string | number>): string {
81
+ if (!context) return text;
82
+ let result = text;
83
+ for (const [key, value] of Object.entries(context)) {
84
+ result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
85
+ }
86
+ return result;
87
+ }
88
+
89
+ export function formatCLIError(
90
+ code: CLIErrorCode,
91
+ context?: Record<string, string | number>
92
+ ): string {
93
+ const info = ERROR_MESSAGES[code];
94
+ const message = interpolate(info?.message ?? "Unknown error", context);
95
+ const suggestion = info?.suggestion ? interpolate(info.suggestion, context) : undefined;
96
+
97
+ const lines = ["", `❌ Error [${code}]`, ` ${message}`];
98
+ if (suggestion) {
99
+ lines.push("", `πŸ’‘ ${suggestion}`);
100
+ }
101
+ if (info?.docLink) {
102
+ lines.push(`πŸ“– ${info.docLink}`);
103
+ }
104
+ lines.push("");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ export class CLIError extends Error {
109
+ readonly code: CLIErrorCode;
110
+ readonly context?: Record<string, string | number>;
111
+
112
+ constructor(code: CLIErrorCode, context?: Record<string, string | number>) {
113
+ super(formatCLIError(code, context));
114
+ this.code = code;
115
+ this.context = context;
116
+ this.name = "CLIError";
117
+ }
118
+ }
119
+
120
+ export function printCLIError(
121
+ code: CLIErrorCode,
122
+ context?: Record<string, string | number>
123
+ ): void {
124
+ console.error(formatCLIError(code, context));
125
+ }
126
+
127
+ export function handleCLIError(error: unknown): never {
128
+ if (error instanceof CLIError) {
129
+ console.error(error.message);
130
+ process.exit(1);
131
+ }
132
+
133
+ if (error instanceof Error) {
134
+ console.error(`\n❌ Unexpected error: ${error.message}\n`);
135
+ if (process.env.DEBUG) {
136
+ console.error(error.stack);
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ console.error("\n❌ Unknown error occurred\n");
142
+ process.exit(1);
143
+ }