beads-kanban-ui 0.1.0 → 0.1.1

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,141 +0,0 @@
1
- //! Beads Kanban UI Server
2
- //!
3
- //! An Axum-based HTTP server that serves the beads-kanban-ui frontend
4
- //! and provides API endpoints for backend functionality.
5
-
6
- mod db;
7
- mod routes;
8
-
9
- use axum::{
10
- body::Body,
11
- http::{header, Request, Response, StatusCode},
12
- response::IntoResponse,
13
- routing::{get, post},
14
- Router,
15
- };
16
- use rust_embed::Embed;
17
- use std::env;
18
- use std::sync::Arc;
19
- use tower_http::cors::{Any, CorsLayer};
20
- use tracing::{info, Level};
21
- use tracing_subscriber::FmtSubscriber;
22
-
23
- /// Embedded static files from the Next.js build output.
24
- #[derive(Embed)]
25
- #[folder = "../out/"]
26
- struct Assets;
27
-
28
- /// Serves embedded static files, with fallback to index.html for SPA routing.
29
- async fn serve_static(req: Request<Body>) -> impl IntoResponse {
30
- let path = req.uri().path().trim_start_matches('/');
31
-
32
- // Try the exact path first
33
- if let Some(content) = Assets::get(path) {
34
- let mime = mime_guess::from_path(path).first_or_octet_stream();
35
- return Response::builder()
36
- .status(StatusCode::OK)
37
- .header(header::CONTENT_TYPE, mime.as_ref())
38
- .body(Body::from(content.data.into_owned()))
39
- .unwrap();
40
- }
41
-
42
- // Try with .html extension (for Next.js static export)
43
- let html_path = format!("{}.html", path);
44
- if let Some(content) = Assets::get(&html_path) {
45
- return Response::builder()
46
- .status(StatusCode::OK)
47
- .header(header::CONTENT_TYPE, "text/html")
48
- .body(Body::from(content.data.into_owned()))
49
- .unwrap();
50
- }
51
-
52
- // Try index.html in subdirectory
53
- let index_path = if path.is_empty() {
54
- "index.html".to_string()
55
- } else {
56
- format!("{}/index.html", path)
57
- };
58
- if let Some(content) = Assets::get(&index_path) {
59
- return Response::builder()
60
- .status(StatusCode::OK)
61
- .header(header::CONTENT_TYPE, "text/html")
62
- .body(Body::from(content.data.into_owned()))
63
- .unwrap();
64
- }
65
-
66
- // Fallback to root index.html for SPA client-side routing
67
- if let Some(content) = Assets::get("index.html") {
68
- return Response::builder()
69
- .status(StatusCode::OK)
70
- .header(header::CONTENT_TYPE, "text/html")
71
- .body(Body::from(content.data.into_owned()))
72
- .unwrap();
73
- }
74
-
75
- // 404 if nothing found
76
- Response::builder()
77
- .status(StatusCode::NOT_FOUND)
78
- .body(Body::from("Not Found"))
79
- .unwrap()
80
- }
81
-
82
- #[tokio::main]
83
- async fn main() {
84
- // Initialize tracing subscriber for logging
85
- let subscriber = FmtSubscriber::builder()
86
- .with_max_level(Level::INFO)
87
- .finish();
88
- tracing::subscriber::set_global_default(subscriber)
89
- .expect("Failed to set tracing subscriber");
90
-
91
- // Parse port from environment variable, default to 3008
92
- let port: u16 = env::var("PORT")
93
- .ok()
94
- .and_then(|p| p.parse().ok())
95
- .unwrap_or(3008);
96
-
97
- // Configure CORS for development
98
- let cors = CorsLayer::new()
99
- .allow_origin(Any)
100
- .allow_methods(Any)
101
- .allow_headers(Any);
102
-
103
- // Initialize the database
104
- let database = Arc::new(
105
- db::Database::new().expect("Failed to initialize database"),
106
- );
107
- info!("Database initialized");
108
-
109
- // Build the router
110
- let app = Router::new()
111
- .route("/api/health", get(routes::health))
112
- .nest("/api", routes::project_routes().with_state(database))
113
- .route("/api/beads", get(routes::beads::read_beads))
114
- .route("/api/beads/comment", post(routes::beads::add_comment))
115
- .route("/api/fs/list", get(routes::fs::list_directory))
116
- .route("/api/fs/exists", get(routes::fs::path_exists))
117
- .route("/api/fs/read", get(routes::fs::read_file))
118
- .route("/api/fs/open-external", post(routes::fs::open_external))
119
- .route("/api/bd/command", post(routes::cli::bd_command))
120
- .route("/api/git/branch-status", get(routes::git::branch_status))
121
- .route("/api/watch/beads", get(routes::watch_beads))
122
- .fallback(serve_static)
123
- .layer(cors);
124
-
125
- let addr = format!("0.0.0.0:{}", port);
126
- let listener = tokio::net::TcpListener::bind(&addr)
127
- .await
128
- .expect("Failed to bind to address");
129
-
130
- info!("Server starting on http://localhost:{}", port);
131
-
132
- // Open default browser
133
- if let Err(e) = open::that(format!("http://localhost:{}", port)) {
134
- tracing::warn!("Failed to open browser: {}", e);
135
- }
136
-
137
- // Start the server
138
- axum::serve(listener, app)
139
- .await
140
- .expect("Server failed to start");
141
- }
@@ -1,413 +0,0 @@
1
- //! Beads API route handlers.
2
- //!
3
- //! Provides endpoints for reading and modifying beads from .beads/issues.jsonl files.
4
-
5
- use axum::{
6
- extract::Query,
7
- http::StatusCode,
8
- response::IntoResponse,
9
- Json,
10
- };
11
- use chrono::Utc;
12
- use serde::{Deserialize, Serialize};
13
- use std::io::Write;
14
- use std::path::PathBuf;
15
-
16
- use super::validate_path_security;
17
-
18
- /// Query parameters for the beads endpoint.
19
- #[derive(Debug, Deserialize)]
20
- pub struct BeadsParams {
21
- /// The project path containing .beads/issues.jsonl
22
- pub path: String,
23
- }
24
-
25
- /// A dependency relationship in the JSONL file.
26
- #[derive(Debug, Deserialize, Clone)]
27
- struct Dependency {
28
- depends_on_id: String,
29
- #[serde(rename = "type")]
30
- dep_type: String,
31
- }
32
-
33
- /// A single bead/issue from the JSONL file.
34
- #[derive(Debug, Serialize, Deserialize)]
35
- pub struct Bead {
36
- pub id: String,
37
- pub title: String,
38
- #[serde(default)]
39
- pub description: Option<String>,
40
- pub status: String,
41
- #[serde(default)]
42
- pub priority: Option<i32>,
43
- #[serde(default)]
44
- pub issue_type: Option<String>,
45
- #[serde(default)]
46
- pub owner: Option<String>,
47
- #[serde(default)]
48
- pub created_at: Option<String>,
49
- #[serde(default)]
50
- pub created_by: Option<String>,
51
- #[serde(default)]
52
- pub updated_at: Option<String>,
53
- #[serde(default)]
54
- pub closed_at: Option<String>,
55
- #[serde(default)]
56
- pub close_reason: Option<String>,
57
- #[serde(default)]
58
- pub comments: Option<Vec<Comment>>,
59
- #[serde(default)]
60
- pub parent_id: Option<String>,
61
- #[serde(default)]
62
- pub children: Option<Vec<String>>,
63
- #[serde(default, alias = "design")]
64
- pub design_doc: Option<String>,
65
- #[serde(default)]
66
- pub deps: Option<Vec<String>>,
67
- #[serde(default, skip_serializing)]
68
- dependencies: Option<Vec<Dependency>>,
69
- }
70
-
71
- /// A comment on a bead.
72
- #[derive(Debug, Serialize, Deserialize)]
73
- pub struct Comment {
74
- pub id: i64,
75
- pub issue_id: String,
76
- pub author: String,
77
- pub text: String,
78
- pub created_at: String,
79
- }
80
-
81
- /// GET /api/beads?path=/path/to/project
82
- ///
83
- /// Reads the .beads/issues.jsonl file from the specified project path
84
- /// and returns an array of beads.
85
- pub async fn read_beads(Query(params): Query<BeadsParams>) -> impl IntoResponse {
86
- let project_path = PathBuf::from(&params.path);
87
-
88
- // Security: Validate path is within allowed directories
89
- if let Err(e) = validate_path_security(&project_path) {
90
- return (
91
- StatusCode::FORBIDDEN,
92
- Json(serde_json::json!({ "error": e })),
93
- );
94
- }
95
-
96
- let issues_path = project_path.join(".beads").join("issues.jsonl");
97
-
98
- // Check if the file exists
99
- if !issues_path.exists() {
100
- return (
101
- StatusCode::NOT_FOUND,
102
- Json(serde_json::json!({ "error": "No .beads/issues.jsonl found at the specified path" })),
103
- );
104
- }
105
-
106
- // Read the file contents
107
- let contents = match std::fs::read_to_string(&issues_path) {
108
- Ok(c) => c,
109
- Err(e) => {
110
- return (
111
- StatusCode::INTERNAL_SERVER_ERROR,
112
- Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) })),
113
- );
114
- }
115
- };
116
-
117
- // Parse JSONL (each line is a JSON object)
118
- let mut beads = Vec::new();
119
- for (line_num, line) in contents.lines().enumerate() {
120
- let line = line.trim();
121
- if line.is_empty() {
122
- continue;
123
- }
124
-
125
- match serde_json::from_str::<Bead>(line) {
126
- Ok(bead) => beads.push(bead),
127
- Err(e) => {
128
- tracing::warn!(
129
- "Failed to parse bead at line {}: {} - {}",
130
- line_num + 1,
131
- e,
132
- line
133
- );
134
- // Continue parsing other lines - graceful handling of malformed lines
135
- }
136
- }
137
- }
138
-
139
- // Post-process: Transform dependencies into parent_id and children
140
- // Build a map of parent_id -> Vec<child_id>
141
- let mut parent_to_children: std::collections::HashMap<String, Vec<String>> =
142
- std::collections::HashMap::new();
143
-
144
- // First pass: Extract parent-child relationships and set parent_id
145
- for bead in &mut beads {
146
- if let Some(deps) = &bead.dependencies {
147
- for dep in deps {
148
- if dep.dep_type == "parent-child" {
149
- // Set parent_id on this bead
150
- bead.parent_id = Some(dep.depends_on_id.clone());
151
- // Record this bead as a child of the parent
152
- parent_to_children
153
- .entry(dep.depends_on_id.clone())
154
- .or_default()
155
- .push(bead.id.clone());
156
- }
157
- }
158
- }
159
- }
160
-
161
- // Second pass: Set children on parent beads
162
- for bead in &mut beads {
163
- if let Some(children) = parent_to_children.get(&bead.id) {
164
- bead.children = Some(children.clone());
165
- }
166
- }
167
-
168
- (StatusCode::OK, Json(serde_json::json!({ "beads": beads })))
169
- }
170
-
171
- /// Request body for adding a comment to a bead.
172
- #[derive(Debug, Deserialize)]
173
- pub struct AddCommentRequest {
174
- /// The project path containing .beads/issues.jsonl
175
- pub path: String,
176
- /// The ID of the bead to add a comment to
177
- pub bead_id: String,
178
- /// The comment text
179
- pub text: String,
180
- /// The author of the comment (e.g., email address)
181
- pub author: String,
182
- }
183
-
184
- /// Response for the add comment endpoint.
185
- #[derive(Debug, Serialize)]
186
- pub struct AddCommentResponse {
187
- pub success: bool,
188
- pub bead: Option<Bead>,
189
- #[serde(skip_serializing_if = "Option::is_none")]
190
- pub error: Option<String>,
191
- }
192
-
193
- /// POST /api/beads/comment
194
- ///
195
- /// Adds a comment to a specific bead in the .beads/issues.jsonl file.
196
- pub async fn add_comment(Json(payload): Json<AddCommentRequest>) -> impl IntoResponse {
197
- let project_path = PathBuf::from(&payload.path);
198
-
199
- // Security: Validate path is within allowed directories
200
- if let Err(e) = validate_path_security(&project_path) {
201
- return (
202
- StatusCode::FORBIDDEN,
203
- Json(AddCommentResponse {
204
- success: false,
205
- bead: None,
206
- error: Some(e),
207
- }),
208
- );
209
- }
210
-
211
- let issues_path = project_path.join(".beads").join("issues.jsonl");
212
-
213
- // Check if the file exists
214
- if !issues_path.exists() {
215
- return (
216
- StatusCode::NOT_FOUND,
217
- Json(AddCommentResponse {
218
- success: false,
219
- bead: None,
220
- error: Some("No .beads/issues.jsonl found at the specified path".to_string()),
221
- }),
222
- );
223
- }
224
-
225
- // Read the file contents
226
- let contents = match std::fs::read_to_string(&issues_path) {
227
- Ok(c) => c,
228
- Err(e) => {
229
- return (
230
- StatusCode::INTERNAL_SERVER_ERROR,
231
- Json(AddCommentResponse {
232
- success: false,
233
- bead: None,
234
- error: Some(format!("Failed to read file: {}", e)),
235
- }),
236
- );
237
- }
238
- };
239
-
240
- // Parse JSONL and find the target bead
241
- let mut beads: Vec<Bead> = Vec::new();
242
- let mut found_bead_index: Option<usize> = None;
243
- let mut max_comment_id: i64 = 0;
244
-
245
- for (line_num, line) in contents.lines().enumerate() {
246
- let line = line.trim();
247
- if line.is_empty() {
248
- continue;
249
- }
250
-
251
- match serde_json::from_str::<Bead>(line) {
252
- Ok(bead) => {
253
- // Track the maximum comment ID across all beads
254
- if let Some(comments) = &bead.comments {
255
- for comment in comments {
256
- if comment.id > max_comment_id {
257
- max_comment_id = comment.id;
258
- }
259
- }
260
- }
261
-
262
- if bead.id == payload.bead_id {
263
- found_bead_index = Some(beads.len());
264
- }
265
- beads.push(bead);
266
- }
267
- Err(e) => {
268
- tracing::warn!(
269
- "Failed to parse bead at line {}: {} - {}",
270
- line_num + 1,
271
- e,
272
- line
273
- );
274
- // Continue parsing other lines - graceful handling of malformed lines
275
- }
276
- }
277
- }
278
-
279
- // Check if the bead was found
280
- let bead_index = match found_bead_index {
281
- Some(idx) => idx,
282
- None => {
283
- return (
284
- StatusCode::NOT_FOUND,
285
- Json(AddCommentResponse {
286
- success: false,
287
- bead: None,
288
- error: Some(format!("Bead with id '{}' not found", payload.bead_id)),
289
- }),
290
- );
291
- }
292
- };
293
-
294
- // Create the new comment
295
- let new_comment = Comment {
296
- id: max_comment_id + 1,
297
- issue_id: payload.bead_id.clone(),
298
- author: payload.author,
299
- text: payload.text,
300
- created_at: Utc::now().to_rfc3339(),
301
- };
302
-
303
- // Add the comment to the bead
304
- let bead = &mut beads[bead_index];
305
- match &mut bead.comments {
306
- Some(comments) => comments.push(new_comment),
307
- None => bead.comments = Some(vec![new_comment]),
308
- }
309
-
310
- // Write the updated beads back to the file
311
- let file = match std::fs::File::create(&issues_path) {
312
- Ok(f) => f,
313
- Err(e) => {
314
- return (
315
- StatusCode::INTERNAL_SERVER_ERROR,
316
- Json(AddCommentResponse {
317
- success: false,
318
- bead: None,
319
- error: Some(format!("Failed to open file for writing: {}", e)),
320
- }),
321
- );
322
- }
323
- };
324
-
325
- let mut writer = std::io::BufWriter::new(file);
326
- for bead in &beads {
327
- match serde_json::to_string(bead) {
328
- Ok(json_line) => {
329
- if let Err(e) = writeln!(writer, "{}", json_line) {
330
- return (
331
- StatusCode::INTERNAL_SERVER_ERROR,
332
- Json(AddCommentResponse {
333
- success: false,
334
- bead: None,
335
- error: Some(format!("Failed to write to file: {}", e)),
336
- }),
337
- );
338
- }
339
- }
340
- Err(e) => {
341
- return (
342
- StatusCode::INTERNAL_SERVER_ERROR,
343
- Json(AddCommentResponse {
344
- success: false,
345
- bead: None,
346
- error: Some(format!("Failed to serialize bead: {}", e)),
347
- }),
348
- );
349
- }
350
- }
351
- }
352
-
353
- if let Err(e) = writer.flush() {
354
- return (
355
- StatusCode::INTERNAL_SERVER_ERROR,
356
- Json(AddCommentResponse {
357
- success: false,
358
- bead: None,
359
- error: Some(format!("Failed to flush file: {}", e)),
360
- }),
361
- );
362
- }
363
-
364
- // Return the updated bead
365
- let updated_bead = beads.swap_remove(bead_index);
366
- (
367
- StatusCode::OK,
368
- Json(AddCommentResponse {
369
- success: true,
370
- bead: Some(updated_bead),
371
- error: None,
372
- }),
373
- )
374
- }
375
-
376
- #[cfg(test)]
377
- mod tests {
378
- use super::*;
379
-
380
- #[test]
381
- fn test_parse_bead() {
382
- let json = r#"{"id":"test-123","title":"Test Bead","status":"open","priority":2}"#;
383
- let bead: Bead = serde_json::from_str(json).unwrap();
384
- assert_eq!(bead.id, "test-123");
385
- assert_eq!(bead.title, "Test Bead");
386
- assert_eq!(bead.status, "open");
387
- assert_eq!(bead.priority, Some(2));
388
- }
389
-
390
- #[test]
391
- fn test_parse_bead_with_comments() {
392
- let json = r#"{"id":"test-456","title":"With Comments","status":"closed","comments":[{"id":1,"issue_id":"test-456","author":"user","text":"A comment","created_at":"2026-01-01T00:00:00Z"}]}"#;
393
- let bead: Bead = serde_json::from_str(json).unwrap();
394
- assert_eq!(bead.comments.as_ref().unwrap().len(), 1);
395
- assert_eq!(bead.comments.as_ref().unwrap()[0].text, "A comment");
396
- }
397
-
398
- #[test]
399
- fn test_parse_bead_with_design_field() {
400
- // Test that alias "design" works
401
- let json = r#"{"id":"test-789","title":"With Design","status":"open","design":"path/to/design.md"}"#;
402
- let bead: Bead = serde_json::from_str(json).unwrap();
403
- assert_eq!(bead.design_doc, Some("path/to/design.md".to_string()));
404
- }
405
-
406
- #[test]
407
- fn test_parse_bead_with_design_doc_field() {
408
- // Test that original "design_doc" still works
409
- let json = r#"{"id":"test-790","title":"With Design Doc","status":"open","design_doc":"path/to/design2.md"}"#;
410
- let bead: Bead = serde_json::from_str(json).unwrap();
411
- assert_eq!(bead.design_doc, Some("path/to/design2.md".to_string()));
412
- }
413
- }
@@ -1,150 +0,0 @@
1
- //! CLI route handlers for executing bd commands.
2
- //!
3
- //! Provides a secure endpoint for executing whitelisted bd CLI commands.
4
-
5
- use axum::{http::StatusCode, response::IntoResponse, Json};
6
- use serde::{Deserialize, Serialize};
7
- use std::path::Path;
8
- use std::time::Duration;
9
- use tokio::process::Command;
10
-
11
- /// Whitelisted bd subcommands that are allowed to be executed.
12
- const ALLOWED_COMMANDS: &[&str] = &["list", "show", "comment", "update", "close", "create", "ready", "epic"];
13
-
14
- /// Request body for the bd command endpoint.
15
- #[derive(Deserialize)]
16
- pub struct BdCommandRequest {
17
- /// Arguments to pass to the bd command.
18
- pub args: Vec<String>,
19
- /// Optional working directory for command execution.
20
- pub cwd: Option<String>,
21
- }
22
-
23
- /// Response body for the bd command endpoint.
24
- #[derive(Serialize)]
25
- pub struct BdCommandResponse {
26
- /// Standard output from the command.
27
- pub stdout: String,
28
- /// Standard error from the command.
29
- pub stderr: String,
30
- /// Exit code from the command.
31
- pub code: i32,
32
- }
33
-
34
- /// Execute a bd command with the provided arguments.
35
- ///
36
- /// # Security
37
- ///
38
- /// - Only whitelisted subcommands are allowed
39
- /// - Working directory is validated to exist
40
- /// - Command execution has a 30-second timeout
41
- ///
42
- /// # Endpoint
43
- ///
44
- /// `POST /api/bd/command`
45
- pub async fn bd_command(Json(req): Json<BdCommandRequest>) -> impl IntoResponse {
46
- // Validate that we have at least one argument (the subcommand)
47
- if req.args.is_empty() {
48
- return (
49
- StatusCode::BAD_REQUEST,
50
- Json(serde_json::json!({
51
- "error": "No arguments provided. Expected a bd subcommand."
52
- })),
53
- )
54
- .into_response();
55
- }
56
-
57
- // Check if the subcommand is whitelisted
58
- let subcommand = &req.args[0];
59
- if !ALLOWED_COMMANDS.contains(&subcommand.as_str()) {
60
- return (
61
- StatusCode::FORBIDDEN,
62
- Json(serde_json::json!({
63
- "error": format!(
64
- "Command '{}' is not allowed. Allowed commands: {:?}",
65
- subcommand, ALLOWED_COMMANDS
66
- )
67
- })),
68
- )
69
- .into_response();
70
- }
71
-
72
- // Validate and set working directory
73
- let cwd = if let Some(ref dir) = req.cwd {
74
- let path = Path::new(dir);
75
- if !path.exists() {
76
- return (
77
- StatusCode::BAD_REQUEST,
78
- Json(serde_json::json!({
79
- "error": format!("Working directory does not exist: {}", dir)
80
- })),
81
- )
82
- .into_response();
83
- }
84
- if !path.is_dir() {
85
- return (
86
- StatusCode::BAD_REQUEST,
87
- Json(serde_json::json!({
88
- "error": format!("Path is not a directory: {}", dir)
89
- })),
90
- )
91
- .into_response();
92
- }
93
- path.to_path_buf()
94
- } else {
95
- std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf())
96
- };
97
-
98
- // Build and execute the command with timeout
99
- let mut cmd = Command::new("bd");
100
- cmd.args(&req.args).current_dir(&cwd);
101
-
102
- let result = tokio::time::timeout(Duration::from_secs(30), cmd.output()).await;
103
-
104
- match result {
105
- Ok(Ok(output)) => {
106
- let response = BdCommandResponse {
107
- stdout: String::from_utf8_lossy(&output.stdout).to_string(),
108
- stderr: String::from_utf8_lossy(&output.stderr).to_string(),
109
- code: output.status.code().unwrap_or(-1),
110
- };
111
- Json(response).into_response()
112
- }
113
- Ok(Err(e)) => (
114
- StatusCode::INTERNAL_SERVER_ERROR,
115
- Json(serde_json::json!({
116
- "error": format!("Failed to execute command: {}", e)
117
- })),
118
- )
119
- .into_response(),
120
- Err(_) => (
121
- StatusCode::GATEWAY_TIMEOUT,
122
- Json(serde_json::json!({
123
- "error": "Command timed out after 30 seconds"
124
- })),
125
- )
126
- .into_response(),
127
- }
128
- }
129
-
130
- #[cfg(test)]
131
- mod tests {
132
- use super::*;
133
-
134
- #[test]
135
- fn test_allowed_commands_contains_expected() {
136
- assert!(ALLOWED_COMMANDS.contains(&"list"));
137
- assert!(ALLOWED_COMMANDS.contains(&"show"));
138
- assert!(ALLOWED_COMMANDS.contains(&"comment"));
139
- assert!(ALLOWED_COMMANDS.contains(&"update"));
140
- assert!(ALLOWED_COMMANDS.contains(&"close"));
141
- assert!(ALLOWED_COMMANDS.contains(&"create"));
142
- }
143
-
144
- #[test]
145
- fn test_disallowed_commands() {
146
- assert!(!ALLOWED_COMMANDS.contains(&"rm"));
147
- assert!(!ALLOWED_COMMANDS.contains(&"delete"));
148
- assert!(!ALLOWED_COMMANDS.contains(&"exec"));
149
- }
150
- }