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:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user