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:
@@ -0,0 +1,122 @@
|
||||
import { access, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectDirectory,
|
||||
ProjectDirectoryRequestError,
|
||||
validateDirectoryName,
|
||||
} from "./project-directory-service.js";
|
||||
|
||||
describe("project directory creation", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-project-directory-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
it.each(["", " ", ".", "..", "nested/name", "nested\\name", "/absolute", "C:\\absolute"])(
|
||||
"rejects invalid single directory name %j",
|
||||
(name) => {
|
||||
expect(() => validateDirectoryName(name)).toThrow(ProjectDirectoryRequestError);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires the selected parent directory to already exist", async () => {
|
||||
const root = await createRoot();
|
||||
|
||||
await expect(
|
||||
createProjectDirectory(
|
||||
{ parentPath: join(root, "missing"), name: "project" },
|
||||
{
|
||||
registerProject: async () => {
|
||||
throw new Error("registration should not be attempted");
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "parent_directory_not_found" });
|
||||
});
|
||||
|
||||
it("does not substitute the daemon cwd for a missing parent selection", async () => {
|
||||
await expect(
|
||||
createProjectDirectory(
|
||||
{ parentPath: " ", name: "project" },
|
||||
{
|
||||
registerProject: async () => {
|
||||
throw new Error("registration should not be attempted");
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "parent_directory_not_found",
|
||||
message: "Parent directory is required",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects collisions without changing the existing directory", async () => {
|
||||
const root = await createRoot();
|
||||
const existing = join(root, "existing");
|
||||
await mkdir(existing);
|
||||
let registrationAttempted = false;
|
||||
|
||||
await expect(
|
||||
createProjectDirectory(
|
||||
{ parentPath: root, name: "existing" },
|
||||
{
|
||||
registerProject: async () => {
|
||||
registrationAttempted = true;
|
||||
throw new Error("registration should not be attempted");
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "directory_exists", directoryPath: existing });
|
||||
await expect(access(existing)).resolves.toBeUndefined();
|
||||
expect(registrationAttempted).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back the newly created directory when registration fails", async () => {
|
||||
const root = await createRoot();
|
||||
const directoryPath = join(root, "unregistered");
|
||||
|
||||
await expect(
|
||||
createProjectDirectory(
|
||||
{ parentPath: root, name: "unregistered" },
|
||||
{
|
||||
registerProject: async () => {
|
||||
throw new Error("registry unavailable");
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "registration_failed", directoryPath });
|
||||
await expect(access(directoryPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("returns the registered project and created path together", async () => {
|
||||
const root = await createRoot();
|
||||
const directoryPath = join(root, "new-project");
|
||||
const project = {
|
||||
projectId: `directory:${directoryPath}`,
|
||||
rootPath: directoryPath,
|
||||
kind: "non_git" as const,
|
||||
displayName: "new-project",
|
||||
customName: null,
|
||||
createdAt: "2026-07-15T10:00:00Z",
|
||||
updatedAt: "2026-07-15T10:00:00Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createProjectDirectory(
|
||||
{ parentPath: root, name: "new-project" },
|
||||
{ registerProject: async () => project },
|
||||
),
|
||||
).resolves.toEqual({ directoryPath, project });
|
||||
await expect(access(directoryPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { mkdir, rmdir, stat } from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
|
||||
import type { ProjectCreateDirectoryErrorCode } from "@getpaseo/protocol/messages";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import type { PersistedProjectRecord } from "./workspace-registry.js";
|
||||
|
||||
export class ProjectDirectoryRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: ProjectCreateDirectoryErrorCode,
|
||||
message: string,
|
||||
readonly directoryPath: string | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProjectDirectoryRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateProjectDirectoryInput {
|
||||
parentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectDirectoryResult {
|
||||
directoryPath: string;
|
||||
project: PersistedProjectRecord;
|
||||
}
|
||||
|
||||
interface ProjectDirectoryFileSystem {
|
||||
stat(path: string): Promise<{ isDirectory(): boolean }>;
|
||||
mkdir(path: string): Promise<void>;
|
||||
rmdir(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface CreateProjectDirectoryDependencies {
|
||||
filesystem?: ProjectDirectoryFileSystem;
|
||||
registerProject(directoryPath: string): Promise<PersistedProjectRecord>;
|
||||
}
|
||||
|
||||
const nodeProjectDirectoryFileSystem: ProjectDirectoryFileSystem = {
|
||||
stat,
|
||||
async mkdir(path) {
|
||||
await mkdir(path);
|
||||
},
|
||||
rmdir,
|
||||
};
|
||||
|
||||
export async function createProjectDirectory(
|
||||
input: CreateProjectDirectoryInput,
|
||||
dependencies: CreateProjectDirectoryDependencies,
|
||||
): Promise<CreateProjectDirectoryResult> {
|
||||
validateDirectoryName(input.name);
|
||||
|
||||
const requestedParentPath = input.parentPath.trim();
|
||||
if (!requestedParentPath) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"parent_directory_not_found",
|
||||
"Parent directory is required",
|
||||
);
|
||||
}
|
||||
const parentDirectory = resolve(expandTilde(requestedParentPath));
|
||||
const directoryPath = resolve(parentDirectory, input.name);
|
||||
if (!isPathInside(parentDirectory, directoryPath)) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"invalid_name",
|
||||
"Directory name must resolve inside the selected parent",
|
||||
);
|
||||
}
|
||||
|
||||
const filesystem = dependencies.filesystem ?? nodeProjectDirectoryFileSystem;
|
||||
await requireExistingParent(filesystem, parentDirectory);
|
||||
|
||||
try {
|
||||
await filesystem.mkdir(directoryPath);
|
||||
} catch (error) {
|
||||
throw mapCreateDirectoryError(error, directoryPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await dependencies.registerProject(directoryPath);
|
||||
return { directoryPath, project };
|
||||
} catch (registrationError) {
|
||||
try {
|
||||
await filesystem.rmdir(directoryPath);
|
||||
} catch (rollbackError) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"registration_failed",
|
||||
`Failed to register project and roll back directory: ${errorMessage(rollbackError)}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"registration_failed",
|
||||
`Failed to register project: ${errorMessage(registrationError)}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDirectoryName(name: string): void {
|
||||
if (name.trim().length === 0) {
|
||||
throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be empty");
|
||||
}
|
||||
if (name !== name.trim()) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"invalid_name",
|
||||
"Directory name cannot start or end with whitespace",
|
||||
);
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
throw new ProjectDirectoryRequestError("invalid_name", "Directory name cannot be . or ..");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"invalid_name",
|
||||
"Directory name must be a single name without path separators",
|
||||
);
|
||||
}
|
||||
if (isAbsolute(name) || win32.isAbsolute(name) || /^[A-Za-z]:/.test(name)) {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"invalid_name",
|
||||
"Directory name cannot be an absolute path",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireExistingParent(
|
||||
filesystem: ProjectDirectoryFileSystem,
|
||||
parentDirectory: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const parent = await filesystem.stat(parentDirectory);
|
||||
if (parent.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"parent_directory_not_found",
|
||||
`Parent directory not found: ${parentDirectory}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectDirectoryRequestError) {
|
||||
throw error;
|
||||
}
|
||||
const code = nodeErrorCode(error);
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"permission_denied",
|
||||
`Permission denied reading parent directory: ${parentDirectory}`,
|
||||
);
|
||||
}
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"parent_directory_not_found",
|
||||
`Parent directory not found: ${parentDirectory}`,
|
||||
);
|
||||
}
|
||||
throw new ProjectDirectoryRequestError(
|
||||
"filesystem_error",
|
||||
`Failed to inspect parent directory: ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapCreateDirectoryError(
|
||||
error: unknown,
|
||||
directoryPath: string,
|
||||
): ProjectDirectoryRequestError {
|
||||
const code = nodeErrorCode(error);
|
||||
if (code === "EEXIST") {
|
||||
return new ProjectDirectoryRequestError(
|
||||
"directory_exists",
|
||||
`Directory already exists: ${directoryPath}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
if (code === "ENOENT" || code === "ENOTDIR") {
|
||||
return new ProjectDirectoryRequestError(
|
||||
"parent_directory_not_found",
|
||||
`Parent directory not found: ${resolve(directoryPath, "..")}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
|
||||
return new ProjectDirectoryRequestError(
|
||||
"permission_denied",
|
||||
`Permission denied creating directory: ${directoryPath}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
return new ProjectDirectoryRequestError(
|
||||
"filesystem_error",
|
||||
`Failed to create directory: ${errorMessage(error)}`,
|
||||
directoryPath,
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInside(parentDirectory: string, directoryPath: string): boolean {
|
||||
const relativePath = relative(parentDirectory, directoryPath);
|
||||
return (
|
||||
relativePath.length > 0 &&
|
||||
relativePath !== ".." &&
|
||||
!relativePath.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function nodeErrorCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object" || !("code" in error)) {
|
||||
return null;
|
||||
}
|
||||
return typeof error.code === "string" ? error.code : null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
@@ -41,6 +41,11 @@ import type {
|
||||
GitHubPullRequestStatusFacts,
|
||||
GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
import {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
GitHubCommandError,
|
||||
} from "../services/github-service.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
interruptAgentIfRunning(agentId: string): Promise<void>;
|
||||
@@ -273,6 +278,7 @@ interface SessionForTestOptions {
|
||||
github?: Partial<GitHubService>;
|
||||
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
|
||||
workspaceGitService?: {
|
||||
getCheckout?: ReturnType<typeof vi.fn>;
|
||||
getCheckoutDiff?: ReturnType<typeof vi.fn>;
|
||||
getSnapshot?: ReturnType<typeof vi.fn>;
|
||||
suggestBranchesForCwd?: ReturnType<typeof vi.fn>;
|
||||
@@ -315,6 +321,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
};
|
||||
const workspaceGitService = options.workspaceGitService ?? {
|
||||
getCheckout: vi.fn(),
|
||||
getCheckoutDiff: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
suggestBranchesForCwd: vi.fn(),
|
||||
@@ -387,6 +394,223 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
});
|
||||
}
|
||||
|
||||
describe("project command-center RPCs", () => {
|
||||
test("returns normalized repositories from the host GitHub service", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const searchRepositories = vi.fn().mockResolvedValue([
|
||||
{
|
||||
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",
|
||||
},
|
||||
]);
|
||||
const session = createSessionForTest({ messages, github: { searchRepositories } });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.github.search_repositories.request",
|
||||
query: "paseo",
|
||||
limit: 10,
|
||||
requestId: "req-repositories",
|
||||
});
|
||||
|
||||
expect(searchRepositories).toHaveBeenCalledWith({
|
||||
cwd: expect.any(String),
|
||||
query: "paseo",
|
||||
limit: 10,
|
||||
});
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: {
|
||||
status: "success",
|
||||
requestId: "req-repositories",
|
||||
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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
error: new GitHubCliMissingError(),
|
||||
expected: {
|
||||
status: "unavailable",
|
||||
requestId: "req-repositories-error",
|
||||
reason: "gh_missing",
|
||||
repositories: [],
|
||||
available: false,
|
||||
error: "GitHub CLI (gh) is not installed or not in PATH",
|
||||
},
|
||||
},
|
||||
{
|
||||
error: new GitHubAuthenticationError({ stderr: "gh auth login" }),
|
||||
expected: {
|
||||
status: "unauthenticated",
|
||||
requestId: "req-repositories-error",
|
||||
repositories: [],
|
||||
available: false,
|
||||
error: "GitHub CLI is not authenticated. Run gh auth login on the host.",
|
||||
},
|
||||
},
|
||||
{
|
||||
error: new GitHubCommandError({
|
||||
args: ["search", "repos", "paseo"],
|
||||
cwd: "/tmp",
|
||||
exitCode: 1,
|
||||
stderr: "GitHub API unavailable",
|
||||
}),
|
||||
expected: {
|
||||
status: "error",
|
||||
requestId: "req-repositories-error",
|
||||
repositories: [],
|
||||
available: true,
|
||||
error: "GitHub API unavailable",
|
||||
},
|
||||
},
|
||||
])("maps GitHub runtime failures to $expected.status", async ({ error, expected }) => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
github: { searchRepositories: vi.fn().mockRejectedValue(error) },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "workspace.github.search_repositories.request",
|
||||
query: "paseo",
|
||||
requestId: "req-repositories-error",
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: expected,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates a directory and returns its normalized Project descriptor", async () => {
|
||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||
const directoryPath = join(parentDirectory, "new-project");
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const projectUpsert = vi.fn().mockResolvedValue(undefined);
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: projectUpsert,
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await session.handleMessage({
|
||||
type: "project.create_directory.request",
|
||||
parentPath: parentDirectory,
|
||||
name: "new-project",
|
||||
requestId: "req-create-directory",
|
||||
});
|
||||
|
||||
expect(existsSync(directoryPath)).toBe(true);
|
||||
expect(projectUpsert).toHaveBeenCalledOnce();
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: "req-create-directory",
|
||||
directoryPath,
|
||||
project: {
|
||||
projectId: directoryPath,
|
||||
projectDisplayName: "new-project",
|
||||
projectCustomName: null,
|
||||
projectRootPath: directoryPath,
|
||||
projectKind: "non_git",
|
||||
},
|
||||
error: null,
|
||||
errorCode: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls back the directory when Project registration fails", async () => {
|
||||
const parentDirectory = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-session-")));
|
||||
const directoryPath = join(parentDirectory, "unregistered");
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
upsert: vi.fn().mockRejectedValue(new Error("registry unavailable")),
|
||||
},
|
||||
workspaceGitService: {
|
||||
getCheckout: vi.fn(async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: false as const,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await session.handleMessage({
|
||||
type: "project.create_directory.request",
|
||||
parentPath: parentDirectory,
|
||||
name: "unregistered",
|
||||
requestId: "req-registration-failure",
|
||||
});
|
||||
|
||||
expect(existsSync(directoryPath)).toBe(false);
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: "req-registration-failure",
|
||||
directoryPath,
|
||||
project: null,
|
||||
error: "Failed to register project: registry unavailable",
|
||||
errorCode: "registration_failed",
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
rmSync(parentDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("file explorer binary responses", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -184,7 +184,13 @@ import type pino from "pino";
|
||||
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { createGitHubService, type GitHubService } from "../services/github-service.js";
|
||||
import {
|
||||
createGitHubService,
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
GitHubCommandError,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
||||
import {
|
||||
summarizeFetchWorkspacesEntries,
|
||||
@@ -217,6 +223,10 @@ import {
|
||||
toWorktreeWireError,
|
||||
} from "./worktree-errors.js";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
createProjectDirectory,
|
||||
ProjectDirectoryRequestError,
|
||||
} from "./project-directory-service.js";
|
||||
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
@@ -362,6 +372,10 @@ type FetchWorkspacesResponsePayload = Extract<
|
||||
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
|
||||
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
|
||||
type WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number];
|
||||
type WorkspaceGithubSearchRepositoriesResponsePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace.github.search_repositories.response" }
|
||||
>["payload"];
|
||||
type WorkspaceUpdatePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
@@ -1632,6 +1646,10 @@ export class Session {
|
||||
return this.handleOpenProjectRequest(msg);
|
||||
case "project.add.request":
|
||||
return this.handleProjectAddRequest(msg);
|
||||
case "project.create_directory.request":
|
||||
return this.handleProjectCreateDirectoryRequest(msg);
|
||||
case "workspace.github.search_repositories.request":
|
||||
return this.handleWorkspaceGithubSearchRepositoriesRequest(msg);
|
||||
case "workspace.github.clone.request":
|
||||
return this.handleWorkspaceGithubCloneRequest(msg);
|
||||
case "archive_workspace_request":
|
||||
@@ -4770,6 +4788,128 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProjectCreateDirectoryRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "project.create_directory.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await createProjectDirectory(
|
||||
{ parentPath: request.parentPath, name: request.name },
|
||||
{
|
||||
registerProject: (directoryPath) =>
|
||||
this.workspaceProvisioning.findOrCreateProjectForDirectory(directoryPath),
|
||||
},
|
||||
);
|
||||
this.emit({
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
directoryPath: result.directoryPath,
|
||||
project: this.buildProjectDescriptor(result.project),
|
||||
error: null,
|
||||
errorCode: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const requestError =
|
||||
error instanceof ProjectDirectoryRequestError
|
||||
? error
|
||||
: new ProjectDirectoryRequestError(
|
||||
"registration_failed",
|
||||
error instanceof Error ? error.message : "Failed to create project directory",
|
||||
);
|
||||
this.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
parentPath: request.parentPath,
|
||||
name: request.name,
|
||||
errorCode: requestError.code,
|
||||
},
|
||||
"Failed to create project directory",
|
||||
);
|
||||
this.emit({
|
||||
type: "project.create_directory.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
directoryPath: requestError.directoryPath,
|
||||
project: null,
|
||||
error: requestError.message,
|
||||
errorCode: requestError.code,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceGithubSearchRepositoriesRequest(
|
||||
request: Extract<
|
||||
SessionInboundMessage,
|
||||
{ type: "workspace.github.search_repositories.request" }
|
||||
>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const repositories = await this.github.searchRepositories({
|
||||
cwd: homedir(),
|
||||
query: request.query,
|
||||
limit: request.limit,
|
||||
});
|
||||
this.emit({
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
repositories,
|
||||
status: "success",
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const missing = error instanceof GitHubCliMissingError;
|
||||
const unauthenticated = error instanceof GitHubAuthenticationError;
|
||||
const commandError = error instanceof GitHubCommandError ? error.stderr.trim() : "";
|
||||
let message: string;
|
||||
if (missing) {
|
||||
message = "GitHub CLI (gh) is not installed or not in PATH";
|
||||
} else if (unauthenticated) {
|
||||
message = "GitHub CLI is not authenticated. Run gh auth login on the host.";
|
||||
} else if (commandError) {
|
||||
message = commandError;
|
||||
} else {
|
||||
message = error instanceof Error ? error.message : "GitHub search failed";
|
||||
}
|
||||
let payload: WorkspaceGithubSearchRepositoriesResponsePayload;
|
||||
if (missing) {
|
||||
payload = {
|
||||
status: "unavailable",
|
||||
requestId: request.requestId,
|
||||
repositories: [],
|
||||
reason: "gh_missing",
|
||||
available: false,
|
||||
error: message,
|
||||
};
|
||||
} else if (unauthenticated) {
|
||||
payload = {
|
||||
status: "unauthenticated",
|
||||
requestId: request.requestId,
|
||||
repositories: [],
|
||||
available: false,
|
||||
error: message,
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
status: "error",
|
||||
requestId: request.requestId,
|
||||
repositories: [],
|
||||
available: true,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
this.sessionLogger.warn({ err: error }, "GitHub repository search failed");
|
||||
this.emit({
|
||||
type: "workspace.github.search_repositories.response",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceGithubCloneRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.github.clone.request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1268,6 +1268,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspacePinning: true,
|
||||
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
|
||||
workspaceGithubClone: true,
|
||||
// COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15.
|
||||
workspaceGithubRepositorySearch: true,
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createGitHubService,
|
||||
type GitHubCommandRunner,
|
||||
type GitHubCommandRunnerOptions,
|
||||
} from "./github-service.js";
|
||||
|
||||
interface RunnerCall {
|
||||
args: string[];
|
||||
options: GitHubCommandRunnerOptions;
|
||||
}
|
||||
|
||||
function createRunner(outputs: string[]): { calls: RunnerCall[]; runner: GitHubCommandRunner } {
|
||||
const calls: RunnerCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
runner: async (args, options) => {
|
||||
calls.push({ args, options });
|
||||
return { stdout: outputs.shift() ?? "", stderr: "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GitHub repository search", () => {
|
||||
it("lists recent owned repositories for an empty query and normalizes clone identity", async () => {
|
||||
const runner = createRunner([
|
||||
JSON.stringify([
|
||||
{
|
||||
id: " R_recent ",
|
||||
name: " paseo ",
|
||||
nameWithOwner: " getpaseo/paseo ",
|
||||
description: null,
|
||||
visibility: "PUBLIC",
|
||||
updatedAt: "2026-07-15T12:00:00Z",
|
||||
sshUrl: " git@github.com:getpaseo/paseo.git ",
|
||||
url: "https://github.com/getpaseo/paseo",
|
||||
},
|
||||
]),
|
||||
"ssh\n",
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.searchRepositories({ cwd: "/tmp", query: " ", limit: 8 }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
id: "R_recent",
|
||||
name: "paseo",
|
||||
nameWithOwner: "getpaseo/paseo",
|
||||
description: null,
|
||||
visibility: "public",
|
||||
updatedAt: "2026-07-15T12:00:00Z",
|
||||
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
||||
},
|
||||
]);
|
||||
expect(runner.calls).toEqual([
|
||||
{
|
||||
args: [
|
||||
"repo",
|
||||
"list",
|
||||
"--json",
|
||||
"id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url",
|
||||
"--limit",
|
||||
"8",
|
||||
],
|
||||
options: { cwd: "/tmp" },
|
||||
},
|
||||
{
|
||||
args: ["config", "get", "git_protocol", "--host", "github.com"],
|
||||
options: { cwd: "/tmp" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("searches accessible repositories for a typed query", async () => {
|
||||
const runner = createRunner([
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 42,
|
||||
name: "private-repo",
|
||||
fullName: "octo/private-repo",
|
||||
description: "Private project",
|
||||
visibility: "PRIVATE",
|
||||
updatedAt: "2026-07-14T08:00:00Z",
|
||||
url: "https://github.com/octo/private-repo",
|
||||
},
|
||||
]),
|
||||
"https",
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.searchRepositories({ cwd: "/tmp", query: " private project ", limit: 5 }),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
id: "42",
|
||||
name: "private-repo",
|
||||
nameWithOwner: "octo/private-repo",
|
||||
description: "Private project",
|
||||
visibility: "private",
|
||||
updatedAt: "2026-07-14T08:00:00Z",
|
||||
cloneUrl: "https://github.com/octo/private-repo",
|
||||
},
|
||||
]);
|
||||
expect(runner.calls).toEqual([
|
||||
{
|
||||
args: [
|
||||
"search",
|
||||
"repos",
|
||||
"private project",
|
||||
"--json",
|
||||
"id,name,fullName,description,visibility,updatedAt,url",
|
||||
"--sort",
|
||||
"updated",
|
||||
"--order",
|
||||
"desc",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
options: { cwd: "/tmp" },
|
||||
},
|
||||
{
|
||||
args: ["config", "get", "git_protocol", "--host", "github.com"],
|
||||
options: { cwd: "/tmp" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,27 @@ const GitHubPullRequestSummarySchema = z.object({
|
||||
updatedAt: z.string().catch(""),
|
||||
});
|
||||
|
||||
const GitHubRepositoryListItemSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
nameWithOwner: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
visibility: z.string(),
|
||||
updatedAt: z.string(),
|
||||
sshUrl: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
const GitHubRepositorySearchItemSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
name: z.string(),
|
||||
fullName: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
visibility: z.string(),
|
||||
updatedAt: z.string(),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
const PullRequestCheckRunNodeSchema = z.object({
|
||||
__typename: z.literal("CheckRun"),
|
||||
databaseId: z.number().nullable().optional(),
|
||||
@@ -783,6 +804,22 @@ export interface GitHubSearchResult {
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubRepositorySummary {
|
||||
id: string;
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
description: string | null;
|
||||
visibility: "public" | "private" | "internal";
|
||||
updatedAt: string;
|
||||
cloneUrl: string;
|
||||
}
|
||||
|
||||
export interface SearchGitHubRepositoriesOptions {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type SearchGitHubIssuesAndPrsOptions = {
|
||||
cwd: string;
|
||||
query: string;
|
||||
@@ -819,6 +856,7 @@ export interface GitHubService {
|
||||
): Promise<GitHubPullRequestTimeline>;
|
||||
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
|
||||
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
|
||||
searchRepositories(options: SearchGitHubRepositoriesOptions): Promise<GitHubRepositorySummary[]>;
|
||||
createPullRequest(
|
||||
options: CreateGitHubPullRequestOptions,
|
||||
): Promise<GitHubPullRequestCreateResult>;
|
||||
@@ -1368,6 +1406,49 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
});
|
||||
},
|
||||
|
||||
async searchRepositories(input) {
|
||||
const limit = input.limit ?? 20;
|
||||
const query = input.query.trim();
|
||||
if (query.length === 0) {
|
||||
const [stdout, cloneProtocol] = await Promise.all([
|
||||
run(
|
||||
[
|
||||
"repo",
|
||||
"list",
|
||||
"--json",
|
||||
"id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url",
|
||||
"--limit",
|
||||
String(limit),
|
||||
],
|
||||
{ cwd: input.cwd },
|
||||
),
|
||||
resolveConfiguredCloneProtocol(input.cwd, run),
|
||||
]);
|
||||
return parseRepositoryList(stdout, cloneProtocol);
|
||||
}
|
||||
|
||||
const [stdout, cloneProtocol] = await Promise.all([
|
||||
run(
|
||||
[
|
||||
"search",
|
||||
"repos",
|
||||
query,
|
||||
"--json",
|
||||
"id,name,fullName,description,visibility,updatedAt,url",
|
||||
"--sort",
|
||||
"updated",
|
||||
"--order",
|
||||
"desc",
|
||||
"--limit",
|
||||
String(limit),
|
||||
],
|
||||
{ cwd: input.cwd },
|
||||
),
|
||||
resolveConfiguredCloneProtocol(input.cwd, run),
|
||||
]);
|
||||
return parseRepositorySearch(stdout, cloneProtocol);
|
||||
},
|
||||
|
||||
async searchIssuesAndPrs(input) {
|
||||
if (input.force && !input.reason) {
|
||||
throw new Error("GitHubService forced read requires a reason");
|
||||
@@ -2169,6 +2250,90 @@ function parsePullRequestSummaries(stdout: string): GitHubPullRequestSummary[] {
|
||||
return parsed.map(toPullRequestSummary);
|
||||
}
|
||||
|
||||
type GitHubCloneProtocol = "https" | "ssh";
|
||||
|
||||
async function resolveConfiguredCloneProtocol(
|
||||
cwd: string,
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>,
|
||||
): Promise<GitHubCloneProtocol> {
|
||||
try {
|
||||
const protocol = (
|
||||
await run(["config", "get", "git_protocol", "--host", "github.com"], {
|
||||
cwd,
|
||||
})
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return protocol === "ssh" ? "ssh" : "https";
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubCommandError) {
|
||||
return "https";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRepositoryList(
|
||||
stdout: string,
|
||||
cloneProtocol: GitHubCloneProtocol,
|
||||
): GitHubRepositorySummary[] {
|
||||
const parsed = z.array(GitHubRepositoryListItemSchema).parse(JSON.parse(stdout || "[]"));
|
||||
return parsed.map((repository) =>
|
||||
normalizeRepositorySummary({
|
||||
...repository,
|
||||
nameWithOwner: repository.nameWithOwner,
|
||||
cloneUrl: cloneProtocol === "ssh" ? repository.sshUrl : repository.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function parseRepositorySearch(
|
||||
stdout: string,
|
||||
cloneProtocol: GitHubCloneProtocol,
|
||||
): GitHubRepositorySummary[] {
|
||||
const parsed = z.array(GitHubRepositorySearchItemSchema).parse(JSON.parse(stdout || "[]"));
|
||||
return parsed.map((repository) =>
|
||||
normalizeRepositorySummary({
|
||||
...repository,
|
||||
nameWithOwner: repository.fullName,
|
||||
cloneUrl:
|
||||
cloneProtocol === "ssh" ? `git@github.com:${repository.fullName}.git` : repository.url,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRepositorySummary(repository: {
|
||||
id: string | number;
|
||||
name: string;
|
||||
nameWithOwner: string;
|
||||
description?: string | null;
|
||||
visibility: string;
|
||||
updatedAt: string;
|
||||
cloneUrl: string;
|
||||
}): GitHubRepositorySummary {
|
||||
const nameWithOwner = repository.nameWithOwner.trim();
|
||||
if (!nameWithOwner.includes("/")) {
|
||||
throw new Error(`GitHub repository is missing owner identity: ${nameWithOwner}`);
|
||||
}
|
||||
return {
|
||||
id: String(repository.id).trim(),
|
||||
name: repository.name.trim(),
|
||||
nameWithOwner,
|
||||
description: repository.description ?? null,
|
||||
visibility: normalizeRepositoryVisibility(repository.visibility),
|
||||
updatedAt: repository.updatedAt,
|
||||
cloneUrl: repository.cloneUrl.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepositoryVisibility(visibility: string): GitHubRepositorySummary["visibility"] {
|
||||
const normalized = visibility.toLowerCase();
|
||||
if (normalized === "public" || normalized === "private" || normalized === "internal") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Unknown GitHub repository visibility: ${visibility}`);
|
||||
}
|
||||
|
||||
function parsePullRequestSummary(stdout: string): GitHubPullRequestSummary {
|
||||
return toPullRequestSummary(GitHubPullRequestSummarySchema.parse(JSON.parse(stdout || "{}")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user