mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
chore(merge): integrate main into workspace runtimes
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
import { createChatCommand } from "./commands/chat/index.js";
|
||||
import { createLoopCommand } from "./commands/loop/index.js";
|
||||
import { createPermitCommand } from "./commands/permit/index.js";
|
||||
import { createProviderCommand } from "./commands/provider/index.js";
|
||||
import { createScheduleCommand } from "./commands/schedule/index.js";
|
||||
@@ -168,7 +166,6 @@ export function createCli(): Command {
|
||||
program.addCommand(createHubCommand());
|
||||
|
||||
// Chat commands
|
||||
program.addCommand(createChatCommand());
|
||||
|
||||
// Terminal commands
|
||||
program.addCommand(createTerminalCommand());
|
||||
@@ -176,9 +173,6 @@ export function createCli(): Command {
|
||||
// Workspace script commands
|
||||
program.addCommand(createScriptCommand());
|
||||
|
||||
// Loop commands
|
||||
program.addCommand(createLoopCommand());
|
||||
|
||||
// Schedule commands
|
||||
program.addCommand(createScheduleCommand());
|
||||
program.addCommand(createHeartbeatCommand());
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult } from "../../output/index.js";
|
||||
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
|
||||
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
|
||||
|
||||
export interface ChatCreateOptions extends ChatCommandOptions {
|
||||
purpose?: string;
|
||||
}
|
||||
|
||||
export async function runCreateCommand(
|
||||
name: string,
|
||||
options: ChatCreateOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ChatRoomRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.createChatRoom({
|
||||
name,
|
||||
purpose: options.purpose,
|
||||
});
|
||||
return {
|
||||
type: "single",
|
||||
data: toChatRoomRow(payload.room!),
|
||||
schema: chatRoomSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult } from "../../output/index.js";
|
||||
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
|
||||
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
|
||||
|
||||
export async function runDeleteCommand(
|
||||
room: string,
|
||||
options: ChatCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ChatRoomRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.deleteChatRoom({ room });
|
||||
return {
|
||||
type: "single",
|
||||
data: toChatRoomRow(payload.room!),
|
||||
schema: chatRoomSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runInspectCommand } from "./inspect.js";
|
||||
import { runDeleteCommand } from "./delete.js";
|
||||
import { runPostCommand } from "./post.js";
|
||||
import { runReadCommand } from "./read.js";
|
||||
import { runWaitCommand } from "./wait.js";
|
||||
|
||||
export function createChatCommand(): Command {
|
||||
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("create")
|
||||
.description("Create a chat room")
|
||||
.argument("<name>", "Room name (must be unique)")
|
||||
.option("--purpose <text>", "Room purpose/description"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(chat.command("ls").description("List chat rooms")).action(
|
||||
withOutput(runLsCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("inspect")
|
||||
.description("Inspect a chat room")
|
||||
.argument("<name-or-id>", "Room name or ID"),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("delete")
|
||||
.description("Delete a chat room")
|
||||
.argument("<name-or-id>", "Room name or ID"),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("post")
|
||||
.description("Post a chat message")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.argument("<message>", "Message body")
|
||||
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
|
||||
).action(withOutput(runPostCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("read")
|
||||
.description("Read chat messages")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.option("--limit <n>", "Maximum number of messages to return")
|
||||
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
|
||||
.option("--agent <agent-id>", "Filter by author agent ID"),
|
||||
).action(withOutput(runReadCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
chat
|
||||
.command("wait")
|
||||
.description("Wait for new chat messages")
|
||||
.argument("<name-or-id>", "Room name or ID")
|
||||
.option("--timeout <duration>", "Maximum wait time"),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
return chat;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult } from "../../output/index.js";
|
||||
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
|
||||
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
|
||||
|
||||
export async function runInspectCommand(
|
||||
room: string,
|
||||
options: ChatCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ChatRoomRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.inspectChatRoom({ room });
|
||||
return {
|
||||
type: "single",
|
||||
data: toChatRoomRow(payload.room!),
|
||||
schema: chatRoomSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { ListResult } from "../../output/index.js";
|
||||
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
|
||||
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
|
||||
|
||||
export async function runLsCommand(
|
||||
options: ChatCommandOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ChatRoomRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.listChatRooms();
|
||||
return {
|
||||
type: "list",
|
||||
data: payload.rooms.map(toChatRoomRow),
|
||||
schema: chatRoomSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { SingleResult } from "../../output/index.js";
|
||||
import {
|
||||
attachAgentNamesToMessages,
|
||||
connectChatClient,
|
||||
resolveChatAuthorAgentId,
|
||||
toChatCommandError,
|
||||
type ChatCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
|
||||
|
||||
export interface ChatPostOptions extends ChatCommandOptions {
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export async function runPostCommand(
|
||||
room: string,
|
||||
body: string,
|
||||
options: ChatPostOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ChatMessageRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.postChatMessage({
|
||||
room,
|
||||
body,
|
||||
authorAgentId: resolveChatAuthorAgentId(),
|
||||
replyToMessageId: options.replyTo,
|
||||
});
|
||||
const [message] = await attachAgentNamesToMessages(client, [
|
||||
toChatMessageRow(payload.message!),
|
||||
]);
|
||||
return {
|
||||
type: "single",
|
||||
data: message,
|
||||
schema: chatMessageSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { ListResult } from "../../output/index.js";
|
||||
import {
|
||||
attachAgentNamesToMessages,
|
||||
connectChatClient,
|
||||
parseSinceValue,
|
||||
toChatCommandError,
|
||||
type ChatCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
|
||||
|
||||
export interface ChatReadOptions extends ChatCommandOptions {
|
||||
limit?: string;
|
||||
since?: string;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
function parseLimit(value?: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw {
|
||||
code: "INVALID_LIMIT",
|
||||
message: "Invalid --limit value",
|
||||
details: "Use a non-negative integer.",
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function runReadCommand(
|
||||
room: string,
|
||||
options: ChatReadOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ChatMessageRow>> {
|
||||
const { client } = await connectChatClient(options.host);
|
||||
try {
|
||||
const payload = await client.readChatMessages({
|
||||
room,
|
||||
limit: parseLimit(options.limit),
|
||||
since: parseSinceValue(options.since),
|
||||
authorAgentId: options.agent,
|
||||
});
|
||||
const messages = await attachAgentNamesToMessages(
|
||||
client,
|
||||
payload.messages.map(toChatMessageRow),
|
||||
);
|
||||
return {
|
||||
type: "list",
|
||||
data: messages,
|
||||
schema: chatMessageSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
|
||||
|
||||
export interface ChatRoomRow {
|
||||
name: string;
|
||||
id: string;
|
||||
purpose: string;
|
||||
messages: number;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageRow {
|
||||
id: string;
|
||||
author: string;
|
||||
authorName: string | null;
|
||||
createdAt: string;
|
||||
replyTo: string;
|
||||
mentionAgentIds: string[];
|
||||
mentionLabels: string[];
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 22 },
|
||||
{ header: "ID", field: "id", width: 36 },
|
||||
{ header: "PURPOSE", field: "purpose", width: 30 },
|
||||
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
|
||||
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
|
||||
],
|
||||
};
|
||||
|
||||
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "ID", field: "id", width: 36 },
|
||||
{ header: "AUTHOR", field: "author", width: 16 },
|
||||
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
|
||||
{ header: "CREATED", field: "createdAt", width: 24 },
|
||||
{ header: "REPLY TO", field: "replyTo", width: 36 },
|
||||
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
|
||||
{ header: "MESSAGE", field: "body", width: 60 },
|
||||
],
|
||||
renderHuman: renderChatTranscript,
|
||||
};
|
||||
|
||||
function renderChatTranscript(
|
||||
result: AnyCommandResult<ChatMessageRow>,
|
||||
_options: OutputOptions,
|
||||
): string {
|
||||
const data = result.type === "list" ? result.data : [result.data];
|
||||
if (data.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return data.map(renderChatMessageBlock).join("\n\n");
|
||||
}
|
||||
|
||||
function renderChatMessageBlock(message: ChatMessageRow): string {
|
||||
const authorLabel = message.authorName
|
||||
? `${message.authorName} (${message.author})`
|
||||
: message.author;
|
||||
const lines = [
|
||||
`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`,
|
||||
];
|
||||
|
||||
if (message.replyTo !== "-") {
|
||||
lines.push(`│ reply-to: msg ${message.replyTo}`);
|
||||
}
|
||||
if (message.mentionLabels.length > 0) {
|
||||
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
|
||||
}
|
||||
|
||||
lines.push("│");
|
||||
|
||||
const bodyLines = message.body.split(/\r?\n/);
|
||||
for (const line of bodyLines) {
|
||||
lines.push(`│ ${line}`);
|
||||
}
|
||||
|
||||
lines.push("│");
|
||||
lines.push("└─");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatTimestamp(input: string): string {
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return input;
|
||||
}
|
||||
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
|
||||
}
|
||||
|
||||
export function toChatRoomRow(room: {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: string | null;
|
||||
messageCount: number;
|
||||
lastMessageAt: string | null;
|
||||
}): ChatRoomRow {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
purpose: room.purpose ?? "-",
|
||||
messages: room.messageCount,
|
||||
lastMessageAt: room.lastMessageAt ?? "-",
|
||||
};
|
||||
}
|
||||
|
||||
export function toChatMessageRow(message: {
|
||||
id: string;
|
||||
authorAgentId: string;
|
||||
createdAt: string;
|
||||
replyToMessageId: string | null;
|
||||
mentionAgentIds: string[];
|
||||
body: string;
|
||||
}): ChatMessageRow {
|
||||
return {
|
||||
id: message.id,
|
||||
author: message.authorAgentId,
|
||||
authorName: null,
|
||||
createdAt: message.createdAt,
|
||||
replyTo: message.replyToMessageId ?? "-",
|
||||
mentionAgentIds: message.mentionAgentIds,
|
||||
mentionLabels: message.mentionAgentIds,
|
||||
body: message.body,
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
import type { ChatMessageRow } from "./schema.js";
|
||||
|
||||
export interface ChatCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function connectChatClient(host?: string) {
|
||||
const daemonHost = getDaemonHost({ host });
|
||||
try {
|
||||
const client = await connectToDaemon({ host });
|
||||
return { client, daemonHost };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachAgentNamesToMessages(
|
||||
client: Awaited<ReturnType<typeof connectToDaemon>>,
|
||||
messages: ChatMessageRow[],
|
||||
options: { timeout?: number; bestEffort?: boolean } = {},
|
||||
): Promise<ChatMessageRow[]> {
|
||||
const agentIds = new Set<string>();
|
||||
for (const message of messages) {
|
||||
agentIds.add(message.author);
|
||||
for (const mentionId of message.mentionAgentIds) {
|
||||
agentIds.add(mentionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (agentIds.size === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
|
||||
try {
|
||||
payload = await client.fetchAgents({
|
||||
filter: { includeArchived: true },
|
||||
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (options.bestEffort) {
|
||||
return messages;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const agentNames = new Map<string, string>();
|
||||
for (const entry of payload.entries) {
|
||||
const title = entry.agent.title?.trim();
|
||||
if (title) {
|
||||
agentNames.set(entry.agent.id, title);
|
||||
}
|
||||
}
|
||||
|
||||
return messages.map((message) => ({
|
||||
...message,
|
||||
authorName: agentNames.get(message.author) ?? null,
|
||||
mentionLabels: message.mentionAgentIds.map((agentId) => {
|
||||
const name = agentNames.get(agentId);
|
||||
return name ? `${name} (${agentId})` : agentId;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
|
||||
if (err && typeof err === "object" && "code" in err && "message" in err) {
|
||||
return err as CommandError;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rpcCode =
|
||||
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
|
||||
? err.code
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
code: rpcCode ?? code,
|
||||
message: `Failed to ${action}: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSinceValue(input?: string): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const durationMs = parseDuration(input);
|
||||
return new Date(Date.now() - durationMs).toISOString();
|
||||
} catch {
|
||||
const timestamp = new Date(input);
|
||||
if (Number.isNaN(timestamp.getTime())) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_SINCE",
|
||||
message: "Invalid --since value",
|
||||
details: "Use a duration like 10m or an ISO timestamp.",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return timestamp.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTimeoutMs(input?: string): number | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const timeoutMs = parseDuration(input);
|
||||
if (timeoutMs <= 0) {
|
||||
throw new Error("Timeout must be positive");
|
||||
}
|
||||
return timeoutMs;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "INVALID_TIMEOUT",
|
||||
message: "Invalid timeout value",
|
||||
details: message,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveChatAuthorAgentId(): string {
|
||||
const agentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
return agentId && agentId.length > 0 ? agentId : "manual";
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import type { ListResult } from "../../output/index.js";
|
||||
import {
|
||||
attachAgentNamesToMessages,
|
||||
connectChatClient,
|
||||
parseTimeoutMs,
|
||||
toChatCommandError,
|
||||
type ChatCommandOptions,
|
||||
} from "./shared.js";
|
||||
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
|
||||
|
||||
export interface ChatWaitOptions extends ChatCommandOptions {
|
||||
timeout?: string;
|
||||
}
|
||||
|
||||
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
|
||||
|
||||
export async function runWaitCommand(
|
||||
room: string,
|
||||
options: ChatWaitOptions,
|
||||
_command: Command,
|
||||
): Promise<ListResult<ChatMessageRow>> {
|
||||
const timeoutMs = parseTimeoutMs(options.timeout);
|
||||
const { client } = await connectChatClient(options.host);
|
||||
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
|
||||
const hasExplicitTimeout = deadline !== null;
|
||||
const remainingTimeoutMs = () =>
|
||||
deadline === null ? undefined : Math.max(1, deadline - Date.now());
|
||||
try {
|
||||
const latest = await client.readChatMessages({
|
||||
room,
|
||||
limit: 1,
|
||||
...(hasExplicitTimeout
|
||||
? {
|
||||
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const afterMessageId = latest.messages[0]?.id;
|
||||
const payload = await client.waitForChatMessages({
|
||||
room,
|
||||
afterMessageId,
|
||||
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
|
||||
});
|
||||
const messages = await attachAgentNamesToMessages(
|
||||
client,
|
||||
payload.messages.map(toChatMessageRow),
|
||||
hasExplicitTimeout
|
||||
? {
|
||||
timeout: remainingTimeoutMs(),
|
||||
bestEffort: true,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
return {
|
||||
type: "list",
|
||||
data: messages,
|
||||
schema: chatMessageSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,35 @@ describe("Hub HTTP client", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads configuration resources in the Hub's slug vocabulary", async () => {
|
||||
const requests: Array<{ url: string | undefined; body: string }> = [];
|
||||
const origin = await startServer(
|
||||
() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
daemons: [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "macbook" }],
|
||||
github: [
|
||||
{
|
||||
slug: "getpaseo",
|
||||
accountLogin: "getpaseo",
|
||||
accountType: "Organization",
|
||||
repositories: ["getpaseo/paseo"],
|
||||
},
|
||||
],
|
||||
discord: [{ slug: "paseo", guildName: "Paseo" }],
|
||||
slack: [{ slug: "paseo", teamName: "Paseo" }],
|
||||
},
|
||||
}),
|
||||
requests,
|
||||
);
|
||||
|
||||
const resources = await new HubHttpClient().listConfigurationResources(origin, "secret");
|
||||
|
||||
assert.equal(resources.daemons[0]?.slug, "macbook");
|
||||
assert.equal(resources.discord[0]?.slug, "paseo");
|
||||
assert.equal(requests[0]?.url, "/api/v1/configuration-resources");
|
||||
});
|
||||
|
||||
it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => {
|
||||
const requests: Array<{ url: string | undefined; body: string }> = [];
|
||||
const origin = await startServer(
|
||||
@@ -126,10 +155,14 @@ describe("Hub HTTP client", () => {
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof HubCommandError);
|
||||
assert.equal(error.message.includes("operator-secret"), false);
|
||||
assert.equal(error.message.includes("Correct the canonical"), false);
|
||||
assert.equal(error.details?.includes("operator-secret"), false);
|
||||
assert.equal(error.message.includes("sensitive bundle content"), false);
|
||||
assert.equal(error.details?.includes("sensitive bundle content"), false);
|
||||
assert.match(error.details ?? "", /\.paseo\/workflows\/answer\.yml\.steps\.work\.agent/u);
|
||||
assert.equal(
|
||||
error.details,
|
||||
".paseo/workflows/answer.yml: steps.work.agent: unknown named agent [redacted]",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("Hub commands", () => {
|
||||
|
||||
assert.deepEqual(names, [
|
||||
"login",
|
||||
"init",
|
||||
"connect",
|
||||
"status",
|
||||
"disconnect",
|
||||
@@ -344,7 +345,6 @@ describe("Hub commands", () => {
|
||||
daemon.beforeDisconnect = () => {
|
||||
assert.equal(credentials.active()?.credential, "human-secret");
|
||||
};
|
||||
|
||||
const result = await runHubLogout(
|
||||
{ json: true, disconnectDaemon: true, force: true },
|
||||
{
|
||||
@@ -359,9 +359,31 @@ describe("Hub commands", () => {
|
||||
assert.equal(daemon.disconnects, 1);
|
||||
assert.deepEqual(daemon.disconnectForces, [true]);
|
||||
assert.equal(result.data.daemonDisconnected, true);
|
||||
assert.equal(result.data.warning, undefined);
|
||||
});
|
||||
|
||||
it("disconnect reports the current Hub and preserves it in the final result", async () => {
|
||||
it("logout reports a daemon disconnect warning", async () => {
|
||||
const credentials = new MemoryCredentials();
|
||||
credentials.save({ origin: "https://hub.test", credential: "human-secret" });
|
||||
const daemon = new FakeDaemon("https://hub.test");
|
||||
daemon.disconnectWarning = "Hub could not be reached; cleanup may remain pending.";
|
||||
|
||||
const result = await runHubLogout(
|
||||
{ json: true, disconnectDaemon: true },
|
||||
{
|
||||
credentials,
|
||||
daemon: new FakeDaemonConnection(daemon),
|
||||
isInteractive: () => false,
|
||||
confirmDisconnect: async () => false,
|
||||
reporter: quietReporter,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.data.daemonDisconnected, true);
|
||||
assert.equal(result.data.warning, "Hub could not be reached; cleanup may remain pending.");
|
||||
});
|
||||
|
||||
it("disconnect reports clean local status without a ghost Hub origin", async () => {
|
||||
const daemon = new FakeDaemon("https://hub.test");
|
||||
const progress: string[] = [];
|
||||
|
||||
@@ -374,7 +396,7 @@ describe("Hub commands", () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(progress, ["Disconnecting this daemon from https://hub.test"]);
|
||||
assert.equal(result.data[0]?.hub, "https://hub.test");
|
||||
assert.equal(result.data[0]?.hub, null);
|
||||
});
|
||||
|
||||
it("preserves login when an accepted daemon disconnect fails", async () => {
|
||||
@@ -448,6 +470,7 @@ class FakeDaemon implements HubDaemonClient {
|
||||
disconnects = 0;
|
||||
readonly disconnectForces: boolean[] = [];
|
||||
disconnectError: Error | null = null;
|
||||
disconnectWarning: string | null = null;
|
||||
beforeDisconnect: (() => void) | null = null;
|
||||
|
||||
constructor(private readonly origin: string) {}
|
||||
@@ -466,7 +489,10 @@ class FakeDaemon implements HubDaemonClient {
|
||||
this.disconnectForces.push(force);
|
||||
this.beforeDisconnect?.();
|
||||
if (this.disconnectError !== null) throw this.disconnectError;
|
||||
return { status: hubStatus("not_connected", null) };
|
||||
return {
|
||||
status: hubStatus("not_connected", null),
|
||||
...(this.disconnectWarning ? { warning: this.disconnectWarning } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async close() {}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type HubValidationResult,
|
||||
} from "./hub-client/index.js";
|
||||
import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js";
|
||||
import { discoverHubBundle } from "./deploy-bundle.js";
|
||||
import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js";
|
||||
import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js";
|
||||
import { addHubResolutionHelp } from "./help.js";
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface HubDeployOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
interface HubDeployEnvironment {
|
||||
export interface HubDeployEnvironment {
|
||||
cwd: string;
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
credentials?: HubCredentialStore;
|
||||
@@ -76,6 +76,18 @@ export async function runHubDeploy(
|
||||
credentials: new PrivateHubCredentialStore(),
|
||||
hub: new HubHttpClient(),
|
||||
},
|
||||
): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> {
|
||||
const deployInput = await discoverHubBundle({
|
||||
cwd: environment.cwd,
|
||||
...(options.project === undefined ? {} : { project: options.project }),
|
||||
});
|
||||
return runHubDeployBundle(options, deployInput, environment);
|
||||
}
|
||||
|
||||
export async function runHubDeployBundle(
|
||||
options: HubDeployOptions,
|
||||
deployInput: HubDeployBundle,
|
||||
environment: HubDeployEnvironment,
|
||||
): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> {
|
||||
const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env);
|
||||
const resolution = {
|
||||
@@ -84,10 +96,6 @@ export async function runHubDeploy(
|
||||
credentials,
|
||||
};
|
||||
const origin = resolveHubOrigin(resolution);
|
||||
const deployInput = await discoverHubBundle({
|
||||
cwd: environment.cwd,
|
||||
...(options.project === undefined ? {} : { project: options.project }),
|
||||
});
|
||||
const action = options.dryRun === true ? "Validating" : "Deploying";
|
||||
reportHubProgress(
|
||||
environment.reporter ?? processHubReporter,
|
||||
|
||||
@@ -31,7 +31,7 @@ export function runHubDisconnect(
|
||||
);
|
||||
}
|
||||
const response = await client.disconnectHub(options.force ?? false);
|
||||
return hubStatusResult(response.status, response.warning, current.hubOrigin);
|
||||
return hubStatusResult(response.status, response.warning);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function addHubDisconnectCommand(
|
||||
addJsonAndDaemonHostOptions(
|
||||
parent
|
||||
.command("disconnect")
|
||||
.option("--force", "Remove local authority even if the Hub is offline"),
|
||||
.option("--force", "Remove local authority without notifying the Hub"),
|
||||
).action(
|
||||
withOutput(async (...args) => {
|
||||
const options = args.at(-2) as HubDisconnectOptions;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HubCommandError } from "../error.js";
|
||||
import {
|
||||
authorizationPollSchema,
|
||||
authorizationSchema,
|
||||
configurationResourcesSchema,
|
||||
enrollmentTokenSchema,
|
||||
installResponseSchema,
|
||||
projectsResponseSchema,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
type CliAuthorization,
|
||||
type CliAuthorizationPoll,
|
||||
type HubInstallResult,
|
||||
type HubConfigurationResources,
|
||||
type HubProject,
|
||||
type HubValidationResult,
|
||||
} from "./internal/contracts.js";
|
||||
@@ -19,6 +21,7 @@ export type {
|
||||
CliAuthorization,
|
||||
CliAuthorizationPoll,
|
||||
HubInstallResult,
|
||||
HubConfigurationResources,
|
||||
HubProject,
|
||||
HubValidationResult,
|
||||
} from "./internal/contracts.js";
|
||||
@@ -80,6 +83,18 @@ export class HubHttpClient {
|
||||
return response.projects;
|
||||
}
|
||||
|
||||
listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources> {
|
||||
return requestHub({
|
||||
origin,
|
||||
path: "/api/v1/configuration-resources",
|
||||
method: "GET",
|
||||
apiKey,
|
||||
successStatus: 200,
|
||||
schema: configurationResourcesSchema,
|
||||
failureMessage: "Hub configuration resource listing failed",
|
||||
});
|
||||
}
|
||||
|
||||
installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> {
|
||||
return requestHub({
|
||||
origin: input.origin,
|
||||
|
||||
@@ -36,6 +36,24 @@ const projectSchema = z
|
||||
|
||||
export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
|
||||
|
||||
export const configurationResourcesSchema = z
|
||||
.object({
|
||||
daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()),
|
||||
github: z.array(
|
||||
z
|
||||
.object({
|
||||
slug: z.string().min(1),
|
||||
accountLogin: z.string().min(1),
|
||||
accountType: z.string().min(1),
|
||||
repositories: z.array(z.string().min(1)),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()),
|
||||
slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const installResponseSchema = z
|
||||
.object({
|
||||
projectSlug: z.string().min(1),
|
||||
@@ -56,5 +74,6 @@ export const enrollmentTokenSchema = z
|
||||
export type CliAuthorization = z.infer<typeof authorizationSchema>;
|
||||
export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
|
||||
export type HubProject = z.infer<typeof projectSchema>;
|
||||
export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
|
||||
export type HubInstallResult = z.infer<typeof installResponseSchema>;
|
||||
export type HubValidationResult = z.infer<typeof validationResponseSchema>;
|
||||
|
||||
@@ -51,9 +51,14 @@ export async function hubRequestFailure(
|
||||
);
|
||||
}
|
||||
const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
|
||||
const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
|
||||
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
|
||||
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
|
||||
const message =
|
||||
details !== undefined || parsed.data.detail === undefined
|
||||
? title
|
||||
: `${title}: ${parsed.data.detail}`;
|
||||
let code = "HUB_REQUEST_FAILED";
|
||||
if (response.status === 422) code = "HUB_VALIDATION_FAILED";
|
||||
if (response.status === 404) code = "HUB_NOT_FOUND";
|
||||
return new HubCommandError(
|
||||
code,
|
||||
redactSecret(message, apiKey),
|
||||
@@ -82,12 +87,20 @@ function formatFieldIssues(
|
||||
|
||||
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
|
||||
if (path === undefined || typeof path === "string") return path;
|
||||
const [file, ...fieldPath] = path;
|
||||
if (typeof file === "string" && file.startsWith(".paseo/") && fieldPath.length > 0) {
|
||||
return `${file}: ${formatPathSegments(fieldPath)}`;
|
||||
}
|
||||
return formatPathSegments(path) || undefined;
|
||||
}
|
||||
|
||||
function formatPathSegments(path: readonly (string | number)[]): string {
|
||||
let formatted = "";
|
||||
for (const segment of path) {
|
||||
if (typeof segment === "number") formatted += `[${segment}]`;
|
||||
else formatted += formatted.length === 0 ? segment : `.${segment}`;
|
||||
}
|
||||
return formatted || undefined;
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function redactSecret(value: string, secret: string | undefined): string {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { addHubProjectsCommand } from "./projects.js";
|
||||
import { processHubReporter, type HubReporter } from "./reporter.js";
|
||||
import { hubStatusResult } from "./status-output.js";
|
||||
import { addHubResolutionHelp } from "./help.js";
|
||||
import { addHubInitCommand } from "./init.js";
|
||||
|
||||
interface HubCommandEnvironment {
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
@@ -56,6 +57,7 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {})
|
||||
flow: environment.login,
|
||||
reporter: environment.reporter,
|
||||
});
|
||||
addHubInitCommand(hub, environment);
|
||||
addHubConnectCommand(hub, {
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { discoverHubBundle } from "./deploy-bundle.js";
|
||||
import { runHubDeploy } from "./deploy.js";
|
||||
import {
|
||||
createHubInitScaffold,
|
||||
planHubInitOpening,
|
||||
resolveHubInitConnection,
|
||||
resolveHubInitProjects,
|
||||
type HubInitProvider,
|
||||
} from "./init-plan.js";
|
||||
import { githubRepositoryFromRemote } from "./init.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
const project = (slug: string) => ({
|
||||
id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4",
|
||||
slug,
|
||||
name: slug.toUpperCase(),
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe("Hub init planning", () => {
|
||||
it("includes login only when there is no active login", () => {
|
||||
expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({
|
||||
replaceExisting: false,
|
||||
steps: ["login", "connect", "project", "scaffold"],
|
||||
});
|
||||
expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({
|
||||
replaceExisting: false,
|
||||
steps: ["connect", "project", "scaffold"],
|
||||
});
|
||||
});
|
||||
|
||||
it("plans a confirmed replacement for an existing .paseo directory", () => {
|
||||
expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({
|
||||
replaceExisting: true,
|
||||
steps: ["connect", "project", "scaffold"],
|
||||
});
|
||||
});
|
||||
|
||||
it("stops, defaults, or asks according to the project count", () => {
|
||||
expect(resolveHubInitProjects([])).toEqual({ kind: "none" });
|
||||
expect(resolveHubInitProjects([project("one")])).toEqual({
|
||||
kind: "selected",
|
||||
project: project("one"),
|
||||
});
|
||||
expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({
|
||||
kind: "choose",
|
||||
projects: [project("one"), project("two")],
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => {
|
||||
const status = {
|
||||
state: "connected",
|
||||
daemonId: "daemon-1",
|
||||
hubOrigin: "https://hub.test",
|
||||
scopes: ["hub.execution.*"],
|
||||
connectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
expect(resolveHubInitConnection(status, "https://hub.test")).toEqual({
|
||||
kind: "connected",
|
||||
daemonId: "daemon-1",
|
||||
});
|
||||
expect(resolveHubInitConnection(status, "https://other.test")).toEqual({
|
||||
kind: "conflict",
|
||||
origin: "https://hub.test",
|
||||
});
|
||||
expect(
|
||||
resolveHubInitConnection({ ...status, state: "reconnecting" }, "https://hub.test"),
|
||||
).toEqual({ kind: "pending", state: "reconnecting" });
|
||||
expect(
|
||||
resolveHubInitConnection(
|
||||
{ ...status, state: "not_connected", daemonId: null, hubOrigin: null },
|
||||
"https://hub.test",
|
||||
),
|
||||
).toEqual({ kind: "connect" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hub init scaffold", () => {
|
||||
it.each([
|
||||
["github", { repo: "getpaseo/paseo", user: "boudra" }],
|
||||
["slack", { workspace: "paseo", user: "boudra" }],
|
||||
["discord", { guild: "paseo", user: "boudra" }],
|
||||
] satisfies readonly [HubInitProvider, Record<string, string>][])(
|
||||
"creates a closed %s trigger that the deploy discovery path accepts",
|
||||
async (provider, providerFilters) => {
|
||||
const cwd = await temporaryDirectory();
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd,
|
||||
daemonSlug: "build-studio",
|
||||
provider,
|
||||
providerFilters,
|
||||
});
|
||||
await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true });
|
||||
await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub);
|
||||
await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow);
|
||||
|
||||
const bundle = await discoverHubBundle({ cwd, project: "paseo" });
|
||||
expect(bundle.workflowCount).toBe(1);
|
||||
expect(bundle.files.map((file) => file.path)).toEqual([
|
||||
".paseo/hub.yml",
|
||||
scaffold.workflowPath,
|
||||
]);
|
||||
const parsed = YAML.parse(scaffold.workflow) as {
|
||||
filters: { from_users: string[]; channels?: string[] };
|
||||
};
|
||||
expect(parsed.filters.from_users).toEqual([providerFilters.user]);
|
||||
expect(parsed.filters.channels).toBeUndefined();
|
||||
|
||||
let validatedPaths: readonly string[] = [];
|
||||
const result = await runHubDeploy(
|
||||
{ project: "paseo", hub: "https://hub.test", dryRun: true },
|
||||
{
|
||||
cwd,
|
||||
env: { PASEO_HUB_API_KEY: "test-key" },
|
||||
reporter: { progress() {} },
|
||||
hub: {
|
||||
async validateConfiguration(input) {
|
||||
validatedPaths = input.files.map((file) => file.path);
|
||||
for (const file of input.files) YAML.parse(file.content);
|
||||
return { projectSlug: input.projectSlug, valid: true };
|
||||
},
|
||||
async installConfiguration() {
|
||||
throw new Error("dry-run must not install");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 });
|
||||
expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("GitHub origin detection", () => {
|
||||
it.each([
|
||||
["git@github.com:getpaseo/paseo.git", "getpaseo/paseo"],
|
||||
["ssh://git@github.com/getpaseo/paseo.git", "getpaseo/paseo"],
|
||||
["https://github.com/getpaseo/paseo.git", "getpaseo/paseo"],
|
||||
["https://gitlab.com/getpaseo/paseo.git", undefined],
|
||||
])("resolves %s", (remote, expected) => {
|
||||
expect(githubRepositoryFromRemote(remote)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-"));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import type { HubDeployBundle } from "./deploy-bundle.js";
|
||||
import type { HubProject } from "./hub-client/index.js";
|
||||
import type { HubStatus } from "./daemon-client.js";
|
||||
|
||||
export type HubInitProvider = "github" | "slack" | "discord";
|
||||
|
||||
export type HubInitProjectResolution =
|
||||
| { kind: "none" }
|
||||
| { kind: "selected"; project: HubProject }
|
||||
| { kind: "choose"; projects: readonly HubProject[] };
|
||||
|
||||
export type HubInitConnectionResolution =
|
||||
| { kind: "connected"; daemonId: string }
|
||||
| { kind: "pending"; state: "connecting" | "reconnecting" }
|
||||
| { kind: "connect" }
|
||||
| { kind: "conflict"; origin: string };
|
||||
|
||||
export interface HubInitOpeningPlan {
|
||||
replaceExisting: boolean;
|
||||
steps: readonly ("login" | "connect" | "project" | "scaffold")[];
|
||||
}
|
||||
|
||||
export interface HubInitScaffoldInput {
|
||||
cwd: string;
|
||||
daemonSlug: string;
|
||||
provider: HubInitProvider;
|
||||
providerFilters: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface HubInitScaffold {
|
||||
hub: string;
|
||||
workflowPath: string;
|
||||
workflow: string;
|
||||
testAction: string;
|
||||
}
|
||||
|
||||
export function planHubInitOpening(input: {
|
||||
loggedIn: boolean;
|
||||
paseoDirectoryExists: boolean;
|
||||
}): HubInitOpeningPlan {
|
||||
return {
|
||||
replaceExisting: input.paseoDirectoryExists,
|
||||
steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"],
|
||||
};
|
||||
}
|
||||
|
||||
export function createHubInitBundle(
|
||||
projectSlug: string,
|
||||
scaffold: HubInitScaffold,
|
||||
): HubDeployBundle {
|
||||
return {
|
||||
projectSlug,
|
||||
workflowCount: 1,
|
||||
files: [
|
||||
{ path: ".paseo/hub.yml", content: scaffold.hub },
|
||||
{ path: scaffold.workflowPath, content: scaffold.workflow },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution {
|
||||
if (projects.length === 0) return { kind: "none" };
|
||||
if (projects.length === 1) return { kind: "selected", project: projects[0]! };
|
||||
return { kind: "choose", projects };
|
||||
}
|
||||
|
||||
export function resolveHubInitConnection(
|
||||
status: HubStatus,
|
||||
origin: string,
|
||||
): HubInitConnectionResolution {
|
||||
if (status.state === "connected" && status.hubOrigin === origin && status.daemonId !== null) {
|
||||
return { kind: "connected", daemonId: status.daemonId };
|
||||
}
|
||||
if (
|
||||
(status.state === "connecting" || status.state === "reconnecting") &&
|
||||
status.hubOrigin === origin
|
||||
) {
|
||||
return { kind: "pending", state: status.state };
|
||||
}
|
||||
if (status.hubOrigin !== null && status.state !== "not_connected" && status.state !== "revoked") {
|
||||
return { kind: "conflict", origin: status.hubOrigin };
|
||||
}
|
||||
return { kind: "connect" };
|
||||
}
|
||||
|
||||
export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold {
|
||||
const environmentName = input.daemonSlug;
|
||||
const hub = YAML.stringify(
|
||||
{
|
||||
environments: {
|
||||
[environmentName]: {
|
||||
kind: "daemon",
|
||||
daemon: input.daemonSlug,
|
||||
cwd: path.resolve(input.cwd),
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
codex: {
|
||||
provider: "codex",
|
||||
mode: "full-access",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 0 },
|
||||
);
|
||||
const provider = providerScaffold(input.provider, input.providerFilters, environmentName);
|
||||
return {
|
||||
hub,
|
||||
workflowPath: `.paseo/workflows/${input.provider}-help.yml`,
|
||||
workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }),
|
||||
testAction: provider.testAction,
|
||||
};
|
||||
}
|
||||
|
||||
function providerScaffold(
|
||||
provider: HubInitProvider,
|
||||
filters: Readonly<Record<string, string>>,
|
||||
environment: string,
|
||||
): { workflow: object; testAction: string } {
|
||||
if (provider === "github") {
|
||||
const repo = requireFilter(filters, "repo");
|
||||
const user = requireFilter(filters, "user");
|
||||
return {
|
||||
workflow: workflow({
|
||||
name: "github-help",
|
||||
on: "github.issue_comment",
|
||||
filters: { repo, contains: "@paseo", from_users: [user] },
|
||||
environment,
|
||||
}),
|
||||
testAction: `Comment \`@paseo have a look\` on ${repo}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const user = requireFilter(filters, "user");
|
||||
if (provider === "slack") {
|
||||
const workspace = requireFilter(filters, "workspace");
|
||||
return {
|
||||
workflow: workflow({
|
||||
name: "slack-help",
|
||||
on: "slack.mention",
|
||||
filters: { workspace, from_users: [user] },
|
||||
environment,
|
||||
reply: "slack.reply",
|
||||
}),
|
||||
testAction: "Mention `@Paseo have a look` in Slack.",
|
||||
};
|
||||
}
|
||||
|
||||
const guild = requireFilter(filters, "guild");
|
||||
return {
|
||||
workflow: workflow({
|
||||
name: "discord-help",
|
||||
on: "discord.mention",
|
||||
filters: { guild, from_users: [user] },
|
||||
environment,
|
||||
reply: "discord.reply",
|
||||
}),
|
||||
testAction: "Mention `@Paseo have a look` in Discord.",
|
||||
};
|
||||
}
|
||||
|
||||
function workflow(input: {
|
||||
name: string;
|
||||
on: string;
|
||||
filters: object;
|
||||
environment: string;
|
||||
reply?: "slack.reply" | "discord.reply";
|
||||
}): object {
|
||||
const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then ";
|
||||
return {
|
||||
name: input.name,
|
||||
on: input.on,
|
||||
max_runtime: "2h",
|
||||
filters: input.filters,
|
||||
steps: [
|
||||
{
|
||||
id: "work",
|
||||
environment: input.environment,
|
||||
max_runtime: "90m",
|
||||
idle_timeout: "10m",
|
||||
agent: "codex",
|
||||
prompt: [
|
||||
{
|
||||
text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
|
||||
},
|
||||
],
|
||||
...(input.reply === undefined
|
||||
? {}
|
||||
: { allow_outputs: [{ type: input.reply, max: 1, required: true }] }),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function requireFilter(filters: Readonly<Record<string, string>>, name: string): string {
|
||||
const value = filters[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required for this provider`);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import {
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
isCancel,
|
||||
log,
|
||||
note,
|
||||
outro,
|
||||
select,
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Command } from "commander";
|
||||
import { DEFAULT_HUB_ORIGIN, resolveHubCredential } from "./authority.js";
|
||||
import type { HubCredentialStore } from "./credentials.js";
|
||||
import type { HubDaemonConnection } from "./daemon-client.js";
|
||||
import { withHubDaemon } from "./daemon-client.js";
|
||||
import { HubCommandError } from "./error.js";
|
||||
import type { HubConfigurationResources, HubHttpClient, HubProject } from "./hub-client/index.js";
|
||||
import {
|
||||
createHubInitBundle,
|
||||
createHubInitScaffold,
|
||||
planHubInitOpening,
|
||||
resolveHubInitConnection,
|
||||
resolveHubInitProjects,
|
||||
type HubInitProvider,
|
||||
} from "./init-plan.js";
|
||||
import type { CliLoginFlow } from "./login-flow.js";
|
||||
import { runHubLogin } from "./login.js";
|
||||
import { runHubConnect } from "./connect.js";
|
||||
import { runHubDeployBundle } from "./deploy.js";
|
||||
import { runHubProjects } from "./projects.js";
|
||||
import type { HubReporter } from "./reporter.js";
|
||||
import { normalizeHubOrigin } from "./origin.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DAEMON_READY_TIMEOUT_MS = 60_000;
|
||||
const DAEMON_READY_POLL_MS = 250;
|
||||
|
||||
interface HubInitEnvironment {
|
||||
env: Readonly<Record<string, string | undefined>>;
|
||||
credentials: HubCredentialStore;
|
||||
hub: HubHttpClient;
|
||||
login: Pick<CliLoginFlow, "authorize">;
|
||||
daemon: HubDaemonConnection;
|
||||
reporter: HubReporter;
|
||||
cwd(): string;
|
||||
}
|
||||
|
||||
class HubInitCancelledError extends Error {}
|
||||
|
||||
function initErrorMessage(error: unknown): string {
|
||||
if (error instanceof HubCommandError && error.details) {
|
||||
return `${error.message}\n${error.details}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void {
|
||||
parent
|
||||
.command("init")
|
||||
.description("Create and optionally deploy a safe starter Hub bundle")
|
||||
.action(async () => {
|
||||
try {
|
||||
await runHubInit(environment);
|
||||
} catch (error) {
|
||||
if (error instanceof HubInitCancelledError) {
|
||||
cancel(error.message);
|
||||
return;
|
||||
}
|
||||
cancel(initErrorMessage(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runHubInit(environment: HubInitEnvironment): Promise<void> {
|
||||
requireInteractiveTerminal();
|
||||
intro("Set up Paseo Hub");
|
||||
|
||||
const cwd = environment.cwd();
|
||||
const activeLogin = environment.credentials.active();
|
||||
const opening = planHubInitOpening({
|
||||
loggedIn: activeLogin !== null,
|
||||
paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")),
|
||||
});
|
||||
if (
|
||||
opening.replaceExisting &&
|
||||
!(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))
|
||||
) {
|
||||
throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
|
||||
}
|
||||
|
||||
const origin = await ensureLogin(activeLogin?.origin, environment);
|
||||
const daemonId = await ensureDaemonConnection(origin, environment);
|
||||
const project = await chooseProject(origin, environment);
|
||||
const resources = await loadConfigurationResources(origin, environment);
|
||||
const daemon = resources.daemons.find(({ id }) => id === daemonId);
|
||||
if (daemon === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_RESOURCE_MISSING",
|
||||
"The connected daemon is not available in this Hub organization. Reconnect it and try again.",
|
||||
);
|
||||
}
|
||||
log.success(`Connected as ${daemon.slug}`);
|
||||
const provider = await chooseProvider();
|
||||
const providerFilters = await collectProviderFilters(provider, resources, cwd);
|
||||
const scaffold = createHubInitScaffold({
|
||||
cwd,
|
||||
daemonSlug: daemon.slug,
|
||||
provider,
|
||||
providerFilters,
|
||||
});
|
||||
|
||||
const bundle = createHubInitBundle(project.slug, scaffold);
|
||||
|
||||
await withSpinner("Validating bundle", async () => {
|
||||
await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, {
|
||||
cwd,
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
hub: environment.hub,
|
||||
reporter: { progress() {} },
|
||||
});
|
||||
});
|
||||
log.success("Dry run passed");
|
||||
await writeScaffold(cwd, scaffold, opening.replaceExisting);
|
||||
log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
|
||||
|
||||
const deploy = await requiredConfirm("Deploy now?", true);
|
||||
if (deploy) {
|
||||
await withSpinner("Deploying bundle", async () => {
|
||||
await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
|
||||
cwd,
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
hub: environment.hub,
|
||||
reporter: { progress() {} },
|
||||
});
|
||||
});
|
||||
log.success("Deployed");
|
||||
} else {
|
||||
log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
|
||||
}
|
||||
|
||||
const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
|
||||
note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow");
|
||||
outro(deploy ? "Hub is ready" : "Hub bundle is ready");
|
||||
}
|
||||
|
||||
async function ensureLogin(
|
||||
activeOrigin: string | undefined,
|
||||
environment: HubInitEnvironment,
|
||||
): Promise<string> {
|
||||
const endpoint = await requiredSelect({
|
||||
message: "Hub endpoint",
|
||||
initialValue:
|
||||
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
|
||||
options: [
|
||||
{ value: "hosted", label: "hub.paseo.sh" },
|
||||
{ value: "custom", label: "Custom endpoint…" },
|
||||
],
|
||||
});
|
||||
const origin =
|
||||
endpoint === "hosted"
|
||||
? DEFAULT_HUB_ORIGIN
|
||||
: await requiredText({
|
||||
message: "Custom Hub URL",
|
||||
initialValue:
|
||||
activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
|
||||
? environment.env.PASEO_HUB_URL
|
||||
: activeOrigin,
|
||||
validate(value) {
|
||||
try {
|
||||
normalizeHubOrigin(value ?? "");
|
||||
} catch {
|
||||
return "Enter a valid Hub URL";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalizedOrigin = normalizeHubOrigin(origin);
|
||||
if (environment.credentials.get(normalizedOrigin) !== null) {
|
||||
log.success(`Logged in to ${normalizedOrigin}`);
|
||||
return normalizedOrigin;
|
||||
}
|
||||
await runHubLogin(
|
||||
normalizedOrigin,
|
||||
{},
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
flow: environment.login,
|
||||
reporter: environment.reporter,
|
||||
},
|
||||
);
|
||||
log.success(`Logged in to ${normalizedOrigin}`);
|
||||
return normalizedOrigin;
|
||||
}
|
||||
|
||||
async function ensureDaemonConnection(
|
||||
origin: string,
|
||||
environment: HubInitEnvironment,
|
||||
): Promise<string> {
|
||||
const status = await withHubDaemon(environment.daemon, undefined, async (daemon) =>
|
||||
daemon.getHubStatus().then((response) => response.status),
|
||||
);
|
||||
const connection = resolveHubInitConnection(status, origin);
|
||||
if (connection.kind === "connected") {
|
||||
return connection.daemonId;
|
||||
}
|
||||
if (connection.kind === "pending") {
|
||||
return waitForDaemonReady(origin, environment.daemon);
|
||||
}
|
||||
if (connection.kind === "conflict") {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_ALREADY_CONNECTED",
|
||||
`This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`,
|
||||
);
|
||||
}
|
||||
if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
|
||||
throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
|
||||
}
|
||||
await runHubConnect(
|
||||
origin,
|
||||
{},
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
hub: environment.hub,
|
||||
daemon: environment.daemon,
|
||||
reporter: environment.reporter,
|
||||
},
|
||||
);
|
||||
return waitForDaemonReady(origin, environment.daemon);
|
||||
}
|
||||
|
||||
async function waitForDaemonReady(
|
||||
origin: string,
|
||||
connection: HubDaemonConnection,
|
||||
): Promise<string> {
|
||||
return withSpinner("Waiting for the daemon to connect", async (reporter) =>
|
||||
withHubDaemon(connection, undefined, async (daemon) => {
|
||||
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
||||
while (true) {
|
||||
const status = (await daemon.getHubStatus()).status;
|
||||
const resolution = resolveHubInitConnection(status, origin);
|
||||
if (resolution.kind === "connected") return resolution.daemonId;
|
||||
if (resolution.kind === "conflict") {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_ALREADY_CONNECTED",
|
||||
`This daemon connected to ${resolution.origin} while Hub init was waiting for ${origin}.`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "connect") {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_CONNECTION_LOST",
|
||||
"The daemon lost its Hub relationship while Hub init was waiting for it.",
|
||||
);
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new HubCommandError(
|
||||
"HUB_DAEMON_CONNECTION_TIMEOUT",
|
||||
"The daemon did not connect within 60 seconds. Check `paseo hub status`, then run Hub init again.",
|
||||
);
|
||||
}
|
||||
reporter.progress(`Daemon is ${resolution.state}`);
|
||||
await delay(DAEMON_READY_POLL_MS);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseProject(origin: string, environment: HubInitEnvironment): Promise<HubProject> {
|
||||
const result = await runHubProjects(
|
||||
{ hub: origin },
|
||||
{
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
hub: environment.hub,
|
||||
reporter: { progress() {} },
|
||||
},
|
||||
);
|
||||
const resolution = resolveHubInitProjects(result.data.projects);
|
||||
if (resolution.kind === "none") {
|
||||
throw new HubInitCancelledError(
|
||||
`No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "selected") {
|
||||
return resolution.project;
|
||||
}
|
||||
const slug = await requiredSelect({
|
||||
message: "Project",
|
||||
options: resolution.projects.map((project) => ({
|
||||
value: project.slug,
|
||||
label: project.name,
|
||||
hint: project.slug,
|
||||
})),
|
||||
});
|
||||
const project = resolution.projects.find((candidate) => candidate.slug === slug);
|
||||
if (project === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_PROJECT_SELECTION_INVALID",
|
||||
"The selected Hub project is unavailable.",
|
||||
);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
async function chooseProvider(): Promise<HubInitProvider> {
|
||||
return requiredSelect({
|
||||
message: "Trigger provider",
|
||||
options: [
|
||||
{ value: "github", label: "GitHub", hint: "issue or pull request comment" },
|
||||
{ value: "slack", label: "Slack", hint: "channel mention" },
|
||||
{ value: "discord", label: "Discord", hint: "channel mention" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function collectProviderFilters(
|
||||
provider: HubInitProvider,
|
||||
resources: HubConfigurationResources,
|
||||
cwd: string,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
if (provider === "github") {
|
||||
const [login, originRemote] = await Promise.all([
|
||||
readGhValue(["api", "user", "--jq", ".login"]),
|
||||
readCommandValue("git", ["remote", "get-url", "origin"], cwd),
|
||||
]);
|
||||
const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
|
||||
if (repo === undefined) {
|
||||
throw new HubCommandError(
|
||||
"HUB_GITHUB_REPOSITORY_UNDETECTED",
|
||||
"Could not detect a GitHub repository from the origin remote.",
|
||||
);
|
||||
}
|
||||
if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
|
||||
const connected = resources.github.flatMap(({ repositories }) => repositories);
|
||||
throw new HubCommandError(
|
||||
"HUB_GITHUB_REPOSITORY_NOT_CONNECTED",
|
||||
`${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
user: await requiredText({
|
||||
message: "Your GitHub username (only this user can trigger the bot)",
|
||||
initialValue: login,
|
||||
}),
|
||||
repo,
|
||||
};
|
||||
}
|
||||
if (provider === "slack") {
|
||||
return {
|
||||
workspace: await chooseConnection(
|
||||
"Slack workspace",
|
||||
resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName })),
|
||||
),
|
||||
user: await requiredText({
|
||||
message: "Your Slack username (only this user can trigger the bot)",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
guild: await chooseConnection(
|
||||
"Discord server",
|
||||
resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName })),
|
||||
),
|
||||
user: await requiredText({
|
||||
message: "Your Discord username (only this user can trigger the bot)",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadConfigurationResources(
|
||||
origin: string,
|
||||
environment: HubInitEnvironment,
|
||||
): Promise<HubConfigurationResources> {
|
||||
try {
|
||||
return await withSpinner("Loading Hub connections", () =>
|
||||
environment.hub.listConfigurationResources(
|
||||
origin,
|
||||
resolveHubCredential({
|
||||
options: { origin },
|
||||
env: environment.env,
|
||||
credentials: environment.credentials,
|
||||
origin,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
|
||||
throw new HubCommandError(
|
||||
"HUB_UPDATE_REQUIRED",
|
||||
"This Hub does not support guided setup. Update the Hub and try again.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseConnection(
|
||||
message: string,
|
||||
connections: readonly { slug: string; label: string }[],
|
||||
): Promise<string> {
|
||||
if (connections.length === 0) {
|
||||
throw new HubCommandError(
|
||||
"HUB_PROVIDER_CONNECTION_REQUIRED",
|
||||
`No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`,
|
||||
);
|
||||
}
|
||||
if (connections.length === 1) return connections[0]!.slug;
|
||||
return requiredSelect({
|
||||
message,
|
||||
options: connections.map(({ slug, label }) => ({ value: slug, label })),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeScaffold(
|
||||
cwd: string,
|
||||
scaffold: ReturnType<typeof createHubInitScaffold>,
|
||||
replaceExisting: boolean,
|
||||
): Promise<void> {
|
||||
const root = path.join(cwd, ".paseo");
|
||||
const staging = path.join(cwd, `.paseo-init-${randomUUID()}`);
|
||||
const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`);
|
||||
let movedExisting = false;
|
||||
try {
|
||||
await mkdir(path.join(staging, "workflows"), { recursive: true });
|
||||
await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" });
|
||||
await writeFile(
|
||||
path.join(staging, "workflows", path.basename(scaffold.workflowPath)),
|
||||
scaffold.workflow,
|
||||
{
|
||||
flag: "wx",
|
||||
},
|
||||
);
|
||||
if (replaceExisting) {
|
||||
await rename(root, backup);
|
||||
movedExisting = true;
|
||||
}
|
||||
await rename(staging, root);
|
||||
if (movedExisting) await rm(backup, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true });
|
||||
if (movedExisting) {
|
||||
await rename(backup, root).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
return error instanceof Error && "code" in error && typeof error.code === "string"
|
||||
? error.code
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function readGhValue(args: readonly string[]): Promise<string | undefined> {
|
||||
return readCommandValue("gh", args);
|
||||
}
|
||||
|
||||
async function readCommandValue(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
cwd?: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(command, [...args], { encoding: "utf8", cwd });
|
||||
return stdout.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function githubRepositoryFromRemote(remote: string): string | undefined {
|
||||
const trimmed = remote.trim().replace(/\.git$/u, "");
|
||||
const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+\/[^/]+)$/u.exec(trimmed);
|
||||
if (ssh?.[1] !== undefined) return ssh[1];
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname !== "github.com") return undefined;
|
||||
const repository = url.pathname.replace(/^\//u, "");
|
||||
return /^[^/]+\/[^/]+$/u.test(repository) ? repository : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function withSpinner<T>(
|
||||
message: string,
|
||||
action: (reporter: HubReporter) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const progress = spinner();
|
||||
progress.start(message);
|
||||
try {
|
||||
const result = await action({ progress: (nextMessage) => progress.message(nextMessage) });
|
||||
progress.stop(message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
progress.error(message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredText(options: Parameters<typeof text>[0]): Promise<string> {
|
||||
const answer = await text({
|
||||
...options,
|
||||
validate(value) {
|
||||
const input = value ?? "";
|
||||
const customError = options.validate?.(input);
|
||||
if (customError !== undefined) return customError;
|
||||
return input.trim().length === 0 ? "A value is required" : undefined;
|
||||
},
|
||||
});
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer.trim();
|
||||
}
|
||||
|
||||
async function requiredConfirm(message: string, initialValue: boolean): Promise<boolean> {
|
||||
const answer = await confirm({ message, initialValue });
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer;
|
||||
}
|
||||
|
||||
async function requiredSelect<T extends string>(
|
||||
options: Parameters<typeof select<T>>[0],
|
||||
): Promise<T> {
|
||||
const answer = await select<T>(options);
|
||||
if (isCancel(answer)) throw new HubInitCancelledError("Hub init cancelled.");
|
||||
return answer;
|
||||
}
|
||||
|
||||
function requireInteractiveTerminal(): void {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface HubLogoutResult {
|
||||
origin: string | null;
|
||||
status: "logged_out" | "not_logged_in";
|
||||
daemonDisconnected: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
const schema: OutputSchema<HubLogoutResult> = {
|
||||
@@ -19,6 +20,7 @@ const schema: OutputSchema<HubLogoutResult> = {
|
||||
{ header: "HUB", field: "origin" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "DAEMON DISCONNECTED", field: "daemonDisconnected" },
|
||||
{ header: "WARNING", field: "warning" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -70,11 +72,16 @@ export async function runHubLogout(
|
||||
options,
|
||||
`Disconnecting this daemon from ${active.origin}`,
|
||||
);
|
||||
await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
||||
await daemon.disconnectHub(options.force ?? false);
|
||||
const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
|
||||
return daemon.disconnectHub(options.force ?? false);
|
||||
});
|
||||
dependencies.credentials.logoutActive();
|
||||
return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true });
|
||||
return logoutResult({
|
||||
origin: active.origin,
|
||||
status: "logged_out",
|
||||
daemonDisconnected: disconnect.status.state === "not_connected",
|
||||
...(disconnect.warning ? { warning: disconnect.warning } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDependencies): void {
|
||||
@@ -83,7 +90,7 @@ export function addHubLogoutCommand(parent: Command, dependencies: HubLogoutDepe
|
||||
.command("logout")
|
||||
.description("Remove the active stored Hub CLI login")
|
||||
.option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub")
|
||||
.option("--force", "Remove daemon authority even if Hub is offline"),
|
||||
.option("--force", "Remove daemon authority without notifying the Hub"),
|
||||
).action(
|
||||
withOutput(async (...args) => {
|
||||
const options = args.at(-2) as HubLogoutOptions;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
|
||||
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
|
||||
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
|
||||
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
|
||||
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
|
||||
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
|
||||
|
||||
export function createLoopCommand(): Command {
|
||||
const loop = new Command("loop").description("Run iterative worker loops");
|
||||
|
||||
addJsonAndDaemonHostOptions(addLoopRunOptions(loop.command("run"))).action(
|
||||
withOutput(runLoopRunCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(addLoopLsOptions(loop.command("ls"))).action(
|
||||
withOutput(runLoopLsCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(addLoopInspectOptions(loop.command("inspect"))).action(
|
||||
withOutput(runLoopInspectCommand),
|
||||
);
|
||||
|
||||
addDaemonHostOption(addLoopLogsOptions(loop.command("logs"))).action(runLoopLogsCommand);
|
||||
|
||||
addJsonAndDaemonHostOptions(addLoopStopOptions(loop.command("stop"))).action(
|
||||
withOutput(runLoopStopCommand),
|
||||
);
|
||||
|
||||
return loop;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
|
||||
import type { LoopDaemonClient, LoopRecord } from "./types.js";
|
||||
|
||||
interface InspectRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LoopInspectOptions extends CommandOptions {}
|
||||
|
||||
export function addLoopInspectOptions(command: Command): Command {
|
||||
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
|
||||
}
|
||||
|
||||
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
|
||||
return {
|
||||
idField: "key",
|
||||
columns: [
|
||||
{ header: "KEY", field: "key", width: 18 },
|
||||
{ header: "VALUE", field: "value", width: 80 },
|
||||
],
|
||||
serialize: () => loop,
|
||||
};
|
||||
}
|
||||
|
||||
function toRows(loop: LoopRecord): InspectRow[] {
|
||||
return [
|
||||
{ key: "Id", value: loop.id },
|
||||
{ key: "Name", value: loop.name ?? "null" },
|
||||
{ key: "Status", value: loop.status },
|
||||
{ key: "Cwd", value: loop.cwd },
|
||||
{ key: "Provider", value: loop.provider },
|
||||
{ key: "Model", value: loop.model ?? "null" },
|
||||
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
|
||||
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
|
||||
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
|
||||
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
|
||||
{ key: "Prompt", value: loop.prompt },
|
||||
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
|
||||
{
|
||||
key: "VerifyChecks",
|
||||
value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]",
|
||||
},
|
||||
{ key: "Archive", value: String(loop.archive) },
|
||||
{ key: "SleepMs", value: String(loop.sleepMs) },
|
||||
{
|
||||
key: "MaxIterations",
|
||||
value: loop.maxIterations === null ? "null" : String(loop.maxIterations),
|
||||
},
|
||||
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
|
||||
{ key: "CreatedAt", value: loop.createdAt },
|
||||
{ key: "UpdatedAt", value: loop.updatedAt },
|
||||
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
|
||||
{
|
||||
key: "Iterations",
|
||||
value:
|
||||
loop.iterations.length === 0
|
||||
? "[]"
|
||||
: loop.iterations
|
||||
.map((iteration) => {
|
||||
const summary = [
|
||||
`#${iteration.index}`,
|
||||
iteration.status,
|
||||
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
|
||||
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
|
||||
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return summary;
|
||||
})
|
||||
.join(" | "),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export type LoopInspectResult = ListResult<InspectRow>;
|
||||
|
||||
export async function runLoopInspectCommand(
|
||||
id: string,
|
||||
options: LoopInspectOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopInspectResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client;
|
||||
try {
|
||||
client = (await connectToDaemon({
|
||||
host: options.host,
|
||||
})) as unknown as LoopDaemonClient;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.loopInspect(id);
|
||||
await client.close();
|
||||
if (payload.error || !payload.loop) {
|
||||
throw new Error(payload.error ?? `Loop not found: ${id}`);
|
||||
}
|
||||
return {
|
||||
type: "list",
|
||||
data: toRows(payload.loop),
|
||||
schema: createInspectSchema(payload.loop),
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
throw {
|
||||
code: "LOOP_INSPECT_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
|
||||
|
||||
export interface LoopLogsOptions extends CommandOptions {
|
||||
pollInterval?: string;
|
||||
}
|
||||
|
||||
export function addLoopLogsOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Stream loop logs")
|
||||
.argument("<id>", "Loop ID")
|
||||
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
|
||||
}
|
||||
|
||||
function parsePollInterval(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw {
|
||||
code: "INVALID_POLL_INTERVAL",
|
||||
message: "--poll-interval must be a positive integer",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function renderLogEntry(entry: LoopLogEntry): string {
|
||||
const prefix = [
|
||||
entry.timestamp,
|
||||
entry.source,
|
||||
entry.iteration === null ? null : `iteration=${entry.iteration}`,
|
||||
entry.level === "error" ? "ERROR" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return `${prefix}\n${entry.text}`;
|
||||
}
|
||||
|
||||
export async function runLoopLogsCommand(
|
||||
id: string,
|
||||
options: LoopLogsOptions,
|
||||
_command: Command,
|
||||
): Promise<void> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
|
||||
let client: LoopDaemonClient;
|
||||
try {
|
||||
client = (await connectToDaemon({
|
||||
host: options.host,
|
||||
})) as unknown as LoopDaemonClient;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
|
||||
console.error("Start the daemon with: paseo daemon start");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function streamLogs(cursor: number): Promise<void> {
|
||||
const payload = await client.loopLogs(id, cursor);
|
||||
if (payload.error || !payload.loop) {
|
||||
throw new Error(payload.error ?? `Loop not found: ${id}`);
|
||||
}
|
||||
for (const entry of payload.entries) {
|
||||
console.log(renderLogEntry(entry));
|
||||
}
|
||||
if (payload.loop.status !== "running") {
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
return streamLogs(payload.nextCursor);
|
||||
}
|
||||
|
||||
try {
|
||||
await streamLogs(0);
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Error: Failed to stream loop logs: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
|
||||
import type { LoopDaemonClient, LoopListItem } from "./types.js";
|
||||
|
||||
interface LoopListRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
status: string;
|
||||
cwd: string;
|
||||
updated: string;
|
||||
activeIteration: string;
|
||||
}
|
||||
|
||||
export interface LoopLsOptions extends CommandOptions {}
|
||||
|
||||
export const loopLsSchema: OutputSchema<LoopListRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "LOOP ID", field: "id", width: 10 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "ITER", field: "activeIteration", width: 8 },
|
||||
{ header: "CWD", field: "cwd", width: 40 },
|
||||
{ header: "UPDATED", field: "updated", width: 24 },
|
||||
],
|
||||
};
|
||||
|
||||
export function addLoopLsOptions(command: Command): Command {
|
||||
return command.description("List loops");
|
||||
}
|
||||
|
||||
function toRow(loop: LoopListItem): LoopListRow {
|
||||
return {
|
||||
id: loop.id,
|
||||
name: loop.name,
|
||||
status: loop.status,
|
||||
cwd: loop.cwd,
|
||||
updated: loop.updatedAt,
|
||||
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoopLsResult = ListResult<LoopListRow>;
|
||||
|
||||
export async function runLoopLsCommand(
|
||||
options: LoopLsOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopLsResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client;
|
||||
try {
|
||||
client = (await connectToDaemon({
|
||||
host: options.host,
|
||||
})) as unknown as LoopDaemonClient;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.loopList();
|
||||
await client.close();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
type: "list",
|
||||
data: payload.loops.map(toRow),
|
||||
schema: loopLsSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
throw {
|
||||
code: "LOOP_LIST_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildLoopRunInput } from "./run.js";
|
||||
import type { LoopRunOptions } from "./types.js";
|
||||
|
||||
describe("buildLoopRunInput", () => {
|
||||
it("should parse provider/model from provider string", () => {
|
||||
const options: LoopRunOptions = {
|
||||
provider: "opencode/hy3-preview-free",
|
||||
model: undefined,
|
||||
verifyProvider: undefined,
|
||||
verifyModel: undefined,
|
||||
verify: undefined,
|
||||
verifyCheck: [],
|
||||
archive: false,
|
||||
name: undefined,
|
||||
sleep: undefined,
|
||||
maxIterations: undefined,
|
||||
maxTime: undefined,
|
||||
} as LoopRunOptions;
|
||||
|
||||
const result = buildLoopRunInput("test prompt", options);
|
||||
|
||||
expect(result.provider).toBe("opencode");
|
||||
expect(result.model).toBe("hy3-preview-free");
|
||||
});
|
||||
|
||||
it("should use provider only when no model in string", () => {
|
||||
const options = {
|
||||
provider: "opencode",
|
||||
model: undefined,
|
||||
verifyProvider: undefined,
|
||||
verifyModel: undefined,
|
||||
verify: undefined,
|
||||
verifyCheck: [],
|
||||
archive: false,
|
||||
name: undefined,
|
||||
sleep: undefined,
|
||||
maxIterations: undefined,
|
||||
maxTime: undefined,
|
||||
} as LoopRunOptions;
|
||||
|
||||
const result = buildLoopRunInput("test prompt", options);
|
||||
|
||||
expect(result.provider).toBe("opencode");
|
||||
expect(result.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should pass verifierProvider with model parsing", () => {
|
||||
const options = {
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
verifyProvider: "codex/gpt-5.4",
|
||||
verifyModel: undefined,
|
||||
verify: undefined,
|
||||
verifyCheck: [],
|
||||
archive: false,
|
||||
name: undefined,
|
||||
sleep: undefined,
|
||||
maxIterations: undefined,
|
||||
maxTime: undefined,
|
||||
} as LoopRunOptions;
|
||||
|
||||
const result = buildLoopRunInput("test prompt", options);
|
||||
|
||||
expect(result.verifierProvider).toBe("codex");
|
||||
expect(result.verifierModel).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
it("should prefer explicit model over parsed model", () => {
|
||||
const options = {
|
||||
provider: "opencode/hy3-preview-free",
|
||||
model: "other-model",
|
||||
verifyProvider: undefined,
|
||||
verifyModel: undefined,
|
||||
verify: undefined,
|
||||
verifyCheck: [],
|
||||
archive: false,
|
||||
name: undefined,
|
||||
sleep: undefined,
|
||||
maxIterations: undefined,
|
||||
maxTime: undefined,
|
||||
} as LoopRunOptions;
|
||||
|
||||
const result = buildLoopRunInput("test prompt", options);
|
||||
|
||||
expect(result.provider).toBe("opencode");
|
||||
expect(result.model).toBe("other-model");
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
CommandError,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
|
||||
|
||||
export interface LoopRunRow {
|
||||
id: string;
|
||||
status: string;
|
||||
name: string | null;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface LoopRunOptions extends CommandOptions {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
verifyProvider?: string;
|
||||
verifyModel?: string;
|
||||
verifyMode?: string;
|
||||
verify?: string;
|
||||
verifyCheck?: string[];
|
||||
archive?: boolean;
|
||||
name?: string;
|
||||
sleep?: string;
|
||||
maxIterations?: string;
|
||||
maxTime?: string;
|
||||
}
|
||||
|
||||
export const loopRunSchema: OutputSchema<LoopRunRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "LOOP ID", field: "id", width: 10 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "CWD", field: "cwd", width: 40 },
|
||||
],
|
||||
};
|
||||
|
||||
export function addLoopRunOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Start a loop")
|
||||
.argument("<prompt>", "Prompt for each fresh worker iteration")
|
||||
.option("--provider <provider>", "Default provider for worker and verifier agents")
|
||||
.option("--model <model>", "Default model for worker and verifier agents")
|
||||
.option(
|
||||
"--mode <mode>",
|
||||
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
|
||||
)
|
||||
.option("--verify-provider <provider>", "Provider for the verifier agent")
|
||||
.option("--verify-model <model>", "Model for the verifier agent")
|
||||
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
|
||||
.option("--verify <prompt>", "Verifier agent prompt")
|
||||
.option(
|
||||
"--verify-check <command>",
|
||||
"Shell command that must exit 0 (repeatable)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option("--archive", "Archive worker and verifier agents after each iteration")
|
||||
.option("--name <name>", "Optional loop name")
|
||||
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
|
||||
.option("--max-iterations <n>", "Maximum number of iterations")
|
||||
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
|
||||
}
|
||||
|
||||
function toRow(loop: LoopRecord): LoopRunRow {
|
||||
return {
|
||||
id: loop.id,
|
||||
status: loop.status,
|
||||
name: loop.name,
|
||||
cwd: loop.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMaxIterations(value: string | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw {
|
||||
code: "INVALID_MAX_ITERATIONS",
|
||||
message: "--max-iterations must be a positive integer",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// oxlint-disable complexity
|
||||
export function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
|
||||
const verifyPrompt = options.verify?.trim();
|
||||
if (options.verify !== undefined && !verifyPrompt) {
|
||||
throw {
|
||||
code: "INVALID_VERIFY_PROMPT",
|
||||
message: "--verify cannot be empty",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const result: LoopRunInput = {
|
||||
prompt,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
|
||||
// Resolve provider/model
|
||||
if (options.provider) {
|
||||
const { provider, model } = resolveProviderAndModel({ provider: options.provider });
|
||||
if (provider) result.provider = provider;
|
||||
// Explicit --model takes precedence over parsed model
|
||||
if (options.model?.trim()) {
|
||||
result.model = options.model.trim();
|
||||
} else if (model) {
|
||||
result.model = model;
|
||||
}
|
||||
} else if (options.model?.trim()) {
|
||||
result.model = options.model.trim();
|
||||
}
|
||||
|
||||
if (options.mode?.trim()) {
|
||||
result.modeId = options.mode.trim();
|
||||
}
|
||||
|
||||
// Resolve verifier provider/model
|
||||
if (options.verifyProvider) {
|
||||
const { provider, model } = resolveProviderAndModel({ provider: options.verifyProvider });
|
||||
if (provider) result.verifierProvider = provider;
|
||||
// Explicit --verify-model takes precedence over parsed model
|
||||
if (options.verifyModel?.trim()) {
|
||||
result.verifierModel = options.verifyModel.trim();
|
||||
} else if (model) {
|
||||
result.verifierModel = model;
|
||||
}
|
||||
} else if (options.verifyModel?.trim()) {
|
||||
result.verifierModel = options.verifyModel.trim();
|
||||
}
|
||||
|
||||
if (options.verifyMode?.trim()) {
|
||||
result.verifierModeId = options.verifyMode.trim();
|
||||
}
|
||||
|
||||
if (verifyPrompt) result.verifyPrompt = verifyPrompt;
|
||||
if (options.verifyCheck && options.verifyCheck.length > 0) {
|
||||
result.verifyChecks = options.verifyCheck;
|
||||
}
|
||||
if (options.archive) result.archive = true;
|
||||
if (options.name?.trim()) result.name = options.name.trim();
|
||||
if (options.sleep) result.sleepMs = parseDuration(options.sleep);
|
||||
if (options.maxIterations) {
|
||||
result.maxIterations = parseMaxIterations(options.maxIterations);
|
||||
}
|
||||
if (options.maxTime) result.maxTimeMs = parseDuration(options.maxTime);
|
||||
|
||||
return result;
|
||||
}
|
||||
// oxlint-enable complexity
|
||||
|
||||
export type LoopRunResult = SingleResult<LoopRunRow>;
|
||||
|
||||
export async function runLoopRunCommand(
|
||||
prompt: string,
|
||||
options: LoopRunOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopRunResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const input = buildLoopRunInput(prompt, options);
|
||||
let client;
|
||||
try {
|
||||
client = (await connectToDaemon({
|
||||
host: options.host,
|
||||
})) as unknown as LoopDaemonClient;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.loopRun(input);
|
||||
await client.close();
|
||||
if (payload.error || !payload.loop) {
|
||||
throw new Error(payload.error ?? "Loop creation failed");
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: toRow(payload.loop),
|
||||
schema: loopRunSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
throw error;
|
||||
}
|
||||
throw {
|
||||
code: "LOOP_RUN_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
CommandError,
|
||||
OutputSchema,
|
||||
SingleResult,
|
||||
} from "../../output/index.js";
|
||||
import type { LoopDaemonClient, LoopRecord } from "./types.js";
|
||||
|
||||
interface LoopStopRow {
|
||||
id: string;
|
||||
status: string;
|
||||
activeIteration: string;
|
||||
}
|
||||
|
||||
export interface LoopStopOptions extends CommandOptions {}
|
||||
|
||||
export const loopStopSchema: OutputSchema<LoopStopRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "LOOP ID", field: "id", width: 10 },
|
||||
{ header: "STATUS", field: "status", width: 10 },
|
||||
{ header: "ITER", field: "activeIteration", width: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
export function addLoopStopOptions(command: Command): Command {
|
||||
return command.description("Stop a running loop").argument("<id>", "Loop ID");
|
||||
}
|
||||
|
||||
function toRow(loop: LoopRecord): LoopStopRow {
|
||||
return {
|
||||
id: loop.id,
|
||||
status: loop.status,
|
||||
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoopStopResult = SingleResult<LoopStopRow>;
|
||||
|
||||
export async function runLoopStopCommand(
|
||||
id: string,
|
||||
options: LoopStopOptions,
|
||||
_command: Command,
|
||||
): Promise<LoopStopResult> {
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
let client;
|
||||
try {
|
||||
client = (await connectToDaemon({
|
||||
host: options.host,
|
||||
})) as unknown as LoopDaemonClient;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.loopStop(id);
|
||||
await client.close();
|
||||
if (payload.error || !payload.loop) {
|
||||
throw new Error(payload.error ?? `Loop not found: ${id}`);
|
||||
}
|
||||
return {
|
||||
type: "single",
|
||||
data: toRow(payload.loop),
|
||||
schema: loopStopSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
throw {
|
||||
code: "LOOP_STOP_FAILED",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
|
||||
|
||||
export interface LoopLogEntry {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
iteration: number | null;
|
||||
source: "loop" | "worker" | "verifier" | "verify-check";
|
||||
level: "info" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LoopVerifyCheckResult {
|
||||
command: string;
|
||||
exitCode: number;
|
||||
passed: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface LoopVerifyPromptResult {
|
||||
passed: boolean;
|
||||
reason: string;
|
||||
verifierAgentId: string | null;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface LoopIterationRecord {
|
||||
index: number;
|
||||
workerAgentId: string | null;
|
||||
workerStartedAt: string;
|
||||
workerCompletedAt: string | null;
|
||||
verifierAgentId: string | null;
|
||||
status: "running" | "succeeded" | "failed" | "stopped";
|
||||
workerOutcome: "completed" | "failed" | "canceled" | null;
|
||||
failureReason: string | null;
|
||||
verifyChecks: LoopVerifyCheckResult[];
|
||||
verifyPrompt: LoopVerifyPromptResult | null;
|
||||
}
|
||||
|
||||
export interface LoopRecord {
|
||||
id: string;
|
||||
name: string | null;
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
provider: string;
|
||||
model: string | null;
|
||||
modeId: string | null;
|
||||
workerProvider: string | null;
|
||||
workerModel: string | null;
|
||||
verifierProvider: string | null;
|
||||
verifierModel: string | null;
|
||||
verifierModeId: string | null;
|
||||
verifyPrompt: string | null;
|
||||
verifyChecks: string[];
|
||||
archive: boolean;
|
||||
sleepMs: number;
|
||||
maxIterations: number | null;
|
||||
maxTimeMs: number | null;
|
||||
status: LoopStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
stopRequestedAt: string | null;
|
||||
iterations: LoopIterationRecord[];
|
||||
logs: LoopLogEntry[];
|
||||
nextLogSeq: number;
|
||||
activeIteration: number | null;
|
||||
activeWorkerAgentId: string | null;
|
||||
activeVerifierAgentId: string | null;
|
||||
}
|
||||
|
||||
export interface LoopListItem {
|
||||
id: string;
|
||||
name: string | null;
|
||||
status: LoopStatus;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
activeIteration: number | null;
|
||||
}
|
||||
|
||||
export interface LoopLogsResult {
|
||||
loop: LoopRecord;
|
||||
entries: LoopLogEntry[];
|
||||
nextCursor: number;
|
||||
}
|
||||
|
||||
export interface LoopRunPayload {
|
||||
requestId: string;
|
||||
loop: LoopRecord | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoopListPayload {
|
||||
requestId: string;
|
||||
loops: LoopListItem[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoopInspectPayload {
|
||||
requestId: string;
|
||||
loop: LoopRecord | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoopLogsPayload {
|
||||
requestId: string;
|
||||
loop: LoopRecord | null;
|
||||
entries: LoopLogEntry[];
|
||||
nextCursor: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoopStopPayload {
|
||||
requestId: string;
|
||||
loop: LoopRecord | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface LoopRunInput {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
workerProvider?: string;
|
||||
workerModel?: string;
|
||||
verifierProvider?: string;
|
||||
verifierModel?: string;
|
||||
verifierModeId?: string;
|
||||
verifyPrompt?: string;
|
||||
verifyChecks?: string[];
|
||||
archive?: boolean;
|
||||
name?: string;
|
||||
sleepMs?: number;
|
||||
maxIterations?: number;
|
||||
maxTimeMs?: number;
|
||||
}
|
||||
|
||||
export interface LoopDaemonClient {
|
||||
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
|
||||
loopList(): Promise<LoopListPayload>;
|
||||
loopInspect(id: string): Promise<LoopInspectPayload>;
|
||||
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
|
||||
loopStop(id: string): Promise<LoopStopPayload>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
export interface ProviderDiagnosticResult {
|
||||
provider: string;
|
||||
diagnostic: string;
|
||||
}
|
||||
|
||||
export const providerDiagnosticSchema: OutputSchema<ProviderDiagnosticResult> = {
|
||||
idField: "provider",
|
||||
columns: [
|
||||
{ header: "PROVIDER", field: "provider" },
|
||||
{ header: "DIAGNOSTIC", field: "diagnostic" },
|
||||
],
|
||||
renderHuman(result) {
|
||||
if (result.type === "single") return result.data.diagnostic;
|
||||
return result.data.map((entry) => entry.diagnostic).join("\n\n");
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderDiagnosticOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runDiagnosticCommand(
|
||||
provider: string,
|
||||
options: ProviderDiagnosticOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<ProviderDiagnosticResult>> {
|
||||
const client = await connectToDaemon({ host: options.host });
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider.trim().toLowerCase());
|
||||
return {
|
||||
type: "single",
|
||||
data: {
|
||||
provider: result.provider,
|
||||
diagnostic: result.diagnostic,
|
||||
},
|
||||
schema: providerDiagnosticSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { runDiagnosticCommand } from "./diagnostic.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
@@ -19,5 +20,12 @@ export function createProviderCommand(): Command {
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
provider
|
||||
.command("diagnostic")
|
||||
.description("Show provider installation, environment, and availability diagnostics")
|
||||
.argument("<provider>", "Provider name"),
|
||||
).action(withOutput(runDiagnosticCommand));
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { runArchiveCommand } from "./archive.js";
|
||||
import { runCreateCommand } from "./create.js";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runRenameCommand } from "./rename.js";
|
||||
|
||||
export function createWorkspaceCommand(): Command {
|
||||
const workspace = new Command("workspace").description("Manage workspaces");
|
||||
@@ -32,6 +33,18 @@ export function createWorkspaceCommand(): Command {
|
||||
withOutput(runLsCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("rename")
|
||||
.description("Set a workspace's user-visible title")
|
||||
.argument("<workspace-id>", "Workspace id")
|
||||
.argument("[title]", "New workspace title")
|
||||
.option("--reset", "Clear the title and revert to the branch or directory name")
|
||||
// Commander 12 accepts excess positionals by default, which would rename to
|
||||
// the first word of an unquoted multi-word title and silently drop the rest.
|
||||
.allowExcessArguments(false),
|
||||
).action(withOutput(runRenameCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
workspace
|
||||
.command("archive")
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createWorkspaceCommand } from "./index.js";
|
||||
import { resolveWorkspaceTitle } from "./rename.js";
|
||||
|
||||
function catchError(run: () => unknown): unknown {
|
||||
try {
|
||||
run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("workspace rename title", () => {
|
||||
it("trims the requested title", () => {
|
||||
expect(resolveWorkspaceTitle({ title: " Fix login " })).toBe("Fix login");
|
||||
});
|
||||
|
||||
it("clears the title when resetting to the derived name", () => {
|
||||
expect(resolveWorkspaceTitle({ reset: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a reset that also carries a title", () => {
|
||||
expect(
|
||||
catchError(() => resolveWorkspaceTitle({ title: "Fix login", reset: true })),
|
||||
).toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
// An explicit empty title is still a title the caller passed, not a reset.
|
||||
expect(catchError(() => resolveWorkspaceTitle({ title: "", reset: true }))).toMatchObject({
|
||||
code: "INVALID_OPTIONS",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a non-empty title when not resetting", () => {
|
||||
expect(catchError(() => resolveWorkspaceTitle({}))).toMatchObject({ code: "MISSING_TITLE" });
|
||||
expect(catchError(() => resolveWorkspaceTitle({ title: " " }))).toMatchObject({
|
||||
code: "MISSING_TITLE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace rename arguments", () => {
|
||||
function parseRename(argv: string[]): unknown {
|
||||
const workspace = createWorkspaceCommand()
|
||||
.exitOverride()
|
||||
.configureOutput({ writeErr: () => undefined });
|
||||
workspace.commands.find((command) => command.name() === "rename")?.exitOverride();
|
||||
return catchError(() => workspace.parse(argv, { from: "user" }));
|
||||
}
|
||||
|
||||
it("rejects an unquoted multi-word title instead of dropping words", () => {
|
||||
expect(parseRename(["rename", "ws-1", "Auth", "rework"])).toMatchObject({
|
||||
code: "commander.excessArguments",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
interface WorkspaceRenameResult {
|
||||
workspaceId: string;
|
||||
/** Raw title override; null once it has been reset to the derived name. */
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
const workspaceRenameSchema: OutputSchema<WorkspaceRenameResult> = {
|
||||
idField: "workspaceId",
|
||||
columns: [
|
||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
||||
{ header: "TITLE", field: "title", width: 30 },
|
||||
],
|
||||
};
|
||||
|
||||
export interface WorkspaceRenameOptions {
|
||||
host?: string;
|
||||
reset?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the title to send to the daemon. Null clears the override and
|
||||
* reverts the workspace to its branch- or directory-derived name.
|
||||
*/
|
||||
export function resolveWorkspaceTitle(input: { title?: string; reset?: boolean }): string | null {
|
||||
const title = input.title?.trim() ?? "";
|
||||
if (input.reset) {
|
||||
if (input.title !== undefined) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message: "--reset cannot be combined with a title",
|
||||
details: "Pass a title to rename the workspace, or --reset to revert to the derived name",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (title.length === 0) {
|
||||
throw {
|
||||
code: "MISSING_TITLE",
|
||||
message: "Title cannot be empty",
|
||||
details: "Usage: paseo workspace rename <workspace-id> <title> | --reset",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
export async function runRenameCommand(
|
||||
workspaceId: string,
|
||||
titleArg: string | undefined,
|
||||
options: WorkspaceRenameOptions,
|
||||
_command: Command,
|
||||
): Promise<SingleResult<WorkspaceRenameResult>> {
|
||||
const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset });
|
||||
|
||||
const host = getDaemonHost({ host: options.host });
|
||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
});
|
||||
try {
|
||||
const applied = await client.setWorkspaceTitle(workspaceId, title);
|
||||
return {
|
||||
type: "single",
|
||||
data: { workspaceId, title: applied.title },
|
||||
schema: workspaceRenameSchema,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError;
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user