feat(cli): single-instance guard, ambient discovery, graceful errors, cwd-robust
Make the network MVP simple to operate: server start refuses a second instance on a port already serving AgentHub (single source of truth, prevents discovery split-brain); commands auto-discover a LAN server when run outside any project so the CLI works from any directory with zero config; walk up to the nearest .agenthub project (cwd-robust) and serve the project root; replace raw stack traces with actionable messages; add tests for findProjectRoot and the single-instance guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9b8d587e9d
commit
79f5c8afde
0
bin/agenthub.js
Normal file → Executable file
0
bin/agenthub.js
Normal file → Executable file
@ -1,5 +1,34 @@
|
|||||||
import { startServer } from '../../server/index.js';
|
import { startServer } from '../../server/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe a URL to check whether an AgentHub server is already answering there.
|
||||||
|
* We identify it by the shape of GET /status ({ body: string }).
|
||||||
|
*/
|
||||||
|
async function isAgentHubServer(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
return typeof data === 'object' && data !== null && 'body' in data;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function serverStart(cwd: string, options: { port: number; host: string }): Promise<void> {
|
export async function serverStart(cwd: string, options: { port: number; host: string }): Promise<void> {
|
||||||
|
// Single-instance guard: never start a second server on a port that already
|
||||||
|
// serves an AgentHub project. Keeps a single source of truth, prevents the
|
||||||
|
// split-brain that multiple discovery broadcasters would otherwise cause.
|
||||||
|
const probeHost = options.host === '0.0.0.0' || options.host === '::' ? '127.0.0.1' : options.host;
|
||||||
|
const probeUrl = `http://${probeHost}:${options.port}`;
|
||||||
|
|
||||||
|
if (await isAgentHubServer(probeUrl)) {
|
||||||
|
console.log(
|
||||||
|
`An AgentHub server is already running on port ${options.port} (${probeUrl}). ` +
|
||||||
|
`Reusing it — not starting a second instance.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await startServer(cwd, options);
|
await startServer(cwd, options);
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/cli/index.ts
116
src/cli/index.ts
@ -8,22 +8,47 @@ import { decisionCreate, decisionList } from './commands/decision.js';
|
|||||||
import { delegate } from './commands/delegate.js';
|
import { delegate } from './commands/delegate.js';
|
||||||
import { serverStart } from './commands/server.js';
|
import { serverStart } from './commands/server.js';
|
||||||
import { loadConfig } from '../core/config.js';
|
import { loadConfig } from '../core/config.js';
|
||||||
|
import { findProjectRoot } from '../core/paths.js';
|
||||||
|
import { discoverServer } from '../discovery.js';
|
||||||
import { remoteClient, RemoteError } from './remoteClient.js';
|
import { remoteClient, RemoteError } from './remoteClient.js';
|
||||||
|
|
||||||
function getServerUrl(program: Command, cwd: string): string | undefined {
|
interface ResolvedContext {
|
||||||
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
serverUrl?: string;
|
||||||
if (flag) return flag;
|
projectCwd: string;
|
||||||
try {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
return config.serverUrl;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remoteOnly(): never {
|
/**
|
||||||
console.error('Remote mode is not supported for this command. Run it locally or omit --server.');
|
* Decide, for a single command invocation, whether to talk to a remote server
|
||||||
process.exit(1);
|
* or operate on local files, and which directory holds the project.
|
||||||
|
*
|
||||||
|
* Precedence:
|
||||||
|
* 1. --server flag / AGENTHUB_SERVER env var (explicit wins)
|
||||||
|
* 2. Nearest `.agenthub` project found by walking up from cwd (cwd-robust):
|
||||||
|
* use its configured serverUrl if set, otherwise local mode at that root.
|
||||||
|
* 3. No project anywhere → try zero-config LAN auto-discovery; if a server is
|
||||||
|
* found, use it; otherwise fall back to local mode (which then errors
|
||||||
|
* gracefully instead of crashing with a raw stack trace).
|
||||||
|
*/
|
||||||
|
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
|
||||||
|
const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER;
|
||||||
|
if (flag) return { serverUrl: flag, projectCwd: cwd };
|
||||||
|
|
||||||
|
const root = findProjectRoot(cwd);
|
||||||
|
if (root) {
|
||||||
|
try {
|
||||||
|
const config = loadConfig(root);
|
||||||
|
return { serverUrl: config.serverUrl, projectCwd: root };
|
||||||
|
} catch {
|
||||||
|
return { projectCwd: root };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovered = await discoverServer(2000);
|
||||||
|
if (discovered) {
|
||||||
|
console.error(`No local AgentHub project found — using discovered server at ${discovered}.`);
|
||||||
|
return { serverUrl: discovered, projectCwd: cwd };
|
||||||
|
}
|
||||||
|
return { projectCwd: cwd };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<void> {
|
async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<void> {
|
||||||
@ -63,14 +88,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
.description('Show project status')
|
.description('Show project status')
|
||||||
.option('-u, --update', 'Regenerate status before showing')
|
.option('-u, --update', 'Regenerate status before showing')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl);
|
const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl);
|
||||||
console.log(body);
|
console.log(body);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
status(cwd, options);
|
status(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,21 +107,21 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--category <category>', 'Category')
|
.option('--category <category>', 'Category')
|
||||||
.option('--content <content>', 'Content')
|
.option('--content <content>', 'Content')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const memory = await remoteClient.addMemory(serverUrl, options);
|
const memory = await remoteClient.addMemory(serverUrl, options);
|
||||||
console.log(`Memory saved as ${memory.id}.`);
|
console.log(`Memory saved as ${memory.id}.`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await memoryAdd(cwd, options);
|
await memoryAdd(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
memoryCmd
|
memoryCmd
|
||||||
.command('search <query>')
|
.command('search <query>')
|
||||||
.description('Search memory and tasks')
|
.description('Search memory and tasks')
|
||||||
.action(async (query) => {
|
.action(async (query) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const results = await remoteClient.searchMemory(serverUrl, query);
|
const results = await remoteClient.searchMemory(serverUrl, query);
|
||||||
@ -104,14 +129,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
|
for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
memorySearch(cwd, query);
|
memorySearch(projectCwd, query);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
memoryCmd
|
memoryCmd
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List memory entries')
|
.description('List memory entries')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const memories = await remoteClient.listMemory(serverUrl);
|
const memories = await remoteClient.listMemory(serverUrl);
|
||||||
@ -119,7 +144,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const m of memories) console.log(`${m.id}: ${m.title}`);
|
for (const m of memories) console.log(`${m.id}: ${m.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
memoryList(cwd);
|
memoryList(projectCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(memoryCmd);
|
program.addCommand(memoryCmd);
|
||||||
@ -132,14 +157,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--role <role>', 'Role')
|
.option('--role <role>', 'Role')
|
||||||
.option('--priority <priority>', 'Priority')
|
.option('--priority <priority>', 'Priority')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const task = await remoteClient.createTask(serverUrl, options);
|
const task = await remoteClient.createTask(serverUrl, options);
|
||||||
console.log(`Created ${task.id}: ${task.title}`);
|
console.log(`Created ${task.id}: ${task.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await taskCreate(cwd, options);
|
await taskCreate(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
taskCmd
|
taskCmd
|
||||||
@ -148,7 +173,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--status <status>', 'Filter by status')
|
.option('--status <status>', 'Filter by status')
|
||||||
.option('--role <role>', 'Filter by role')
|
.option('--role <role>', 'Filter by role')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const tasks = await remoteClient.listTasks(serverUrl, options);
|
const tasks = await remoteClient.listTasks(serverUrl, options);
|
||||||
@ -156,14 +181,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
|
for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
taskList(cwd, options);
|
taskList(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
taskCmd
|
taskCmd
|
||||||
.command('show <id>')
|
.command('show <id>')
|
||||||
.description('Show a task')
|
.description('Show a task')
|
||||||
.action(async (id) => {
|
.action(async (id) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const { task, body } = await remoteClient.getTask(serverUrl, id);
|
const { task, body } = await remoteClient.getTask(serverUrl, id);
|
||||||
@ -172,7 +197,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
console.log('\n' + body);
|
console.log('\n' + body);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
taskShow(cwd, id);
|
taskShow(projectCwd, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
taskCmd
|
taskCmd
|
||||||
@ -180,28 +205,28 @@ export function createProgram(cwd: string): Command {
|
|||||||
.description('Claim a task')
|
.description('Claim a task')
|
||||||
.requiredOption('--agent <agent>', 'Agent name')
|
.requiredOption('--agent <agent>', 'Agent name')
|
||||||
.action(async (id, options) => {
|
.action(async (id, options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
await remoteClient.claimTask(serverUrl, id, options.agent);
|
await remoteClient.claimTask(serverUrl, id, options.agent);
|
||||||
console.log(`${id} claimed by ${options.agent}.`);
|
console.log(`${id} claimed by ${options.agent}.`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
taskClaim(cwd, id, options.agent);
|
taskClaim(projectCwd, id, options.agent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
taskCmd
|
taskCmd
|
||||||
.command('done <id>')
|
.command('done <id>')
|
||||||
.description('Mark a task as done')
|
.description('Mark a task as done')
|
||||||
.action(async (id) => {
|
.action(async (id) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
await remoteClient.doneTask(serverUrl, id);
|
await remoteClient.doneTask(serverUrl, id);
|
||||||
console.log(`${id} marked as done.`);
|
console.log(`${id} marked as done.`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
taskDone(cwd, id);
|
taskDone(projectCwd, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(taskCmd);
|
program.addCommand(taskCmd);
|
||||||
@ -216,21 +241,21 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--summary <summary>', 'Summary')
|
.option('--summary <summary>', 'Summary')
|
||||||
.option('--context <context>', 'Context')
|
.option('--context <context>', 'Context')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const handoff = await remoteClient.createHandoff(serverUrl, options);
|
const handoff = await remoteClient.createHandoff(serverUrl, options);
|
||||||
console.log(`Handoff created: ${handoff.id}`);
|
console.log(`Handoff created: ${handoff.id}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await handoffCreate(cwd, options);
|
await handoffCreate(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
handoffCmd
|
handoffCmd
|
||||||
.command('read <id>')
|
.command('read <id>')
|
||||||
.description('Read a handoff')
|
.description('Read a handoff')
|
||||||
.action(async (id) => {
|
.action(async (id) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
|
const { handoff, body } = await remoteClient.getHandoff(serverUrl, id);
|
||||||
@ -240,14 +265,14 @@ export function createProgram(cwd: string): Command {
|
|||||||
console.log('\n' + body);
|
console.log('\n' + body);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
handoffRead(cwd, id);
|
handoffRead(projectCwd, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
handoffCmd
|
handoffCmd
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List handoffs')
|
.description('List handoffs')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const handoffs = await remoteClient.listHandoffs(serverUrl);
|
const handoffs = await remoteClient.listHandoffs(serverUrl);
|
||||||
@ -255,7 +280,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
|
for (const h of handoffs) console.log(`${h.id}: ${h.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
handoffList(cwd);
|
handoffList(projectCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(handoffCmd);
|
program.addCommand(handoffCmd);
|
||||||
@ -268,21 +293,21 @@ export function createProgram(cwd: string): Command {
|
|||||||
.option('--context <context>', 'Context')
|
.option('--context <context>', 'Context')
|
||||||
.option('--decision <decision>', 'Decision')
|
.option('--decision <decision>', 'Decision')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const decision = await remoteClient.createDecision(serverUrl, options);
|
const decision = await remoteClient.createDecision(serverUrl, options);
|
||||||
console.log(`Decision recorded: ${decision.id}`);
|
console.log(`Decision recorded: ${decision.id}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await decisionCreate(cwd, options);
|
await decisionCreate(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
decisionCmd
|
decisionCmd
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List decisions')
|
.description('List decisions')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const decisions = await remoteClient.listDecisions(serverUrl);
|
const decisions = await remoteClient.listDecisions(serverUrl);
|
||||||
@ -290,7 +315,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
for (const d of decisions) console.log(`${d.id}: ${d.title}`);
|
for (const d of decisions) console.log(`${d.id}: ${d.title}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
decisionList(cwd);
|
decisionList(projectCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
program.addCommand(decisionCmd);
|
program.addCommand(decisionCmd);
|
||||||
@ -300,7 +325,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
.description('Suggest or auto-delegate open tasks')
|
.description('Suggest or auto-delegate open tasks')
|
||||||
.option('--auto', 'Create handoff automatically')
|
.option('--auto', 'Create handoff automatically')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const serverUrl = getServerUrl(program, cwd);
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
if (serverUrl) {
|
if (serverUrl) {
|
||||||
await runRemote(serverUrl, async () => {
|
await runRemote(serverUrl, async () => {
|
||||||
const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
|
const result = await remoteClient.delegate(serverUrl, options.auto ?? false);
|
||||||
@ -314,7 +339,7 @@ export function createProgram(cwd: string): Command {
|
|||||||
else console.log('Run with --auto to create the handoff.');
|
else console.log('Run with --auto to create the handoff.');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await delegate(cwd, options);
|
await delegate(projectCwd, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -324,7 +349,12 @@ export function createProgram(cwd: string): Command {
|
|||||||
.description('Start the optional AgentHub API server')
|
.description('Start the optional AgentHub API server')
|
||||||
.option('-p, --port <port>', 'Port', '3377')
|
.option('-p, --port <port>', 'Port', '3377')
|
||||||
.option('-h, --host <host>', 'Host to bind to', '127.0.0.1')
|
.option('-h, --host <host>', 'Host to bind to', '127.0.0.1')
|
||||||
.action((options) => serverStart(cwd, { port: parseInt(options.port, 10), host: options.host }));
|
.action((options) => {
|
||||||
|
// Serve the project root if we are inside one, so `server start` works
|
||||||
|
// from any subdirectory and always serves the single source of truth.
|
||||||
|
const projectCwd = findProjectRoot(cwd) ?? cwd;
|
||||||
|
serverStart(projectCwd, { port: parseInt(options.port, 10), host: options.host });
|
||||||
|
});
|
||||||
program.addCommand(serverCmd);
|
program.addCommand(serverCmd);
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { join } from 'path';
|
import { existsSync } from 'fs';
|
||||||
|
import { dirname, join, parse } from 'path';
|
||||||
|
|
||||||
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'status';
|
export type EntityType = 'tasks' | 'handoffs' | 'decisions' | 'memory' | 'status';
|
||||||
|
|
||||||
@ -6,6 +7,22 @@ export function getAgentHubDir(cwd: string = process.cwd()): string {
|
|||||||
return join(cwd, '.agenthub');
|
return join(cwd, '.agenthub');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up from `startCwd` to find the nearest directory that contains an
|
||||||
|
* initialized AgentHub project (`.agenthub/agenthub.config.json`).
|
||||||
|
* Returns the project root, or `undefined` if none is found.
|
||||||
|
* This makes the CLI usable from any subdirectory of the project.
|
||||||
|
*/
|
||||||
|
export function findProjectRoot(startCwd: string = process.cwd()): string | undefined {
|
||||||
|
let dir = startCwd;
|
||||||
|
const { root } = parse(dir);
|
||||||
|
while (true) {
|
||||||
|
if (existsSync(join(dir, '.agenthub', 'agenthub.config.json'))) return dir;
|
||||||
|
if (dir === root) return undefined;
|
||||||
|
dir = dirname(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getEntityDir(cwd: string, type: EntityType): string {
|
export function getEntityDir(cwd: string, type: EntityType): string {
|
||||||
return join(getAgentHubDir(cwd), type);
|
return join(getAgentHubDir(cwd), type);
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/index.ts
16
src/index.ts
@ -1,4 +1,18 @@
|
|||||||
import { createProgram } from './cli/index.js';
|
import { createProgram } from './cli/index.js';
|
||||||
|
|
||||||
const program = createProgram(process.cwd());
|
const program = createProgram(process.cwd());
|
||||||
program.parse();
|
|
||||||
|
program.parseAsync().catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.startsWith('AgentHub not initialized')) {
|
||||||
|
console.error(
|
||||||
|
'No AgentHub project here. Run `agenthub init`, or connect to a running server ' +
|
||||||
|
'with `agenthub init --server auto` (or set AGENTHUB_SERVER=http://<host>:3377).',
|
||||||
|
);
|
||||||
|
} else if (message.startsWith('File not found')) {
|
||||||
|
console.error('Not found. Check the ID — or you may be pointed at the wrong server.');
|
||||||
|
} else {
|
||||||
|
console.error(`Error: ${message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@ -30,7 +30,15 @@ export async function startServer(cwd: string, options: { port?: number; host?:
|
|||||||
|
|
||||||
return { app, url };
|
return { app, url };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const e = err as NodeJS.ErrnoException;
|
||||||
|
if (e.code === 'EADDRINUSE') {
|
||||||
|
console.error(
|
||||||
|
`Port ${port} is already in use by another process that is not AgentHub. ` +
|
||||||
|
`Stop it, or start with a different --port.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
tests/single-instance.test.ts
Normal file
57
tests/single-instance.test.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { startServer } from '../src/server/index.js';
|
||||||
|
import { serverStart } from '../src/cli/commands/server.js';
|
||||||
|
import { findProjectRoot } from '../src/core/paths.js';
|
||||||
|
import { init } from '../src/cli/commands/init.js';
|
||||||
|
|
||||||
|
describe('single source of truth', () => {
|
||||||
|
describe('findProjectRoot (cwd-robust)', () => {
|
||||||
|
let cwd: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
cwd = mkdtempSync(join(tmpdir(), 'ah-root-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the project root from a nested subdirectory', async () => {
|
||||||
|
await init(cwd, { yes: true, projectName: 'root-test' });
|
||||||
|
const nested = join(cwd, 'apps', 'web', 'src');
|
||||||
|
mkdirSync(nested, { recursive: true });
|
||||||
|
expect(findProjectRoot(nested)).toBe(cwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when there is no project anywhere above', () => {
|
||||||
|
const empty = mkdtempSync(join(tmpdir(), 'ah-empty-'));
|
||||||
|
expect(findProjectRoot(empty)).toBeUndefined();
|
||||||
|
rmSync(empty, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('server start guard (single instance)', () => {
|
||||||
|
let cwd: string;
|
||||||
|
let server: Awaited<ReturnType<typeof startServer>>;
|
||||||
|
beforeEach(async () => {
|
||||||
|
cwd = mkdtempSync(join(tmpdir(), 'ah-guard-'));
|
||||||
|
await init(cwd, { yes: true, projectName: 'guard-test' });
|
||||||
|
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.app.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start a second server on a port already serving AgentHub', async () => {
|
||||||
|
const port = Number(new URL(server.url).port);
|
||||||
|
const logs: string[] = [];
|
||||||
|
const original = console.log;
|
||||||
|
console.log = (msg: string) => logs.push(msg);
|
||||||
|
await serverStart(cwd, { host: '127.0.0.1', port });
|
||||||
|
console.log = original;
|
||||||
|
expect(logs.some((m) => m.includes('already running'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user