Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import type { Command } from 'commander';
|
|
import { withAuth } from './with-auth.js';
|
|
import { selectItem } from './select-dialog.js';
|
|
import {
|
|
fetchMissions,
|
|
fetchMission,
|
|
createMission,
|
|
updateMission,
|
|
fetchMissionTasks,
|
|
createMissionTask,
|
|
updateMissionTask,
|
|
fetchProjects,
|
|
} from '../tui/gateway-api.js';
|
|
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
|
|
|
function formatMission(m: MissionInfo): string {
|
|
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
|
}
|
|
|
|
function showMissionDetail(m: MissionInfo) {
|
|
console.log(` ID: ${m.id}`);
|
|
console.log(` Name: ${m.name}`);
|
|
console.log(` Status: ${m.status}`);
|
|
console.log(` Phase: ${m.phase ?? '—'}`);
|
|
console.log(` Project: ${m.projectId ?? '—'}`);
|
|
console.log(` Description: ${m.description ?? '—'}`);
|
|
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
|
}
|
|
|
|
function showTaskDetail(t: MissionTaskInfo) {
|
|
console.log(` ID: ${t.id}`);
|
|
console.log(` Status: ${t.status}`);
|
|
console.log(` Description: ${t.description ?? '—'}`);
|
|
console.log(` Notes: ${t.notes ?? '—'}`);
|
|
console.log(` PR: ${t.pr ?? '—'}`);
|
|
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
|
}
|
|
|
|
export function registerMissionCommand(program: Command) {
|
|
const cmd = program
|
|
.command('mission')
|
|
.description('Manage missions')
|
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
|
.option('--list', 'List all missions')
|
|
.option('--init', 'Create a new mission')
|
|
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
|
.option('--update <idOrName>', 'Update a mission')
|
|
.option('--project <idOrName>', 'Scope to project')
|
|
.argument('[id]', 'Show mission detail by ID')
|
|
.action(
|
|
async (
|
|
id: string | undefined,
|
|
opts: {
|
|
gateway: string;
|
|
list?: boolean;
|
|
init?: boolean;
|
|
plan?: string;
|
|
update?: string;
|
|
project?: string;
|
|
},
|
|
) => {
|
|
const auth = await withAuth(opts.gateway);
|
|
|
|
if (opts.list) {
|
|
return listMissions(auth.gateway, auth.cookie);
|
|
}
|
|
if (opts.init) {
|
|
return initMission(auth.gateway, auth.cookie);
|
|
}
|
|
if (opts.plan) {
|
|
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
|
}
|
|
if (opts.update) {
|
|
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
|
}
|
|
if (id) {
|
|
return showMission(auth.gateway, auth.cookie, id);
|
|
}
|
|
|
|
// Default: interactive select
|
|
return interactiveSelect(auth.gateway, auth.cookie);
|
|
},
|
|
);
|
|
|
|
// Task subcommand
|
|
cmd
|
|
.command('task')
|
|
.description('Manage mission tasks')
|
|
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:4000')
|
|
.option('--list', 'List tasks for a mission')
|
|
.option('--new', 'Create a task')
|
|
.option('--update <taskId>', 'Update a task')
|
|
.option('--mission <idOrName>', 'Mission ID or name')
|
|
.argument('[taskId]', 'Show task detail')
|
|
.action(
|
|
async (
|
|
taskId: string | undefined,
|
|
taskOpts: {
|
|
gateway: string;
|
|
list?: boolean;
|
|
new?: boolean;
|
|
update?: string;
|
|
mission?: string;
|
|
},
|
|
) => {
|
|
const auth = await withAuth(taskOpts.gateway);
|
|
|
|
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
|
if (!missionId) return;
|
|
|
|
if (taskOpts.list) {
|
|
return listTasks(auth.gateway, auth.cookie, missionId);
|
|
}
|
|
if (taskOpts.new) {
|
|
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
|
}
|
|
if (taskOpts.update) {
|
|
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
|
}
|
|
if (taskId) {
|
|
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
|
}
|
|
|
|
return listTasks(auth.gateway, auth.cookie, missionId);
|
|
},
|
|
);
|
|
|
|
return cmd;
|
|
}
|
|
|
|
async function resolveMissionByName(
|
|
gateway: string,
|
|
cookie: string,
|
|
idOrName: string,
|
|
): Promise<MissionInfo | undefined> {
|
|
const missions = await fetchMissions(gateway, cookie);
|
|
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
|
}
|
|
|
|
async function resolveMissionId(
|
|
gateway: string,
|
|
cookie: string,
|
|
idOrName?: string,
|
|
): Promise<string | undefined> {
|
|
if (idOrName) {
|
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
if (!mission) {
|
|
console.error(`Mission "${idOrName}" not found.`);
|
|
return undefined;
|
|
}
|
|
return mission.id;
|
|
}
|
|
|
|
// Interactive select
|
|
const missions = await fetchMissions(gateway, cookie);
|
|
const selected = await selectItem(missions, {
|
|
message: 'Select a mission:',
|
|
render: formatMission,
|
|
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
|
});
|
|
return selected?.id;
|
|
}
|
|
|
|
async function listMissions(gateway: string, cookie: string) {
|
|
const missions = await fetchMissions(gateway, cookie);
|
|
if (missions.length === 0) {
|
|
console.log('No missions found.');
|
|
return;
|
|
}
|
|
console.log(`Missions (${missions.length}):\n`);
|
|
for (const m of missions) {
|
|
const phase = m.phase ? ` [${m.phase}]` : '';
|
|
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
|
}
|
|
}
|
|
|
|
async function showMission(gateway: string, cookie: string, id: string) {
|
|
try {
|
|
const mission = await fetchMission(gateway, cookie, id);
|
|
showMissionDetail(mission);
|
|
} catch {
|
|
// Try resolving by name
|
|
const m = await resolveMissionByName(gateway, cookie, id);
|
|
if (!m) {
|
|
console.error(`Mission "${id}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
showMissionDetail(m);
|
|
}
|
|
}
|
|
|
|
async function interactiveSelect(gateway: string, cookie: string) {
|
|
const missions = await fetchMissions(gateway, cookie);
|
|
const selected = await selectItem(missions, {
|
|
message: 'Select a mission:',
|
|
render: formatMission,
|
|
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
|
});
|
|
if (selected) {
|
|
showMissionDetail(selected);
|
|
}
|
|
}
|
|
|
|
async function initMission(gateway: string, cookie: string) {
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
const name = await ask('Mission name: ');
|
|
if (!name.trim()) {
|
|
console.error('Name is required.');
|
|
return;
|
|
}
|
|
|
|
// Project selection
|
|
const projects = await fetchProjects(gateway, cookie);
|
|
let projectId: string | undefined;
|
|
if (projects.length > 0) {
|
|
const selected = await selectItem(projects, {
|
|
message: 'Assign to project (required):',
|
|
render: (p) => `${p.name} (${p.status})`,
|
|
emptyMessage: 'No projects found.',
|
|
});
|
|
if (selected) projectId = selected.id;
|
|
}
|
|
|
|
const description = await ask('Description (optional): ');
|
|
|
|
const mission = await createMission(gateway, cookie, {
|
|
name: name.trim(),
|
|
projectId,
|
|
description: description.trim() || undefined,
|
|
status: 'planning',
|
|
});
|
|
|
|
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function planMission(
|
|
gateway: string,
|
|
cookie: string,
|
|
idOrName: string,
|
|
_projectIdOrName?: string,
|
|
) {
|
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
if (!mission) {
|
|
console.error(`Mission "${idOrName}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`Planning mission: ${mission.name}\n`);
|
|
|
|
try {
|
|
const { runPrdWizard } = await import('@mosaic/prdy');
|
|
await runPrdWizard({
|
|
name: mission.name,
|
|
projectPath: process.cwd(),
|
|
interactive: true,
|
|
});
|
|
} catch (err) {
|
|
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
|
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
if (!mission) {
|
|
console.error(`Mission "${idOrName}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
console.log(`Updating mission: ${mission.name}\n`);
|
|
|
|
const name = await ask(`Name [${mission.name}]: `);
|
|
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
|
const status = await ask(`Status [${mission.status}]: `);
|
|
|
|
const updates: Record<string, unknown> = {};
|
|
if (name.trim()) updates['name'] = name.trim();
|
|
if (description.trim()) updates['description'] = description.trim();
|
|
if (status.trim()) updates['status'] = status.trim();
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
console.log('No changes.');
|
|
return;
|
|
}
|
|
|
|
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
|
console.log(`\nMission "${updated.name}" updated.`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
// ── Task operations ──
|
|
|
|
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
|
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
|
if (tasks.length === 0) {
|
|
console.log('No tasks found.');
|
|
return;
|
|
}
|
|
console.log(`Tasks (${tasks.length}):\n`);
|
|
for (const t of tasks) {
|
|
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
|
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
|
}
|
|
}
|
|
|
|
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
|
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
|
const task = tasks.find((t) => t.id === taskId);
|
|
if (!task) {
|
|
console.error(`Task "${taskId}" not found.`);
|
|
process.exit(1);
|
|
}
|
|
showTaskDetail(task);
|
|
}
|
|
|
|
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
const description = await ask('Task description: ');
|
|
if (!description.trim()) {
|
|
console.error('Description is required.');
|
|
return;
|
|
}
|
|
|
|
const status = await ask('Status [not-started]: ');
|
|
|
|
const task = await createMissionTask(gateway, cookie, missionId, {
|
|
description: description.trim(),
|
|
status: status.trim() || 'not-started',
|
|
});
|
|
|
|
console.log(`\nTask created (${task.id}).`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
async function updateTaskWizard(
|
|
gateway: string,
|
|
cookie: string,
|
|
missionId: string,
|
|
taskId: string,
|
|
) {
|
|
const readline = await import('node:readline');
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
|
|
try {
|
|
const status = await ask('New status: ');
|
|
const notes = await ask('Notes (optional): ');
|
|
const pr = await ask('PR (optional): ');
|
|
|
|
const updates: Record<string, unknown> = {};
|
|
if (status.trim()) updates['status'] = status.trim();
|
|
if (notes.trim()) updates['notes'] = notes.trim();
|
|
if (pr.trim()) updates['pr'] = pr.trim();
|
|
|
|
if (Object.keys(updates).length === 0) {
|
|
console.log('No changes.');
|
|
return;
|
|
}
|
|
|
|
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
|
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|