Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions apps/server/src/routes/beads/client/cli-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@

import { spawn } from 'child_process';
import { promisify } from 'util';
import { exec as execCallback } from 'child_process';
import { exec as execCallback, execFile as execFileCallback } from 'child_process';
import { getBdBin } from '../common.js';

const exec = promisify(execCallback);
const execFile = promisify(execFileCallback);

export interface BdOptions {
cwd?: string;
Expand All @@ -27,11 +28,11 @@ export interface BdResult {
/**
* Execute bd CLI command using spawn (for long-running processes)
*/
export function runBd(args: string[], options: BdOptions = {}): Promise<string> {
return new Promise((resolve, reject) => {
const bdBin = getBdBin();
const spawnArgs = options.noDb ? ['--no-db', ...args] : args;
export async function runBd(args: string[], options: BdOptions = {}): Promise<string> {
const bdBin = await getBdBin();
const spawnArgs = options.noDb ? ['--no-db', ...args] : args;

return new Promise((resolve, reject) => {
const child = spawn(bdBin, spawnArgs, {
cwd: options.cwd,
env: { ...process.env, ...options.env },
Expand Down Expand Up @@ -69,14 +70,17 @@ export function runBd(args: string[], options: BdOptions = {}): Promise<string>
}

/**
* Execute bd CLI command using exec (for simple commands)
* Execute bd CLI command using execFile (safe alternative to shell-based exec)
*
* NOTE: Previously used exec() with shell command construction which was
* vulnerable to command injection. Changed to execFile() which takes arguments
* as a separate array and doesn't go through shell interpretation.
*/
export function runBdExec(args: string[], options: BdOptions = {}): Promise<string> {
const bdBin = getBdBin();
export async function runBdExec(args: string[], options: BdOptions = {}): Promise<string> {
const bdBin = await getBdBin();
const spawnArgs = options.noDb ? ['--no-db', ...args] : args;
const cmd = `"${bdBin}" ${spawnArgs.map((arg) => `"${arg}"`).join(' ')}`;

return exec(cmd, {
return execFile(bdBin, spawnArgs, {
cwd: options.cwd,
env: { ...process.env, ...options.env },
})
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/routes/beads/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,51 @@
* Common utilities for beads routes
*/

import { execFile } from 'child_process';
import { promisify } from 'util';
import { platform } from 'os';

const execFileAsync = promisify(execFile);

/**
* Get the path to the bd CLI binary
*
* Cross-platform implementation that uses 'which' on Unix/Linux/macOS
* and 'where' on Windows to locate the bd CLI executable.
*/
export async function getBdBin(): Promise<string> {
try {
// Use 'where' on Windows, 'which' on Unix/Linux/macOS
const command = platform() === 'win32' ? 'where' : 'which';
const { stdout } = await execFileAsync(command, ['bd']);
return stdout.trim();
} catch {
throw new Error('bd CLI not found in PATH');
}
}

/**
* Derives a readable string message from an unknown error value.
*
* @param error - The error value to extract a message from.
* @returns The error's `message` if `error` is an `Error`, otherwise the stringified `error` (`String(error)`).
*/

export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

/**
* Log a standardized Beads error message to the console.
*
* Extracts a readable message from `error` and writes it to stderr prefixed with a `[Beads]` tag and the provided context.
*
* @param error - The error value to derive a message from (may be any value).
* @param context - Short contextual label included in the log prefix (e.g., the route or operation name)
*/
export function logError(error: unknown, context: string): void {
const message = getErrorMessage(error);
console.error(`[Beads] ${context}:`, message);
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/routes/beads/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { createDeleteHandler } from './routes/delete.js';
import { createReadyWorkHandler } from './routes/ready.js';
import { createValidateHandler } from './routes/validate.js';

/**
* Create an Express Router configured with Beads-related POST endpoints.
*
* @param beadsService - Service used by route handlers to perform Beads operations
* @returns The configured Express Router containing the Beads endpoints (POST /list, /create, /update, /delete, /ready, and /validate)
*/
export function createBeadsRoutes(beadsService: BeadsService): Router {
const router = Router();

Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/routes/beads/routes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Creates an Express request handler for creating a beads issue.
*
* The handler validates that `projectPath` and `issue.title` are present in the request body,
* calls the provided service to create the issue, and sends a JSON response:
* on success `{ success: true, issue }`, on validation failure a 400 with `{ success: false, error }`,
* and on internal error a 500 with `{ success: false, error }`.
*
* @returns An Express-compatible request handler that performs the described validation, creation, and responses.
*/
export function createCreateHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/routes/beads/routes/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Create an Express POST /delete handler that deletes a beads issue.
*
* @param beadsService - Service used to perform issue deletion
* @returns An Express request handler that validates `projectPath` and `issueId` from the request body, optionally accepts `force`, invokes `beadsService.deleteIssue`, responds with `{ success: true }` on success, and returns structured JSON errors with appropriate HTTP status codes on validation failure or internal errors
*/
export function createDeleteHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/routes/beads/routes/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Creates an Express handler that lists bead issues for a given project.
*
* The returned async middleware expects req.body to contain `projectPath` (string) and an optional
* `filters` object with any of: `status`, `type`, `labels`, `priorityMin`, `priorityMax`,
* `titleContains`, `descContains`, and `ids`. If `projectPath` is missing the handler responds with
* HTTP 400 and an error JSON; on success it responds with `{ success: true, issues }`; on failure it
* logs the error and responds with HTTP 500 and a standardized error message.
*
* @returns An Express-compatible async middleware (req, res) that lists issues for the specified project.
*/
export function createListHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/routes/beads/routes/ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Creates an Express handler that returns "ready work" issues for a project.
*
* The handler expects `projectPath` (required) and `limit` (optional) in the request body.
* If `projectPath` is missing, it responds with HTTP 400 and an error payload.
* On success it responds with `{ success: true, issues }`. On failure it logs the error
* and responds with HTTP 500 and a user-facing error message.
*
* @param beadsService - Service used to fetch ready-work issues for a given project
* @returns An Express middleware function that processes ready-work requests
*/
export function createReadyWorkHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/routes/beads/routes/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Create an Express route handler that updates an existing beads issue.
*
* @param beadsService - Service used to perform the issue update
* @returns An Express request handler that validates `projectPath`, `issueId`, and `updates` from the request body, calls the service to apply changes, and sends JSON responses: on success `{ success: true, issue }`, on validation failure a 400 with `{ success: false, error }`, and on unexpected errors a 500 with `{ success: false, error }`.
*/
export function createUpdateHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/routes/beads/routes/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type { Request, Response } from 'express';
import { BeadsService } from '../../../services/beads-service.js';
import { getErrorMessage, logError } from '../common.js';

/**
* Creates an Express route handler that validates beads installation or a specific project and sends a JSON response.
*
* @param beadsService - Service used to check installation, get version, or validate a project
* @returns An Express request handler that responds with `{ success: true, ... }` on success (either `{ installed, version }` or the project validation result) and with HTTP 500 and `{ success: false, error }` on failure
*/
export function createValidateHandler(beadsService: BeadsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
Expand Down
43 changes: 25 additions & 18 deletions apps/server/src/services/beads-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import fsCallback from 'fs';

const execFileAsync = promisify(execFile);

export class BeadsService {
private watchTimeout?: NodeJS.Timeout;
// Note: watchTimeout removed from instance to prevent race conditions
// when watchDatabase is called multiple times on the same instance

/**
* Check if bd CLI is installed
Expand Down Expand Up @@ -58,9 +60,9 @@ export class BeadsService {

try {
await fs.access(dbPath);
return { installed: true, initialized: true, version };
return { installed: true, initialized: true, version: version ?? undefined };
} catch {
return { installed: true, initialized: false, version };
return { installed: true, initialized: false, version: version ?? undefined };
}
}

Expand Down Expand Up @@ -266,8 +268,8 @@ export class BeadsService {
type: 'blocks' | 'related' | 'parent' | 'discovered-from'
): Promise<void> {
try {
const command = `bd dep add ${issueId} ${depId} --type ${type}`;
await execAsync(command, { cwd: projectPath });
const args = ['dep', 'add', issueId, depId, '--type', type];
await execFileAsync('bd', args, { cwd: projectPath });
} catch (error) {
throw new Error(`Failed to add dependency: ${error}`);
}
Expand All @@ -278,8 +280,8 @@ export class BeadsService {
*/
async removeDependency(projectPath: string, issueId: string, depId: string): Promise<void> {
try {
const command = `bd dep remove ${issueId} ${depId}`;
await execAsync(command, { cwd: projectPath });
const args = ['dep', 'remove', issueId, depId];
await execFileAsync('bd', args, { cwd: projectPath });
} catch (error) {
throw new Error(`Failed to remove dependency: ${error}`);
}
Expand All @@ -290,11 +292,11 @@ export class BeadsService {
*/
async getReadyWork(projectPath: string, limit?: number): Promise<any[]> {
try {
let command = 'bd ready --json';
const args = ['ready', '--json'];
if (limit) {
command += ` --limit ${limit}`;
args.push('--limit', String(limit));
}
const { stdout } = await execAsync(command, { cwd: projectPath });
const { stdout } = await execFileAsync('bd', args, { cwd: projectPath });
const issues = JSON.parse(stdout);
return issues;
} catch (error) {
Expand All @@ -310,7 +312,7 @@ export class BeadsService {
*/
async getStats(projectPath: string): Promise<any> {
try {
const { stdout } = await execAsync('bd stats --json', { cwd: projectPath });
const { stdout } = await execFileAsync('bd', ['stats', '--json'], { cwd: projectPath });
const stats = JSON.parse(stdout);
return stats;
} catch (error) {
Expand All @@ -331,34 +333,39 @@ export class BeadsService {
*/
async sync(projectPath: string): Promise<void> {
try {
await execAsync('bd sync', { cwd: projectPath });
await execFileAsync('bd', ['sync'], { cwd: projectPath });
} catch (error) {
throw new Error(`Failed to sync database: ${error}`);
}
}

/**
* Watch the database for changes
*
* Uses a local timeout variable (not instance property) to avoid race conditions
* when watchDatabase is called multiple times concurrently on the same instance.
*/
async watchDatabase(projectPath: string, callback: () => void): Promise<() => void> {
const dbPath = this.getDatabasePath(projectPath);

try {
const watcher = fs.watch(dbPath, () => {
let watchTimeout: NodeJS.Timeout | undefined;

const watcher = fsCallback.watch(dbPath, () => {
// Debounce rapid changes
if (this.watchTimeout) {
clearTimeout(this.watchTimeout);
if (watchTimeout) {
clearTimeout(watchTimeout);
}
this.watchTimeout = setTimeout(() => {
watchTimeout = setTimeout(() => {
callback();
}, 500);
});

// Return cleanup function
return () => {
watcher.close();
if (this.watchTimeout) {
clearTimeout(this.watchTimeout);
if (watchTimeout) {
clearTimeout(watchTimeout);
}
};
} catch (error) {
Expand Down
5 changes: 5 additions & 0 deletions apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ interface UseNavigationProps {
cycleNextProject: () => void;
}

/**
* Constructs sidebar navigation sections and their keyboard shortcuts based on the provided state and visibility flags.
*
* @returns An object containing `navSections` — an array of navigation sections (labels and items) to render, and `navigationShortcuts` — an array of keyboard shortcut descriptors with keys, actions, and descriptions.
*/
export function useNavigation({
shortcuts,
hideSpecEditor,
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/components/ui/keyboard-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
beads: 'Beads',
toggleSidebar: 'Toggle Sidebar',
addFeature: 'Add Feature',
addContextFile: 'Add Context File',
Expand All @@ -115,6 +116,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
beads: 'navigation',
toggleSidebar: 'ui',
addFeature: 'action',
addContextFile: 'action',
Expand Down
Loading