Manage projects from the CLI (#3460)

* feat(cli): manage projects

Expose the existing project lifecycle through one CLI command group so projects can be managed without opening the app.

* fix(cli): preserve remote project paths

Project paths belong to the daemon when a target is explicit. Require that path instead of resolving the CLI machine's cwd, and keep local convenience resolution for the default connection.

* docs(cli): document project management

* docs(cli): remove repeated project reference
This commit is contained in:
Mohamed Boudra
2026-08-16 20:32:08 +02:00
committed by GitHub
parent ac7796035d
commit ac4331456c
16 changed files with 417 additions and 7 deletions
+2 -1
View File
@@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
import { createCli } from "./cli.js";
describe("canonical CLI surface", () => {
it("shows workspace and heartbeat commands while hiding worktree compatibility", () => {
it("shows project, workspace, and heartbeat commands while hiding worktree compatibility", () => {
const cli = createCli();
const help = cli.helpInformation();
expect(help).toContain("project");
expect(help).toContain("workspace");
expect(help).toContain("heartbeat");
expect(help).not.toContain("worktree");
+2
View File
@@ -4,6 +4,7 @@ import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createPluginCommand } from "./commands/plugin/index.js";
import { createProjectCommand } from "./commands/project/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createScriptCommand } from "./commands/script/index.js";
@@ -194,6 +195,7 @@ export function createCli(): Command {
program.addCommand(createSpeechCommand());
// Workspace commands
program.addCommand(createProjectCommand());
program.addCommand(createWorkspaceCommand());
// COMPAT(worktreeCli): legacy command alias added before workspace was the product unit.
// Added in v0.2.0; remove after 2027-01-17.
@@ -0,0 +1,52 @@
import path from "node:path";
import type { Command } from "commander";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
import { projectSchema, toProjectRow, type ProjectRow } from "./shared.js";
export function resolveProjectPath(input: {
pathArg?: string;
cwd: string;
daemonTarget?: string;
}): string {
if (input.daemonTarget) {
if (input.pathArg === undefined) {
throw {
code: "MISSING_PATH",
message: "Project path is required when targeting a daemon explicitly",
details: "Usage: paseo project create <path> --host <host>",
} satisfies CommandError;
}
return input.pathArg;
}
return path.resolve(input.cwd, input.pathArg ?? ".");
}
export async function runCreateCommand(
pathArg: string | undefined,
options: CommandOptions,
_command: Command,
): Promise<SingleResult<ProjectRow>> {
const projectPath = resolveProjectPath({
pathArg,
cwd: process.cwd(),
daemonTarget: options.host ?? process.env.PASEO_HOST,
});
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
});
try {
const payload = await client.addProject(projectPath);
if (!payload.project) {
throw new Error(payload.error ?? "Project creation failed");
}
return { type: "single", data: toProjectRow(payload.project), schema: projectSchema };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}
@@ -0,0 +1,44 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
interface ProjectDeleteResult {
projectId: string;
removedWorkspaceIds: string[];
}
const projectDeleteSchema = {
idField: "projectId" as const,
columns: [
{ header: "PROJECT ID", field: "projectId" as const, width: 20 },
{
header: "REMOVED WORKSPACES",
field: (result: ProjectDeleteResult) => result.removedWorkspaceIds.length,
width: 18,
},
],
};
export async function runDeleteCommand(
projectId: string,
options: CommandOptions,
_command: Command,
): Promise<SingleResult<ProjectDeleteResult>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
});
try {
const result = await client.removeProject(projectId);
return {
type: "single",
data: { projectId, removedWorkspaceIds: result.removedWorkspaceIds },
schema: projectDeleteSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}
@@ -0,0 +1,41 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runDeleteCommand } from "./delete.js";
import { runLsCommand } from "./ls.js";
import { runRenameCommand } from "./rename.js";
export function createProjectCommand(): Command {
const project = new Command("project").description("Manage projects");
addJsonAndDaemonHostOptions(
project
.command("create")
.description("Register a project directory")
.argument("[path]", "Project directory (default: current directory)"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(project.command("ls").description("List projects")).action(
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
project
.command("rename")
.description("Set a project's user-visible name")
.argument("<project-id>", "Project id")
.argument("[name]", "New project name")
.option("--reset", "Clear the custom name and use the directory name")
.allowExcessArguments(false),
).action(withOutput(runRenameCommand));
addJsonAndDaemonHostOptions(
project
.command("delete")
.description("Delete a project and its workspaces")
.argument("<project-id>", "Project id"),
).action(withOutput(runDeleteCommand));
return project;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult } from "../../output/index.js";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
import { projectSchema, toProjectRow, type ProjectRow } from "./shared.js";
export async function runLsCommand(
options: CommandOptions,
_command: Command,
): Promise<ListResult<ProjectRow>> {
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
});
try {
const payload = await client.listProjects();
return {
type: "list",
data: payload.projects.map(toProjectRow),
schema: projectSchema,
};
} finally {
await client.close().catch(() => undefined);
}
}
@@ -0,0 +1,109 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveProjectPath, runCreateCommand } from "./create.js";
import { runDeleteCommand } from "./delete.js";
import { createProjectCommand } from "./index.js";
import { runLsCommand } from "./ls.js";
import { resolveProjectName, runRenameCommand } from "./rename.js";
const project = {
projectId: "project-1",
projectDisplayName: "Paseo",
projectCustomName: null,
projectRootPath: "/tmp/paseo",
projectKind: "git" as const,
};
const addProject = vi.fn(async () => ({ project, error: null }));
const listProjects = vi.fn(async () => ({ projects: [project] }));
const renameProject = vi.fn(async (_projectId: string, name: string | null) => ({
customName: name,
}));
const removeProject = vi.fn(async () => ({ removedWorkspaceIds: ["workspace-1"] }));
const close = vi.fn(async () => undefined);
vi.mock("../../utils/client.js", () => ({
buildDaemonConnectionCommandError: vi.fn(({ error }: { error: unknown }) => error),
connectToDaemon: vi.fn(async () => ({
addProject,
listProjects,
renameProject,
removeProject,
close,
})),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe("project commands", () => {
it("exposes the complete project lifecycle", () => {
expect(createProjectCommand().commands.map((command) => command.name())).toEqual([
"create",
"ls",
"rename",
"delete",
]);
});
it("creates a project from an absolute directory path", async () => {
const result = await runCreateCommand("relative/project", {}, {} as never);
expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project"));
expect(result.data).toEqual({
projectId: "project-1",
name: "Paseo",
kind: "git",
path: "/tmp/paseo",
});
expect(close).toHaveBeenCalled();
});
it("resolves paths locally and leaves daemon-owned paths unchanged", () => {
expect(resolveProjectPath({ cwd: "/home/user", pathArg: "repo" })).toBe(
path.resolve("/home/user/repo"),
);
expect(resolveProjectPath({ cwd: "/home/user" })).toBe(path.resolve("/home/user"));
expect(
resolveProjectPath({ cwd: "/home/user", pathArg: "/srv/repo", daemonTarget: "host:6767" }),
).toBe("/srv/repo");
expect(
resolveProjectPath({ cwd: "/home/user", pathArg: "~/repo", daemonTarget: "host:6767" }),
).toBe("~/repo");
});
it("requires a daemon-owned path for an explicit target", () => {
expect(() => resolveProjectPath({ cwd: "/home/user", daemonTarget: "host:6767" })).toThrow();
});
it("lists projects", async () => {
const result = await runLsCommand({}, {} as never);
expect(listProjects).toHaveBeenCalled();
expect(result.data).toHaveLength(1);
expect(result.data[0]?.projectId).toBe("project-1");
});
it("renames and resets a project", async () => {
expect(resolveProjectName({ name: " New name " })).toBe("New name");
expect(resolveProjectName({ reset: true })).toBeNull();
const result = await runRenameCommand("project-1", " New name ", {}, {} as never);
expect(renameProject).toHaveBeenCalledWith("project-1", "New name");
expect(result.data).toEqual({ projectId: "project-1", name: "New name" });
});
it("rejects an empty project name", () => {
expect(() => resolveProjectName({ name: " " })).toThrow();
});
it("deletes a project", async () => {
const result = await runDeleteCommand("project-1", {}, {} as never);
expect(removeProject).toHaveBeenCalledWith("project-1");
expect(result.data).toEqual({
projectId: "project-1",
removedWorkspaceIds: ["workspace-1"],
});
});
});
@@ -0,0 +1,64 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js";
interface ProjectRenameResult {
projectId: string;
name: string | null;
}
const projectRenameSchema = {
idField: "projectId" as const,
columns: [
{ header: "PROJECT ID", field: "projectId" as const, width: 20 },
{ header: "NAME", field: "name" as const, width: 30 },
],
};
export function resolveProjectName(input: { name?: string; reset?: boolean }): string | null {
if (input.reset) {
if (input.name !== undefined) {
throw {
code: "INVALID_OPTIONS",
message: "--reset cannot be combined with a name",
} satisfies CommandError;
}
return null;
}
const name = input.name?.trim() ?? "";
if (name.length === 0) {
throw {
code: "MISSING_NAME",
message: "Project name cannot be empty",
details: "Usage: paseo project rename <project-id> <name> | --reset",
} satisfies CommandError;
}
return name;
}
export async function runRenameCommand(
projectId: string,
nameArg: string | undefined,
options: CommandOptions & { reset?: boolean },
_command: Command,
): Promise<SingleResult<ProjectRenameResult>> {
const name = resolveProjectName({ name: nameArg, reset: options.reset });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw buildDaemonConnectionCommandError({ host: options.host, error });
});
try {
const applied = await client.renameProject(projectId, name);
return {
type: "single",
data: { projectId, name: applied.customName },
schema: projectRenameSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}
@@ -0,0 +1,28 @@
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
import type { OutputSchema } from "../../output/index.js";
export interface ProjectRow {
projectId: string;
name: string;
kind: "git" | "non_git" | "directory";
path: string;
}
export const projectSchema: OutputSchema<ProjectRow> = {
idField: "projectId",
columns: [
{ header: "PROJECT ID", field: "projectId", width: 20 },
{ header: "NAME", field: "name", width: 24 },
{ header: "KIND", field: "kind", width: 10 },
{ header: "PATH", field: "path", width: 42 },
],
};
export function toProjectRow(project: WorkspaceProjectDescriptorPayload): ProjectRow {
return {
projectId: project.projectId,
name: project.projectDisplayName,
kind: project.projectKind,
path: project.projectRootPath,
};
}
+2 -2
View File
@@ -54,13 +54,13 @@ describe("runCli", () => {
it("classifies existing unknown directories as open-project invocations", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-"));
const project = path.join(root, "project");
const project = path.join(root, "repository");
mkdirSync(project);
try {
expect(
createCliParseArgv({
argv: ["project"],
argv: ["repository"],
cwd: root,
nodeArgv: ["node", "paseo"],
}),