Skip to content

Commit 9db5ef1

Browse files
arul28claude
andcommitted
Add Project flow
Adds end-to-end "Add Project" surface: chooser + create/clone forms reachable from command palette and topbar, project scaffold service for local create and remote clone, and a publish-to-GitHub dialog backed by createRepository + publishCurrentProject in githubService. Supporting IPC, preload bridges, and shared types follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cb6722c commit 9db5ef1

26 files changed

Lines changed: 4665 additions & 510 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- `npm --prefix apps/ade-cli run test`
3232
- `npm --prefix apps/ade-cli run build`
3333
- Run the smallest relevant subset first when iterating, then finish with the broader checks that cover the touched surfaces.
34+
- If even running the full desktop test suit, u ahve to shard like ci
3435

3536
## Terminology
3637

apps/desktop/src/main/main.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "./s
3939
import { createAgentChatService } from "./services/chat/agentChatService";
4040
import { shutdownAcpCliConnections } from "./services/chat/acpCliPool";
4141
import { createGithubService } from "./services/github/githubService";
42+
import { createProjectScaffoldService } from "./services/projects/projectScaffoldService";
4243
import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService";
4344
import { createPrService } from "./services/prs/prService";
4445
import { createPrPollingService } from "./services/prs/prPollingService";
@@ -1658,6 +1659,11 @@ app.whenReady().then(async () => {
16581659
appDataDir: app.getPath("userData"),
16591660
});
16601661

1662+
const projectScaffoldService = createProjectScaffoldService({
1663+
logger,
1664+
githubService,
1665+
});
1666+
16611667
const feedbackReporterService = createFeedbackReporterService({
16621668
db,
16631669
logger,
@@ -3755,6 +3761,7 @@ app.whenReady().then(async () => {
37553761
conflictService,
37563762
aiIntegrationService,
37573763
githubService,
3764+
projectScaffoldService,
37583765
feedbackReporterService,
37593766
prService,
37603767
prPollingService,
@@ -3830,6 +3837,19 @@ app.whenReady().then(async () => {
38303837
const logger = createFileLogger(
38313838
path.join(app.getPath("userData"), "ade-idle.jsonl"),
38323839
);
3840+
// Welcome-screen IPCs (project create/clone, listMyRepos) need scaffold +
3841+
// github services even before a project is opened. Build minimal versions
3842+
// here that share the user-data token store. detectRepo / publishCurrent
3843+
// require an active project and will throw clearly when called dormant.
3844+
const dormantGithubService = createGithubService({
3845+
logger,
3846+
projectRoot: normalizedRoot,
3847+
appDataDir: app.getPath("userData"),
3848+
});
3849+
const dormantProjectScaffoldService = createProjectScaffoldService({
3850+
logger,
3851+
githubService: dormantGithubService,
3852+
});
38333853
return {
38343854
db: null,
38353855
logger,
@@ -3868,7 +3888,8 @@ app.whenReady().then(async () => {
38683888
iosSimulatorService: null,
38693889
appControlService: null,
38703890
builtInBrowserService: null,
3871-
githubService: null,
3891+
githubService: dormantGithubService,
3892+
projectScaffoldService: dormantProjectScaffoldService,
38723893
feedbackReporterService: null,
38733894
prService: null,
38743895
prPollingService: null,

apps/desktop/src/main/services/github/githubService.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,208 @@ describe("githubService.getStatus", () => {
571571
expect(status.repoAccessError).toBeNull();
572572
});
573573
});
574+
575+
// ---------------------------------------------------------------------------
576+
// createRepository — POST /user/repos
577+
// ---------------------------------------------------------------------------
578+
579+
describe("githubService.createRepository", () => {
580+
beforeEach(() => {
581+
vi.clearAllMocks();
582+
process.env.GITHUB_TOKEN = "ghp_env_token";
583+
});
584+
585+
function lastFetchCall() {
586+
const calls = mockFetch.mock.calls;
587+
return calls[calls.length - 1] as [string, RequestInit];
588+
}
589+
590+
it("POSTs the canonical body shape and parses the response", async () => {
591+
mockFetch.mockResolvedValueOnce(
592+
jsonResponse(201, {
593+
clone_url: "https://github.com/alice/test.git",
594+
ssh_url: "git@github.com:alice/test.git",
595+
html_url: "https://github.com/alice/test",
596+
default_branch: "main",
597+
}),
598+
);
599+
600+
const result = await makeService().createRepository({
601+
name: "test",
602+
description: "hello world",
603+
isPrivate: true,
604+
});
605+
606+
const [url, init] = lastFetchCall();
607+
expect(url).toContain("/user/repos");
608+
expect(init.method).toBe("POST");
609+
expect(JSON.parse(init.body as string)).toEqual({
610+
name: "test",
611+
description: "hello world",
612+
private: true,
613+
auto_init: false,
614+
});
615+
expect(result).toEqual({
616+
cloneUrl: "https://github.com/alice/test.git",
617+
sshUrl: "git@github.com:alice/test.git",
618+
htmlUrl: "https://github.com/alice/test",
619+
defaultBranch: "main",
620+
});
621+
});
622+
623+
it("omits the description field when blank", async () => {
624+
mockFetch.mockResolvedValueOnce(
625+
jsonResponse(201, {
626+
clone_url: "https://github.com/alice/test.git",
627+
ssh_url: "git@github.com:alice/test.git",
628+
html_url: "https://github.com/alice/test",
629+
default_branch: "main",
630+
}),
631+
);
632+
633+
await makeService().createRepository({ name: "test", isPrivate: false });
634+
635+
const [, init] = lastFetchCall();
636+
const body = JSON.parse(init.body as string);
637+
expect(body).not.toHaveProperty("description");
638+
expect(body.private).toBe(false);
639+
});
640+
641+
it("propagates GitHub error messages on 4xx", async () => {
642+
mockFetch.mockResolvedValueOnce(
643+
jsonResponse(422, {
644+
message: "Validation Failed",
645+
errors: [{ message: "name already exists on this account" }],
646+
}),
647+
);
648+
649+
await expect(
650+
makeService().createRepository({ name: "existing", isPrivate: true }),
651+
).rejects.toThrow(/validation failed.*already exists/i);
652+
});
653+
});
654+
655+
// ---------------------------------------------------------------------------
656+
// publishCurrentProject — orchestrates createRepo + remote add + push
657+
// ---------------------------------------------------------------------------
658+
659+
describe("githubService.publishCurrentProject", () => {
660+
beforeEach(() => {
661+
vi.clearAllMocks();
662+
process.env.GITHUB_TOKEN = "ghp_env_token";
663+
});
664+
665+
it("returns state=pushed when HEAD exists, after creating the repo and pushing", async () => {
666+
runGitMock
667+
// get-url origin: no remote yet
668+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "fatal: No such remote 'origin'" })
669+
// remote add origin: ok
670+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" })
671+
// rev-parse HEAD: ok (commit exists)
672+
.mockResolvedValueOnce({ exitCode: 0, stdout: "abc123\n", stderr: "" })
673+
// push -u origin HEAD: ok
674+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" });
675+
676+
mockFetch.mockResolvedValueOnce(
677+
jsonResponse(201, {
678+
clone_url: "https://github.com/alice/proj.git",
679+
ssh_url: "git@github.com:alice/proj.git",
680+
html_url: "https://github.com/alice/proj",
681+
default_branch: "main",
682+
}),
683+
);
684+
685+
const result = await makeService().publishCurrentProject({
686+
name: "proj",
687+
isPrivate: true,
688+
});
689+
690+
expect(result).toEqual({ state: "pushed", htmlUrl: "https://github.com/alice/proj" });
691+
const gitCalls = runGitMock.mock.calls.map((c) => c[0]);
692+
expect(gitCalls[0]).toEqual(["remote", "get-url", "origin"]);
693+
expect(gitCalls[1]).toEqual(["remote", "add", "origin", "https://github.com/alice/proj.git"]);
694+
expect(gitCalls[2]).toEqual(["rev-parse", "--verify", "HEAD"]);
695+
expect(gitCalls[3]).toEqual(["push", "-u", "origin", "HEAD"]);
696+
});
697+
698+
it("returns state=remote_added when the project has no commits yet", async () => {
699+
runGitMock
700+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) // get-url origin
701+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) // remote add
702+
.mockResolvedValueOnce({ exitCode: 128, stdout: "", stderr: "fatal: Needed a single revision" }); // rev-parse HEAD fails
703+
704+
mockFetch.mockResolvedValueOnce(
705+
jsonResponse(201, {
706+
clone_url: "https://github.com/alice/empty.git",
707+
ssh_url: "git@github.com:alice/empty.git",
708+
html_url: "https://github.com/alice/empty",
709+
default_branch: "main",
710+
}),
711+
);
712+
713+
const result = await makeService().publishCurrentProject({
714+
name: "empty",
715+
isPrivate: true,
716+
});
717+
718+
expect(result).toEqual({ state: "remote_added", htmlUrl: "https://github.com/alice/empty" });
719+
// Should NOT have called push
720+
expect(runGitMock.mock.calls.map((c) => (c[0] as string[])[0])).not.toContain("push");
721+
});
722+
723+
it("throws remote_already_exists when origin is already configured", async () => {
724+
runGitMock.mockResolvedValueOnce({
725+
exitCode: 0,
726+
stdout: "git@github.com:someone/already.git\n",
727+
stderr: "",
728+
});
729+
730+
let caught: any;
731+
try {
732+
await makeService().publishCurrentProject({ name: "x", isPrivate: true });
733+
} catch (err) {
734+
caught = err;
735+
}
736+
expect(caught).toBeInstanceOf(Error);
737+
expect(caught.code).toBe("remote_already_exists");
738+
// Must NOT have hit the API
739+
expect(mockFetch).not.toHaveBeenCalled();
740+
});
741+
742+
it("throws github_not_connected when no token is stored", async () => {
743+
delete process.env.GITHUB_TOKEN;
744+
delete process.env.ADE_GITHUB_TOKEN;
745+
746+
let caught: any;
747+
try {
748+
await makeService().publishCurrentProject({ name: "x", isPrivate: true });
749+
} catch (err) {
750+
caught = err;
751+
}
752+
expect(caught).toBeInstanceOf(Error);
753+
expect(caught.code).toBe("github_not_connected");
754+
expect(runGitMock).not.toHaveBeenCalled();
755+
expect(mockFetch).not.toHaveBeenCalled();
756+
});
757+
758+
it("surfaces a clear error when the push step fails", async () => {
759+
runGitMock
760+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" })
761+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" })
762+
.mockResolvedValueOnce({ exitCode: 0, stdout: "abc\n", stderr: "" })
763+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "Authentication failed" });
764+
765+
mockFetch.mockResolvedValueOnce(
766+
jsonResponse(201, {
767+
clone_url: "https://github.com/alice/proj.git",
768+
ssh_url: "git@github.com:alice/proj.git",
769+
html_url: "https://github.com/alice/proj",
770+
default_branch: "main",
771+
}),
772+
);
773+
774+
await expect(
775+
makeService().publishCurrentProject({ name: "proj", isPrivate: true }),
776+
).rejects.toThrow(/authentication failed/i);
777+
});
778+
});

apps/desktop/src/main/services/github/githubService.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function detectGitHubTokenType(token: string): GitHubStatus["tokenType"] {
1717
return "unknown";
1818
}
1919

20-
function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | null {
20+
export function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | null {
2121
const remoteUrl = remoteUrlRaw.trim();
2222
if (!remoteUrl) return null;
2323

@@ -48,7 +48,7 @@ function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | nul
4848
return null;
4949
}
5050

51-
function parseNextLink(linkHeader: string | null): string | null {
51+
export function parseNextLink(linkHeader: string | null): string | null {
5252
if (!linkHeader) return null;
5353
for (const part of linkHeader.split(",")) {
5454
const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/);
@@ -644,6 +644,80 @@ export function createGithubService({
644644
return data ?? null;
645645
};
646646

647+
const createRepository = async (args: {
648+
name: string;
649+
description?: string;
650+
isPrivate: boolean;
651+
}): Promise<{ cloneUrl: string; sshUrl: string; htmlUrl: string; defaultBranch: string }> => {
652+
const body: Record<string, unknown> = {
653+
name: args.name,
654+
private: args.isPrivate,
655+
auto_init: false,
656+
};
657+
if (args.description != null && args.description.trim().length > 0) {
658+
body.description = args.description.trim();
659+
}
660+
const { data } = await apiRequest<Record<string, unknown>>({
661+
method: "POST",
662+
path: "/user/repos",
663+
body,
664+
});
665+
return {
666+
cloneUrl: asString(data.clone_url),
667+
sshUrl: asString(data.ssh_url),
668+
htmlUrl: asString(data.html_url),
669+
defaultBranch: asString(data.default_branch) || "main",
670+
};
671+
};
672+
673+
const publishCurrentProject = async (
674+
args: { name: string; description?: string; isPrivate: boolean },
675+
): Promise<{ state: "pushed" | "remote_added"; htmlUrl: string }> => {
676+
if (!readStoredToken()) {
677+
const err = new Error("GitHub is not connected. Add a token in Settings.") as Error & { code?: string };
678+
err.code = "github_not_connected";
679+
throw err;
680+
}
681+
682+
const existingRemote = await runGit(["remote", "get-url", "origin"], { cwd: projectRoot, timeoutMs: 8_000 });
683+
if (existingRemote.exitCode === 0 && existingRemote.stdout.trim().length > 0) {
684+
const err = new Error("This project already has a GitHub remote named 'origin'.") as Error & { code?: string };
685+
err.code = "remote_already_exists";
686+
throw err;
687+
}
688+
689+
const created = await createRepository({
690+
name: args.name,
691+
description: args.description,
692+
isPrivate: args.isPrivate,
693+
});
694+
695+
const remoteAddRes = await runGit(["remote", "add", "origin", created.cloneUrl], {
696+
cwd: projectRoot,
697+
timeoutMs: 8_000,
698+
});
699+
if (remoteAddRes.exitCode !== 0) {
700+
throw new Error(`Failed to add origin remote: ${remoteAddRes.stderr.trim() || `exit ${remoteAddRes.exitCode}`}`);
701+
}
702+
703+
const headRes = await runGit(["rev-parse", "--verify", "HEAD"], { cwd: projectRoot, timeoutMs: 5_000 });
704+
let resultState: "pushed" | "remote_added";
705+
if (headRes.exitCode === 0) {
706+
const pushRes = await runGit(["push", "-u", "origin", "HEAD"], { cwd: projectRoot, timeoutMs: 5 * 60_000 });
707+
if (pushRes.exitCode !== 0) {
708+
throw new Error(`Failed to push to origin: ${pushRes.stderr.trim() || `exit ${pushRes.exitCode}`}`);
709+
}
710+
resultState = "pushed";
711+
} else {
712+
resultState = "remote_added";
713+
}
714+
715+
cachedStatus = null;
716+
cachedAt = 0;
717+
718+
return { state: resultState, htmlUrl: created.htmlUrl };
719+
};
720+
647721
return {
648722
getStatus,
649723

@@ -675,6 +749,12 @@ export function createGithubService({
675749

676750
detectRepo,
677751
apiRequest,
752+
parseNextLink,
753+
parseGitHubRepoFromRemoteUrl,
754+
755+
// Repo creation + publish
756+
createRepository,
757+
publishCurrentProject,
678758

679759
// Polling/picker read helpers
680760
listRepoLabels,

0 commit comments

Comments
 (0)