@kmgeon/taskflow 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +374 -0
  2. package/bin/task-mcp.mjs +19 -0
  3. package/bin/task.mjs +19 -0
  4. package/docs/clean-code.md +29 -0
  5. package/docs/git.md +36 -0
  6. package/docs/guideline.md +25 -0
  7. package/docs/security.md +32 -0
  8. package/docs/step-by-step.md +29 -0
  9. package/docs/superpowers/specs/2026-03-21-cli-advisor-design.md +383 -0
  10. package/docs/superpowers/specs/2026-03-21-init-redesign-design.md +429 -0
  11. package/docs/superpowers/specs/2026-03-21-skill-architecture-design.md +362 -0
  12. package/docs/superpowers/specs/2026-03-23-t-create-task-run-design.md +40 -0
  13. package/docs/superpowers/specs/2026-03-23-task-run-design.md +44 -0
  14. package/docs/tdd.md +41 -0
  15. package/package.json +114 -0
  16. package/src/app/(protected)/dashboard/page.tsx +7 -0
  17. package/src/app/(protected)/layout.tsx +10 -0
  18. package/src/app/api/[[...hono]]/route.ts +13 -0
  19. package/src/app/example/page.tsx +11 -0
  20. package/src/app/favicon.ico +0 -0
  21. package/src/app/globals.css +168 -0
  22. package/src/app/layout.tsx +35 -0
  23. package/src/app/page.tsx +5 -0
  24. package/src/app/providers.tsx +57 -0
  25. package/src/backend/config/index.ts +36 -0
  26. package/src/backend/hono/app.ts +32 -0
  27. package/src/backend/hono/context.ts +38 -0
  28. package/src/backend/http/response.ts +64 -0
  29. package/src/backend/middleware/context.ts +23 -0
  30. package/src/backend/middleware/error.ts +31 -0
  31. package/src/backend/middleware/supabase.ts +23 -0
  32. package/src/backend/supabase/client.ts +17 -0
  33. package/src/cli/commands/__tests__/task-commands.test.ts +170 -0
  34. package/src/cli/commands/advisor.ts +45 -0
  35. package/src/cli/commands/ask.ts +50 -0
  36. package/src/cli/commands/board.ts +72 -0
  37. package/src/cli/commands/init.ts +184 -0
  38. package/src/cli/commands/list.ts +138 -0
  39. package/src/cli/commands/run.ts +143 -0
  40. package/src/cli/commands/set-status.ts +50 -0
  41. package/src/cli/commands/show.ts +28 -0
  42. package/src/cli/commands/tree.ts +72 -0
  43. package/src/cli/index.ts +38 -0
  44. package/src/cli/lib/__tests__/formatter.test.ts +123 -0
  45. package/src/cli/lib/error-boundary.test.ts +135 -0
  46. package/src/cli/lib/error-boundary.ts +70 -0
  47. package/src/cli/lib/formatter.ts +764 -0
  48. package/src/cli/lib/trd.ts +33 -0
  49. package/src/cli/lib/validate.test.ts +89 -0
  50. package/src/cli/lib/validate.ts +43 -0
  51. package/src/cli/prompts/task-run.md +25 -0
  52. package/src/components/layout/AppLayout.tsx +15 -0
  53. package/src/components/layout/Sidebar.tsx +124 -0
  54. package/src/components/ui/accordion.tsx +58 -0
  55. package/src/components/ui/avatar.tsx +50 -0
  56. package/src/components/ui/badge.tsx +36 -0
  57. package/src/components/ui/button.tsx +56 -0
  58. package/src/components/ui/card.tsx +79 -0
  59. package/src/components/ui/checkbox.tsx +30 -0
  60. package/src/components/ui/dialog.tsx +122 -0
  61. package/src/components/ui/dropdown-menu.tsx +200 -0
  62. package/src/components/ui/file-upload.tsx +50 -0
  63. package/src/components/ui/form.tsx +179 -0
  64. package/src/components/ui/input.tsx +25 -0
  65. package/src/components/ui/label.tsx +26 -0
  66. package/src/components/ui/scroll-area.tsx +48 -0
  67. package/src/components/ui/select.tsx +160 -0
  68. package/src/components/ui/separator.tsx +31 -0
  69. package/src/components/ui/sheet.tsx +140 -0
  70. package/src/components/ui/textarea.tsx +22 -0
  71. package/src/components/ui/toast.tsx +129 -0
  72. package/src/components/ui/toaster.tsx +35 -0
  73. package/src/core/ai/claude-client.ts +79 -0
  74. package/src/core/claude-runner/flag-builder.ts +57 -0
  75. package/src/core/claude-runner/index.ts +2 -0
  76. package/src/core/claude-runner/spawner.ts +86 -0
  77. package/src/core/prd/__tests__/auto-analyzer.test.ts +35 -0
  78. package/src/core/prd/__tests__/generator.test.ts +26 -0
  79. package/src/core/prd/__tests__/scanner.test.ts +35 -0
  80. package/src/core/prd/auto-analyzer.ts +9 -0
  81. package/src/core/prd/generator.ts +8 -0
  82. package/src/core/prd/scanner.ts +117 -0
  83. package/src/core/project/__tests__/claude-setup.test.ts +133 -0
  84. package/src/core/project/__tests__/config.test.ts +30 -0
  85. package/src/core/project/__tests__/init.test.ts +37 -0
  86. package/src/core/project/__tests__/skill-setup.test.ts +62 -0
  87. package/src/core/project/claude-setup.ts +224 -0
  88. package/src/core/project/config.ts +34 -0
  89. package/src/core/project/docs-setup.ts +26 -0
  90. package/src/core/project/docs-templates.ts +205 -0
  91. package/src/core/project/init.ts +40 -0
  92. package/src/core/project/skill-setup.ts +32 -0
  93. package/src/core/project/skill-templates.ts +277 -0
  94. package/src/core/task/index.ts +16 -0
  95. package/src/core/types.ts +58 -0
  96. package/src/features/example/backend/error.ts +9 -0
  97. package/src/features/example/backend/route.ts +52 -0
  98. package/src/features/example/backend/schema.ts +25 -0
  99. package/src/features/example/backend/service.ts +73 -0
  100. package/src/features/example/components/example-status.test.tsx +97 -0
  101. package/src/features/example/components/example-status.tsx +160 -0
  102. package/src/features/example/hooks/useExampleQuery.ts +23 -0
  103. package/src/features/example/lib/dto.test.ts +57 -0
  104. package/src/features/example/lib/dto.ts +5 -0
  105. package/src/features/kanban/backend/__tests__/sse-broadcaster.test.ts +137 -0
  106. package/src/features/kanban/backend/__tests__/sse-event-format.test.ts +55 -0
  107. package/src/features/kanban/backend/route.ts +55 -0
  108. package/src/features/kanban/backend/sse-broadcaster.ts +142 -0
  109. package/src/features/kanban/backend/sse-route.ts +43 -0
  110. package/src/features/kanban/components/KanbanBoard.tsx +105 -0
  111. package/src/features/kanban/components/KanbanColumn.tsx +51 -0
  112. package/src/features/kanban/components/KanbanError.tsx +29 -0
  113. package/src/features/kanban/components/KanbanSkeleton.tsx +46 -0
  114. package/src/features/kanban/components/ProgressCard.tsx +42 -0
  115. package/src/features/kanban/components/TaskCard.tsx +76 -0
  116. package/src/features/kanban/components/__tests__/kanban-components.test.tsx +86 -0
  117. package/src/features/kanban/hooks/useTaskSse.ts +66 -0
  118. package/src/features/kanban/hooks/useTasksQuery.ts +52 -0
  119. package/src/features/kanban/lib/__tests__/kanban-utils.test.ts +97 -0
  120. package/src/features/kanban/lib/kanban-utils.ts +37 -0
  121. package/src/features/taskflow/constants.ts +54 -0
  122. package/src/features/taskflow/index.ts +27 -0
  123. package/src/features/taskflow/lib/__tests__/filter.test.ts +89 -0
  124. package/src/features/taskflow/lib/__tests__/graph.test.ts +247 -0
  125. package/src/features/taskflow/lib/__tests__/repository.test.ts +233 -0
  126. package/src/features/taskflow/lib/__tests__/serializer.test.ts +98 -0
  127. package/src/features/taskflow/lib/advisor/__tests__/advisor-integration.test.ts +98 -0
  128. package/src/features/taskflow/lib/advisor/ai-advisor.test.ts +40 -0
  129. package/src/features/taskflow/lib/advisor/ai-advisor.ts +20 -0
  130. package/src/features/taskflow/lib/advisor/context-builder.test.ts +73 -0
  131. package/src/features/taskflow/lib/advisor/context-builder.ts +151 -0
  132. package/src/features/taskflow/lib/advisor/db.test.ts +106 -0
  133. package/src/features/taskflow/lib/advisor/db.ts +185 -0
  134. package/src/features/taskflow/lib/advisor/local-summary.test.ts +53 -0
  135. package/src/features/taskflow/lib/advisor/local-summary.ts +72 -0
  136. package/src/features/taskflow/lib/advisor/prompts.ts +86 -0
  137. package/src/features/taskflow/lib/filter.ts +54 -0
  138. package/src/features/taskflow/lib/fs-utils.ts +50 -0
  139. package/src/features/taskflow/lib/graph.ts +148 -0
  140. package/src/features/taskflow/lib/index-builder.ts +42 -0
  141. package/src/features/taskflow/lib/repository.ts +168 -0
  142. package/src/features/taskflow/lib/serializer.ts +62 -0
  143. package/src/features/taskflow/lib/watcher.ts +40 -0
  144. package/src/features/taskflow/types.ts +71 -0
  145. package/src/hooks/use-toast.ts +194 -0
  146. package/src/lib/remote/api-client.ts +40 -0
  147. package/src/lib/supabase/client.ts +8 -0
  148. package/src/lib/supabase/server.ts +46 -0
  149. package/src/lib/supabase/types.ts +3 -0
  150. package/src/lib/utils.ts +6 -0
  151. package/src/mcp/index.ts +7 -0
  152. package/src/mcp/server.ts +21 -0
  153. package/src/mcp/tools/brainstorm.ts +48 -0
  154. package/src/mcp/tools/prd.ts +71 -0
  155. package/src/mcp/tools/project.ts +39 -0
  156. package/src/mcp/tools/task-status.ts +40 -0
  157. package/src/mcp/tools/task.ts +82 -0
  158. package/src/mcp/util.ts +6 -0
@@ -0,0 +1,143 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import inquirer from "inquirer";
6
+ import { withCliErrorBoundary } from "../lib/error-boundary.js";
7
+ import { findTrdFiles } from "../lib/trd.js";
8
+
9
+ function setupRalphLoop(
10
+ projectRoot: string,
11
+ trdContent: string,
12
+ trdName: string,
13
+ ): void {
14
+ const prompt = `다음 TRD를 기반으로 태스크를 분해하고 모두 구현하세요.
15
+
16
+ ## 단계 1: 태스크 분해
17
+ 아래 TRD를 읽고 구현 가능한 단위의 태스크로 분해하세요:
18
+ - 각 태스크는 4시간 이내에 완료 가능한 크기
19
+ - MCP \`create_task\` 도구로 각 태스크를 생성하세요
20
+ - group 파라미터에 "${trdName}" 을 설정하세요
21
+ - 태스크 간 의존성을 dependencies에 명시하세요
22
+ - 태스크 목록을 사용자에게 보여주고 승인을 받으세요
23
+
24
+ ## 단계 2: 순차 구현
25
+ 태스크가 생성되면 하나씩 순서대로 구현하세요:
26
+ 1. MCP \`get_next_task\`로 다음 태스크 조회 (group: "${trdName}")
27
+ 2. MCP \`set_task_status\`로 InProgress 변경
28
+ 3. 코드 구현 + 테스트 실행
29
+ 4. MCP \`set_task_status\`로 Done 변경
30
+ 5. 다음 태스크로 반복
31
+
32
+ 모든 태스크가 완료되면 작업 요약을 출력하세요.
33
+
34
+ ## TRD 내용
35
+
36
+ ${trdContent}`;
37
+
38
+ const ralphConfig = `---
39
+ active: true
40
+ iteration: 1
41
+ session_id:
42
+ max_iterations: 0
43
+ completion_promise: null
44
+ started_at: "${new Date().toISOString()}"
45
+ ---
46
+
47
+ ${prompt}
48
+ `;
49
+
50
+ const claudeDir = path.join(projectRoot, ".claude");
51
+ fs.mkdirSync(claudeDir, { recursive: true });
52
+ fs.writeFileSync(
53
+ path.join(claudeDir, "ralph-loop.local.md"),
54
+ ralphConfig,
55
+ "utf-8",
56
+ );
57
+ }
58
+
59
+ export function registerRunCommand(program: Command) {
60
+ program
61
+ .command("run")
62
+ .description(
63
+ "TRD를 선택하고 Ralph Loop으로 태스크 분해 + 자동 구현합니다",
64
+ )
65
+ .action(
66
+ withCliErrorBoundary(async () => {
67
+ const cwd = process.cwd();
68
+
69
+ // 1. TRD 파일 목록 조회
70
+ const trdFiles = findTrdFiles(cwd);
71
+
72
+ if (trdFiles.length === 0) {
73
+ console.log(
74
+ chalk.yellow(
75
+ "\n⚠ TRD 파일이 없습니다. /t-create 스킬로 먼저 TRD를 생성하세요.\n",
76
+ ),
77
+ );
78
+ return;
79
+ }
80
+
81
+ // 2. TRD 선택 UI
82
+ console.log(chalk.bold("\n📋 TRD 목록:\n"));
83
+ for (let i = 0; i < trdFiles.length; i++) {
84
+ const t = trdFiles[i];
85
+ console.log(` ${chalk.cyan(`${i + 1})`)} ${t.name} ${chalk.dim(`(${t.fileName})`)}`);
86
+ }
87
+ console.log();
88
+
89
+ let selectedTrd: string;
90
+ if (trdFiles.length === 1) {
91
+ const { confirm } = await inquirer.prompt<{ confirm: boolean }>([
92
+ {
93
+ type: "confirm",
94
+ name: "confirm",
95
+ message: `"${trdFiles[0].name}" TRD를 실행할까요?`,
96
+ default: true,
97
+ },
98
+ ]);
99
+ if (!confirm) return;
100
+ selectedTrd = trdFiles[0].filePath;
101
+ } else {
102
+ const { num } = await inquirer.prompt<{ num: number }>([
103
+ {
104
+ type: "number",
105
+ name: "num",
106
+ message: "번호를 입력하세요:",
107
+ validate: (v: number) =>
108
+ v >= 1 && v <= trdFiles.length
109
+ ? true
110
+ : `1~${trdFiles.length} 사이 번호를 입력하세요`,
111
+ },
112
+ ]);
113
+ selectedTrd = trdFiles[num - 1].filePath;
114
+ }
115
+
116
+ // 3. TRD 내용 읽기
117
+ const trdContent = fs.readFileSync(selectedTrd, "utf-8");
118
+ const selected = trdFiles.find((t) => t.filePath === selectedTrd)!;
119
+
120
+ console.log(
121
+ chalk.bold(
122
+ `\n🚀 "${selected.name}" TRD를 기반으로 자동 구현을 시작합니다.`,
123
+ ),
124
+ );
125
+
126
+ // 4. Ralph Loop 셋업
127
+ setupRalphLoop(cwd, trdContent, selected.name);
128
+
129
+ console.log(chalk.green("✔ Ralph Loop이 설정되었습니다.\n"));
130
+ console.log(chalk.gray(" 다음 단계:"));
131
+ console.log(
132
+ chalk.gray(
133
+ " Claude Code에서 아무 메시지나 입력하면 Ralph Loop이 시작됩니다.",
134
+ ),
135
+ );
136
+ console.log(
137
+ chalk.gray(
138
+ " 중지하려면: /ralph-loop:cancel-ralph\n",
139
+ ),
140
+ );
141
+ }),
142
+ );
143
+ }
@@ -0,0 +1,50 @@
1
+ import type { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { readTask, updateTask } from "@/features/taskflow/lib/repository";
4
+ import { withCliErrorBoundary } from "../lib/error-boundary.js";
5
+ import type { TaskStatus } from "@/features/taskflow/types";
6
+ import { TASK_STATUSES } from "@/features/taskflow/types";
7
+
8
+ function validateStatus(value: string): TaskStatus {
9
+ if (!(TASK_STATUSES as readonly string[]).includes(value)) {
10
+ throw new Error(
11
+ `유효하지 않은 상태입니다: ${value}\n 허용: ${TASK_STATUSES.join(", ")}`,
12
+ );
13
+ }
14
+ return value as TaskStatus;
15
+ }
16
+
17
+ export function registerSetStatusCommand(program: Command) {
18
+ program
19
+ .command("set-status")
20
+ .description("태스크 상태를 변경합니다")
21
+ .argument("<id>", "태스크 ID")
22
+ .requiredOption("--to <status>", "변경할 상태 (Todo, InProgress, Blocked, Done)")
23
+ .action(
24
+ withCliErrorBoundary(async (id: string, opts: { to: string }) => {
25
+ const projectRoot = process.cwd();
26
+ const newStatus = validateStatus(opts.to);
27
+
28
+ const existing = await readTask(projectRoot, id);
29
+ if (!existing) {
30
+ console.error(chalk.red(`✖ 태스크를 찾을 수 없습니다: ${id}`));
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+
35
+ if (existing.status === newStatus) {
36
+ console.log(chalk.yellow(`이미 "${newStatus}" 상태입니다.`));
37
+ return;
38
+ }
39
+
40
+ const oldStatus = existing.status;
41
+ const updated = await updateTask(projectRoot, id, { status: newStatus });
42
+
43
+ console.log(
44
+ chalk.green(
45
+ `✔ [${updated.id}] ${updated.title}: ${oldStatus} → ${newStatus}`,
46
+ ),
47
+ );
48
+ }),
49
+ );
50
+ }
@@ -0,0 +1,28 @@
1
+ import type { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { readTask } from "@/features/taskflow/lib/repository";
4
+ import { formatTaskDetail } from "../lib/formatter.js";
5
+ import { withCliErrorBoundary } from "../lib/error-boundary.js";
6
+
7
+ export function registerShowCommand(program: Command) {
8
+ program
9
+ .command("show")
10
+ .description("태스크 상세 정보를 출력합니다")
11
+ .argument("<id>", "태스크 ID")
12
+ .action(
13
+ withCliErrorBoundary(async (id: string) => {
14
+ const projectRoot = process.cwd();
15
+ const task = await readTask(projectRoot, id);
16
+
17
+ if (!task) {
18
+ console.error(chalk.red(`✖ 태스크를 찾을 수 없습니다: ${id}`));
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+
23
+ console.log("");
24
+ console.log(formatTaskDetail(task));
25
+ console.log("");
26
+ }),
27
+ );
28
+ }
@@ -0,0 +1,72 @@
1
+ import type { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import { listTasks } from "@/features/taskflow/lib/repository";
4
+ import { formatDependencyTree } from "../lib/formatter.js";
5
+ import { withCliErrorBoundary } from "../lib/error-boundary.js";
6
+ import type { Task } from "@/features/taskflow/types";
7
+ import { getTrdGroupNames } from "../lib/trd.js";
8
+
9
+ function formatGroupTree(tasks: Task[], trdGroupNames: string[]): void {
10
+ const groups = new Map<string, Task[]>();
11
+
12
+ // TRD 그룹을 먼저 빈 상태로 등록
13
+ for (const name of trdGroupNames) {
14
+ groups.set(name, []);
15
+ }
16
+
17
+ for (const task of tasks) {
18
+ const name = task.group ?? "(그룹 없음)";
19
+ let list = groups.get(name);
20
+ if (!list) {
21
+ list = [];
22
+ groups.set(name, list);
23
+ }
24
+ list.push(task);
25
+ }
26
+
27
+ for (const [name, groupTasks] of groups) {
28
+ const done = groupTasks.filter((t) => t.status === "Done").length;
29
+ const total = groupTasks.length;
30
+
31
+ console.log(chalk.bold(`\n── ${name} (${done}/${total}) ──\n`));
32
+ console.log(formatDependencyTree(groupTasks, {}));
33
+ }
34
+ }
35
+
36
+ export function registerTreeCommand(program: Command) {
37
+ program
38
+ .command("tree")
39
+ .description("의존성 트리를 출력합니다 (그룹별)")
40
+ .option("--detail [group]", "특정 그룹의 상세 트리")
41
+ .option("--root <id>", "특정 태스크를 루트로 지정")
42
+ .option("--depth <n>", "최대 깊이 제한", parseInt)
43
+ .action(
44
+ withCliErrorBoundary(
45
+ async (opts: { detail?: string | boolean; root?: string; depth?: number }) => {
46
+ const projectRoot = process.cwd();
47
+ const tasks = await listTasks(projectRoot);
48
+ const trdGroups = getTrdGroupNames(projectRoot);
49
+
50
+ if (tasks.length === 0 && trdGroups.length === 0) {
51
+ console.log(chalk.yellow("\n⚠ 태스크가 없습니다.\n"));
52
+ return;
53
+ }
54
+
55
+ if (opts.detail !== undefined || opts.root) {
56
+ const groupFilter = typeof opts.detail === "string" ? opts.detail : undefined;
57
+ const filtered = groupFilter
58
+ ? tasks.filter((t) => t.group?.includes(groupFilter))
59
+ : tasks;
60
+
61
+ console.log("");
62
+ console.log(formatDependencyTree(filtered, { rootId: opts.root, maxDepth: opts.depth }));
63
+ console.log("");
64
+ return;
65
+ }
66
+
67
+ formatGroupTree(tasks, trdGroups);
68
+ console.log(chalk.gray("\n 상세 보기: task tree --detail [그룹명]\n"));
69
+ },
70
+ ),
71
+ );
72
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import { registerInitCommand } from "./commands/init.js";
6
+ import { registerListCommand } from "./commands/list.js";
7
+ import { registerBoardCommand } from "./commands/board.js";
8
+ import { registerTreeCommand } from "./commands/tree.js";
9
+ import { registerShowCommand } from "./commands/show.js";
10
+ import { registerSetStatusCommand } from "./commands/set-status.js";
11
+ import { registerAskCommand } from "./commands/ask.js";
12
+ import { registerAdvisorCommand } from "./commands/advisor.js";
13
+ import { registerRunCommand } from "./commands/run.js";
14
+
15
+ // CTRL+C 안전 종료
16
+ process.on("SIGINT", () => {
17
+ console.log(chalk.yellow("\n\n👋 작업을 취소했습니다. 언제든 다시 시도해주세요!"));
18
+ process.exit(0);
19
+ });
20
+
21
+ const program = new Command();
22
+
23
+ program
24
+ .name("task")
25
+ .description("TaskFlow — AI-powered task manager for developers")
26
+ .version("0.1.0");
27
+
28
+ registerInitCommand(program);
29
+ registerListCommand(program);
30
+ registerBoardCommand(program);
31
+ registerTreeCommand(program);
32
+ registerShowCommand(program);
33
+ registerSetStatusCommand(program);
34
+ registerAskCommand(program);
35
+ registerAdvisorCommand(program);
36
+ registerRunCommand(program);
37
+
38
+ program.parse();
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncate, formatTaskTable, formatTaskDetail } from "../formatter";
3
+ import type { Task } from "@/features/taskflow/types";
4
+
5
+ const now = new Date().toISOString();
6
+
7
+ function makeTask(overrides: Partial<Task> = {}): Task {
8
+ return {
9
+ id: "001",
10
+ title: "Test task",
11
+ status: "Todo",
12
+ priority: 5,
13
+ dependencies: [],
14
+ createdAt: now,
15
+ updatedAt: now,
16
+ description: "",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("truncate", () => {
22
+ it("should not truncate short strings", () => {
23
+ expect(truncate("hello", 10)).toBe("hello");
24
+ });
25
+
26
+ it("should truncate long strings with ellipsis", () => {
27
+ expect(truncate("a very long string that exceeds", 15)).toBe("a very long s..");
28
+ });
29
+
30
+ it("should handle exact length", () => {
31
+ expect(truncate("exact", 5)).toBe("exact");
32
+ });
33
+
34
+ it("should handle maxLen <= 3", () => {
35
+ expect(truncate("hello", 3)).toBe("hel");
36
+ });
37
+
38
+ it("should handle empty string", () => {
39
+ expect(truncate("", 10)).toBe("");
40
+ });
41
+ });
42
+
43
+ describe("formatTaskTable", () => {
44
+ it("should show empty message for no tasks", () => {
45
+ const output = formatTaskTable([]);
46
+ expect(output).toContain("태스크가 없습니다");
47
+ });
48
+
49
+ it("should render table with tasks", () => {
50
+ const tasks = [
51
+ makeTask({ id: "001", title: "Setup", priority: 8, status: "InProgress" }),
52
+ makeTask({ id: "002", title: "Auth", priority: 5, status: "Todo" }),
53
+ ];
54
+ const output = formatTaskTable(tasks);
55
+
56
+ // strip ANSI
57
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
58
+
59
+ expect(plain).toContain("001");
60
+ expect(plain).toContain("002");
61
+ expect(plain).toContain("Setup");
62
+ expect(plain).toContain("Auth");
63
+ expect(plain).toContain("InProgress");
64
+ expect(plain).toContain("Todo");
65
+ });
66
+
67
+ it("should show dependency count", () => {
68
+ const tasks = [
69
+ makeTask({ id: "001", dependencies: ["002", "003"] }),
70
+ ];
71
+ const output = formatTaskTable(tasks);
72
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
73
+ expect(plain).toContain("2");
74
+ });
75
+
76
+ it("should truncate long titles", () => {
77
+ const tasks = [
78
+ makeTask({ title: "This is an extremely long task title that should be truncated in the table view" }),
79
+ ];
80
+ const output = formatTaskTable(tasks);
81
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
82
+ expect(plain).toContain("..");
83
+ });
84
+ });
85
+
86
+ describe("formatTaskDetail", () => {
87
+ it("should display all task fields", () => {
88
+ const task = makeTask({
89
+ id: "005",
90
+ title: "Implement auth",
91
+ status: "InProgress",
92
+ priority: 8,
93
+ dependencies: ["001", "002"],
94
+ parentId: "000",
95
+ description: "OAuth2 implementation details",
96
+ });
97
+
98
+ const output = formatTaskDetail(task);
99
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
100
+
101
+ expect(plain).toContain("005");
102
+ expect(plain).toContain("Implement auth");
103
+ expect(plain).toContain("InProgress");
104
+ expect(plain).toContain("8");
105
+ expect(plain).toContain("001, 002");
106
+ expect(plain).toContain("000");
107
+ expect(plain).toContain("OAuth2 implementation details");
108
+ });
109
+
110
+ it("should show '없음' for no dependencies", () => {
111
+ const task = makeTask({ dependencies: [] });
112
+ const output = formatTaskDetail(task);
113
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
114
+ expect(plain).toContain("없음");
115
+ });
116
+
117
+ it("should omit parentId if not set", () => {
118
+ const task = makeTask({ parentId: undefined });
119
+ const output = formatTaskDetail(task);
120
+ const plain = output.replace(/\x1b\[[0-9;]*m/g, "");
121
+ expect(plain).not.toContain("상위 태스크");
122
+ });
123
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { withCliErrorBoundary, humanizeError } from "./error-boundary.js";
3
+
4
+ beforeEach(() => {
5
+ vi.clearAllMocks();
6
+ });
7
+
8
+ // ── humanizeError ──
9
+
10
+ describe("humanizeError", () => {
11
+ it("일반 Error 메시지를 반환해야 한다", () => {
12
+ expect(humanizeError(new Error("뭔가 잘못됨"))).toBe("뭔가 잘못됨");
13
+ });
14
+
15
+ it("EACCES 코드에 대한 한국어 메시지를 반환해야 한다", () => {
16
+ const err = Object.assign(new Error("원본"), { code: "EACCES" });
17
+ const msg = humanizeError(err);
18
+ expect(msg).toContain("접근 권한");
19
+ expect(msg).toContain("EACCES");
20
+ });
21
+
22
+ it("ETIMEDOUT 코드에 대한 한국어 메시지를 반환해야 한다", () => {
23
+ const err = Object.assign(new Error("원본"), { code: "ETIMEDOUT" });
24
+ expect(humanizeError(err)).toContain("시간이 초과");
25
+ });
26
+
27
+ it("ENOSPC 코드에 대한 한국어 메시지를 반환해야 한다", () => {
28
+ const err = Object.assign(new Error("원본"), { code: "ENOSPC" });
29
+ expect(humanizeError(err)).toContain("디스크 공간");
30
+ });
31
+
32
+ it("ENAMETOOLONG 코드에 대한 한국어 메시지를 반환해야 한다", () => {
33
+ const err = Object.assign(new Error("원본"), { code: "ENAMETOOLONG" });
34
+ expect(humanizeError(err)).toContain("경로가 너무 깁니다");
35
+ });
36
+
37
+ it("알 수 없는 코드는 원본 메시지를 반환해야 한다", () => {
38
+ const err = Object.assign(new Error("원본 메시지"), { code: "UNKNOWN" });
39
+ expect(humanizeError(err)).toBe("원본 메시지");
40
+ });
41
+
42
+ it("Error가 아닌 값은 문자열로 변환해야 한다", () => {
43
+ expect(humanizeError("문자열 에러")).toBe("문자열 에러");
44
+ expect(humanizeError(42)).toBe("42");
45
+ expect(humanizeError(null)).toBe("null");
46
+ });
47
+ });
48
+
49
+ // ── withCliErrorBoundary ──
50
+
51
+ describe("withCliErrorBoundary", () => {
52
+ let consoleSpy: ReturnType<typeof vi.spyOn>;
53
+
54
+ beforeEach(() => {
55
+ consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
56
+ vi.spyOn(console, "log").mockImplementation(() => {});
57
+ });
58
+
59
+ afterEach(() => {
60
+ process.exitCode = undefined;
61
+ vi.restoreAllMocks();
62
+ });
63
+
64
+ it("성공 시 원래 반환값을 전달해야 한다", async () => {
65
+ const fn = vi.fn().mockResolvedValue("result");
66
+ const wrapped = withCliErrorBoundary(fn);
67
+ const result = await wrapped();
68
+ expect(result).toBe("result");
69
+ });
70
+
71
+ it("일반 에러 발생 시 exitCode를 1로 설정해야 한다", async () => {
72
+ const fn = vi.fn().mockRejectedValue(new Error("실패"));
73
+ const wrapped = withCliErrorBoundary(fn);
74
+
75
+ await wrapped();
76
+
77
+ expect(process.exitCode).toBe(1);
78
+ });
79
+
80
+ it("일반 에러 발생 시 한국어 오류 메시지를 출력해야 한다", async () => {
81
+ const fn = vi.fn().mockRejectedValue(new Error("실패"));
82
+ const wrapped = withCliErrorBoundary(fn);
83
+
84
+ await wrapped();
85
+
86
+ const allOutput = consoleSpy.mock.calls.flat().join(" ");
87
+ expect(allOutput).toContain("오류가 발생했습니다");
88
+ expect(allOutput).toContain("실패");
89
+ expect(allOutput).toContain("해결 방법");
90
+ });
91
+
92
+ it("CTRL+C (ExitPromptError) 시 exitCode를 0으로 설정해야 한다", async () => {
93
+ const err = new Error("User force closed");
94
+ err.name = "ExitPromptError";
95
+ const fn = vi.fn().mockRejectedValue(err);
96
+ const wrapped = withCliErrorBoundary(fn);
97
+
98
+ await wrapped();
99
+
100
+ expect(process.exitCode).toBe(0);
101
+ });
102
+
103
+ it("CTRL+C 시 친절한 종료 메시지를 출력해야 한다", async () => {
104
+ const logSpy = vi.spyOn(console, "log");
105
+ const err = new Error("User force closed");
106
+ err.name = "ExitPromptError";
107
+ const fn = vi.fn().mockRejectedValue(err);
108
+ const wrapped = withCliErrorBoundary(fn);
109
+
110
+ await wrapped();
111
+
112
+ const allOutput = logSpy.mock.calls.flat().join(" ");
113
+ expect(allOutput).toContain("취소");
114
+ });
115
+
116
+ it("시스템 에러 코드에 대해 한국어 메시지를 출력해야 한다", async () => {
117
+ const err = Object.assign(new Error("permission denied"), { code: "EACCES" });
118
+ const fn = vi.fn().mockRejectedValue(err);
119
+ const wrapped = withCliErrorBoundary(fn);
120
+
121
+ await wrapped();
122
+
123
+ const allOutput = consoleSpy.mock.calls.flat().join(" ");
124
+ expect(allOutput).toContain("접근 권한");
125
+ });
126
+
127
+ it("인자를 원래 함수에 전달해야 한다", async () => {
128
+ const fn = vi.fn().mockResolvedValue("ok");
129
+ const wrapped = withCliErrorBoundary(fn);
130
+
131
+ await wrapped("arg1", "arg2");
132
+
133
+ expect(fn).toHaveBeenCalledWith("arg1", "arg2");
134
+ });
135
+ });
@@ -0,0 +1,70 @@
1
+ import chalk from "chalk";
2
+
3
+ const ERROR_MESSAGES: Record<string, string> = {
4
+ EACCES: "파일 또는 디렉토리에 대한 접근 권한이 없습니다.",
5
+ EPERM: "작업에 필요한 권한이 없습니다.",
6
+ EROFS: "읽기 전용 파일 시스템에는 쓸 수 없습니다.",
7
+ ENAMETOOLONG: "파일 경로가 너무 깁니다. 프로젝트명이나 기능명을 줄여주세요.",
8
+ ENOSPC: "디스크 공간이 부족합니다.",
9
+ ETIMEDOUT: "요청 시간이 초과되었습니다. 네트워크 연결을 확인해주세요.",
10
+ ECONNREFUSED: "서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.",
11
+ ENOTFOUND: "서버를 찾을 수 없습니다. 네트워크 연결을 확인해주세요.",
12
+ };
13
+
14
+ export function humanizeError(error: unknown): string {
15
+ if (error instanceof Error) {
16
+ const code = (error as NodeJS.ErrnoException).code;
17
+ if (code && ERROR_MESSAGES[code]) {
18
+ return `${ERROR_MESSAGES[code]} (${code})`;
19
+ }
20
+ return error.message;
21
+ }
22
+ return String(error);
23
+ }
24
+
25
+ export function withCliErrorBoundary<
26
+ T extends (...args: any[]) => Promise<any>,
27
+ >(fn: T): T {
28
+ return (async (...args: any[]) => {
29
+ try {
30
+ return await fn(...args);
31
+ } catch (error: unknown) {
32
+ // CTRL+C (Inquirer ExitPromptError)는 조용히 종료
33
+ if (isUserCancellation(error)) {
34
+ console.log(chalk.yellow("\n\n👋 작업을 취소했습니다. 언제든 다시 시도해주세요!"));
35
+ process.exitCode = 0;
36
+ return;
37
+ }
38
+
39
+ console.error("");
40
+ console.error(chalk.red("✖ 오류가 발생했습니다:"));
41
+ console.error(chalk.red(` ${humanizeError(error)}`));
42
+ console.error("");
43
+ console.error(chalk.gray("💡 해결 방법:"));
44
+ console.error(chalk.gray(" • 명령어를 다시 실행하여 재시도해주세요"));
45
+ console.error(chalk.gray(" • 문제가 지속되면 --help 옵션으로 사용법을 확인하세요"));
46
+ console.error("");
47
+
48
+ process.exitCode = 1;
49
+ }
50
+ }) as T;
51
+ }
52
+
53
+ function isUserCancellation(error: unknown): boolean {
54
+ if (!error || typeof error !== "object") return false;
55
+
56
+ // Inquirer의 ExitPromptError
57
+ const name = (error as Error).name;
58
+ if (name === "ExitPromptError") return true;
59
+
60
+ // 일반적인 사용자 취소 메시지
61
+ const message = (error as Error).message ?? "";
62
+ if (
63
+ message.includes("User force closed") ||
64
+ message.includes("prompt was closed")
65
+ ) {
66
+ return true;
67
+ }
68
+
69
+ return false;
70
+ }