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.
- package/README.md +16 -222
- package/package.json +18 -55
- package/.designs/beads-kanban-ui-bj0.md +0 -73
- package/.designs/beads-kanban-ui-qxq.md +0 -144
- package/.designs/epic-support.md +0 -282
- package/.env.local.example +0 -2
- package/.eslintrc.json +0 -3
- package/.gitattributes +0 -3
- package/.github/workflows/release.yml +0 -123
- package/.history/README_20260121193710.md +0 -227
- package/.history/README_20260121193918.md +0 -227
- package/.history/README_20260121193921.md +0 -227
- package/.history/README_20260121193933.md +0 -227
- package/.history/README_20260121193934.md +0 -227
- package/.history/README_20260121193944.md +0 -227
- package/.history/README_20260121193953.md +0 -227
- package/.history/src/app/page_20260121133429.tsx +0 -134
- package/.history/src/app/page_20260121133928.tsx +0 -134
- package/.history/src/app/page_20260121144850.tsx +0 -138
- package/.history/src/app/page_20260121144854.tsx +0 -138
- package/.history/src/app/page_20260121144858.tsx +0 -138
- package/.history/src/app/page_20260121144902.tsx +0 -138
- package/.history/src/app/page_20260121144906.tsx +0 -138
- package/.history/src/app/page_20260121144911.tsx +0 -138
- package/.history/src/app/page_20260121144928.tsx +0 -138
- package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
- package/.playwright-mcp/beams-test.png +0 -0
- package/.playwright-mcp/card-verification.png +0 -0
- package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
- package/.playwright-mcp/dialog-width-test.png +0 -0
- package/.playwright-mcp/homepage.png +0 -0
- package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
- package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
- package/.playwright-mcp/morphing-dialog-open.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
- package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
- package/.playwright-mcp/screenshot-after-click.png +0 -0
- package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
- package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
- package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
- package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
- package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
- package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
- package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
- package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
- package/Screenshots/bead-detail.png +0 -0
- package/Screenshots/dashboard.png +0 -0
- package/Screenshots/kanban-board.png +0 -0
- package/components.json +0 -27
- package/logo/logo.svg +0 -1
- package/next.config.js +0 -9
- package/npm/README.md +0 -37
- package/npm/package.json +0 -20
- package/postcss.config.js +0 -6
- package/public/logo.svg +0 -1
- package/restart.sh +0 -5
- package/server/Cargo.lock +0 -1685
- package/server/Cargo.toml +0 -24
- package/server/src/db.rs +0 -570
- package/server/src/main.rs +0 -141
- package/server/src/routes/beads.rs +0 -413
- package/server/src/routes/cli.rs +0 -150
- package/server/src/routes/fs.rs +0 -360
- package/server/src/routes/git.rs +0 -169
- package/server/src/routes/mod.rs +0 -107
- package/server/src/routes/projects.rs +0 -177
- package/server/src/routes/watch.rs +0 -211
- package/src/app/globals.css +0 -101
- package/src/app/layout.tsx +0 -36
- package/src/app/page.tsx +0 -348
- package/src/app/project/kanban-board.tsx +0 -356
- package/src/app/project/page.tsx +0 -18
- package/src/app/settings/page.tsx +0 -224
- package/src/components/Beams.css +0 -5
- package/src/components/Beams.jsx +0 -307
- package/src/components/Galaxy.css +0 -5
- package/src/components/Galaxy.jsx +0 -333
- package/src/components/activity-timeline.tsx +0 -172
- package/src/components/add-project-dialog.tsx +0 -219
- package/src/components/bead-card.tsx +0 -196
- package/src/components/bead-detail.tsx +0 -306
- package/src/components/color-picker.tsx +0 -101
- package/src/components/comment-input.tsx +0 -155
- package/src/components/comment-list.tsx +0 -147
- package/src/components/dependency-badge.tsx +0 -106
- package/src/components/design-doc-dialog.tsx +0 -58
- package/src/components/design-doc-preview.tsx +0 -97
- package/src/components/design-doc-viewer.tsx +0 -199
- package/src/components/editable-project-name.tsx +0 -178
- package/src/components/epic-card.tsx +0 -263
- package/src/components/folder-browser.tsx +0 -273
- package/src/components/footer.tsx +0 -27
- package/src/components/kanban/default.tsx +0 -184
- package/src/components/kanban-column.tsx +0 -167
- package/src/components/project-card.tsx +0 -191
- package/src/components/quick-filter-bar.tsx +0 -279
- package/src/components/scan-directory-dialog.tsx +0 -368
- package/src/components/status-donut.tsx +0 -197
- package/src/components/subtask-list.tsx +0 -128
- package/src/components/tag-picker.tsx +0 -252
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/alert-dialog.tsx +0 -141
- package/src/components/ui/avatar.tsx +0 -67
- package/src/components/ui/badge.tsx +0 -230
- package/src/components/ui/button.tsx +0 -433
- package/src/components/ui/card/index.tsx +0 -24
- package/src/components/ui/card/roiui-card.module.css +0 -197
- package/src/components/ui/card/roiui-card.tsx +0 -154
- package/src/components/ui/card/shadcn-card.tsx +0 -76
- package/src/components/ui/chart.tsx +0 -369
- package/src/components/ui/dialog.tsx +0 -122
- package/src/components/ui/dropdown-menu.tsx +0 -201
- package/src/components/ui/input.tsx +0 -22
- package/src/components/ui/kanban.tsx +0 -522
- package/src/components/ui/morphing-dialog.tsx +0 -457
- package/src/components/ui/popover.tsx +0 -33
- package/src/components/ui/progress.tsx +0 -28
- package/src/components/ui/scroll-area.tsx +0 -48
- package/src/components/ui/select.tsx +0 -159
- package/src/components/ui/separator.tsx +0 -31
- package/src/components/ui/sheet.tsx +0 -142
- package/src/components/ui/skeleton.tsx +0 -15
- package/src/components/ui/toast.tsx +0 -129
- package/src/components/ui/toaster.tsx +0 -35
- package/src/components/ui/tooltip.tsx +0 -30
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/use-bead-filters.ts +0 -261
- package/src/hooks/use-beads.ts +0 -162
- package/src/hooks/use-branch-statuses.ts +0 -161
- package/src/hooks/use-epics.ts +0 -173
- package/src/hooks/use-file-watcher.ts +0 -111
- package/src/hooks/use-keyboard-navigation.ts +0 -282
- package/src/hooks/use-project.ts +0 -61
- package/src/hooks/use-projects.ts +0 -93
- package/src/hooks/use-toast.ts +0 -194
- package/src/hooks/useClickOutside.tsx +0 -26
- package/src/lib/.gitkeep +0 -0
- package/src/lib/api.ts +0 -186
- package/src/lib/beads-parser.ts +0 -252
- package/src/lib/cli.ts +0 -193
- package/src/lib/db.ts +0 -145
- package/src/lib/design-doc.ts +0 -74
- package/src/lib/epic-parser.ts +0 -242
- package/src/lib/git.ts +0 -102
- package/src/lib/utils.ts +0 -12
- package/src/types/index.ts +0 -107
- package/tailwind.config.ts +0 -85
- package/tsconfig.json +0 -26
- /package/{npm/bin → bin}/cli.js +0 -0
- /package/{npm/scripts → scripts}/postinstall.js +0 -0
package/server/src/main.rs
DELETED
|
@@ -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(¶ms.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
|
-
}
|
package/server/src/routes/cli.rs
DELETED
|
@@ -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
|
-
}
|