Merge bb-6aj-3-scanner
This commit is contained in:
commit
89a9941d88
29 changed files with 2036 additions and 49 deletions
78
src/lib/bd-path.ts
Normal file
78
src/lib/bd-path.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ResolveBdExecutableOptions {
|
||||
explicitPath?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface BdExecutableResolution {
|
||||
executable: string;
|
||||
source: 'config' | 'path';
|
||||
}
|
||||
|
||||
export class BdExecutableNotFoundError extends Error {
|
||||
readonly code = 'BD_NOT_FOUND';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BdExecutableNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function splitEnvPath(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const value = env.Path ?? env.PATH ?? '';
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.split(';').map((segment) => segment.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function executableCandidates(directory: string): string[] {
|
||||
return ['bd.exe', 'bd.cmd', 'bd.bat', 'bd'].map((name) => path.join(directory, name));
|
||||
}
|
||||
|
||||
function buildNotFoundMessage(explicitPath?: string | null): string {
|
||||
const lines = [
|
||||
'bd.exe was not found.',
|
||||
'Install it with: npm install -g @beads/bd',
|
||||
'Or configure an explicit executable path in request payload/config.',
|
||||
];
|
||||
|
||||
if (explicitPath) {
|
||||
lines.push(`Configured path was not found: ${explicitPath}`);
|
||||
}
|
||||
|
||||
return lines.join(' ');
|
||||
}
|
||||
|
||||
export async function resolveBdExecutable(options: ResolveBdExecutableOptions = {}): Promise<BdExecutableResolution> {
|
||||
if (options.explicitPath && options.explicitPath.trim()) {
|
||||
const explicit = path.resolve(options.explicitPath);
|
||||
if (await fileExists(explicit)) {
|
||||
return { executable: explicit, source: 'config' };
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage(options.explicitPath));
|
||||
}
|
||||
|
||||
for (const dir of splitEnvPath(options.env)) {
|
||||
for (const candidate of executableCandidates(dir)) {
|
||||
if (await fileExists(candidate)) {
|
||||
return { executable: candidate, source: 'path' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BdExecutableNotFoundError(buildNotFoundMessage());
|
||||
}
|
||||
163
src/lib/bridge.ts
Normal file
163
src/lib/bridge.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { BdExecutableNotFoundError, resolveBdExecutable } from './bd-path';
|
||||
|
||||
const execFileAsync = promisify(nodeExecFile);
|
||||
|
||||
export type BdFailureClassification = 'not_found' | 'timeout' | 'non_zero_exit' | 'bad_args' | 'unknown';
|
||||
|
||||
export interface RunBdCommandOptions {
|
||||
projectRoot: string;
|
||||
args: string[];
|
||||
timeoutMs?: number;
|
||||
explicitBdPath?: string | null;
|
||||
}
|
||||
|
||||
export interface RunBdCommandResult {
|
||||
success: boolean;
|
||||
classification: BdFailureClassification | null;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
durationMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
type ExecFileOptions = {
|
||||
cwd: string;
|
||||
timeout: number;
|
||||
windowsHide: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ExecFileLike = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions,
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
interface RunBdCommandDeps {
|
||||
resolveBdExecutable: typeof resolveBdExecutable;
|
||||
execFile: ExecFileLike;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
function normalizeOutput(text: unknown): string {
|
||||
if (typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text.replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function toErrorMessage(value: unknown): string {
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
return String(value ?? 'Unknown error');
|
||||
}
|
||||
|
||||
function classifyFailure(error: NodeJS.ErrnoException & { stderr?: string; killed?: boolean; signal?: string }): BdFailureClassification {
|
||||
if (error.code === 'ENOENT') {
|
||||
return 'not_found';
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.killed || error.signal === 'SIGTERM') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
const stderr = normalizeOutput(error.stderr);
|
||||
if (typeof error.code === 'number') {
|
||||
if (/(unknown|invalid|required|usage)/i.test(stderr)) {
|
||||
return 'bad_args';
|
||||
}
|
||||
return 'non_zero_exit';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function runBdCommand(
|
||||
options: RunBdCommandOptions,
|
||||
injectedDeps?: Partial<RunBdCommandDeps>,
|
||||
): Promise<RunBdCommandResult> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options.timeoutMs ?? 30_000;
|
||||
const cwd = options.projectRoot;
|
||||
const args = [...options.args];
|
||||
|
||||
const deps: RunBdCommandDeps = {
|
||||
resolveBdExecutable: injectedDeps?.resolveBdExecutable ?? resolveBdExecutable,
|
||||
execFile: injectedDeps?.execFile ?? execFileAsync,
|
||||
env: injectedDeps?.env ?? process.env,
|
||||
};
|
||||
|
||||
let command = options.explicitBdPath ?? 'bd.exe';
|
||||
|
||||
try {
|
||||
const resolved = await deps.resolveBdExecutable({
|
||||
explicitPath: options.explicitBdPath,
|
||||
env: deps.env,
|
||||
});
|
||||
command = resolved.executable;
|
||||
|
||||
const { stdout, stderr } = await deps.execFile(command, args, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true,
|
||||
env: deps.env,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
classification: null,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(stdout),
|
||||
stderr: normalizeOutput(stderr),
|
||||
code: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (rawError) {
|
||||
if (rawError instanceof BdExecutableNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
classification: 'not_found',
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
code: null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: rawError.message,
|
||||
};
|
||||
}
|
||||
|
||||
const error = rawError as NodeJS.ErrnoException & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
killed?: boolean;
|
||||
signal?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
success: false,
|
||||
classification: classifyFailure(error),
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
stdout: normalizeOutput(error.stdout),
|
||||
stderr: normalizeOutput(error.stderr),
|
||||
code: typeof error.code === 'number' ? error.code : null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
295
src/lib/mutations.ts
Normal file
295
src/lib/mutations.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { runBdCommand, type RunBdCommandResult } from './bridge';
|
||||
|
||||
export type MutationOperation = 'create' | 'update' | 'close' | 'reopen' | 'comment';
|
||||
export type MutationStatus = 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed';
|
||||
|
||||
interface MutationBasePayload {
|
||||
projectRoot: string;
|
||||
bdPath?: string;
|
||||
}
|
||||
|
||||
export interface CreateMutationPayload extends MutationBasePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
issueType?: string;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: MutationStatus;
|
||||
priority?: number;
|
||||
assignee?: string;
|
||||
labels?: string[];
|
||||
}
|
||||
|
||||
export interface CloseMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ReopenMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CommentMutationPayload extends MutationBasePayload {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type MutationPayload =
|
||||
| CreateMutationPayload
|
||||
| UpdateMutationPayload
|
||||
| CloseMutationPayload
|
||||
| ReopenMutationPayload
|
||||
| CommentMutationPayload;
|
||||
|
||||
export interface MutationErrorShape {
|
||||
classification: 'bad_args' | 'not_found' | 'timeout' | 'non_zero_exit' | 'unknown';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MutationResponse {
|
||||
ok: boolean;
|
||||
operation: MutationOperation;
|
||||
command: RunBdCommandResult;
|
||||
error?: MutationErrorShape;
|
||||
}
|
||||
|
||||
export class MutationValidationError extends Error {
|
||||
readonly code = 'MUTATION_VALIDATION_ERROR';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'MutationValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new MutationValidationError(`"${field}" is required.`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new MutationValidationError('Expected a string value.');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asOptionalPriority(value: unknown): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > 4) {
|
||||
throw new MutationValidationError('"priority" must be a number between 0 and 4.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalLabels(value: unknown): string[] | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
throw new MutationValidationError('"labels" must be an array of strings.');
|
||||
}
|
||||
const labels = value.map((label) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new MutationValidationError('"labels" must be an array of non-empty strings.');
|
||||
}
|
||||
return label.trim();
|
||||
});
|
||||
|
||||
return labels.length ? labels : undefined;
|
||||
}
|
||||
|
||||
function asOptionalStatus(value: unknown): MutationStatus | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const status = asNonEmptyString(value, 'status');
|
||||
if (!['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status)) {
|
||||
throw new MutationValidationError('"status" is invalid.');
|
||||
}
|
||||
return status as MutationStatus;
|
||||
}
|
||||
|
||||
function parseBasePayload(raw: unknown): MutationBasePayload {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new MutationValidationError('Payload must be a JSON object.');
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
return {
|
||||
projectRoot: asNonEmptyString(data.projectRoot, 'projectRoot'),
|
||||
bdPath: asOptionalString(data.bdPath),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMutationPayload(operation: MutationOperation, payload: unknown): MutationPayload {
|
||||
const base = parseBasePayload(payload);
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
if (operation === 'create') {
|
||||
return {
|
||||
...base,
|
||||
title: asNonEmptyString(data.title, 'title'),
|
||||
description: asOptionalString(data.description),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
issueType: asOptionalString(data.issueType),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const mapped: UpdateMutationPayload = {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
title: asOptionalString(data.title),
|
||||
description: asOptionalString(data.description),
|
||||
status: asOptionalStatus(data.status),
|
||||
priority: asOptionalPriority(data.priority),
|
||||
assignee: asOptionalString(data.assignee),
|
||||
labels: asOptionalLabels(data.labels),
|
||||
};
|
||||
|
||||
if (!mapped.title && !mapped.description && !mapped.status && mapped.priority === undefined && !mapped.assignee && !mapped.labels) {
|
||||
throw new MutationValidationError('At least one update field is required.');
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
reason: asOptionalString(data.reason),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id: asNonEmptyString(data.id, 'id'),
|
||||
text: asNonEmptyString(data.text, 'text'),
|
||||
};
|
||||
}
|
||||
|
||||
function pushOptionalArg(args: string[], flag: string, value: string | undefined): void {
|
||||
if (value) {
|
||||
args.push(flag, value);
|
||||
}
|
||||
}
|
||||
|
||||
function pushOptionalLabels(args: string[], labels: string[] | undefined): void {
|
||||
if (labels && labels.length > 0) {
|
||||
args.push('-l', labels.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBdMutationArgs(operation: MutationOperation, payload: MutationPayload): string[] {
|
||||
if (operation === 'create') {
|
||||
const data = payload as CreateMutationPayload;
|
||||
const args = ['create', data.title];
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-t', data.issueType);
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'update') {
|
||||
const data = payload as UpdateMutationPayload;
|
||||
const args = ['update', data.id];
|
||||
pushOptionalArg(args, '--title', data.title);
|
||||
pushOptionalArg(args, '-d', data.description);
|
||||
pushOptionalArg(args, '-s', data.status);
|
||||
if (data.priority !== undefined) {
|
||||
args.push('-p', String(data.priority));
|
||||
}
|
||||
pushOptionalArg(args, '-a', data.assignee);
|
||||
pushOptionalLabels(args, data.labels);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'close') {
|
||||
const data = payload as CloseMutationPayload;
|
||||
const args = ['close', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
if (operation === 'reopen') {
|
||||
const data = payload as ReopenMutationPayload;
|
||||
const args = ['reopen', data.id];
|
||||
pushOptionalArg(args, '-r', data.reason);
|
||||
args.push('--json');
|
||||
return args;
|
||||
}
|
||||
|
||||
const data = payload as CommentMutationPayload;
|
||||
return ['comments', 'add', data.id, data.text, '--json'];
|
||||
}
|
||||
|
||||
interface ExecuteMutationDeps {
|
||||
runBdCommand: typeof runBdCommand;
|
||||
}
|
||||
|
||||
export async function executeMutation(
|
||||
operation: MutationOperation,
|
||||
payload: MutationPayload,
|
||||
deps: Partial<ExecuteMutationDeps> = {},
|
||||
): Promise<MutationResponse> {
|
||||
const runner = deps.runBdCommand ?? runBdCommand;
|
||||
const args = buildBdMutationArgs(operation, payload);
|
||||
const command = await runner({
|
||||
projectRoot: payload.projectRoot,
|
||||
args,
|
||||
explicitBdPath: payload.bdPath,
|
||||
});
|
||||
|
||||
if (!command.success) {
|
||||
return {
|
||||
ok: false,
|
||||
operation,
|
||||
command,
|
||||
error: {
|
||||
classification: command.classification ?? 'unknown',
|
||||
message: command.error ?? (command.stderr || 'Mutation command failed.'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
operation,
|
||||
command,
|
||||
};
|
||||
}
|
||||
140
src/lib/registry.ts
Normal file
140
src/lib/registry.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
|
||||
export interface RegistryProject {
|
||||
path: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface RegistryDocument {
|
||||
version: 1;
|
||||
projects: RegistryProject[];
|
||||
}
|
||||
|
||||
export class RegistryValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RegistryValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
export function registryFilePath(): string {
|
||||
return path.join(userProfileRoot(), '.beadboard', 'projects.json');
|
||||
}
|
||||
|
||||
function ensureWindowsAbsolutePath(input: string): string {
|
||||
const normalized = canonicalizeWindowsPath(input.trim());
|
||||
if (!/^[A-Za-z]:\\/.test(normalized)) {
|
||||
throw new RegistryValidationError('Project path must be a Windows absolute path (e.g. C:\\Repos\\Project).');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeProject(input: string): RegistryProject {
|
||||
const normalized = ensureWindowsAbsolutePath(input);
|
||||
return {
|
||||
path: toDisplayPath(normalized),
|
||||
key: windowsPathKey(normalized),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjects(input: unknown): RegistryProject[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: RegistryProject[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = item as { path?: unknown };
|
||||
if (typeof candidate.path !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const project = normalizeProject(candidate.path);
|
||||
if (!seen.has(project.key)) {
|
||||
seen.add(project.key);
|
||||
normalized.push(project);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readRegistryDocument(): Promise<RegistryDocument> {
|
||||
const filePath = registryFilePath();
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
return {
|
||||
version: 1,
|
||||
projects: normalizeProjects(parsed.projects),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { version: 1, projects: [] };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRegistryDocument(document: RegistryDocument): Promise<void> {
|
||||
const filePath = registryFilePath();
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<RegistryProject[]> {
|
||||
const document = await readRegistryDocument();
|
||||
return document.projects;
|
||||
}
|
||||
|
||||
export async function addProject(projectPath: string): Promise<{ added: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
|
||||
if (document.projects.some((entry) => entry.key === project.key)) {
|
||||
return { added: false, projects: document.projects };
|
||||
}
|
||||
|
||||
document.projects.push(project);
|
||||
await writeRegistryDocument(document);
|
||||
return { added: true, projects: document.projects };
|
||||
}
|
||||
|
||||
export async function removeProject(projectPath: string): Promise<{ removed: boolean; projects: RegistryProject[] }> {
|
||||
const document = await readRegistryDocument();
|
||||
const project = normalizeProject(projectPath);
|
||||
const nextProjects = document.projects.filter((entry) => entry.key !== project.key);
|
||||
|
||||
if (nextProjects.length === document.projects.length) {
|
||||
return { removed: false, projects: document.projects };
|
||||
}
|
||||
|
||||
const nextDocument: RegistryDocument = {
|
||||
version: 1,
|
||||
projects: nextProjects,
|
||||
};
|
||||
|
||||
await writeRegistryDocument(nextDocument);
|
||||
return { removed: true, projects: nextDocument.projects };
|
||||
}
|
||||
223
src/lib/scanner.ts
Normal file
223
src/lib/scanner.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { canonicalizeWindowsPath, toDisplayPath, windowsPathKey } from './pathing';
|
||||
import { listProjects } from './registry';
|
||||
|
||||
export type ScanMode = 'default' | 'full-drive';
|
||||
|
||||
export interface ScannerProject {
|
||||
root: string;
|
||||
key: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export interface ScanStats {
|
||||
scannedDirectories: number;
|
||||
ignoredDirectories: number;
|
||||
skippedDirectories: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
mode?: ScanMode;
|
||||
maxDepth?: number;
|
||||
roots?: string[];
|
||||
ignoreDirectories?: string[];
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
mode: ScanMode;
|
||||
roots: string[];
|
||||
projects: ScannerProject[];
|
||||
stats: ScanStats;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_DEPTH = 6;
|
||||
const DEFAULT_IGNORE_DIRECTORIES = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.next',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'coverage',
|
||||
'artifacts',
|
||||
'logs',
|
||||
'.worktrees', // TODO: confirm whether worktrees should be scan targets.
|
||||
];
|
||||
|
||||
function userProfileRoot(): string {
|
||||
return process.env.USERPROFILE?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
function toCanonicalRoot(input: string): string {
|
||||
return canonicalizeWindowsPath(input);
|
||||
}
|
||||
|
||||
function shouldSkipFsError(error: NodeJS.ErrnoException): boolean {
|
||||
return error.code === 'ENOENT' || error.code === 'ENOTDIR' || error.code === 'EACCES' || error.code === 'EPERM';
|
||||
}
|
||||
|
||||
async function ensureDirectoryExists(input: string): Promise<string | null> {
|
||||
try {
|
||||
const stat = await fs.stat(input);
|
||||
return stat.isDirectory() ? input : null;
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFullDriveRoots(): Promise<string[]> {
|
||||
const candidates = ['C:\\', 'D:\\'];
|
||||
const roots: string[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const existing = await ensureDirectoryExists(candidate);
|
||||
if (existing) {
|
||||
roots.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function resolveScanRoots(options: ScanOptions = {}): Promise<string[]> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const registryProjects = await listProjects();
|
||||
const roots = [
|
||||
userProfileRoot(),
|
||||
...registryProjects.map((project) => project.path),
|
||||
...(options.roots ?? []),
|
||||
];
|
||||
|
||||
if (mode === 'full-drive') {
|
||||
roots.push(...(await resolveFullDriveRoots()));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalizedRoots: string[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await ensureDirectoryExists(normalized);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalizedRoots.push(existing);
|
||||
}
|
||||
|
||||
return normalizedRoots;
|
||||
}
|
||||
|
||||
function buildIgnoreSet(additional: string[] = []): Set<string> {
|
||||
return new Set(
|
||||
[...DEFAULT_IGNORE_DIRECTORIES, ...additional].map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function recordProject(projects: Map<string, ScannerProject>, root: string): void {
|
||||
const normalized = toCanonicalRoot(root);
|
||||
const key = windowsPathKey(normalized);
|
||||
if (!projects.has(key)) {
|
||||
projects.set(key, {
|
||||
root: normalized,
|
||||
key,
|
||||
displayPath: toDisplayPath(normalized),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function scanRoot(
|
||||
root: string,
|
||||
maxDepth: number,
|
||||
ignoreSet: Set<string>,
|
||||
projects: Map<string, ScannerProject>,
|
||||
stats: ScanStats,
|
||||
): Promise<void> {
|
||||
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.scannedDirectories += 1;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(current.dir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (shouldSkipFsError(error as NodeJS.ErrnoException)) {
|
||||
stats.skippedDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let hasBeads = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name === '.beads') {
|
||||
hasBeads = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryName = entry.name.toLowerCase();
|
||||
if (ignoreSet.has(entryName)) {
|
||||
stats.ignoredDirectories += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.depth < maxDepth) {
|
||||
queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBeads) {
|
||||
recordProject(projects, current.dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanForProjects(options: ScanOptions = {}): Promise<ScanResult> {
|
||||
const mode: ScanMode = options.mode ?? 'default';
|
||||
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
||||
const ignoreSet = buildIgnoreSet(options.ignoreDirectories);
|
||||
const roots = await resolveScanRoots(options);
|
||||
const projects = new Map<string, ScannerProject>();
|
||||
const stats: ScanStats = {
|
||||
scannedDirectories: 0,
|
||||
ignoredDirectories: 0,
|
||||
skippedDirectories: 0,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
const start = Date.now();
|
||||
|
||||
for (const root of roots) {
|
||||
await scanRoot(root, maxDepth, ignoreSet, projects, stats);
|
||||
}
|
||||
|
||||
stats.elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
mode,
|
||||
roots,
|
||||
projects: Array.from(projects.values()),
|
||||
stats,
|
||||
};
|
||||
}
|
||||
56
src/lib/writeback.ts
Normal file
56
src/lib/writeback.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { BeadIssue, BeadStatus } from './types';
|
||||
|
||||
export type MutationStep =
|
||||
| { operation: 'close'; payload: { id: string; reason?: string } }
|
||||
| { operation: 'reopen'; payload: { id: string; reason?: string } }
|
||||
| { operation: 'update'; payload: { id: string; status: 'open' | 'in_progress' | 'blocked' | 'deferred' } };
|
||||
|
||||
function isBoardStatus(status: BeadStatus): status is 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed' {
|
||||
return ['open', 'in_progress', 'blocked', 'deferred', 'closed'].includes(status);
|
||||
}
|
||||
|
||||
export function planStatusTransition(
|
||||
issue: Pick<BeadIssue, 'id' | 'status'>,
|
||||
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
|
||||
): MutationStep[] {
|
||||
if (!isBoardStatus(issue.status) || issue.status === targetStatus) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (targetStatus === 'closed') {
|
||||
return [{ operation: 'close', payload: { id: issue.id, reason: 'Moved to closed via board drag-and-drop' } }];
|
||||
}
|
||||
|
||||
if (issue.status === 'closed') {
|
||||
if (targetStatus === 'open') {
|
||||
return [{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } }];
|
||||
}
|
||||
|
||||
return [
|
||||
{ operation: 'reopen', payload: { id: issue.id, reason: 'Moved from closed via board drag-and-drop' } },
|
||||
{ operation: 'update', payload: { id: issue.id, status: targetStatus } },
|
||||
];
|
||||
}
|
||||
|
||||
return [{ operation: 'update', payload: { id: issue.id, status: targetStatus } }];
|
||||
}
|
||||
|
||||
export function applyOptimisticStatus(
|
||||
issues: BeadIssue[],
|
||||
issueId: string,
|
||||
targetStatus: 'open' | 'in_progress' | 'blocked' | 'deferred' | 'closed',
|
||||
atIso: string = new Date().toISOString(),
|
||||
): BeadIssue[] {
|
||||
return issues.map((issue) => {
|
||||
if (issue.id !== issueId) {
|
||||
return issue;
|
||||
}
|
||||
|
||||
return {
|
||||
...issue,
|
||||
status: targetStatus,
|
||||
updated_at: atIso,
|
||||
closed_at: targetStatus === 'closed' ? atIso : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue