beads-kanban-ui 0.1.0 → 0.1.2

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 (154) hide show
  1. package/README.md +16 -222
  2. package/package.json +18 -55
  3. package/.designs/beads-kanban-ui-bj0.md +0 -73
  4. package/.designs/beads-kanban-ui-qxq.md +0 -144
  5. package/.designs/epic-support.md +0 -282
  6. package/.env.local.example +0 -2
  7. package/.eslintrc.json +0 -3
  8. package/.gitattributes +0 -3
  9. package/.github/workflows/release.yml +0 -123
  10. package/.history/README_20260121193710.md +0 -227
  11. package/.history/README_20260121193918.md +0 -227
  12. package/.history/README_20260121193921.md +0 -227
  13. package/.history/README_20260121193933.md +0 -227
  14. package/.history/README_20260121193934.md +0 -227
  15. package/.history/README_20260121193944.md +0 -227
  16. package/.history/README_20260121193953.md +0 -227
  17. package/.history/src/app/page_20260121133429.tsx +0 -134
  18. package/.history/src/app/page_20260121133928.tsx +0 -134
  19. package/.history/src/app/page_20260121144850.tsx +0 -138
  20. package/.history/src/app/page_20260121144854.tsx +0 -138
  21. package/.history/src/app/page_20260121144858.tsx +0 -138
  22. package/.history/src/app/page_20260121144902.tsx +0 -138
  23. package/.history/src/app/page_20260121144906.tsx +0 -138
  24. package/.history/src/app/page_20260121144911.tsx +0 -138
  25. package/.history/src/app/page_20260121144928.tsx +0 -138
  26. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  27. package/.playwright-mcp/beams-test.png +0 -0
  28. package/.playwright-mcp/card-verification.png +0 -0
  29. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  30. package/.playwright-mcp/dialog-width-test.png +0 -0
  31. package/.playwright-mcp/homepage.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  33. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  34. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  38. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  39. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  40. package/.playwright-mcp/screenshot-after-click.png +0 -0
  41. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  42. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  43. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  44. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  45. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  46. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  47. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  48. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  49. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  50. package/Screenshots/bead-detail.png +0 -0
  51. package/Screenshots/dashboard.png +0 -0
  52. package/Screenshots/kanban-board.png +0 -0
  53. package/components.json +0 -27
  54. package/logo/logo.svg +0 -1
  55. package/next.config.js +0 -9
  56. package/npm/README.md +0 -37
  57. package/npm/package.json +0 -20
  58. package/postcss.config.js +0 -6
  59. package/public/logo.svg +0 -1
  60. package/restart.sh +0 -5
  61. package/server/Cargo.lock +0 -1685
  62. package/server/Cargo.toml +0 -24
  63. package/server/src/db.rs +0 -570
  64. package/server/src/main.rs +0 -141
  65. package/server/src/routes/beads.rs +0 -413
  66. package/server/src/routes/cli.rs +0 -150
  67. package/server/src/routes/fs.rs +0 -360
  68. package/server/src/routes/git.rs +0 -169
  69. package/server/src/routes/mod.rs +0 -107
  70. package/server/src/routes/projects.rs +0 -177
  71. package/server/src/routes/watch.rs +0 -211
  72. package/src/app/globals.css +0 -101
  73. package/src/app/layout.tsx +0 -36
  74. package/src/app/page.tsx +0 -348
  75. package/src/app/project/kanban-board.tsx +0 -356
  76. package/src/app/project/page.tsx +0 -18
  77. package/src/app/settings/page.tsx +0 -224
  78. package/src/components/Beams.css +0 -5
  79. package/src/components/Beams.jsx +0 -307
  80. package/src/components/Galaxy.css +0 -5
  81. package/src/components/Galaxy.jsx +0 -333
  82. package/src/components/activity-timeline.tsx +0 -172
  83. package/src/components/add-project-dialog.tsx +0 -219
  84. package/src/components/bead-card.tsx +0 -196
  85. package/src/components/bead-detail.tsx +0 -306
  86. package/src/components/color-picker.tsx +0 -101
  87. package/src/components/comment-input.tsx +0 -155
  88. package/src/components/comment-list.tsx +0 -147
  89. package/src/components/dependency-badge.tsx +0 -106
  90. package/src/components/design-doc-dialog.tsx +0 -58
  91. package/src/components/design-doc-preview.tsx +0 -97
  92. package/src/components/design-doc-viewer.tsx +0 -199
  93. package/src/components/editable-project-name.tsx +0 -178
  94. package/src/components/epic-card.tsx +0 -263
  95. package/src/components/folder-browser.tsx +0 -273
  96. package/src/components/footer.tsx +0 -27
  97. package/src/components/kanban/default.tsx +0 -184
  98. package/src/components/kanban-column.tsx +0 -167
  99. package/src/components/project-card.tsx +0 -191
  100. package/src/components/quick-filter-bar.tsx +0 -279
  101. package/src/components/scan-directory-dialog.tsx +0 -368
  102. package/src/components/status-donut.tsx +0 -197
  103. package/src/components/subtask-list.tsx +0 -128
  104. package/src/components/tag-picker.tsx +0 -252
  105. package/src/components/ui/.gitkeep +0 -0
  106. package/src/components/ui/alert-dialog.tsx +0 -141
  107. package/src/components/ui/avatar.tsx +0 -67
  108. package/src/components/ui/badge.tsx +0 -230
  109. package/src/components/ui/button.tsx +0 -433
  110. package/src/components/ui/card/index.tsx +0 -24
  111. package/src/components/ui/card/roiui-card.module.css +0 -197
  112. package/src/components/ui/card/roiui-card.tsx +0 -154
  113. package/src/components/ui/card/shadcn-card.tsx +0 -76
  114. package/src/components/ui/chart.tsx +0 -369
  115. package/src/components/ui/dialog.tsx +0 -122
  116. package/src/components/ui/dropdown-menu.tsx +0 -201
  117. package/src/components/ui/input.tsx +0 -22
  118. package/src/components/ui/kanban.tsx +0 -522
  119. package/src/components/ui/morphing-dialog.tsx +0 -457
  120. package/src/components/ui/popover.tsx +0 -33
  121. package/src/components/ui/progress.tsx +0 -28
  122. package/src/components/ui/scroll-area.tsx +0 -48
  123. package/src/components/ui/select.tsx +0 -159
  124. package/src/components/ui/separator.tsx +0 -31
  125. package/src/components/ui/sheet.tsx +0 -142
  126. package/src/components/ui/skeleton.tsx +0 -15
  127. package/src/components/ui/toast.tsx +0 -129
  128. package/src/components/ui/toaster.tsx +0 -35
  129. package/src/components/ui/tooltip.tsx +0 -30
  130. package/src/hooks/.gitkeep +0 -0
  131. package/src/hooks/use-bead-filters.ts +0 -261
  132. package/src/hooks/use-beads.ts +0 -162
  133. package/src/hooks/use-branch-statuses.ts +0 -161
  134. package/src/hooks/use-epics.ts +0 -173
  135. package/src/hooks/use-file-watcher.ts +0 -111
  136. package/src/hooks/use-keyboard-navigation.ts +0 -282
  137. package/src/hooks/use-project.ts +0 -61
  138. package/src/hooks/use-projects.ts +0 -93
  139. package/src/hooks/use-toast.ts +0 -194
  140. package/src/hooks/useClickOutside.tsx +0 -26
  141. package/src/lib/.gitkeep +0 -0
  142. package/src/lib/api.ts +0 -186
  143. package/src/lib/beads-parser.ts +0 -252
  144. package/src/lib/cli.ts +0 -193
  145. package/src/lib/db.ts +0 -145
  146. package/src/lib/design-doc.ts +0 -74
  147. package/src/lib/epic-parser.ts +0 -242
  148. package/src/lib/git.ts +0 -102
  149. package/src/lib/utils.ts +0 -12
  150. package/src/types/index.ts +0 -107
  151. package/tailwind.config.ts +0 -85
  152. package/tsconfig.json +0 -26
  153. /package/{npm/bin → bin}/cli.js +0 -0
  154. /package/{npm/scripts → scripts}/postinstall.js +0 -0
@@ -1,360 +0,0 @@
1
- //! Filesystem API route handlers.
2
- //!
3
- //! Provides endpoints for listing directories and checking path existence.
4
-
5
- use axum::{
6
- extract::Query,
7
- http::StatusCode,
8
- response::IntoResponse,
9
- Json,
10
- };
11
- use serde::{Deserialize, Serialize};
12
- use std::path::PathBuf;
13
-
14
- use super::validate_path_security;
15
-
16
- /// Query parameters for the list directory endpoint.
17
- #[derive(Debug, Deserialize)]
18
- pub struct FsListParams {
19
- /// The directory path to list
20
- pub path: String,
21
- }
22
-
23
- /// Query parameters for the path exists endpoint.
24
- #[derive(Debug, Deserialize)]
25
- pub struct FsExistsParams {
26
- /// The path to check for existence
27
- pub path: String,
28
- }
29
-
30
- /// Query parameters for the read file endpoint.
31
- #[derive(Debug, Deserialize)]
32
- pub struct FsReadParams {
33
- /// The file path to read (relative, e.g., ".designs/epic.md")
34
- pub path: String,
35
- /// The project path (absolute directory path)
36
- pub project_path: String,
37
- }
38
-
39
- /// Request body for opening a path in an external application.
40
- #[derive(Debug, Deserialize)]
41
- pub struct OpenExternalRequest {
42
- /// The path to open
43
- pub path: String,
44
- /// Target application: "vscode", "cursor", or "finder"
45
- pub target: String,
46
- }
47
-
48
- /// A single directory entry.
49
- #[derive(Debug, Serialize)]
50
- pub struct DirectoryEntry {
51
- /// The file/directory name
52
- pub name: String,
53
- /// The full path
54
- pub path: String,
55
- /// Whether this entry is a directory
56
- #[serde(rename = "isDirectory")]
57
- pub is_directory: bool,
58
- }
59
-
60
- /// GET /api/fs/list?path=/some/directory
61
- ///
62
- /// Lists the contents of a directory, filtering out hidden files
63
- /// except for .beads directories.
64
- pub async fn list_directory(Query(params): Query<FsListParams>) -> impl IntoResponse {
65
- let dir_path = PathBuf::from(&params.path);
66
-
67
- // Security: Validate path is within allowed directories
68
- if let Err(e) = validate_path_security(&dir_path) {
69
- return (
70
- StatusCode::FORBIDDEN,
71
- Json(serde_json::json!({ "error": e })),
72
- );
73
- }
74
-
75
- // Check if path exists and is a directory
76
- if !dir_path.exists() {
77
- return (
78
- StatusCode::NOT_FOUND,
79
- Json(serde_json::json!({ "error": "Path does not exist" })),
80
- );
81
- }
82
-
83
- if !dir_path.is_dir() {
84
- return (
85
- StatusCode::BAD_REQUEST,
86
- Json(serde_json::json!({ "error": "Path is not a directory" })),
87
- );
88
- }
89
-
90
- // Read directory entries
91
- let read_dir = match std::fs::read_dir(&dir_path) {
92
- Ok(rd) => rd,
93
- Err(e) => {
94
- return (
95
- StatusCode::INTERNAL_SERVER_ERROR,
96
- Json(serde_json::json!({ "error": format!("Failed to read directory: {}", e) })),
97
- );
98
- }
99
- };
100
-
101
- let mut entries: Vec<DirectoryEntry> = Vec::new();
102
-
103
- for entry_result in read_dir {
104
- let entry = match entry_result {
105
- Ok(e) => e,
106
- Err(e) => {
107
- tracing::warn!("Failed to read directory entry: {}", e);
108
- continue;
109
- }
110
- };
111
-
112
- let name = entry.file_name().to_string_lossy().to_string();
113
-
114
- // Filter out hidden files except .beads
115
- if name.starts_with('.') && name != ".beads" {
116
- continue;
117
- }
118
-
119
- let path = entry.path();
120
- let is_directory = path.is_dir();
121
-
122
- entries.push(DirectoryEntry {
123
- name,
124
- path: path.to_string_lossy().to_string(),
125
- is_directory,
126
- });
127
- }
128
-
129
- // Sort entries: directories first, then alphabetically
130
- entries.sort_by(|a, b| {
131
- match (a.is_directory, b.is_directory) {
132
- (true, false) => std::cmp::Ordering::Less,
133
- (false, true) => std::cmp::Ordering::Greater,
134
- _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
135
- }
136
- });
137
-
138
- (StatusCode::OK, Json(serde_json::json!({ "entries": entries })))
139
- }
140
-
141
- /// GET /api/fs/exists?path=/some/path
142
- ///
143
- /// Checks if a path exists on the filesystem.
144
- pub async fn path_exists(Query(params): Query<FsExistsParams>) -> impl IntoResponse {
145
- let path = PathBuf::from(&params.path);
146
-
147
- // Security: Validate path is within allowed directories
148
- if let Err(e) = validate_path_security(&path) {
149
- return (
150
- StatusCode::FORBIDDEN,
151
- Json(serde_json::json!({ "error": e })),
152
- );
153
- }
154
-
155
- let exists = path.exists();
156
-
157
- (StatusCode::OK, Json(serde_json::json!({ "exists": exists })))
158
- }
159
-
160
- /// GET /api/fs/read?path=.designs/{EPIC_ID}.md&project_path=/absolute/path
161
- ///
162
- /// Reads a design document file from the .designs directory.
163
- ///
164
- /// # Security constraints:
165
- /// - Max file size: 100KB
166
- /// - Only .md extension allowed
167
- /// - Path must be within project directory
168
- /// - Path must start with ".designs/"
169
- pub async fn read_file(Query(params): Query<FsReadParams>) -> impl IntoResponse {
170
- // Security: Path must start with .designs/
171
- if !params.path.starts_with(".designs/") {
172
- return (
173
- StatusCode::FORBIDDEN,
174
- Json(serde_json::json!({
175
- "error": "Access denied: path must start with .designs/"
176
- })),
177
- );
178
- }
179
-
180
- // Parse relative path to validate extension
181
- let relative_path = PathBuf::from(&params.path);
182
-
183
- // Security: Only .md extension allowed
184
- if relative_path.extension().and_then(|s| s.to_str()) != Some("md") {
185
- return (
186
- StatusCode::FORBIDDEN,
187
- Json(serde_json::json!({
188
- "error": "Access denied: only .md files are allowed"
189
- })),
190
- );
191
- }
192
-
193
- // Join project path with relative design doc path to get absolute path
194
- let project_root = PathBuf::from(&params.project_path);
195
- let file_path = project_root.join(&params.path);
196
-
197
- // Security: Validate absolute path is within allowed directories
198
- if let Err(e) = validate_path_security(&file_path) {
199
- return (
200
- StatusCode::FORBIDDEN,
201
- Json(serde_json::json!({ "error": e })),
202
- );
203
- }
204
-
205
- // Check if file exists
206
- if !file_path.exists() {
207
- return (
208
- StatusCode::NOT_FOUND,
209
- Json(serde_json::json!({ "error": "File does not exist" })),
210
- );
211
- }
212
-
213
- // Check if path is a file (not a directory)
214
- if !file_path.is_file() {
215
- return (
216
- StatusCode::BAD_REQUEST,
217
- Json(serde_json::json!({ "error": "Path is not a file" })),
218
- );
219
- }
220
-
221
- // Security: Check file size (max 100KB)
222
- let metadata = match std::fs::metadata(&file_path) {
223
- Ok(m) => m,
224
- Err(e) => {
225
- return (
226
- StatusCode::INTERNAL_SERVER_ERROR,
227
- Json(serde_json::json!({
228
- "error": format!("Failed to read file metadata: {}", e)
229
- })),
230
- );
231
- }
232
- };
233
-
234
- const MAX_FILE_SIZE: u64 = 100 * 1024; // 100KB
235
- if metadata.len() > MAX_FILE_SIZE {
236
- return (
237
- StatusCode::PAYLOAD_TOO_LARGE,
238
- Json(serde_json::json!({
239
- "error": format!("File too large: {} bytes (max {} bytes)", metadata.len(), MAX_FILE_SIZE)
240
- })),
241
- );
242
- }
243
-
244
- // Read file contents
245
- let contents = match std::fs::read_to_string(&file_path) {
246
- Ok(c) => c,
247
- Err(e) => {
248
- return (
249
- StatusCode::INTERNAL_SERVER_ERROR,
250
- Json(serde_json::json!({
251
- "error": format!("Failed to read file: {}", e)
252
- })),
253
- );
254
- }
255
- };
256
-
257
- (
258
- StatusCode::OK,
259
- Json(serde_json::json!({
260
- "content": contents,
261
- "path": params.path
262
- })),
263
- )
264
- }
265
-
266
- /// POST /api/fs/open-external
267
- ///
268
- /// Opens a path in an external application (VS Code, Cursor, or Finder/Explorer).
269
- ///
270
- /// # Security constraints:
271
- /// - Path must be within user's home directory
272
- /// - Target must be one of: "vscode", "cursor", "finder"
273
- pub async fn open_external(Json(request): Json<OpenExternalRequest>) -> impl IntoResponse {
274
- let path = PathBuf::from(&request.path);
275
-
276
- // Security: Validate path is within allowed directories
277
- if let Err(e) = validate_path_security(&path) {
278
- return (
279
- StatusCode::FORBIDDEN,
280
- Json(serde_json::json!({ "error": e })),
281
- );
282
- }
283
-
284
- // Check if path exists
285
- if !path.exists() {
286
- return (
287
- StatusCode::NOT_FOUND,
288
- Json(serde_json::json!({ "error": "Path does not exist" })),
289
- );
290
- }
291
-
292
- // Execute the appropriate command based on target
293
- let result = match request.target.as_str() {
294
- "vscode" => {
295
- // Use "code" command for VS Code
296
- std::process::Command::new("code").arg(&path).spawn()
297
- }
298
- "cursor" => {
299
- // Use "cursor" command for Cursor
300
- std::process::Command::new("cursor").arg(&path).spawn()
301
- }
302
- "finder" => {
303
- // Use the `open` crate for cross-platform support
304
- // On macOS: opens Finder, on Linux: file manager, on Windows: Explorer
305
- match open::that(&path) {
306
- Ok(_) => {
307
- return (
308
- StatusCode::OK,
309
- Json(serde_json::json!({ "success": true })),
310
- );
311
- }
312
- Err(e) => {
313
- return (
314
- StatusCode::INTERNAL_SERVER_ERROR,
315
- Json(serde_json::json!({
316
- "error": format!("Failed to open: {}", e)
317
- })),
318
- );
319
- }
320
- }
321
- }
322
- _ => {
323
- return (
324
- StatusCode::BAD_REQUEST,
325
- Json(serde_json::json!({
326
- "error": "Invalid target. Must be 'vscode', 'cursor', or 'finder'"
327
- })),
328
- );
329
- }
330
- };
331
-
332
- match result {
333
- Ok(_) => (
334
- StatusCode::OK,
335
- Json(serde_json::json!({ "success": true })),
336
- ),
337
- Err(e) => (
338
- StatusCode::INTERNAL_SERVER_ERROR,
339
- Json(serde_json::json!({
340
- "error": format!("Failed to open: {}. Make sure the application is installed.", e)
341
- })),
342
- ),
343
- }
344
- }
345
-
346
- #[cfg(test)]
347
- mod tests {
348
- use super::*;
349
-
350
- #[test]
351
- fn test_directory_entry_serialization() {
352
- let entry = DirectoryEntry {
353
- name: "test".to_string(),
354
- path: "/home/user/test".to_string(),
355
- is_directory: true,
356
- };
357
- let json = serde_json::to_string(&entry).unwrap();
358
- assert!(json.contains("\"isDirectory\":true"));
359
- }
360
- }
@@ -1,169 +0,0 @@
1
- //! Git route handlers for checking repository status.
2
- //!
3
- //! Provides endpoints for querying git branch status and repository state.
4
-
5
- use axum::{extract::Query, http::StatusCode, response::IntoResponse, Json};
6
- use serde::{Deserialize, Serialize};
7
- use std::path::Path;
8
- use tokio::process::Command;
9
-
10
- /// Query parameters for the branch status endpoint.
11
- #[derive(Deserialize)]
12
- pub struct GitStatusParams {
13
- /// Path to the git repository.
14
- pub path: String,
15
- /// Branch name to check status for.
16
- pub branch: String,
17
- }
18
-
19
- /// Response body for the branch status endpoint.
20
- #[derive(Serialize)]
21
- pub struct BranchStatusResponse {
22
- /// Whether the branch exists.
23
- pub exists: bool,
24
- /// Number of commits ahead of main.
25
- pub ahead: i32,
26
- /// Number of commits behind main.
27
- pub behind: i32,
28
- /// Whether there are uncommitted changes.
29
- pub dirty: bool,
30
- }
31
-
32
- /// Get the status of a git branch relative to main.
33
- ///
34
- /// # Endpoint
35
- ///
36
- /// `GET /api/git/branch-status?path=...&branch=...`
37
- ///
38
- /// # Response
39
- ///
40
- /// Returns branch existence, ahead/behind counts, and dirty status.
41
- pub async fn branch_status(Query(params): Query<GitStatusParams>) -> impl IntoResponse {
42
- let repo_path = Path::new(&params.path);
43
-
44
- // Validate repository path exists
45
- if !repo_path.exists() {
46
- return (
47
- StatusCode::BAD_REQUEST,
48
- Json(serde_json::json!({
49
- "error": format!("Repository path does not exist: {}", params.path)
50
- })),
51
- )
52
- .into_response();
53
- }
54
-
55
- if !repo_path.is_dir() {
56
- return (
57
- StatusCode::BAD_REQUEST,
58
- Json(serde_json::json!({
59
- "error": format!("Path is not a directory: {}", params.path)
60
- })),
61
- )
62
- .into_response();
63
- }
64
-
65
- // Check if branch exists
66
- let branch_exists = check_branch_exists(&params.path, &params.branch).await;
67
-
68
- if !branch_exists {
69
- return Json(BranchStatusResponse {
70
- exists: false,
71
- ahead: 0,
72
- behind: 0,
73
- dirty: false,
74
- })
75
- .into_response();
76
- }
77
-
78
- // Get ahead/behind counts relative to main
79
- let (ahead, behind) = get_ahead_behind(&params.path, &params.branch).await;
80
-
81
- // Check for uncommitted changes
82
- let dirty = check_dirty(&params.path).await;
83
-
84
- Json(BranchStatusResponse {
85
- exists: true,
86
- ahead,
87
- behind,
88
- dirty,
89
- })
90
- .into_response()
91
- }
92
-
93
- /// Check if a branch exists in the repository.
94
- async fn check_branch_exists(repo_path: &str, branch: &str) -> bool {
95
- let output = Command::new("git")
96
- .args(["rev-parse", "--verify", branch])
97
- .current_dir(repo_path)
98
- .output()
99
- .await;
100
-
101
- matches!(output, Ok(o) if o.status.success())
102
- }
103
-
104
- /// Get the number of commits ahead and behind relative to main.
105
- async fn get_ahead_behind(repo_path: &str, branch: &str) -> (i32, i32) {
106
- // Try both 'main' and 'master' as the base branch
107
- let base_branches = ["main", "master"];
108
-
109
- for base in base_branches {
110
- let output = Command::new("git")
111
- .args([
112
- "rev-list",
113
- "--left-right",
114
- "--count",
115
- &format!("{}...{}", base, branch),
116
- ])
117
- .current_dir(repo_path)
118
- .output()
119
- .await;
120
-
121
- if let Ok(output) = output {
122
- if output.status.success() {
123
- let stdout = String::from_utf8_lossy(&output.stdout);
124
- let parts: Vec<&str> = stdout.trim().split('\t').collect();
125
- if parts.len() == 2 {
126
- let behind = parts[0].parse().unwrap_or(0);
127
- let ahead = parts[1].parse().unwrap_or(0);
128
- return (ahead, behind);
129
- }
130
- }
131
- }
132
- }
133
-
134
- (0, 0)
135
- }
136
-
137
- /// Check if the repository has uncommitted changes.
138
- async fn check_dirty(repo_path: &str) -> bool {
139
- let output = Command::new("git")
140
- .args(["status", "--porcelain"])
141
- .current_dir(repo_path)
142
- .output()
143
- .await;
144
-
145
- match output {
146
- Ok(o) => !o.stdout.is_empty(),
147
- Err(_) => false,
148
- }
149
- }
150
-
151
- #[cfg(test)]
152
- mod tests {
153
- use super::*;
154
-
155
- #[test]
156
- fn test_branch_status_response_serialization() {
157
- let response = BranchStatusResponse {
158
- exists: true,
159
- ahead: 5,
160
- behind: 2,
161
- dirty: false,
162
- };
163
- let json = serde_json::to_string(&response).unwrap();
164
- assert!(json.contains("\"exists\":true"));
165
- assert!(json.contains("\"ahead\":5"));
166
- assert!(json.contains("\"behind\":2"));
167
- assert!(json.contains("\"dirty\":false"));
168
- }
169
- }
@@ -1,107 +0,0 @@
1
- //! Route handlers for the beads-server API.
2
- //!
3
- //! This module contains all HTTP route handlers.
4
- //! Additional handlers will be added as API endpoints are implemented.
5
-
6
- pub mod beads;
7
- pub mod cli;
8
- pub mod fs;
9
- pub mod git;
10
- pub mod projects;
11
- pub mod watch;
12
-
13
- pub use projects::project_routes;
14
- pub use watch::watch_beads;
15
-
16
- use axum::{response::IntoResponse, Json};
17
- use directories::UserDirs;
18
- use serde::Serialize;
19
- use std::path::Path;
20
-
21
- /// Health check response structure.
22
- #[derive(Serialize)]
23
- pub struct HealthResponse {
24
- pub status: &'static str,
25
- }
26
-
27
- /// Health check endpoint handler.
28
- ///
29
- /// Returns a JSON response indicating the server is running.
30
- pub async fn health() -> impl IntoResponse {
31
- Json(HealthResponse { status: "ok" })
32
- }
33
-
34
- /// Validates that a path is within allowed directories (user home).
35
- ///
36
- /// # Security
37
- ///
38
- /// This function ensures that:
39
- /// - The path is within the user's home directory
40
- /// - No path traversal attacks are possible
41
- ///
42
- /// # Returns
43
- ///
44
- /// - `Ok(())` if the path is valid and within allowed directories
45
- /// - `Err(String)` with an error message if validation fails
46
- pub fn validate_path_security(path: &Path) -> Result<(), String> {
47
- // Get the user's home directory
48
- let user_dirs = match UserDirs::new() {
49
- Some(u) => u,
50
- None => return Err("Could not determine user directories".to_string()),
51
- };
52
-
53
- let home_dir = user_dirs.home_dir();
54
-
55
- // Canonicalize paths for comparison (resolves symlinks and ..)
56
- let canonical_path = match path.canonicalize() {
57
- Ok(p) => p,
58
- Err(_) => {
59
- // If path doesn't exist yet, check the parent
60
- if let Some(parent) = path.parent() {
61
- match parent.canonicalize() {
62
- Ok(p) => p.join(path.file_name().unwrap_or_default()),
63
- Err(_) => return Err("Invalid path".to_string()),
64
- }
65
- } else {
66
- return Err("Invalid path".to_string());
67
- }
68
- }
69
- };
70
-
71
- let canonical_home = match home_dir.canonicalize() {
72
- Ok(h) => h,
73
- Err(_) => return Err("Could not canonicalize home directory".to_string()),
74
- };
75
-
76
- // Check if the path starts with the home directory
77
- if !canonical_path.starts_with(&canonical_home) {
78
- return Err("Access denied: path must be within home directory".to_string());
79
- }
80
-
81
- Ok(())
82
- }
83
-
84
- #[cfg(test)]
85
- mod tests {
86
- use super::*;
87
- use std::path::PathBuf;
88
-
89
- #[test]
90
- fn test_validate_home_path() {
91
- if let Some(user_dirs) = UserDirs::new() {
92
- let test_path = user_dirs.home_dir().join("test");
93
- // This might fail if test doesn't exist, but the parent check should work
94
- let result = validate_path_security(&test_path);
95
- // Should either succeed or fail with "Invalid path" (if test doesn't exist)
96
- assert!(result.is_ok() || result.unwrap_err().contains("Invalid"));
97
- }
98
- }
99
-
100
- #[test]
101
- fn test_reject_outside_home() {
102
- let result = validate_path_security(&PathBuf::from("/etc/passwd"));
103
- assert!(result.is_err());
104
- let err_msg = result.unwrap_err();
105
- assert!(err_msg.contains("denied") || err_msg.contains("Invalid"));
106
- }
107
- }