import { TamError } from './core.js';
export const API_PREFIX = '/plugins/dsh-tam/api';
const MAX_BODY_BYTES = 1024 * 1024;
function sendJson(res, status, payload) {
const body = JSON.stringify(payload);
res.statusCode = status;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('cache-control', 'no-store');
res.end(body);
}
function sendError(res, error) {
const code = error instanceof TamError ? error.code : 'internal';
const status = code === 'not_found'
? 404
: code === 'conflict'
? 409
: code === 'invalid'
? 400
: 500;
sendJson(res, status, {
ok: false,
error: {
code,
message: error instanceof Error ? error.message : String(error),
...error instanceof TamError && error.details !== undefined ? { details: error.details } : {},
},
});
}
async function readJsonBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
throw new TamError('request body too large', { code: 'invalid' });
}
chunks.push(chunk);
}
if (chunks.length === 0) return {};
const text = Buffer.concat(chunks).toString('utf8').trim();
if (text === '') return {};
try {
const value = JSON.parse(text);
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('body must be a JSON object');
}
return value;
} catch (error) {
throw new TamError(`invalid JSON body: ${error instanceof Error ? error.message : String(error)}`, {
code: 'invalid',
});
}
}
function pushFlag(argv, flag, value) {
if (value === undefined || value === null || value === '') return;
argv.push(flag, String(value));
}
function parseTaskId(raw) {
const id = Number(raw);
if (!Number.isInteger(id) || id < 1) {
throw new TamError('task id must be a positive integer', { code: 'invalid' });
}
return id;
}
function projectOf(url, body) {
const queryProject = url.searchParams.get('project');
return queryProject || (typeof body?.project === 'string' ? body.project : undefined);
}
function listArgs(url) {
const argv = ['task', 'list'];
pushFlag(argv, '--status', url.searchParams.get('status') ?? undefined);
pushFlag(argv, '--assigned', url.searchParams.get('assigned') ?? undefined);
pushFlag(argv, '--priority', url.searchParams.get('priority') ?? undefined);
pushFlag(argv, '--search', url.searchParams.get('search') ?? undefined);
pushFlag(argv, '--limit', url.searchParams.get('limit') ?? undefined);
for (const tag of url.searchParams.getAll('tag')) argv.push('--tag', tag);
return argv;
}
function createArgs(body) {
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) throw new TamError('title is required', { code: 'invalid' });
const argv = ['task', 'add', title];
pushFlag(argv, '--description', body.description);
pushFlag(argv, '--priority', body.priority);
for (const tag of Array.isArray(body.tags) ? body.tags : []) argv.push('--tag', String(tag));
pushFlag(argv, '--idempotency-key', body.idempotency_key);
return argv;
}
function updateArgs(body) {
const argv = ['task', 'update'];
pushFlag(argv, '--title', body.title);
pushFlag(argv, '--description', body.description);
pushFlag(argv, '--status', body.status);
pushFlag(argv, '--priority', body.priority);
if (Object.hasOwn(body, 'tags')) {
argv.push('--tags', (Array.isArray(body.tags) ? body.tags : []).map(String).join(','));
}
return argv;
}
export function createTamHttpHandler(client) {
return async function tamHttpHandler(req, res) {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const path = url.pathname.slice(API_PREFIX.length) || '/';
const method = req.method ?? 'GET';
if (method === 'GET' && path === '/meta') {
sendJson(res, 200, {
ok: true,
data: {
defaultProject: client.defaultProject,
defaultAgent: client.defaultAgent,
command: client.command,
},
});
return;
}
if (method === 'GET' && path === '/projects') {
sendJson(res, 200, { ok: true, data: await client.run(['project', 'list']) });
return;
}
if (method === 'POST' && path === '/projects') {
const body = await readJsonBody(req);
if (typeof body.name !== 'string' || body.name.trim() === '') {
throw new TamError('name is required', { code: 'invalid' });
}
sendJson(res, 200, {
ok: true,
data: await client.run(['project', 'add', body.name.trim()]),
});
return;
}
if (method === 'GET' && path === '/tasks') {
sendJson(res, 200, {
ok: true,
data: await client.run(listArgs(url), { project: projectOf(url) }),
});
return;
}
if (method === 'POST' && path === '/tasks') {
const body = await readJsonBody(req);
sendJson(res, 200, {
ok: true,
data: await client.run(createArgs(body), {
project: projectOf(url, body),
agent: typeof body.agent === 'string' ? body.agent : undefined,
}),
});
return;
}
const taskMatch = /^\/tasks\/([^/]+)$/.exec(path);
if (taskMatch) {
const id = parseTaskId(decodeURIComponent(taskMatch[1]));
if (method === 'GET') {
sendJson(res, 200, {
ok: true,
data: await client.run(['task', 'show', String(id)], { project: projectOf(url) }),
});
return;
}
if (method === 'PATCH' || method === 'PUT') {
const body = await readJsonBody(req);
const argv = updateArgs(body);
argv.splice(2, 0, String(id));
sendJson(res, 200, {
ok: true,
data: await client.run(argv, {
project: projectOf(url, body),
agent: typeof body.agent === 'string' ? body.agent : undefined,
}),
});
return;
}
if (method === 'DELETE') {
sendJson(res, 200, {
ok: true,
data: await client.run(['task', 'del', String(id)], { project: projectOf(url) }),
});
return;
}
}
sendJson(res, 404, {
ok: false,
error: { code: 'not_found', message: `no tam API route for ${method} ${path}` },
});
} catch (error) {
sendError(res, error);
}
};
}