feat(cli): manage workspace scripts (#1992)

* feat(cli): manage workspace scripts

* docs: document workspace script management

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-07-24 21:12:14 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 21597bdc1b
commit 21404fbdec
22 changed files with 800 additions and 55 deletions
+4
View File
@@ -7,6 +7,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createScriptCommand } from "./commands/script/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { createWorkspaceCommand } from "./commands/workspace/index.js";
@@ -171,6 +172,9 @@ export function createCli(): Command {
// Terminal commands
program.addCommand(createTerminalCommand());
// Workspace script commands
program.addCommand(createScriptCommand());
// Loop commands
program.addCommand(createLoopCommand());
+39
View File
@@ -0,0 +1,39 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runLsCommand } from "./ls.js";
import { runStartCommand } from "./start.js";
import { runStopCommand } from "./stop.js";
function addWorkspaceSelectionOptions(command: Command): Command {
return command
.option("--cwd <path>", "Workspace directory (default: current directory)")
.option(
"--workspace <workspace-id>",
"Workspace ID (required when a directory has multiple workspaces)",
);
}
export function createScriptCommand(): Command {
const script = new Command("script").description("Manage configured workspace scripts");
addJsonAndDaemonHostOptions(
addWorkspaceSelectionOptions(
script.command("ls").description("List configured workspace scripts"),
),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
addWorkspaceSelectionOptions(
script.command("start").description("Start a configured workspace script").argument("<name>"),
),
).action(withOutput(runStartCommand));
addJsonAndDaemonHostOptions(
addWorkspaceSelectionOptions(
script.command("stop").description("Stop a running workspace script").argument("<name>"),
),
).action(withOutput(runStopCommand));
return script;
}
+32
View File
@@ -0,0 +1,32 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
connectWorkspaceScriptClient,
resolveWorkspaceScriptWorkspaceId,
toWorkspaceScriptCommandError,
type WorkspaceScriptCommandOptions,
} from "./shared.js";
import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js";
export async function runLsCommand(
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<ListResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.listWorkspaceScripts(workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
return { type: "list", data: payload.scripts ?? [], schema: workspaceScriptSchema };
} catch (error) {
throw toWorkspaceScriptCommandError(
"WORKSPACE_SCRIPT_LIST_FAILED",
"list workspace scripts",
error,
);
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,17 @@
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
import type { OutputSchema } from "../../output/index.js";
export type WorkspaceScriptRow = WorkspaceScriptPayload;
export const workspaceScriptSchema: OutputSchema<WorkspaceScriptRow> = {
idField: "scriptName",
columns: [
{ header: "NAME", field: "scriptName", width: 20 },
{ header: "TYPE", field: "type", width: 9 },
{ header: "LIFECYCLE", field: "lifecycle", width: 10 },
{ header: "HEALTH", field: (script) => script.health ?? "-", width: 10 },
{ header: "PORT", field: (script) => script.port ?? "-", width: 7 },
{ header: "PROXY URL", field: (script) => script.proxyUrl ?? "-", width: 42 },
{ header: "TERMINAL", field: (script) => script.terminalId ?? "-", width: 12 },
],
};
@@ -0,0 +1,78 @@
import { resolve } from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface WorkspaceScriptCommandOptions extends CommandOptions {
host?: string;
cwd?: string;
workspace?: string;
}
export async function connectWorkspaceScriptClient(host?: string): Promise<DaemonClient> {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) {
await client.close().catch(() => {});
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use workspace script management.",
} satisfies CommandError;
}
return client;
} catch (error) {
if (error && typeof error === "object" && "code" in error && "message" in error) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export async function resolveWorkspaceScriptWorkspaceId(
client: DaemonClient,
options: WorkspaceScriptCommandOptions,
): Promise<string> {
if (options.workspace) {
return options.workspace;
}
const cwd = resolve(options.cwd ?? process.cwd());
const payload = await client.fetchWorkspaces({ page: { limit: 200 } });
const matches = payload.entries.filter(
(workspace) => resolve(workspace.workspaceDirectory) === cwd,
);
if (matches.length === 1) {
return matches[0]!.id;
}
if (matches.length > 1) {
throw {
code: "WORKSPACE_AMBIGUOUS",
message: `Multiple workspaces use ${cwd}`,
details: "Pass --workspace <workspace-id> to select one.",
} satisfies CommandError;
}
throw {
code: "WORKSPACE_NOT_FOUND",
message: `No Paseo workspace found for ${cwd}`,
details: "Open the directory in Paseo first, or pass --workspace <workspace-id>.",
} satisfies CommandError;
}
export function toWorkspaceScriptCommandError(
code: string,
action: string,
error: unknown,
): CommandError {
if (error && typeof error === "object" && "code" in error && "message" in error) {
return error as CommandError;
}
const message = error instanceof Error ? error.message : String(error);
return { code, message: `Failed to ${action}: ${message}` };
}
+36
View File
@@ -0,0 +1,36 @@
import type { Command } from "commander";
import type { CommandError, SingleResult } from "../../output/index.js";
import {
connectWorkspaceScriptClient,
resolveWorkspaceScriptWorkspaceId,
toWorkspaceScriptCommandError,
type WorkspaceScriptCommandOptions,
} from "./shared.js";
import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js";
export async function runStartCommand(
scriptName: string,
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName);
if (payload.error || !payload.script) {
throw {
code: "WORKSPACE_SCRIPT_START_FAILED",
message: payload.error ?? `Script '${scriptName}' did not return status metadata`,
} satisfies CommandError;
}
return { type: "single", data: payload.script, schema: workspaceScriptSchema };
} catch (error) {
throw toWorkspaceScriptCommandError(
"WORKSPACE_SCRIPT_START_FAILED",
"start workspace script",
error,
);
} finally {
await client.close().catch(() => {});
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { Command } from "commander";
import type { CommandError, SingleResult } from "../../output/index.js";
import {
connectWorkspaceScriptClient,
resolveWorkspaceScriptWorkspaceId,
toWorkspaceScriptCommandError,
type WorkspaceScriptCommandOptions,
} from "./shared.js";
import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js";
export async function runStopCommand(
scriptName: string,
options: WorkspaceScriptCommandOptions,
_command: Command,
): Promise<SingleResult<WorkspaceScriptRow>> {
const client = await connectWorkspaceScriptClient(options.host);
try {
const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options);
const payload = await client.stopWorkspaceScript(workspaceId, scriptName);
if (payload.error || !payload.script) {
throw {
code: "WORKSPACE_SCRIPT_STOP_FAILED",
message: payload.error ?? `Script '${scriptName}' did not return status metadata`,
} satisfies CommandError;
}
return { type: "single", data: payload.script, schema: workspaceScriptSchema };
} catch (error) {
throw toWorkspaceScriptCommandError(
"WORKSPACE_SCRIPT_STOP_FAILED",
"stop workspace script",
error,
);
} finally {
await client.close().catch(() => {});
}
}