mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add a keyboard-driven project setup flow (#2097)
* feat(projects): add keyboard-driven project setup * test(app): update add project loading assertion * fix(projects): harden GitHub project setup
This commit is contained in:
170
packages/protocol/src/messages.project-command-center.test.ts
Normal file
170
packages/protocol/src/messages.project-command-center.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseServerInfoStatusPayload,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("project command-center protocol", () => {
|
||||
it("parses the dotted GitHub repository search request and normalized success response", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "workspace.github.search_repositories.request",
|
||||
query: "paseo",
|
||||
limit: 12,
|
||||
requestId: "req-search",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "workspace.github.search_repositories.request",
|
||||
query: "paseo",
|
||||
limit: 12,
|
||||
requestId: "req-search",
|
||||
});
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: {
|
||||
status: "success",
|
||||
requestId: "req-search",
|
||||
repositories: [
|
||||
{
|
||||
id: "R_paseo",
|
||||
name: "paseo",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
description: "Development environment in your pocket",
|
||||
visibility: "public",
|
||||
updatedAt: "2026-07-15T10:00:00Z",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
},
|
||||
],
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
}).payload,
|
||||
).toEqual({
|
||||
status: "success",
|
||||
requestId: "req-search",
|
||||
repositories: [
|
||||
{
|
||||
id: "R_paseo",
|
||||
name: "paseo",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
description: "Development environment in your pocket",
|
||||
visibility: "public",
|
||||
updatedAt: "2026-07-15T10:00:00Z",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
},
|
||||
],
|
||||
available: true,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "unavailable", reason: "gh_missing", message: "gh is missing" },
|
||||
{ status: "unauthenticated", message: "sign in" },
|
||||
{ status: "error", message: "command failed" },
|
||||
])("parses the GitHub $status runtime state", (payload) => {
|
||||
expect(
|
||||
SessionOutboundMessageSchema.safeParse({
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: {
|
||||
requestId: "req-search",
|
||||
repositories: [],
|
||||
available: payload.status === "error",
|
||||
error: payload.message,
|
||||
...payload,
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("parses atomic project directory creation request and response", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.safeParse({
|
||||
type: "project.create_directory.request",
|
||||
parentPath: "/Users/example/dev",
|
||||
name: "new-project",
|
||||
requestId: "req-create",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: "req-create",
|
||||
directoryPath: "/Users/example/dev/new-project",
|
||||
project: {
|
||||
projectId: "directory:/Users/example/dev/new-project",
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: "/Users/example/dev/new-project",
|
||||
projectKind: "non_git",
|
||||
},
|
||||
error: null,
|
||||
errorCode: null,
|
||||
},
|
||||
}).payload.project?.projectDisplayName,
|
||||
).toBe("new-project");
|
||||
|
||||
expect(
|
||||
SessionInboundMessageSchema.safeParse({
|
||||
type: "project.create_directory.request",
|
||||
parentPath: "",
|
||||
name: "",
|
||||
requestId: "req-invalid-create",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts future project directory error codes without transforming wire values", () => {
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: "req-create",
|
||||
directoryPath: null,
|
||||
project: null,
|
||||
error: "A newer failure",
|
||||
errorCode: "future_failure_reason",
|
||||
},
|
||||
}).payload.errorCode,
|
||||
).toBe("future_failure_reason");
|
||||
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: {
|
||||
status: "success",
|
||||
requestId: "req-search",
|
||||
repositories: [
|
||||
{
|
||||
id: "repo",
|
||||
name: " paseo ",
|
||||
nameWithOwner: " getpaseo/paseo ",
|
||||
description: null,
|
||||
visibility: "public",
|
||||
updatedAt: "2026-07-15T10:00:00Z",
|
||||
cloneUrl: " https://github.com/getpaseo/paseo ",
|
||||
},
|
||||
],
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
}).payload.repositories[0]?.name,
|
||||
).toBe(" paseo ");
|
||||
});
|
||||
|
||||
it("keeps both feature flags optional for older server_info payloads", () => {
|
||||
const parsed = parseServerInfoStatusPayload({
|
||||
status: "server_info",
|
||||
serverId: "server-old",
|
||||
features: {},
|
||||
});
|
||||
|
||||
expect(parsed.features?.workspaceGithubRepositorySearch).toBeUndefined();
|
||||
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1789,14 +1789,39 @@ export const OpenProjectRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// Smallest shorthand repo path is "a/b": owner, slash, repository.
|
||||
const MIN_REPOSITORY_PATH_LENGTH = 3;
|
||||
|
||||
export const ProjectAddRequestSchema = z.object({
|
||||
type: z.literal("project.add.request"),
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
// Smallest shorthand repo path is "a/b": owner, slash, repository.
|
||||
const MIN_REPOSITORY_PATH_LENGTH = 3;
|
||||
export const ProjectCreateDirectoryRequestSchema = z.object({
|
||||
type: z.literal("project.create_directory.request"),
|
||||
parentPath: z.string(),
|
||||
name: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const GithubRepositorySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
nameWithOwner: z.string().min(MIN_REPOSITORY_PATH_LENGTH),
|
||||
description: z.string().nullable(),
|
||||
visibility: z.enum(["public", "private", "internal"]),
|
||||
updatedAt: z.string(),
|
||||
cloneUrl: z.string().min(MIN_REPOSITORY_PATH_LENGTH),
|
||||
});
|
||||
|
||||
export const WorkspaceGithubSearchRepositoriesRequestSchema = z.object({
|
||||
type: z.literal("workspace.github.search_repositories.request"),
|
||||
query: z.string(),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]);
|
||||
|
||||
export const WorkspaceGithubCloneRequestSchema = z.object({
|
||||
@@ -2191,6 +2216,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
LegacyOpenInEditorRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ProjectAddRequestSchema,
|
||||
ProjectCreateDirectoryRequestSchema,
|
||||
WorkspaceGithubSearchRepositoriesRequestSchema,
|
||||
WorkspaceGithubCloneRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
WorkspaceCreateRequestSchema,
|
||||
@@ -2438,6 +2465,10 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspacePinning: z.boolean().optional(),
|
||||
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
|
||||
workspaceGithubClone: z.boolean().optional(),
|
||||
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
|
||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2936,6 +2967,63 @@ export const ProjectAddResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectCreateDirectoryErrorCodeSchema = z.enum([
|
||||
"invalid_name",
|
||||
"parent_directory_not_found",
|
||||
"directory_exists",
|
||||
"permission_denied",
|
||||
"registration_failed",
|
||||
"filesystem_error",
|
||||
]);
|
||||
|
||||
export const ProjectCreateDirectoryResponseSchema = z.object({
|
||||
type: z.literal("project.create_directory.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
directoryPath: z.string().nullable(),
|
||||
project: WorkspaceProjectDescriptorPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
// Error codes are open-ended on the wire so older clients can still parse
|
||||
// responses after a newer daemon learns another failure reason.
|
||||
errorCode: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WorkspaceGithubSearchRepositoriesResponseSchema = z.object({
|
||||
type: z.literal("workspace.github.search_repositories.response"),
|
||||
payload: z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
status: z.literal("success"),
|
||||
requestId: z.string(),
|
||||
repositories: z.array(GithubRepositorySchema),
|
||||
available: z.literal(true),
|
||||
error: z.null(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unavailable"),
|
||||
requestId: z.string(),
|
||||
repositories: z.array(GithubRepositorySchema),
|
||||
reason: z.literal("gh_missing"),
|
||||
available: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("unauthenticated"),
|
||||
requestId: z.string(),
|
||||
repositories: z.array(GithubRepositorySchema),
|
||||
available: z.literal(false),
|
||||
error: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal("error"),
|
||||
requestId: z.string(),
|
||||
repositories: z.array(GithubRepositorySchema),
|
||||
available: z.literal(true),
|
||||
error: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const WorkspaceGithubCloneResponseSchema = z.object({
|
||||
type: z.literal("workspace.github.clone.response"),
|
||||
payload: z.object({
|
||||
@@ -4368,7 +4456,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchRecentProviderSessionsResponseMessageSchema,
|
||||
FetchWorkspacesResponseMessageSchema,
|
||||
ProjectAddResponseSchema,
|
||||
ProjectCreateDirectoryResponseSchema,
|
||||
OpenProjectResponseMessageSchema,
|
||||
WorkspaceGithubSearchRepositoriesResponseSchema,
|
||||
WorkspaceGithubCloneResponseSchema,
|
||||
StartWorkspaceScriptResponseMessageSchema,
|
||||
LegacyListAvailableEditorsResponseMessageSchema,
|
||||
@@ -4527,8 +4617,13 @@ export type FetchRecentProviderSessionsResponseMessage = z.infer<
|
||||
>;
|
||||
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
|
||||
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
|
||||
export type ProjectCreateDirectoryResponse = z.infer<typeof ProjectCreateDirectoryResponseSchema>;
|
||||
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
|
||||
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
|
||||
export type WorkspaceGithubSearchRepositoriesResponse = z.infer<
|
||||
typeof WorkspaceGithubSearchRepositoriesResponseSchema
|
||||
>;
|
||||
export type GithubRepository = z.infer<typeof GithubRepositorySchema>;
|
||||
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>;
|
||||
export type StartWorkspaceScriptResponseMessage = z.infer<
|
||||
typeof StartWorkspaceScriptResponseMessageSchema
|
||||
@@ -4778,6 +4873,11 @@ export type LegacyListAvailableEditorsRequest = z.infer<
|
||||
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
|
||||
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
|
||||
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
|
||||
export type ProjectCreateDirectoryRequest = z.infer<typeof ProjectCreateDirectoryRequestSchema>;
|
||||
export type ProjectCreateDirectoryErrorCode = z.infer<typeof ProjectCreateDirectoryErrorCodeSchema>;
|
||||
export type WorkspaceGithubSearchRepositoriesRequest = z.infer<
|
||||
typeof WorkspaceGithubSearchRepositoriesRequestSchema
|
||||
>;
|
||||
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>;
|
||||
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>;
|
||||
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
|
||||
|
||||
Reference in New Issue
Block a user