Simplify instance lifecycle commands

This commit is contained in:
zenord
2026-08-17 00:49:29 +08:00
parent f9fb4ef776
commit fe7bbd0631
13 changed files with 385 additions and 237 deletions
+29 -105
View File
@@ -1,122 +1,46 @@
#!/usr/bin/env node
import process from "node:process";
import { discoverBackends } from "./acp/discovery.js";
import { assertOperationalConfig, loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { runInstanceCommand } from "./cli/instance.js";
import { localBaseUrl } from "./cli/net.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { defaultStateFile } from "./config.js";
import { startServer } from "./server.js";
interface ParsedArgs { command?: string; rest: string[]; configPath?: string; json: boolean; help: boolean }
const COMMANDS = ["init", "setup", "start", "stop", "restart", "status", "logs", "doctor", "list"] as const;
type Command = typeof COMMANDS[number];
async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv);
if (parsed.help || !parsed.command) { printHelp(); return 0; }
validateCommandArgs(parsed);
if (parsed.command === "instance") return runInstanceCommand(parsed.rest[0], parsed.rest.slice(1));
if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; }
if (parsed.command === "discover-backends" || parsed.command === "discover-agents") {
const backends = await discoverBackends();
if (parsed.json) console.log(JSON.stringify(backends, null, 2));
else for (const backend of backends) {
const version = backend.version ? ` (${backend.version})` : "";
const reason = backend.reason ? ` - ${backend.reason}` : "";
console.log(`${backend.id}\t${backend.status}\t${backend.command} ${backend.args.join(" ")}${version}${reason}`.trim());
}
if (argv.length === 0 || (argv.length === 1 && ["--help", "-h"].includes(argv[0]))) {
printHelp();
return 0;
}
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
assertOperationalConfig(loaded.config, loaded.path);
const running = await startServer(loaded.config);
return await new Promise<number>((resolve) => {
let stopping = false;
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => resolve(0), (error) => { console.error(error); resolve(1); });
};
process.once("SIGINT", () => stop("SIGINT"));
process.once("SIGTERM", () => stop("SIGTERM"));
});
}
if (parsed.command === "status") { await printStatus(parsed.configPath); return 0; }
if (parsed.command === "doctor") { const loaded = loadConfigFile(parsed.configPath); return runDoctor(loaded.config, loaded.path); }
if (parsed.command === "print") {
const loaded = loadConfigFile(parsed.configPath);
await printFeishu(loaded.config);
return 0;
}
throw new Error(`Unknown command: ${parsed.command}`);
}
function parseArgs(argv: string[]): ParsedArgs {
const rest: string[] = []; let command: string | undefined; let configPath: string | undefined; let json = false; let help = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") help = true;
else if (arg === "--json") json = true;
else if (arg === "--config") {
if (configPath !== undefined) throw new Error("--config may only be specified once");
const value = argv[++index];
if (!value || value.startsWith("-")) throw new Error("--config requires a path");
configPath = value;
} else if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
else if (!command) command = arg;
else rest.push(arg);
const [command, ...args] = argv;
const option = argv.find((arg) => arg.startsWith("-"));
if (option) throw new Error(`Unknown option: ${option}`);
if (!COMMANDS.includes(command as Command)) throw new Error(`Unknown command: ${command}`);
if (command === "list") {
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
} else if (args.length !== 1) {
throw new Error(`${command} requires exactly one <bot-id>`);
}
return { command, rest, configPath, json, help };
}
function validateCommandArgs(parsed: ParsedArgs): void {
const { command, rest, configPath, json } = parsed;
if (command === "instance") {
if (configPath || json) throw new Error("instance commands do not accept --config or --json");
return;
}
if (command === "setup") {
if (json || rest.length > 0) throw new Error("setup accepts only --config <path>");
return;
}
if (command === "discover-backends" || command === "discover-agents") {
if (configPath || rest.length > 0) throw new Error(`${command} accepts only --json`);
return;
}
if (["start", "status", "doctor"].includes(command || "")) {
if (json || rest.length > 0) throw new Error(`${command} accepts only --config <path>`);
return;
}
if (command === "print") {
if (json || rest.length !== 1 || rest[0] !== "feishu") throw new Error("print requires exactly the topic 'feishu'");
return;
}
throw new Error(`Unknown command: ${command}`);
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath); const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}`);
console.log(`Config version: ${loaded.config.configVersion}`);
console.log(`Bot: ${loaded.config.bot.id}`);
console.log(`Agent: ${loaded.config.bot.agent.id}`);
console.log(`Platform: ${loaded.config.gateway.platform.type}`);
console.log(`Server: ${loaded.config.gateway.server.host}:${loaded.config.gateway.server.port}`);
console.log(`Workspace: ${loaded.config.bot.workspace}`);
console.log(`State file: ${defaultStateFile(loaded.config)}`);
try {
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
console.log(`Health probe: HTTP ${response.status} ${await response.text()}`);
} catch { console.log("Health probe: not reachable on local URL"); }
return runInstanceCommand(command, args);
}
function printHelp(): void {
console.log(`gori-agent - single-Bot ACP gateway\n\nUsage:\n gori-agent instance init <bot-id>\n gori-agent instance start <bot-id>\n gori-agent instance stop <bot-id>\n gori-agent instance restart <bot-id>\n gori-agent instance status <bot-id>\n gori-agent instance logs <bot-id>\n gori-agent instance doctor <bot-id>\n gori-agent instance list\n\nDebug commands:\n gori-agent setup [--config path]\n gori-agent discover-backends [--json]\n gori-agent start --config path\n gori-agent status --config path\n gori-agent doctor --config path\n gori-agent print feishu --config path\n\nInstances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`);
console.log(`gori-agent - single-Bot ACP gateway
Usage:
gori-agent init <bot-id>
gori-agent setup <bot-id>
gori-agent start <bot-id>
gori-agent stop <bot-id>
gori-agent restart <bot-id>
gori-agent status <bot-id>
gori-agent logs <bot-id>
gori-agent doctor <bot-id>
gori-agent list
Instances root: \${GORI_AGENT_ROOT:-$HOME/.gori-agent}/instances`);
}
main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import process from "node:process";
import { pathToFileURL } from "node:url";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import { startServer } from "../server.js";
interface ShutdownTarget { shutdown(): Promise<void> }
interface SignalEmitter {
once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
}
export async function runInstanceRunner(argv: string[]): Promise<number> {
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
const loaded = loadConfigFile(argv[0]);
assertOperationalConfig(loaded.config, loaded.path);
return waitForShutdown(await startServer(loaded.config));
}
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
return new Promise<number>((resolve) => {
let stopping = false;
const finish = (code: number): void => {
signals.removeListener("SIGINT", onSigint);
signals.removeListener("SIGTERM", onSigterm);
resolve(code);
};
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => finish(0), (error) => {
console.error(error);
finish(1);
});
};
const onSigint = (): void => stop("SIGINT");
const onSigterm = (): void => stop("SIGTERM");
signals.once("SIGINT", onSigint);
signals.once("SIGTERM", onSigterm);
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runInstanceRunner(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
+61 -20
View File
@@ -4,6 +4,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { defaultStateFile } from "../config.js";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import { runDoctor } from "./doctor.js";
@@ -27,24 +28,25 @@ export function instanceConfigPath(botId: string): string { return path.join(ins
export async function runInstanceCommand(action: string | undefined, args: string[]): Promise<number> {
if (action === "list") {
if (args.length !== 0) throw new Error("instance list does not accept <bot-id>");
if (args.length !== 0) throw new Error("list does not accept <bot-id>");
return listInstances();
}
if (!action || !["init", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) {
throw new Error(`Unknown instance command: ${action || "(missing)"}`);
if (!action || !["init", "setup", "start", "stop", "restart", "status", "logs", "doctor"].includes(action)) {
throw new Error(`Unknown command: ${action || "(missing)"}`);
}
if (args.length !== 1) throw new Error(`instance ${action} requires exactly one <bot-id>`);
if (args.length !== 1) throw new Error(`${action} requires exactly one <bot-id>`);
const botId = args[0];
validateBotId(botId);
switch (action) {
case "init": return initInstance(botId);
case "setup": return setupInstance(botId);
case "start": return startInstance(botId);
case "stop": return stopInstance(botId);
case "restart": await stopInstance(botId); return startInstance(botId);
case "status": return statusInstance(botId);
case "logs": return logsInstance(botId);
case "doctor": return doctorInstance(botId);
default: throw new Error(`Unknown instance command: ${action}`);
default: throw new Error(`Unknown command: ${action}`);
}
}
@@ -61,10 +63,22 @@ async function initInstance(botId: string): Promise<number> {
}
}
async function setupInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0;
return doctorInstance(botId);
}
async function startInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config);
assertConfigMode(loaded.path);
assertOperationalConfig(loaded.config, loaded.path);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
@@ -75,12 +89,12 @@ async function startInstance(botId: string): Promise<number> {
const logFd = fs.openSync(paths.logFile, "a", 0o600);
fs.chmodSync(paths.logFile, 0o600);
const cliFile = path.join(path.resolve(new URL("../..", import.meta.url).pathname), "dist", "cli.js");
const runnerFile = instanceRunnerPath();
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(process.execPath, [cliFile, "start", "--config", loaded.path], {
child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home,
env: { ...process.env, GORI_AGENT_HOME: paths.home, GORI_GATEWAY_CONFIG: loaded.path },
env: { ...process.env, GORI_AGENT_HOME: paths.home },
detached: true,
stdio: ["ignore", logFd, logFd]
});
@@ -186,6 +200,7 @@ function listInstances(): number {
function loadInstance(botId: string): ReturnType<typeof loadConfigFile> {
const configFile = instanceConfigPath(botId);
if (fs.existsSync(path.dirname(configFile))) ensurePrivateDirectory(path.dirname(configFile), false);
if (fs.existsSync(configFile)) assertConfigMode(configFile);
const loaded = loadConfigFile(configFile);
if (loaded.config.bot.id !== botId) throw new Error(`Instance directory '${botId}' does not match config bot.id '${loaded.config.bot.id}'`);
return loaded;
@@ -230,28 +245,54 @@ function validateBotId(botId: string): void {
function assertConfigMode(configFile: string): void {
const stat = fs.lstatSync(configFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Refusing to start: config must be a regular file");
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Config must be a regular file");
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`Refusing to start: config file mode must be 0600, got 0${mode.toString(8)}`);
if (mode !== 0o600) throw new Error(`Config file mode must be 0600, got 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("Config file is not owned by the current user");
}
export function instanceRunnerPath(): string {
const moduleFile = fileURLToPath(import.meta.url);
const projectRoot = path.resolve(path.dirname(moduleFile), "../..");
return path.join(projectRoot, "dist", "cli", "instance-runner.js");
}
export function matchesInstanceRunner(commandLine: string[], executable: string, configFile: string): boolean {
if (commandLine.length !== 3) return false;
try {
return fs.realpathSync(executable) === fs.realpathSync(process.execPath)
&& path.resolve(commandLine[1]) === instanceRunnerPath()
&& path.resolve(commandLine[2]) === path.resolve(configFile);
} catch { return false; }
}
type PidInspection = { kind: "absent" } | { kind: "stale"; dev?: number; ino?: number } | { kind: "running" | "foreign"; pid: number };
export function assertSetupPidSafe(botId: string, current: PidInspection): void {
if (current.kind === "running") throw new Error(`Refusing to setup: instance '${botId}' is running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to setup: PID file references unrelated live process ${current.pid}`);
}
function inspectPid(pidFile: string, configFile: string): PidInspection {
let text: string;
let stat: fs.Stats;
try { stat = fs.lstatSync(pidFile); text = fs.readFileSync(pidFile, "utf8").trim(); }
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : { kind: "stale" }; }
try {
stat = fs.lstatSync(pidFile);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`PID file must be a regular file: ${pidFile}`);
const mode = stat.mode & 0o777;
if (mode !== 0o600) throw new Error(`PID file mode must be 0600: ${pidFile} has 0${mode.toString(8)}`);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`PID file is not owned by the current user: ${pidFile}`);
text = fs.readFileSync(pidFile, "utf8").trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
throw error;
}
const pid = Number(text);
if (!Number.isSafeInteger(pid) || pid <= 1 || !isPidRunning(pid)) return { kind: "stale", dev: stat.dev, ino: stat.ino };
try {
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean);
const configIndex = commandLine.indexOf("--config");
const matches = commandLine[2] === "start"
&& commandLine[1]?.endsWith("/dist/cli.js")
&& configIndex >= 0
&& path.resolve(commandLine[configIndex + 1] || "") === path.resolve(configFile);
return { kind: matches ? "running" : "foreign", pid };
const executable = fs.readlinkSync(`/proc/${pid}/exe`);
return { kind: matchesInstanceRunner(commandLine, executable, configFile) ? "running" : "foreign", pid };
} catch { return isPidRunning(pid) ? { kind: "foreign", pid } : { kind: "stale", dev: stat.dev, ino: stat.ino }; }
}
-14
View File
@@ -1,14 +0,0 @@
import type { AppConfig } from "../config.js";
import { webhookBaseUrl } from "./net.js";
export async function printFeishu(config: AppConfig): Promise<void> {
if (config.gateway.platform.type !== "feishu") throw new Error("Current instance platform is not Feishu");
const baseUrl = await webhookBaseUrl(config);
console.log("Feishu/Lark setup instructions");
console.log("");
console.log(`Webhook URL: ${baseUrl}/webhook/feishu`);
console.log("Event subscription: im.message.receive_v1");
console.log("Required config fields: gateway.platform.appId, appSecret, verificationToken, botNames");
console.log("Do not paste appSecret into chats or logs.");
if (!config.gateway.server.publicBaseUrl) console.log("Warning: gateway.server.publicBaseUrl is empty; configure your public HTTPS URL before production use.");
}
+14 -10
View File
@@ -17,11 +17,14 @@ export interface SetupOptions {
log?: (message: string) => void;
}
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<void> {
export async function runSetup(configPath?: string, options: SetupOptions = {}): Promise<boolean> {
const target = path.resolve(configPath || "config.json");
const configExists = fs.existsSync(target);
if (options.requireNew && configExists) throw new Error(`Config already exists: ${target}`);
const current = configExists ? loadConfigFile(target).config : loadExampleConfig();
if (configExists && options.botId && options.botId !== current.bot.id) {
throw new Error(`Cannot change existing bot.id '${current.bot.id}' to '${options.botId}'`);
}
const prompt = options.prompt || createPromptSession();
const discoverAgents = options.discoverAgents || discoverBackends;
const log = options.log || console.log;
@@ -31,10 +34,10 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}):
const discovered = await discoverAgents();
for (const agent of discovered) log(`- ${agent.id}: ${agent.status}${agent.version ? ` (${agent.version})` : ""}${agent.reason ? ` - ${agent.reason}` : ""}`);
const ready = discovered.filter((agent) => agent.status === "ready");
if (ready.length === 0) throw new Error("No ACP agent is available");
const selected = ready.find((agent) => agent.id === current.bot.agent.id) || ready[0];
if (!configExists && ready.length === 0) throw new Error("No ACP agent is available");
const selected = configExists ? current.bot.agent : ready.find((agent) => agent.id === current.bot.agent.id) || ready[0];
const botId = options.botId || await prompt.ask("Bot ID", current.bot.id === "BOT_ID" ? undefined : current.bot.id);
const botId = configExists ? current.bot.id : options.botId || await prompt.ask("Bot ID");
const workspace = path.resolve(await prompt.ask("Bot workspace", current.bot.workspace.startsWith("/absolute/") ? projectRoot() : current.bot.workspace));
const persona = await prompt.ask("Bot persona", current.bot.persona);
const usedPorts = collectUsedInstancePorts(options.instancesDirectory || defaultInstancesDirectory(), target);
@@ -54,11 +57,11 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}):
id: botId,
workspace,
persona,
agent: {
id: selected.id,
command: selected.command,
args: selected.args,
env: !isTemplate && selected.id === current.bot.agent.id ? current.bot.agent.env : {}
agent: configExists ? current.bot.agent : {
id: selected!.id,
command: selected!.command,
args: selected!.args,
env: {}
},
skills: isTemplate ? [] : current.bot.skills,
permissions: isTemplate ? { mode: "deny", allowedTools: [], allowedCommandPatterns: [] } : current.bot.permissions
@@ -67,9 +70,10 @@ export async function runSetup(configPath?: string, options: SetupOptions = {}):
runtime: current.runtime
};
const shouldWrite = options.writeWithoutConfirmation || await prompt.askBoolean(`Write Config v3 to ${target}`, false);
if (!shouldWrite) { log("No changes written."); return; }
if (!shouldWrite) { log("No changes written."); return false; }
writeConfigFile(target, next);
log(`Wrote ${target} with mode 0600`);
return true;
} finally { prompt.close(); }
}