211 lines
6.4 KiB
TypeScript
211 lines
6.4 KiB
TypeScript
// ─────────────────────────────────────────────────────────────
|
|
// Hermes CLI client — type-safe wrapper for `hermes kanban ...`
|
|
// ADR §3.5: "uses stable hermes kanban ... --json CLI commands first"
|
|
// ADR §3.5: "fixed argv and shell: false"
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
import { execFile as execFileCb } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import type {
|
|
Board,
|
|
TaskSummary,
|
|
TaskDetail,
|
|
} from '@kanban/contracts';
|
|
|
|
const execFile = promisify(execFileCb);
|
|
|
|
// ─── Types ───────────────────────────────────────────────────
|
|
|
|
export interface HermesClientConfig {
|
|
/** Path to hermes binary (default: 'hermes') */
|
|
bin: string;
|
|
/** HERMES_HOME for target profile */
|
|
home: string;
|
|
/** Default board slug */
|
|
defaultBoard: string;
|
|
/** Per-command timeout in ms */
|
|
timeoutMs: number;
|
|
}
|
|
|
|
// ─── Allowed commands ────────────────────────────────────────
|
|
|
|
/**
|
|
* Command allowlist. Each entry defines:
|
|
* - subcommand: the `hermes kanban <sub>` string
|
|
* - requiredArgs: positional args the caller must provide
|
|
* - optionalFlags: flags that may be appended
|
|
*
|
|
* This prevents arbitrary pass-through to the hermes CLI.
|
|
*/
|
|
type KanbanSubcommand =
|
|
| 'boards'
|
|
| 'list'
|
|
| 'show'
|
|
| 'create'
|
|
| 'comment'
|
|
| 'assign'
|
|
| 'promote'
|
|
| 'block'
|
|
| 'unblock'
|
|
| 'archive'
|
|
| 'dispatch'
|
|
| 'log';
|
|
|
|
const ALLOWED_SUBCOMMANDS = new Set<KanbanSubcommand>([
|
|
'boards',
|
|
'list',
|
|
'show',
|
|
'create',
|
|
'comment',
|
|
'assign',
|
|
'promote',
|
|
'block',
|
|
'unblock',
|
|
'archive',
|
|
'dispatch',
|
|
'log',
|
|
]);
|
|
|
|
// ─── Client ──────────────────────────────────────────────────
|
|
|
|
export class HermesKanbanClient {
|
|
private readonly config: HermesClientConfig;
|
|
|
|
constructor(config: HermesClientConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Execute a hermes kanban subcommand with --json output.
|
|
* Uses execFile (shell: false) for safety.
|
|
*/
|
|
async exec<T>(
|
|
subcommand: KanbanSubcommand,
|
|
args: string[],
|
|
board?: string
|
|
): Promise<T> {
|
|
if (!ALLOWED_SUBCOMMANDS.has(subcommand)) {
|
|
throw new Error(`Blocked disallowed subcommand: ${subcommand}`);
|
|
}
|
|
|
|
const fullArgs = [
|
|
'kanban',
|
|
subcommand,
|
|
'--board', board ?? this.config.defaultBoard,
|
|
'--json',
|
|
...args,
|
|
];
|
|
|
|
const env = {
|
|
...process.env,
|
|
HERMES_HOME: this.config.home,
|
|
};
|
|
|
|
try {
|
|
const { stdout, stderr } = await execFile(this.config.bin, fullArgs, {
|
|
shell: false, // CRITICAL: never use shell
|
|
timeout: this.config.timeoutMs,
|
|
env,
|
|
maxBuffer: 5 * 1024 * 1024, // 5MB
|
|
});
|
|
|
|
if (stderr) {
|
|
console.warn(`[hermes-client] stderr: ${redact(stderr)}`);
|
|
}
|
|
|
|
return JSON.parse(stdout) as T;
|
|
} catch (err) {
|
|
if (err instanceof Error) {
|
|
// Redact any secrets that might leak into error messages
|
|
throw new Error(`Hermes CLI error: ${redact(err.message)}`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ─── Convenience methods (M2 will flesh these out) ────────
|
|
|
|
async listBoards(): Promise<Board[]> {
|
|
return this.exec<Board[]>('boards', []);
|
|
}
|
|
|
|
async listTasks(
|
|
board: string,
|
|
opts?: { status?: string; assignee?: string; limit?: number }
|
|
): Promise<TaskSummary[]> {
|
|
const args: string[] = [];
|
|
if (opts?.status) args.push('--status', opts.status);
|
|
if (opts?.assignee) args.push('--assignee', opts.assignee);
|
|
if (opts?.limit) args.push('--limit', String(opts.limit));
|
|
return this.exec<TaskSummary[]>('list', args, board);
|
|
}
|
|
|
|
async getTask(board: string, taskId: string): Promise<TaskDetail> {
|
|
return this.exec<TaskDetail>('show', [taskId], board);
|
|
}
|
|
|
|
async createTask(
|
|
board: string,
|
|
payload: { title: string; body?: string; assignee?: string; priority?: number }
|
|
): Promise<TaskDetail> {
|
|
const args = ['--title', payload.title];
|
|
if (payload.body) args.push('--body', payload.body);
|
|
if (payload.assignee) args.push('--assignee', payload.assignee);
|
|
if (payload.priority !== undefined) args.push('--priority', String(payload.priority));
|
|
return this.exec<TaskDetail>('create', args, board);
|
|
}
|
|
|
|
async addComment(board: string, taskId: string, body: string): Promise<void> {
|
|
await this.exec('comment', [taskId, '--body', body], board);
|
|
}
|
|
|
|
async assignTask(board: string, taskId: string, assignee: string): Promise<void> {
|
|
await this.exec('assign', [taskId, '--assignee', assignee], board);
|
|
}
|
|
|
|
async promoteTask(board: string, taskId: string, reason?: string): Promise<void> {
|
|
const args = [taskId];
|
|
if (reason) args.push('--reason', reason);
|
|
await this.exec('promote', args, board);
|
|
}
|
|
|
|
async blockTask(board: string, taskId: string, reason: string): Promise<void> {
|
|
await this.exec('block', [taskId, '--reason', reason], board);
|
|
}
|
|
|
|
async unblockTask(board: string, taskId: string): Promise<void> {
|
|
await this.exec('unblock', [taskId], board);
|
|
}
|
|
|
|
async archiveTask(board: string, taskId: string): Promise<void> {
|
|
await this.exec('archive', [taskId], board);
|
|
}
|
|
|
|
async dispatchBoard(board: string): Promise<void> {
|
|
await this.exec('dispatch', [], board);
|
|
}
|
|
|
|
async getTaskLog(board: string, taskId: string, lines?: number): Promise<string> {
|
|
const args = [taskId];
|
|
if (lines) args.push('--lines', String(lines));
|
|
const result = await this.exec<{ content?: string; log?: string }>('log', args, board);
|
|
return result.content ?? result.log ?? '';
|
|
}
|
|
}
|
|
|
|
// ─── Redaction helper ────────────────────────────────────────
|
|
|
|
const SENSITIVE_PATTERNS = [
|
|
/token[=:]\s*\S+/gi,
|
|
/secret[=:]\s*\S+/gi,
|
|
/password[=:]\s*\S+/gi,
|
|
/api[_-]?key[=:]\s*\S+/gi,
|
|
];
|
|
|
|
function redact(text: string): string {
|
|
let result = text;
|
|
for (const pattern of SENSITIVE_PATTERNS) {
|
|
result = result.replace(pattern, '[REDACTED]');
|
|
}
|
|
return result;
|
|
}
|