diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 4b4df71..56fc410 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -28,13 +28,21 @@ import { Channel, ChannelDetails, type ChannelDraft } from "./channel"; import { Dashboard } from "./dashboard"; import { Github, GithubSidebar } from "./github"; import { useGithubRefresh } from "./github-cache"; -import { desktop, titlebar } from "./desktop"; +import { desktop, here, titlebar } from "./desktop"; import { host } from "./host"; import { Rename } from "./layout/rename"; import { Navigation, type Page, WindowControls } from "./navigation"; import { EmptyProjects, OpenProject } from "./open-project"; -import { type AppProject, channelProject, label, projectId, projects } from "./projects"; +import { + type AppProject, + channelProject, + checkoutId, + label, + projectId, + projects, +} from "./projects"; import { Settings } from "./settings"; +import { SetupProject } from "./setup-project"; import { UpdateNotice } from "./updates"; // Older versions saved an inline GitHub item per project; Ace now opens items on GitHub. @@ -201,6 +209,11 @@ export function App() { {}, ); const [adding, setAdding] = useState(false); + /** A project to clone here; `start` continues into channel creation with its initial text. */ + const [setup, setSetup] = useState<{ + project: AppProject & { repo: string }; + start?: { text?: string; onCreated?: () => void }; + }>(); const [settings, setSettings] = useState(false); const [draft, setDraft] = useState(); const [renaming, setRenaming] = useState<{ id: string; name: string }>(); @@ -268,6 +281,8 @@ export function App() { ); const connected = status === "open" && !!hello.host; const local = current?.host === hello.host; + // GitHub projects can be cloned here; other folders exist only on the hosts that hold them. + const creatable = local || !!current?.repo; const repos: SessionSidebarRepo[] = [ { id: ALL_PROJECTS, @@ -304,7 +319,7 @@ export function App() { rows, sort: "none", collapsed: collapsed[id], - onNewSession: connected && value.host === hello.host + onNewSession: connected && (value.host === hello.host || value.repo) ? () => void create(undefined, undefined, value) : undefined, }; @@ -348,12 +363,38 @@ export function App() { } } + function startSetup(project: AppProject, start?: { text?: string; onCreated?: () => void }) { + if (project.repo) setSetup({ project: { ...project, repo: project.repo }, start }); + } + + /** Channels only start on this host; setup may clone here first, never on the other host. */ + async function finishSetup(path: string) { + if (!setup) return; + const value = await host.request({ + op: "project-clone", + repo: setup.project.repo, + path, + }); + const id = projectId(hello.host, value.path, value.repo); + setSetup(undefined); + if (!setup.start) return chooseProject(id); + const target: AppProject = { + ...value, + id, + host: hello.host, + hosts: new Set([hello.host]), + checkouts: new Set([checkoutId(hello.host, value.path)]), + }; + await create(setup.start.text, setup.start.onCreated, target); + } + async function create( text?: string, onCreated?: () => void, target = current, ): Promise { - if (!target || target.host !== hello.host || !connected || creating.current) return; + if (!target || !connected || creating.current) return; + if (target.host !== hello.host) return startSetup(target, { text, onCreated }); creating.current = true; try { const value = await host.request>({ @@ -475,7 +516,7 @@ export function App() { const value = listings.get(item.uid); if (value) select(value); }} - onNewSession={!allProjects && local && connected + onNewSession={!allProjects && creatable && connected ? () => void create() : undefined} onRename={connected @@ -549,6 +590,7 @@ export function App() { channels={visible} local={local} connected={connected} + onSetup={!local && current.repo ? () => startSetup(current) : undefined} onProject={chooseProject} onOpen={() => void choose()} onChannel={select} @@ -562,7 +604,7 @@ export function App() { kind={page} project={current} connected={connected} - onCreate={local ? create : undefined} + onCreate={creatable ? create : undefined} /> ) : channel @@ -590,27 +632,44 @@ export function App() { Use + beside a project to start a new channel.

)} - {!allProjects && local && ( - - )} {!allProjects && !local && ( -

- {current.name} is checked out on{" "} - {[...current.hosts].join(", ")}. Open a checkout on this host to start - channels here. +

+ {current.name} is checked out on {[...current.hosts].join(", ")}.{" "} + {current.repo + ? `Set it up on ${here} to start channels here.` + : "Open a checkout on this host to start channels here."}

)} + {!allProjects && creatable && ( +
+ {!local && ( + + )} + +
+ )} )} {page === "channels" && channel && } {adding && setAdding(false)} />} + {setup && ( + setSetup(undefined)} + /> + )} {renaming && ( store.getItem(key) !== null)) continue; + for (const checkout of project.checkouts) { + const legacy = `ace:${kind}:/projects/${checkout}/dashboard`; + const store = legacy !== key && stores.find((store) => store.getItem(legacy) !== null); + if (!store) continue; + // A failed write throws before the legacy copy is removed. + store.setItem(key, store.getItem(legacy)!); + store.removeItem(legacy); + break; + } + } catch { + // Unavailable storage leaves both scopes as they were. + } + } + return scope; +} const MODES = [{ id: "ace", name: "Ace", placeholder: "Start a new channel" }]; export function Dashboard( - { project, repos, channels, local, connected, onProject, onOpen, onChannel, onCreate }: { + { project, repos, channels, local, connected, onSetup, onProject, onOpen, onChannel, onCreate }: { project: AppProject; repos: SessionSidebarRepo[]; channels: Listing[]; local: boolean; connected: boolean; + /** Offered for a GitHub project with no checkout on this host. */ + onSetup?: () => void; onProject: (id: string) => void; onOpen: () => void; onChannel: (channel: Listing) => void; @@ -30,6 +61,8 @@ export function Dashboard( ) { const composer = useRef(null); const [busy, setBusy] = useState(false); + const [scope] = useState(() => adopt(project)); + const creatable = local || !!onSetup; const active = channels.filter((channel) => channel.state !== "archived") .sort((a, b) => b.created - a.created); const hour = new Date().getHours(); @@ -74,17 +107,23 @@ export function Dashboard(

{local ? `What would you like to work on in ${project.name}?` - : `Catch up on ${project.name}’s channels on ${ - [...project.hosts].join(", ") - }. Open a checkout on this host to start channels here.`} + : `Catch up on ${project.name}’s channels on ${[...project.hosts].join(", ")}. ${ + onSetup + ? `Set it up on ${here} to start channels here.` + : "Open a checkout on this host to start channels here." + }`}

+ {onSetup && ( + + )} - {local && ( + {creatable && (

{active.length ? "Pick back up" : "Channels"}

- {local && ( + {creatable && ( + + + + + ); +} diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 0a22230..f3e5e63 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -202,6 +202,7 @@ const moveOps = new Set(["move-targets", "move"]); const localOps = new Set([ "projects", "project-open", + "project-clone", "github-list", "github-detail", "github-files", @@ -306,6 +307,11 @@ async function handle( projectsChanged(); return project; } + case "project-clone": { + const project = await projects.setup(request.repo, request.path); + projectsChanged(); + return project; + } case "project-repo": { if (request.host && request.host !== name && !client.peer) { return (await remote(client, request.host)).request( diff --git a/apps/host/src/github.ts b/apps/host/src/github.ts index c39a06d..06724ca 100644 --- a/apps/host/src/github.ts +++ b/apps/host/src/github.ts @@ -66,7 +66,7 @@ type File = { patch?: string; }; -function repository(value: string): string { +export function repository(value: string): string { if (!REPO.test(value) || [".", ".."].includes(value.split("/")[1]!)) { throw new Error("Choose a GitHub repository in owner/name format"); } @@ -112,6 +112,46 @@ async function gh(args: string[]): Promise { return JSON.parse(out) as T; } +/** + * A plain Git clone, so public repositories need no GitHub account and organization SSO cannot + * block them. Private ones use this host's existing Git credential helpers, with the token gh + * would see. Git never prompts, since the helper has no terminal. Ace never removes anything at + * `path`; only Git cleans up what its own failed clone created. + */ +export async function clone(repo: string, path: string): Promise { + const child = Bun.spawn([ + "git", + "clone", + "--", + `https://github.com/${repository(repo)}.git`, + path, + ], { + env: { ...process.env, ...githubEnv(), GIT_TERMINAL_PROMPT: "0" }, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + timeout: 30 * 60_000, + }); + const [error, code] = await Promise.all([new Response(child.stderr).text(), child.exited]); + if (code === 0) return; + if (child.signalCode) throw new Error("Cloning took too long and was stopped. Try again."); + const detail = error.trim().split("\n").filter((line) => !line.startsWith("Cloning into")) + .slice(-4).join("\n"); + if (/\bSAML\b|\bSSO\b|single sign-on/i.test(error)) { + throw new Error( + `${ + repo.split("/")[0] + } requires single sign-on for this repository. Authorize this host's Git credentials for the organization on GitHub, then try again.\n${detail}`, + ); + } + if (/terminal prompts disabled|could not read Username|Authentication failed/i.test(error)) { + throw new Error( + `GitHub needs credentials to clone ${repo}. Set up Git credentials on this host, for example with gh auth setup-git, then try again.`, + ); + } + throw new Error(detail || `Could not clone ${repo}.`); +} + let owner: string | undefined; let checked = 0; diff --git a/apps/host/src/projects.ts b/apps/host/src/projects.ts index e708f25..6fd93fb 100644 --- a/apps/host/src/projects.ts +++ b/apps/host/src/projects.ts @@ -1,7 +1,9 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { + existsSync, mkdirSync, + readdirSync, readFileSync, realpathSync, renameSync, @@ -10,11 +12,11 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { basename, join, resolve } from "node:path"; +import { basename, isAbsolute, join, resolve } from "node:path"; import * as catalog from "./catalog"; import { config } from "./config"; -import { remote, REMOTES } from "./github"; +import { clone, remote, REMOTES, repository } from "./github"; import type { Project } from "./protocol"; type Checkout = { root: string; repo?: string }; @@ -70,14 +72,12 @@ export function list(): Project[] { return [...values.values()]; } +const expand = (value: string) => + value === "~" ? homedir() : value.startsWith("~/") ? homedir() + value.slice(1) : value; + /** Opening a folder must not require model credentials or create a channel. */ export function open(value: string): Project { - const expanded = value === "~" - ? homedir() - : value.startsWith("~/") - ? homedir() + value.slice(1) - : value; - let path = realpathSync(resolve(expanded)); + let path = realpathSync(resolve(expand(value))); if (!statSync(path).isDirectory()) throw new Error("Choose a folder to open as a project"); path = realpathSync(checkout(path).root); const opened = paths(); @@ -95,3 +95,43 @@ export function open(value: string): Project { } return project(path); } + +const cloning = new Set(); + +/** + * Sets up a known GitHub project on this host: clones it into `value`, or opens the checkout of + * it already there. Anything else at `value` is left untouched. + */ +export async function setup(repo: string, value: string): Promise { + repository(repo); + const expanded = expand(value.trim()); + if (!isAbsolute(expanded)) throw new Error("Enter a full folder path, such as ~/code/project"); + const path = resolve(expanded); + if (cloning.has(path)) throw new Error("This folder is already being set up"); + cloning.add(path); + try { + if (existsSync(path)) { + if (!statSync(path).isDirectory()) throw new Error(`${path} is a file. Choose a folder.`); + const real = realpathSync(path); + checkouts.delete(real); + const found = existsSync(join(real, ".git")) ? checkout(real).repo : undefined; + if (found?.toLowerCase() === repo.toLowerCase()) { + // An interrupted clone already names its remote but has no commit to work from. + const head = spawnSync("git", ["-C", real, "rev-parse", "--verify", "-q", "HEAD"]); + if (head.status === 0) return open(real); + throw new Error( + `${path} has an incomplete clone of ${repo}. Remove it or choose another folder.`, + ); + } + if (found) throw new Error(`${path} is a checkout of ${found}. Choose another folder.`); + if (readdirSync(real).length) { + throw new Error(`${path} already has other files. Choose an empty or new folder.`); + } + } + await clone(repo, path); + checkouts.delete(realpathSync(path)); + return open(path); + } finally { + cloning.delete(path); + } +} diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 8d91c89..770a6c4 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -130,6 +130,11 @@ export type HostRequest = | { op: "channels" } | { op: "projects" } | { op: "project-open"; path: string } + /** + * Clone a known GitHub project into `path` on this host, or open the matching checkout already + * there, then add it like `project-open`. The host expands `~`. + */ + | { op: "project-clone"; repo: string; path: string } /** Resolve a known project's GitHub remote on the machine that holds its checkout. */ | { op: "project-repo"; project: string; host?: string } /** GitHub reads use the local owner's GitHub CLI credentials, never a peer's account. */ diff --git a/docs/architecture.md b/docs/architecture.md index 2242240..19815cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,9 +36,14 @@ keeps that grouped view; channel creation and GitHub pages retain a concrete pro The app identifies a project with a GitHub remote by its repository, compared case-insensitively, so checkouts of it on any host are one project; a folder without one is its host and main checkout. -A project keeps every known checkout. Creating channels and archiving inactive ones use this host's -checkout, so a project checked out only on teammates' hosts lists their channels but cannot start -one here until a checkout is opened. Its Channels sidebar shows the person's own channels, then one +A project keeps every known checkout. A GitHub project shows its name wherever it is checked out; +a folder without one on another host is labeled with that host. Creating channels and archiving +inactive ones use this host's checkout. A GitHub project checked out only on teammates' hosts lists +their channels, and its channel creation entry points first offer to set it up on this machine: +the person confirms or edits a destination, `~/code/` by default, before anything is cloned. +A creation that led to setup continues here with its initial text once the project is added; +standalone setup only adds the project. The dashboard draft belongs to the project, so it +survives setup until a channel admits it. Channels never start on the other host. Its Channels sidebar shows the person's own channels, then one section per teammate across all of their hosts, with that teammate's archived channels in a folded section of their own. Saved host-and-checkout selections from earlier versions resolve to the merged project. This is client navigation only; channel records and listings are unchanged. @@ -146,6 +151,12 @@ Opening a folder adds a project to the host's `projects.json`, independently of or model credentials. This is a list of local folders, not channel history. Existing catalog channels also contribute their project paths. Only local owner connections can read or change the opened-project list; teammates see the project information already present in channel listings. +Setting up a known GitHub project is a local-only operation on that list: the host validates the +`owner/name`, expands `~`, and runs `git clone` of `https://github.com//.git` with +argument arrays. Public repositories therefore need no GitHub account, and an organization's SSO +policy on the gh token cannot block them; private ones use the host's existing Git credential +helpers. A destination that already holds a complete checkout of that repository is opened instead; +any other non-empty folder is refused, and Ace never removes what a failed clone leaves behind. A channel's project path is the checkout it works in: its agents' working directory and the base of its lanes. That checkout may be a Git worktree, such as a lane or another tool's worktree. The