diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index cbec85d6..2c77edf0 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -410,7 +410,8 @@ and decision inbox. It links the registered App's installation URL and lists the repositories to pick from: those the App's installations reach and the user administers or maintains, each with its contribution state, an Enable button, or why it cannot be enabled yet (archived, private, no Quest files, -or no OSI-approved license). A repository without Quest files links `/setup`. +or no OSI-approved license). A repository without Quest files enables through +its [setup pull request](#setup-pull-request). The picker is navigation, built from the user's own GitHub App token so its cost does not grow with an installation's size: one read of the user's @@ -445,8 +446,9 @@ external request per file. Blob IDs come from the pinned Git tree; missing, binary, truncated, partial, or hash-mismatched content fails closed. This keeps enrollment's external request count independent of the number of quest files. It rejects private, -unavailable, incomplete, invalid, and symlinked quest snapshots. Repositories -without Quest files follow `/setup`; no repository writes occur during setup. +unavailable, incomplete, invalid, and symlinked quest snapshots. A repository +without Quest files gets the App's [setup pull request](#setup-pull-request) +instead; nothing else in enrollment writes to the repository. The D1 enrollment records a project-wide enabled/paused state, distinct from installation permissions. Every opt-in/resume, pause, and disconnect rechecks @@ -496,6 +498,50 @@ See the official [GitHub license API](https://docs.github.com/en/rest/licenses/l [GraphQL blobs](https://docs.github.com/en/graphql/reference/git#blob), and [App metadata API](https://docs.github.com/en/rest/apps/apps#get-the-authenticated-app). +### Setup pull request + +On a repository whose default branch has no Quest files, **Enable** opens a +short form holding one optional question: what the project is working toward. +Submitting it opens the App's setup pull request, with no runner involved +(`src/github/setup.ts`). The core's `planInit`, the same file plan +`quest init` applies to a checkout, plans it from the default branch's tree: +the skill stubs this deployment's `quest` ships (rendered into +`build/stubs.json` at build time), `quest/README.md` with the answer as its +Goal, the `Quests: ` line in the root agent instructions, the `.gitattributes` +line, and Claude Code's hook entries in `.claude/settings.json`. Heading +syntax in the answer is escaped, and the planned `quest/README.md` must pass +`quest check`, so a goal that would fail it, such as one linking a missing +quest, is refused before anything is written instead of when enabling. A path init would refuse, such as a skill +directory that is not a Quest stub or invalid settings JSON, is named on the +page and nothing is written; kix never deletes or renames a repository's +files. The plan does not follow symlinks, since a Git tree stores their targets +as data: one on a planned path is refused the same way, pointing at running +`quest init` in a clone. + +The App publishes the plan as for job branches: blobs, a tree, and a commit +through the Git Data API on `kix/setup`, outside the quest claim namespace, +then a ready pull request against the default branch. Its body lists each +change and what it leaves out, because those steps need judgement about the +repository: pinning `quest` in its tool manager and adapting existing +instructions. kix's jobs need neither, since runners bring their own `quest`. +Without a `flake.nix`, the body says the `bootstrap` job adds one after the +merge. + +Until the pull request merges the project is enrolled, setting up +(`github_project_setups`), which the project page and picker show with the pull +request's link. Every open, retry, and enable rechecks the maintainer's +authority, the repository's visibility, and its license live. A `pull_request` +webhook that closes or reopens it only asks the service to look: it rereads the +pull request and, once merged, enables contributions through the same live +checks as **Enable**, including `quest check` on the merged tree, with the +enroller's authority, so no second visit is needed. The project page reconciles +the same way when a webhook was missed, and keeps the reason when enabling +fails. A pull request closed without merging leaves the project setting up, +offering to open it again from the current default branch or to disconnect. +Opening again replaces `kix/setup` only while it is still at a commit the App +recorded writing there; a branch with anyone else's commits is refused, naming +it. Disconnecting leaves the pull request and its branch on GitHub. + The setup preview does not reserve or launch ready quests. The jobs/funding quests own live eligibility checks and contribution execution. diff --git a/cloudflare/README.md b/cloudflare/README.md index 250ba503..106aa4fe 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -27,7 +27,10 @@ renders the repository's `docs/*.md` into `build/docs.json`, so the Markdown stays the single source and its relative links keep working on GitHub: links to other docs become `/docs/` and other repository paths point at GitHub. It also snapshots this repository's `quest/` tree and finished quests into -`build/board.json`, which supplies the public board. Enrolled repositories use authenticated +`build/board.json`, which supplies the public board. It renders the skill +stubs `quest init` installs into `build/stubs.json`, for the setup pull request +a repository without Quest files gets when enrolling +([GITHUB.md](GITHUB.md#setup-pull-request)). Enrolled repositories use authenticated GitHub snapshots through the same board model. A shallow clone has no history, so its board shows no finished work. It also copies `design/theme/`, `src/site.css`, the scripts, and the web app manifest into `build/public/`, diff --git a/cloudflare/bunfig.toml b/cloudflare/bunfig.toml index 052751ff..132f7c0e 100644 --- a/cloudflare/bunfig.toml +++ b/cloudflare/bunfig.toml @@ -1,3 +1,7 @@ +# The bundled skills import as text, as in the root bunfig.toml, so the build can render their stubs. +[loader] +".md" = "text" + # Scripts run on Bun even when a tool declares a Node shebang, as in the root bunfig.toml. [run] bun = true diff --git a/cloudflare/migrations/0021_project_setups.sql b/cloudflare/migrations/0021_project_setups.sql new file mode 100644 index 00000000..a53058d8 --- /dev/null +++ b/cloudflare/migrations/0021_project_setups.sql @@ -0,0 +1,23 @@ +-- A repository without Quest files that a maintainer chose to enroll: the +-- App's setup pull request, which writes what `quest init` would. The project +-- is enrolled, setting up, until that pull request merges, when the project +-- moves to github_projects as enabled and this row goes. A closed pull request +-- leaves the row, offering a retry or a disconnect. It never grants authority: +-- opening, retrying, and enabling each recheck GitHub live. +CREATE TABLE github_project_setups ( + id INTEGER PRIMARY KEY NOT NULL, + owner TEXT NOT NULL, + name TEXT NOT NULL, + installation INTEGER NOT NULL, + -- The branch's commit kix last published, and one it is moving the branch to, + -- so a retry recognizes kix's own branch after an interrupted update. + head TEXT, + pending TEXT, + pull INTEGER, + state TEXT NOT NULL CHECK(state IN ('open','closed')), + -- Why the merged setup could not enable contributions, until a retry succeeds. + failure TEXT, + enrolledBy TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + updatedAt INTEGER NOT NULL +); +CREATE INDEX github_project_setups_user ON github_project_setups(enrolledBy); diff --git a/cloudflare/scripts/build.ts b/cloudflare/scripts/build.ts index 74a72840..a3f1e1e1 100644 --- a/cloudflare/scripts/build.ts +++ b/cloudflare/scripts/build.ts @@ -1,6 +1,7 @@ // Build-time inputs for the Worker, written to build/ (ignored by Git): // - build/docs.json: the repository's docs/*.md rendered to HTML. // - build/board.json: this repository's quest tree and finished quests, for the board. +// - build/stubs.json: the skill stubs `quest init` installs, for the setup pull request. // - build/public/: static assets: the theme from design/theme/, src/site.css, the // scripts src/passkey.js, src/app.js, and the service worker src/sw.js, and the // web app manifest. @@ -10,6 +11,7 @@ import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } f import { join, posix } from "node:path"; import { Lexer, Marked, type Token, type Tokens } from "marked"; import type { Snapshot } from "../src/board/project"; +import { SKILLS } from "../../src/cli/skills.ts"; import { LOG, finished, isQuest } from "./history.ts"; const repo = "https://github.com/moq-dev/quest"; @@ -113,6 +115,7 @@ rmSync(build, { recursive: true, force: true }); mkdirSync(join(build, "public"), { recursive: true }); writeFileSync(join(build, "docs.json"), JSON.stringify(rendered)); writeFileSync(join(build, "board.json"), JSON.stringify(snapshot)); +writeFileSync(join(build, "stubs.json"), JSON.stringify(SKILLS.map(({ installed, stub }) => ({ installed, stub })))); cpSync(join(design, "theme"), join(build, "public", "theme"), { recursive: true }); cpSync(join(worker, "src", "site.css"), join(build, "public", "site.css")); for (const file of ["passkey.js", "app.js", "sw.js", "manifest.webmanifest"]) diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 5942396d..4e4a550d 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -1,8 +1,20 @@ import { createHash, createPrivateKey, sign } from "node:crypto"; -import { HOLD_PREFIX, isHoldLabel, latestHold, heldToken, check, isQuest, parse, ready } from "quest/core"; +import { + HOLD_PREFIX, + InitRefused, + isHoldLabel, + latestHold, + heldToken, + check, + isQuest, + parse, + planInit, + ready, +} from "quest/core"; import { cleanAutoMergeRefusal, immediateMerge, queueHead } from "../../../src/github/merge"; import { osiApproved } from "../licenses"; import { REVIEW_CHECK, unreviewed } from "./review"; +import { GOAL_LIMIT, SETUP_BRANCH, STUBS, planCheck, planned, setupGoal, setupPull, treeCheckout } from "./setup"; import { positiveId } from "./webhook"; import { activityQuery, @@ -89,6 +101,13 @@ export class BranchChanged extends PublicationBlocked {} /** A maintainer check that was answered with a denial, rather than one that could not complete. */ export class NotMaintainer extends GitHubError {} +/** The default branch has no Quest files, so the project sets up Quest before enabling. */ +export class NoQuestFiles extends GitHubError { + constructor() { + super("Quest files are required. Open the setup pull request first."); + } +} + /** One file a publication sets or, with null content, deletes. */ /** One path's new content, or null to delete it, with its Git mode: a file, an executable, or a symlink. */ export type Change = { path: string; content: Uint8Array | null; mode: "100644" | "100755" | "120000" }; @@ -131,8 +150,11 @@ export type ContributionState = "enabled" | "paused"; export type Enrollment = GitHubRepository & { state: ContributionState }; /** A repository the App reaches and the user maintains, as the project picker lists it. */ export type Candidate = GitHubRepository & { - /** Its contribution state under its current installation, or null when it is not enrolled there. */ - state: ContributionState | null; + /** + * Its contribution state under its current installation, `setup` while its + * setup pull request is pending, or null when it is not enrolled there. + */ + state: ContributionState | "setup" | null; /** Why contributions cannot be enabled yet; empty when enabling may go on to its live checks. */ problems: string[]; /** Whether its default branch has no Quest files, so setting up Quest comes first. */ @@ -140,6 +162,31 @@ export type Candidate = GitHubRepository & { }; /** A running job attempt whose project was disconnected, which its runner must stop. */ export type Stopped = { actor: string; runner: string; job: string; attempt: number }; +type SetupRow = { + id: number; + owner: string; + name: string; + installation: number; + head: string | null; + pending: string | null; + pull: number | null; + state: "open" | "closed"; + failure: string | null; + enrolledBy: string; +}; +/** + * A project enrolled, setting up: the App's setup pull request, until it + * merges. A null `pull` is a setup whose pull request was not opened yet. + * `failure` is why a merged setup could not enable contributions. + */ +export type ProjectSetup = GitHubRepository & { + pull: number | null; + state: "open" | "closed"; + failure: string | null; +}; +function setupOf(row: SetupRow): ProjectSetup { + return { id: row.id, owner: row.owner, name: row.name, pull: row.pull, state: row.state, failure: row.failure }; +} export type ProjectPreview = { repository: GitHubRepository; branch: string; @@ -480,6 +527,15 @@ export class GitHubService { .all<{ id: number; installation: number; state: ContributionState }>() ).results.map((row) => [row.id, row]), ); + const setups = new Set( + ( + await this.#env.DB.prepare( + "SELECT id FROM github_project_setups WHERE id IN (SELECT value FROM json_each(?))", + ) + .bind(JSON.stringify(found.map(({ repo }) => repo.id))) + .all<{ id: number }>() + ).results.map((row) => row.id), + ); const candidates = await Promise.all( found.map(async ({ repo, installation }): Promise => { const problems: string[] = []; @@ -505,7 +561,7 @@ export class GitHubService { id: repo.id, owner: repo.owner.login, name: repo.name, - state: row?.installation === installation ? row.state : null, + state: row?.installation === installation ? row.state : setups.has(repo.id) ? "setup" : null, problems, setup, }; @@ -635,12 +691,11 @@ export class GitHubService { const root = await this.#api(token, `${path}/git/trees/${sha(commit.commit.tree.sha)}?recursive=1`); if (root.truncated) throw new GitHubError("Repository tree is incomplete"); const quest = root.tree.find((entry) => entry.path === "quest" && entry.type === "tree"); - if (!quest) throw new GitHubError("Quest files are required. Follow the existing Quest setup guide first."); + if (!quest) throw new NoQuestFiles(); const entries = root.tree.filter( (entry) => entry.path.startsWith("quest/") && isQuest(entry.path) && entry.type === "blob", ); - if (!entries.length) - throw new GitHubError("Quest files are required. Follow the existing Quest setup guide first."); + if (!entries.length) throw new NoQuestFiles(); // Read the pinned blobs together: a REST request per file can exhaust a // Worker's subrequest budget during enrollment even for a small quest tree. const fields = entries.map((entry, i) => { @@ -1166,21 +1221,8 @@ export class GitHubService { state === "enabled" ? { contents: "read" } : {}, async (token, current, installation) => { if (state === "enabled") { - const preview = await this.#preview(token, repo, current, installation); - if (preview.licenseError) throw new GitHubError(preview.licenseError); - // The repository or license can change during content reads. Check - // current eligibility again, rather than persisting the preview's grant. - const latest = await this.#api(token, repositoryPath(repo)); - this.#repository(repo, latest); - this.#public(latest); - if ( - latest.default_branch !== current.default_branch || - latest.license?.spdx_id !== current.license?.spdx_id - ) - throw new GitHubError("Repository eligibility changed; review this project again"); - const licenseError = await this.#license(latest); - if (licenseError) throw new GitHubError(licenseError); - await this.#maintainer(token, repo, userId); + await this.#enable(token, repo, current, installation, userId, userId); + return []; } // Recheck the local revocation gate after potentially long content reads. await this.#gate(installation); @@ -1195,37 +1237,340 @@ export class GitHubService { "INSERT OR IGNORE INTO output_cleanup(job,at) SELECT DISTINCT a.job,? FROM job_output_attempts a JOIN jobs j ON j.id=a.job WHERE j.repository=?", ).bind(Date.now(), repo.id), this.#env.DB.prepare("DELETE FROM github_projects WHERE id=?").bind(repo.id), + // A project still setting up has only this; its pull request stays as it is. + this.#env.DB.prepare("DELETE FROM github_project_setups WHERE id=?").bind(repo.id), ]); return running!.results; } - if (state === "paused") { - await this.#env.DB.prepare("UPDATE github_projects SET state='paused',updatedAt=? WHERE id=?") - .bind(Date.now(), repo.id) - .run(); - } else { - await this.#env.DB.batch([ - this.#env.DB.prepare( - "DELETE FROM github_project_state WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?)", - ).bind(repo.id, repo.id, installation), - this.#env.DB.prepare( - "DELETE FROM github_project_activity WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?)", - ).bind(repo.id, repo.id, installation), - this.#env.DB.prepare( - `INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) VALUES(?,?,?,?,'enabled',?,?) - ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,name=excluded.name,installation=excluded.installation,state=excluded.state,updatedAt=excluded.updatedAt`, - ).bind(repo.id, repo.owner, repo.name, installation, userId, Date.now()), - // The project did not exist when the check above ran. - this.#env.DB.prepare( - "INSERT INTO github_project_members(repository,user) VALUES(?,?) ON CONFLICT DO NOTHING", - ).bind(repo.id, userId), - ]); - } + await this.#env.DB.prepare("UPDATE github_projects SET state='paused',updatedAt=? WHERE id=?") + .bind(Date.now(), repo.id) + .run(); return []; }, state !== "enabled", ); } + /** + * Enable contributions after checking eligibility live, enrolling the + * project as `enroller`'s and making `userId`, whose authority the caller + * checked, a member. A project setting up leaves that state. With `pull`, + * as when its setup pull request merged, it enables only while that setup + * is still recorded, so a disconnect or a pause made during the live checks + * stands; it returns whether it enabled. + */ + async #enable( + token: string, + repo: GitHubRepository, + current: Repository, + installation: number, + userId: string, + enroller: string, + pull?: number, + ): Promise { + const preview = await this.#preview(token, repo, current, installation); + if (preview.licenseError) throw new GitHubError(preview.licenseError); + // The repository or license can change during content reads. Check + // current eligibility again, rather than persisting the preview's grant. + const latest = await this.#api(token, repositoryPath(repo)); + this.#repository(repo, latest); + this.#public(latest); + if (latest.default_branch !== current.default_branch || latest.license?.spdx_id !== current.license?.spdx_id) + throw new GitHubError("Repository eligibility changed; review this project again"); + const licenseError = await this.#license(latest); + if (licenseError) throw new GitHubError(licenseError); + await this.#maintainer(token, repo, userId); + // Recheck the local revocation gate after potentially long content reads. + await this.#gate(installation); + // Every write checks the setup in the same transaction, before the last one removes it. + const [guard, args] = + pull === undefined + ? ["1", []] + : ["EXISTS(SELECT 1 FROM github_project_setups WHERE id=? AND pull=?)", [repo.id, pull]]; + const [, , enabled] = await this.#env.DB.batch([ + this.#env.DB.prepare( + `DELETE FROM github_project_state WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?) AND ${guard}`, + ).bind(repo.id, repo.id, installation, ...args), + this.#env.DB.prepare( + `DELETE FROM github_project_activity WHERE repository=? AND EXISTS(SELECT 1 FROM github_projects WHERE id=? AND installation<>?) AND ${guard}`, + ).bind(repo.id, repo.id, installation, ...args), + this.#env.DB.prepare( + `INSERT INTO github_projects(id,owner,name,installation,state,enrolledBy,updatedAt) SELECT ?,?,?,?,'enabled',?,? WHERE ${guard} + ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,name=excluded.name,installation=excluded.installation,state=excluded.state,updatedAt=excluded.updatedAt`, + ).bind(repo.id, repo.owner, repo.name, installation, enroller, Date.now(), ...args), + // The project did not exist when the check above ran. + ...[...new Set([userId, enroller])].map((user) => + this.#env.DB.prepare( + `INSERT INTO github_project_members(repository,user) SELECT ?,? WHERE ${guard} ON CONFLICT DO NOTHING`, + ).bind(repo.id, user, ...args), + ), + pull === undefined + ? this.#env.DB.prepare("DELETE FROM github_project_setups WHERE id=?").bind(repo.id) + : this.#env.DB.prepare("DELETE FROM github_project_setups WHERE id=? AND pull=?").bind(repo.id, pull), + ]); + return enabled!.meta.changes > 0; + } + + /** The setup recorded for a repository, without checking GitHub. */ + async #setupRow(id: number) { + return this.#env.DB.prepare("SELECT * FROM github_project_setups WHERE id=?").bind(id).first(); + } + + /** + * Open the App's setup pull request on a repository with no Quest files, + * writing what `quest init` would, with `goal` as `quest/README.md`'s Goal. + * The project is then enrolled, setting up, until the pull request merges. + * Opening again after the pull request closed publishes a fresh plan on the + * same branch. A conflict `quest init` would refuse names its path, and + * nothing is written. + */ + openSetup(repo: GitHubRepository, userId: string, goal: string): Promise { + if (goal.length > GOAL_LIMIT) throw new GitHubError(`Keep the goal under ${GOAL_LIMIT} characters`); + return this.#authorized( + repo, + userId, + { contents: "write", pull_requests: "write" }, + async (token, current, installation) => { + this.#public(current); + const licenseError = await this.#license(current); + if (licenseError) throw new GitHubError(licenseError); + const enrolled = await this.#env.DB.prepare("SELECT 1 FROM github_projects WHERE id=?") + .bind(repo.id) + .first(); + if (enrolled) throw new GitHubError("This project is already enrolled"); + const row = await this.#setupRow(repo.id); + if (row?.state === "open" && row.pull !== null) return setupOf(row); + + const path = repositoryPath(repo); + const base = await this.#api<{ sha: string; commit: { tree: { sha: string } } }>( + token, + `${path}/commits/${encodeURIComponent(current.default_branch)}`, + ); + const root = await this.#api(token, `${path}/git/trees/${sha(base.commit.tree.sha)}?recursive=1`); + if (root.truncated) + throw new GitHubError( + "The repository is too large to read whole; run `quest init` in a clone instead", + ); + if (root.tree.some((entry) => entry.path.startsWith("quest/") && isQuest(entry.path))) + throw new GitHubError( + "The default branch already has Quest files, so contributions can be enabled directly", + ); + let changes; + try { + const checkout = treeCheckout(root.tree, await this.#planFiles(token, repo, root.tree)); + changes = planInit(checkout, STUBS, { goal: setupGoal(goal) }); + } catch (error) { + if (error instanceof InitRefused) throw new GitHubError(error.message); + throw error; + } + const problems = planCheck( + changes, + root.tree.map((entry) => entry.path), + ); + if (problems.length) + throw new GitHubError( + `The goal would not pass \`quest check\` in quest/README.md (${problems.join("; ")}); reword it and try again.`, + ); + const pull = setupPull(changes, { flake: root.tree.some((entry) => entry.path === "flake.nix") }); + + const branch = this.#disarming( + token, + current, + SETUP_BRANCH, + this.#branch(token, current, SETUP_BRANCH, async () => false), + ); + // Only a branch kix left there is replaced; anything else is the maintainer's. + const existing = await branch.head(); + if (existing !== null && existing !== row?.head && existing !== row?.pending) + throw new PublicationBlocked( + `The ${SETUP_BRANCH} branch already exists with commits the App did not write. Delete or rename it on GitHub, then try again.`, + ); + const files = changes.map((change) => ({ + path: change.path, + content: change.content === null ? null : new TextEncoder().encode(change.content), + mode: "100644" as const, + })); + const message = + "chore: set up Quest\n\nWrites what `quest init` installs: skill stubs, quest/README.md, the agent\ninstructions' Quests line, the merge driver's .gitattributes line, and\nClaude Code's worktree hooks.\n"; + // A retry builds on the default branch's files, but keeps kix's earlier commit as a parent, + // so the branch only fast-forwards: a push since the check above makes it fail rather than vanish. + const commit = + existing === null + ? await branch.commit(base.sha, files, message) + : await branch.commit(existing, files, message, base.sha); + const db = this.#env.DB; + // Record the commit only while the setup is still the one read above, so a disconnect, + // an enable, or another retry made during the GitHub reads stands. + const [unchanged, args] = + row === null + ? ["NOT EXISTS(SELECT 1 FROM github_project_setups WHERE id=?)", [repo.id]] + : [ + "EXISTS(SELECT 1 FROM github_project_setups WHERE id=? AND head IS ? AND pending IS ? AND pull IS ?)", + [repo.id, row.head, row.pending, row.pull], + ]; + const recorded = await db + .prepare( + `INSERT INTO github_project_setups(id,owner,name,installation,head,pending,pull,state,enrolledBy,updatedAt) + SELECT ?,?,?,?,NULL,?,NULL,'open',?,? WHERE ${unchanged} AND NOT EXISTS(SELECT 1 FROM github_projects WHERE id=?) + ON CONFLICT(id) DO UPDATE SET owner=excluded.owner,name=excluded.name,installation=excluded.installation,pending=excluded.pending,pull=NULL,state='open',failure=NULL,enrolledBy=excluded.enrolledBy,updatedAt=excluded.updatedAt`, + ) + .bind(repo.id, repo.owner, repo.name, installation, commit, userId, Date.now(), ...args, repo.id) + .run(); + if (!recorded.meta.changes) + throw new GitHubError("This project changed while its setup was being written; review it again."); + if (!(await branch.move(existing, commit))) + throw new PublicationBlocked( + `The ${SETUP_BRANCH} branch changed while the setup was being written; try again.`, + ); + await db + .prepare( + "UPDATE github_project_setups SET head=?,pending=NULL,updatedAt=? WHERE id=? AND pending=?", + ) + .bind(commit, Date.now(), repo.id, commit) + .run(); + + const pulls = `${path}/pulls`; + const label = `${current.owner.login}:${SETUP_BRANCH}`; + const opened = async () => + ( + await this.#api( + token, + `${pulls}?state=open&per_page=100&head=${encodeURIComponent(label)}`, + ) + )[0] ?? null; + let found = await opened(); + if (!found) + try { + found = await this.#api(token, pulls, "POST", { + head: SETUP_BRANCH, + base: current.default_branch, + title: pull.title, + body: pull.body, + draft: false, + }); + } catch (error) { + // A concurrent setup may have opened it first. + if (!(error instanceof GitHubError) || error.status !== 422) throw error; + found = await opened(); + if (!found) throw error; + } + else if (found.title !== pull.title || found.body !== pull.body) + await this.#api(token, `${pulls}/${number(found.number)}`, "PATCH", pull); + await db + .prepare("UPDATE github_project_setups SET pull=?,state='open',updatedAt=? WHERE id=? AND head=?") + .bind(number(found.number), Date.now(), repo.id, commit) + .run(); + // A disconnect while the pull request opened leaves it on GitHub, untracked, as a disconnect always does. + const now = await this.#setupRow(repo.id); + if (!now) throw new GitHubError("This project was disconnected while its setup was being written."); + return setupOf(now); + }, + ); + } + + /** The text of each file the setup plan may read, from one GraphQL read of their pinned blobs. */ + async #planFiles(token: string, repo: GitHubRepository, tree: Tree["tree"]) { + const entries = tree.filter((entry) => entry.type === "blob" && entry.mode !== "120000" && planned(entry.path)); + const files = new Map(); + if (!entries.length) return files; + const fields = entries.map( + (entry, i) => `q${i}: object(oid: "${sha(entry.sha)}") { ... on Blob { oid text isBinary isTruncated } }`, + ); + const result = await this.#api<{ + data?: { + repository: Record< + string, + { oid: string; text: string | null; isBinary: boolean | null; isTruncated: boolean } | null + > | null; + }; + errors?: unknown[]; + }>(token, "/graphql", "POST", { + query: `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join("\n")} } }`, + variables: { owner: repo.owner, name: repo.name }, + }); + if (result.errors?.length || !result.data?.repository) throw new GitHubError("Repository files unavailable"); + entries.forEach((entry, i) => { + const blob = result.data!.repository![`q${i}`]; + if (!blob || blob.isBinary !== false || blob.isTruncated !== false || typeof blob.text !== "string") return; + // As for the quest snapshot, a lossy conversion must not change what the plan edits. + const content = Buffer.from(blob.text, "utf8"); + const oid = createHash("sha1").update(`blob ${content.length}\0`).update(content).digest("hex"); + if (oid === entry.sha) files.set(entry.path, blob.text); + }); + return files; + } + + /** + * `repo`'s setup reconciled with its pull request, rechecking `userId`'s + * authority: null when it has none, and `enabled` when its pull request + * merged and contributions are now enabled, as its enroller asked. If + * enabling fails, the setup keeps the reason until a later reconciliation + * succeeds. With no `userId`, as from a webhook, the enroller's own + * authority is checked. + */ + async settleSetup(repo: GitHubRepository, userId?: string): Promise { + const row = await this.#setupRow(repo.id); + if (!row) return null; + const actor = userId ?? row.enrolledBy; + return this.#authorized( + repo, + actor, + { contents: "read", pull_requests: "read" }, + async (token, current, installation) => { + const db = this.#env.DB; + // Names are navigation metadata; a rename keeps the same setup. + const named = { ...row, owner: current.owner.login, name: current.name }; + if (named.owner !== row.owner || named.name !== row.name) + await db + .prepare("UPDATE github_project_setups SET owner=?,name=? WHERE id=?") + .bind(named.owner, named.name, repo.id) + .run(); + if (row.pull === null) return setupOf(named); + const pull = await this.#api( + token, + `${repositoryPath(repo)}/pulls/${number(row.pull)}`, + ); + if (pull.merged) { + try { + if (await this.#enable(token, repo, current, installation, actor, row.enrolledBy, row.pull)) + return "enabled"; + // A disconnect, a retry, or another reconciliation changed the setup meanwhile; report where it stands now. + const now = await this.#setupRow(repo.id); + if (now) return setupOf(now); + const project = await db + .prepare("SELECT 1 FROM github_projects WHERE id=?") + .bind(repo.id) + .first(); + return project ? "enabled" : null; + } catch (error) { + if (!(error instanceof GitHubError)) throw error; + await db + .prepare("UPDATE github_project_setups SET failure=?,updatedAt=? WHERE id=? AND pull=?") + .bind(error.message, Date.now(), repo.id, row.pull) + .run(); + return setupOf({ ...named, failure: error.message }); + } + } + const state = pull.state === "open" ? "open" : "closed"; + if (state !== row.state) + await db + .prepare("UPDATE github_project_setups SET state=?,updatedAt=? WHERE id=? AND pull=?") + .bind(state, Date.now(), repo.id, row.pull) + .run(); + return setupOf({ ...named, state }); + }, + ); + } + + /** The setups the user asked for that have not enabled contributions yet. */ + async setups(userId: string): Promise { + return ( + await this.#env.DB.prepare("SELECT * FROM github_project_setups WHERE enrolledBy=? ORDER BY owner,name") + .bind(userId) + .all() + ).results.map(setupOf); + } + // Reads also require authority: installations may contain private repositories. readQuest(repo: GitHubRepository, userId: string, path: string, commit: string) { if ( diff --git a/cloudflare/src/github/setup.ts b/cloudflare/src/github/setup.ts new file mode 100644 index 00000000..d6bdc551 --- /dev/null +++ b/cloudflare/src/github/setup.ts @@ -0,0 +1,161 @@ +// The setup pull request: what `quest init` writes, planned by the core from +// a repository's default branch on GitHub and published by the App, for a +// maintainer enrolling a repository that has no Quest files yet. +import { + type Checkout, + type InitChange, + InitRefused, + type Kind, + SKILL_DIRS, + type Stub, + check, + isQuest, + parse, +} from "quest/core"; +import stubs from "../../build/stubs.json"; + +/** The branch the App publishes the setup on, outside the quest claim namespace. */ +export const SETUP_BRANCH = "kix/setup"; + +/** The skill stubs this deployment's `quest` installs. */ +export const STUBS: readonly Stub[] = stubs; + +/** The longest goal the enrollment form accepts. */ +export const GOAL_LIMIT = 2000; + +export type TreeEntry = { path: string; type: string; mode: string; sha: string }; + +/** Whether the plan may read the file at `path`, so the service fetches it with the tree. */ +export function planned(path: string): boolean { + if (["AGENTS.md", "CLAUDE.md", ".gitattributes", ".claude/settings.json"].includes(path)) return true; + return SKILL_DIRS.some((dir) => { + const rest = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1).split("/") : []; + return rest.length === 2 && rest[0]!.startsWith("quest-") && rest[1] === "SKILL.md"; + }); +} + +/** + * A default branch's tree as the core's file plan reads it, with the text of + * the `planned` files in `files`. Git trees store a symlink's target as data, + * so the plan refuses to follow one rather than guess where it leads. + */ +export function treeCheckout(entries: TreeEntry[], files: Map): Checkout { + const byPath = new Map(entries.map((entry) => [entry.path, entry])); + const lstat = (path: string): Kind | null => { + const entry = byPath.get(path); + if (!entry) return null; + if (entry.mode === "120000") return "symlink"; + return entry.type === "tree" ? "dir" : entry.type === "blob" ? "file" : "other"; + }; + const stat = (path: string) => { + const parts = path.split("/"); + for (let i = 1; i <= parts.length; i++) { + const prefix = parts.slice(0, i).join("/"); + if (lstat(prefix) === "symlink") + throw new InitRefused( + prefix, + `${prefix} is a symbolic link, which the App cannot follow; run \`quest init\` in a clone instead`, + ); + } + return lstat(path) as Exclude | null; + }; + return { + lstat, + stat, + read: (path) => { + stat(path); + const text = files.get(path); + if (text === undefined) + throw new InitRefused(path, `${path} could not be read as text; run \`quest init\` in a clone instead`); + return text; + }, + list: (path) => + entries + .filter( + (entry) => entry.path.startsWith(`${path}/`) && !entry.path.slice(path.length + 1).includes("/"), + ) + .map((entry) => entry.path.slice(path.length + 1)), + real: (path) => path, + }; +} + +/** + * The maintainer's answer to what the project is working toward, as + * `quest/README.md`'s Goal: trimmed, with Unix newlines, and with any line that + * would make a heading escaped, so it stays inside the Goal section. Escaping + * covers the common slips; `planCheck` refuses anything else `quest check` + * would. + */ +export function setupGoal(text: string): string { + return ( + text + .replace(/\r\n?/g, "\n") + .trim() + .split("\n") + // An ATX heading opens with `#`; a line of only `=` or `-` underlines the one above as a setext heading. + .map((line) => line.replace(/^(\s{0,3})(#|(?:=+|-+)\s*$)/, "$1\\$2")) + .join("\n") + ); +} + +/** + * What `quest check` would report on the quest files `changes` write over a + * tree holding `paths`, such as a goal whose Markdown leaves the Goal section, + * so the setup is refused before it is published rather than at the merge. + */ +export function planCheck(changes: InitChange[], paths: Iterable): string[] { + // The tree as it will be after the merge: written files exist, deleted ones do not. + const exists = new Set(paths); + for (const change of changes) + if (change.content === null) exists.delete(change.path); + else exists.add(change.path); + const documents = changes.flatMap((change) => + change.content !== null && change.path.startsWith("quest/") && isQuest(change.path) + ? [parse(change.path, change.content)] + : [], + ); + return check(documents, (path) => exists.has(path)).map((finding) => finding.message); +} + +/** What each planned change is for, in the pull request body. */ +function purpose(change: InitChange): string { + const path = change.path; + if (change.content === null) return "removes the stub of a skill Quest no longer ships"; + if (path.endsWith("/SKILL.md")) return "a skill stub"; + if (path === "quest/README.md") return "the quest tree's root, with the project's goal"; + if (path === ".gitattributes") return "routes `quest/**` through the `quest` merge driver"; + if (path === ".claude/settings.json") return "Claude Code's worktree hooks, which run `quest worktree hook`"; + return "points agents at `quest guide`"; +} + +/** The setup pull request's title and body for `changes`. */ +export function setupPull(changes: InitChange[], options: { flake: boolean }) { + const stubs = changes.filter((change) => change.content !== null && change.path.endsWith("/SKILL.md")); + const others = changes.filter((change) => !stubs.includes(change)); + const lines = [ + "This sets up [Quest](https://github.com/moq-dev/quest) here with what `quest init` writes, so kix.quest can work on this repository's quests.", + "", + ...(stubs.length + ? [ + `- ${stubs.length} skill stubs under ${[...new Set(stubs.map((change) => `\`${change.path.split("/").slice(0, 2).join("/")}/\``))].join(" and ")}: each asks the agent to run \`quest skill \``, + ] + : []), + ...others.map((change) => `- \`${change.path}\`: ${purpose(change)}`), + "", + "Merging it enables contributions on kix.quest. Closing it leaves the project setting up, where you can open it again or disconnect.", + "", + "## Left out", + "", + "These need judgement about this repository, so this pull request leaves them to you or a local agent following [SETUP.md](https://github.com/moq-dev/quest/blob/main/SETUP.md):", + "", + "- Pinning `quest` in the repository's tool manager, so local agents run the version these stubs expect. kix's runners bring their own `quest`, so its jobs don't need it.", + "- Adapting existing agent instructions to Quest beyond the one line added. kix's jobs don't need that either.", + ...(options.flake + ? [] + : [ + "- A Nix dev shell. kix's jobs run in one, and this repository has no `flake.nix`, so after this merges kix offers a bootstrap job that adds one.", + ]), + "", + ]; + return { title: "Set up Quest", body: lines.join("\n") }; +} diff --git a/cloudflare/src/github/webhook.ts b/cloudflare/src/github/webhook.ts index 45d200e2..860d9f6f 100644 --- a/cloudflare/src/github/webhook.ts +++ b/cloudflare/src/github/webhook.ts @@ -227,5 +227,28 @@ githubWebhooks.post("/webhooks/github", async (c) => { // A landing that ended unmerged, such as a merge queue removal, gives a merge job's pull request back. const landed = event === "pull_request" && positiveId(payload.pull_request?.number); if (landed) c.executionCtx.waitUntil(landing.release(c.env, repository, `pulls/${payload.pull_request.number}`)); + // A setup pull request that merged enables its project; one closed or reopened changes what its page offers. + if (landed && (action === "closed" || action === "reopened")) + c.executionCtx.waitUntil(setupChanged(c.env, repository, installation, payload.pull_request.number)); return c.json({ status: "accepted" }, 202); }); + +/** + * Reconcile the setup whose pull request a delivery names. The delivery only + * says to look: the service rereads the pull request and rechecks the + * enroller's authority, so a forged or delayed event cannot enable a project. + */ +async function setupChanged(env: Env, repository: number, installation: number, pull: number) { + const setup = await env.DB.prepare( + "SELECT owner,name FROM github_project_setups WHERE id=? AND installation=? AND pull=?", + ) + .bind(repository, installation, pull) + .first<{ owner: string; name: string }>(); + if (!setup) return; + try { + await new GitHubService(env).settleSetup({ id: repository, ...setup }); + } catch (error) { + // The setup page reconciles again whenever a maintainer opens it. + console.error({ event: "setup_reconcile_failed", repository, message: String(error) }); + } +} diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index 73e5ab65..fa2da56a 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -10,9 +10,17 @@ import { BoardPage, QuestPage, SHOWN, questCrumbs } from "./board/pages"; import { type Project, findProject, home } from "./board/project"; import { DocIndex, DocPage, findDoc } from "./docs"; import { Home } from "./home"; -import { type Candidate, type Enrollment, GitHubError, GitHubService } from "./github/service"; +import { type Candidate, type Enrollment, GitHubError, GitHubService, NoQuestFiles } from "./github/service"; import { readActivity } from "./github/state"; -import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects"; +import { + ProjectsPage, + ProjectSetupPage, + ProjectStopControls, + ProjectStatePage, + QuestSetupPage, + SettingUpPage, + projectURL, +} from "./projects"; import { githubWebhooks } from "./github/webhook"; import { activeHighlights, cards, highlights } from "./inbox/activity"; import * as inbox from "./inbox/model"; @@ -134,6 +142,7 @@ app.get("/projects", async (c) => { { title: "Project setup", description: "Opt your Quest repository into contributions." }, { enrolled = await github.enrolledProject(c.req.param("owner"), c.req.param("repository"), session.user.id); const repository = enrolled ?? (await github.resolveRepository(c.req.param("owner"), c.req.param("repository"))); - const preview = await github.previewProject(repository, session.user.id); + if (!enrolled) { + // A project setting up shows its setup pull request, and enables once that merged. + const setup = await github.settleSetup(repository, session.user.id); + if (setup === "enabled") return c.redirect(projectURL(repository.owner, repository.name), 303); + if (setup) + return page( + c, + { title: `${repository.owner}/${repository.name} setup`, description: "Set up Quest." }, + , + ); + } + let preview; + try { + preview = await github.previewProject(repository, session.user.id); + } catch (error) { + if (!(error instanceof NoQuestFiles) || enrolled) throw error; + return page( + c, + { title: `${repository.owner}/${repository.name} setup`, description: "Set up Quest." }, + , + ); + } return page( c, { @@ -226,6 +256,32 @@ app.post("/projects/github/:owner/:repository", async (c) => { } }); +// Enrolling a repository without Quest files opens the App's setup pull request. +app.post("/projects/github/:owner/:repository/setup", async (c) => { + const session = await createAuth(c.env).api.getSession({ headers: c.req.raw.headers }); + if (!session) return c.json({ error: "Sign in required" }, 401); + const { id, goal = "" } = await c.req.parseBody(); + if (typeof id !== "string" || !/^[1-9]\d*$/.test(id) || typeof goal !== "string") + return c.json({ error: "Invalid project choice" }, 400); + const repository = { id: Number(id), owner: c.req.param("owner"), name: c.req.param("repository") }; + const href = projectURL(repository.owner, repository.name); + const github = new GitHubService(c.env); + try { + // A pull request closed or merged since the last look changes what opening means. + if ((await github.settleSetup(repository, session.user.id)) === "enabled") return c.redirect(href, 303); + await github.openSetup(repository, session.user.id, goal); + return c.redirect(href, 303); + } catch (error) { + if (!(error instanceof GitHubError)) throw error; + return page( + c, + { title: "Setup not opened", description: "The setup pull request could not be opened." }, + , + 403, + ); + } +}); + // Live enrolled-project observations use the same board model as the bundled // repository, but every read rechecks current GitHub maintainer authority. async function liveProject(c: Context<{ Bindings: Env }>, refresh = false) { diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 6eb2acc5..b5c388c5 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -1,5 +1,6 @@ import type { Activity, ProjectState, Pull } from "./github/state"; -import type { Candidate, Enrollment, ProjectPreview } from "./github/service"; +import type { Candidate, Enrollment, ProjectPreview, ProjectSetup } from "./github/service"; +import { GOAL_LIMIT } from "./github/setup"; import { Difficulty, Marker } from "./layout"; import { title } from "./board/model"; @@ -32,18 +33,41 @@ export function ProjectStopControls({ project }: { project: Enrollment }) { /** One repository the App reaches and the user maintains, with its state or why it cannot be enabled. */ function CandidateRow({ candidate }: { candidate: Candidate }) { const href = projectURL(candidate.owner, candidate.name); - const eligible = !candidate.state && !candidate.problems.length; + // A repository without Quest files enables through its setup pull request. + const blocking = candidate.problems.length - (candidate.setup ? 1 : 0); + const eligible = !candidate.state && blocking === 0; return (
  • - + {candidate.owner}/{candidate.name}
  • @@ -69,6 +85,8 @@ function CandidateRow({ candidate }: { candidate: Candidate }) { export function ProjectsPage(props: { projects: Enrollment[]; + /** The user's projects still setting up. */ + setups: ProjectSetup[]; /** The repositories to pick from, or null when the user's GitHub token cannot list them. */ candidates: Candidate[] | null; /** Whether the user has a linked GitHub account, without which nothing can be listed. */ @@ -133,10 +151,18 @@ export function ProjectsPage(props: { - {props.projects.length > 0 && ( + {props.projects.length + props.setups.length > 0 && (

    Your enrolled projects

      + {props.setups.map((setup) => ( +
    • + + {setup.owner}/{setup.name} + {" "} + · Setting up +
    • + ))} {props.projects.map((project) => (
    • @@ -163,6 +189,113 @@ export function ProjectsPage(props: { ); } +/** The goal question, the only one enrolling a repository without Quest files asks. */ +function GoalField() { + return ( + <> + + +

      + It becomes the Goal of quest/README.md, which you can edit before merging. +

      + + ); +} + +/** Enrolling a repository with no Quest files: the setup pull request's one question. */ +export function QuestSetupPage(props: { repository: { id: number; owner: string; name: string }; error?: string }) { + const repo = props.repository; + return ( +
      +

      + Project setup +

      +

      + {repo.owner}/{repo.name} +

      + {props.error &&

      {props.error}

      } +
      +

      Set up Quest

      +

      + This repository has no Quest files on its default branch yet. The App opens a pull request with what{" "} + quest init writes: skill stubs, quest/README.md, one line in the agent + instructions, a .gitattributes line, and Claude Code's worktree hooks. Contributions + are enabled when you merge it. +

      +
      + + +

      + +

      + +
      +
      + ); +} + +/** A project enrolled, setting up: its setup pull request, and what the maintainer can do next. */ +export function SettingUpPage(props: { setup: ProjectSetup; error?: string }) { + const { setup } = props; + const action = projectURL(setup.owner, setup.name); + const pull = `https://github.com/${setup.owner}/${setup.name}/pull/${setup.pull}`; + const retry = setup.pull === null || (setup.state === "closed" && !setup.failure); + return ( +
      +

      + Project setup +

      +

      + {setup.owner}/{setup.name} +

      + {props.error &&

      {props.error}

      } +
      +

      Setting up

      + {setup.failure ? ( + <> +

      + The setup pull request merged, but contributions could not be enabled:{" "} + {setup.failure} +

      +

      + + Check again + +

      + + ) : setup.pull === null ? ( +

      The setup pull request has not been opened yet.

      + ) : setup.state === "open" ? ( +

      + The setup pull request #{setup.pull} is open. Contributions are enabled when + it merges. +

      + ) : ( +

      + The setup pull request #{setup.pull} was closed without merging. Open it + again with the current default branch, or disconnect the project. +

      + )} + {retry && ( +
      + + +

      + +

      + + )} +
      + + + +
      +

      Disconnecting leaves the setup pull request and its branch as they are.

      +
      +
      + ); +} + export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: Enrollment | null; error?: string }) { const { preview } = props; const repo = preview.repository; diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index 5aa6b833..98339340 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -451,7 +451,7 @@ describe("project enrollment", () => { [{ id: 99 }, "identity"], [{ private: true }, "public"], [{ truncated: true }, "incomplete"], - [{ noQuests: true }, "setup guide"], + [{ noQuests: true }, "setup pull request"], [{ invalid: true }, "quest check"], [{ symlink: true }, "regular files"], ] as const)("rejects unavailable or ineligible snapshots: %j", async (options, message) => { @@ -893,7 +893,10 @@ describe("project picker", () => { expect(html).toContain('action="/projects/github/upstream/project"'); expect(html).toContain("Enable contributions"); expect(html).toContain("No Quest files on the default branch yet."); - expect(html).toContain('Set up Quest'); + // A repository without Quest files enables through its setup page's form, which opens the setup pull request. + expect(html).toContain( + 'Enable contributions', + ); expect(html).not.toContain('action="/projects/github/upstream/bare"'); expect(html).toContain("https://github.com/apps/quest-test/installations/new"); // The session's GitHub sign-in token lists repositories. diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts new file mode 100644 index 00000000..865b6c1c --- /dev/null +++ b/cloudflare/test/setup.test.ts @@ -0,0 +1,310 @@ +// Enrolling a repository without Quest files: the App's setup pull request, +// the setting-up state, and enabling once it merges, against the fake GitHub. +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HOOK_COMMAND, REFERENCE_LINE } from "quest/core"; +import { app } from "../src/index"; +import { STUBS, planCheck, setupGoal, treeCheckout } from "../src/github/setup"; +import { type Pull, type Repo, type World, fakeGitHub, intercept } from "../dev/github"; +import { privateKey } from "./github-key"; +import { delivery } from "./project-fixtures"; +import { identity, oauth, origin } from "./login-fixtures"; + +const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; +const db = env.DB; +const route = `${origin}/projects/github/upstream/project`; + +let session: string; +let user: string; +let project: Repo; +let world: World; +let github: typeof fetch; + +beforeEach(async () => { + const who = identity("github"); + session = (await oauth(who)).session!; + user = (await db + .prepare("SELECT userId FROM account WHERE providerId='github' AND accountId=?") + .bind(who.id) + .first<{ userId: string }>())!.userId; + const login = `person${who.id}`; + world = { + app: { id: Number(env.GITHUB_APP_ID), slug: "quest-test" }, + installation: 5, + users: [{ id: Number(who.id), login, name: "Maintainer", email: who.email }], + repos: [ + { + owner: "upstream", + name: "project", + id: 10, + maintainers: [login], + files: [{ path: "AGENTS.md", content: "# Agents\n\nRun the tests.\n" }], + issues: [], + pulls: [], + branches: {}, + commits: {}, + trees: {}, + }, + ], + }; + project = world.repos[0]!; + github = fakeGitHub(world); + vi.spyOn(globalThis, "fetch").mockImplementation(github); +}); +afterEach(() => vi.restoreAllMocks()); + +const post = (path: string, body: Record) => + app.request( + `${route}${path}`, + { method: "POST", headers: { Cookie: session, Origin: origin }, body: new URLSearchParams(body) }, + testEnv, + ); +const view = () => app.request(route, { headers: { Cookie: session } }, testEnv); +const setup = () => db.prepare("SELECT * FROM github_project_setups WHERE id=10").first>(); +const enrolled = () => db.prepare("SELECT state,enrolledBy FROM github_projects WHERE id=10").first(); +const opened = () => project.pulls.filter((pull) => pull.author === "quest-test[bot]"); + +/** The files on the setup branch's commit, read back through the fake's API. */ +async function published(): Promise> { + const head = project.branches["kix/setup"]!; + const commit = project.commits[head]!; + const entries = project.trees[commit.tree]!.entries as { path: string; content?: string; sha?: string | null }[]; + return new Map(entries.map((entry) => [entry.path, entry.sha === null ? null : (entry.content ?? "")])); +} + +/** Merge the setup pull request the way GitHub would: its files land on the default branch. */ +async function merge(pull: Pull) { + for (const [path, content] of await published()) { + project.files = project.files.filter((file) => file.path !== path); + if (content !== null) project.files.push({ path, content }); + } + pull.closed = pull.merged = true; + return delivery("pull_request", { + action: "closed", + installation: { id: 5 }, + repository: { id: 10, name: "project", owner: { login: "upstream" }, default_branch: "main" }, + pull_request: { id: 9001, number: pull.number, merged: true }, + }); +} + +describe("the setup pull request", () => { + it("opens on a repository without Quest files, and enabling waits for it to merge", async () => { + const form = await view(); + expect(form.status).toBe(200); + const html = await form.text(); + expect(html).toContain("Set up Quest"); + expect(html).toContain('name="goal"'); + + const response = await post("/setup", { id: "10", goal: "Ship a fast parser.\n# Not a heading" }); + expect(response.status).toBe(303); + const [pull] = opened(); + expect(pull).toMatchObject({ branch: "kix/setup", title: "Set up Quest", draft: false }); + expect(pull!.body).toContain("## Left out"); + expect(pull!.body).toContain("Pinning `quest`"); + expect(pull!.body).toContain("bootstrap job"); + + const files = await published(); + for (const skill of STUBS) { + expect(files.get(`.claude/skills/${skill.installed}/SKILL.md`)).toBe(skill.stub); + expect(files.get(`.agents/skills/${skill.installed}/SKILL.md`)).toBe(skill.stub); + } + expect(files.get("quest/README.md")).toBe("# Quests\n\n## Goal\n\nShip a fast parser.\n\\# Not a heading\n"); + expect(files.get("AGENTS.md")).toBe(`# Agents\n\nRun the tests.\n\n${REFERENCE_LINE}\n`); + expect(files.get(".gitattributes")).toBe("quest/** merge=quest\n"); + expect(files.get(".claude/settings.json")).toContain(HOOK_COMMAND); + + expect(await setup()).toMatchObject({ state: "open", pull: pull!.number, enrolledBy: user, pending: null }); + expect(await enrolled()).toBeNull(); + const page = await (await view()).text(); + expect(page).toContain("Setting up"); + expect(page).toContain(`/pull/${pull!.number}`); + // Submitting again leaves the open pull request as it is. + expect((await post("/setup", { id: "10" })).status).toBe(303); + expect(opened()).toHaveLength(1); + + expect((await merge(pull!)).status).toBe(202); + expect(await enrolled()).toEqual({ state: "enabled", enrolledBy: user }); + expect(await setup()).toBeNull(); + expect(await db.prepare("SELECT user FROM github_project_members WHERE repository=10").first()).toEqual({ + user, + }); + expect(await (await view()).text()).toContain("Contributions enabled"); + }); + + it("names the path init refuses, and writes nothing", async () => { + project.files.push({ path: ".claude/skills/quest-start/SKILL.md", content: "# My own start\n" }); + const response = await post("/setup", { id: "10" }); + expect(response.status).toBe(403); + expect(await response.text()).toContain(".claude/skills/quest-start/SKILL.md is not a Quest stub"); + expect(project.branches["kix/setup"]).toBeUndefined(); + expect(opened()).toEqual([]); + expect(await setup()).toBeNull(); + }); + + it.each(["Ship parser\n> ## Other", "Ship parser\n- ## Other", "See [the plan](/quest/plan.md)"])( + "refuses a goal quest check would refuse: %j", + async (goal) => { + const response = await post("/setup", { id: "10", goal }); + expect(response.status).toBe(403); + expect(await response.text()).toContain("would not pass"); + expect(project.branches["kix/setup"]).toBeUndefined(); + expect(await setup()).toBeNull(); + }, + ); + + it("leaves a branch it did not write alone", async () => { + const main = Object.keys(project.commits)[0]!; + project.branches["kix/setup"] = main; + const response = await post("/setup", { id: "10" }); + expect(response.status).toBe(403); + expect(await response.text()).toContain("already exists with commits the App did not write"); + expect(project.branches["kix/setup"]).toBe(main); + expect(opened()).toEqual([]); + }); + + it("offers a retry or a disconnect once the pull request closes", async () => { + await post("/setup", { id: "10" }); + const [first] = opened(); + const firstHead = project.branches["kix/setup"]; + first!.closed = true; + const closed = await delivery("pull_request", { + action: "closed", + installation: { id: 5 }, + repository: { id: 10, name: "project", owner: { login: "upstream" }, default_branch: "main" }, + pull_request: { id: 9001, number: first!.number, merged: false }, + }); + expect(closed.status).toBe(202); + expect(await setup()).toMatchObject({ state: "closed", pull: first!.number }); + const html = await (await view()).text(); + expect(html).toContain("was closed without merging"); + expect(html).toContain('name="state" value="disconnected"'); + expect(await enrolled()).toBeNull(); + + // A retry replaces kix's own branch and opens a new pull request from it. + expect((await post("/setup", { id: "10", goal: "Second try" })).status).toBe(303); + const second = opened().find((pull) => !pull.closed)!; + expect(second.number).not.toBe(first!.number); + expect(project.branches["kix/setup"]).not.toBe(firstHead); + expect((await published()).get("quest/README.md")).toContain("Second try"); + expect(await setup()).toMatchObject({ state: "open", pull: second.number }); + + expect((await post("", { id: "10", state: "disconnected" })).status).toBe(303); + expect(await setup()).toBeNull(); + // The pull request stays for the maintainer to close. + expect(second.closed).toBeFalsy(); + }); + + it("refuses a retry when someone pushes to the branch while it is written", async () => { + await post("/setup", { id: "10" }); + const [first] = opened(); + first!.closed = true; + const earlier = project.branches["kix/setup"]!; + // A maintainer's push lands after the ownership check, while the new commit is created. + const theirs = "f".repeat(40); + project.commits[theirs] = { parents: [earlier], tree: project.commits[earlier]!.tree, message: "mine" }; + vi.mocked(globalThis.fetch).mockImplementation( + intercept( + github, + (url, method) => method === "POST" && url.pathname.endsWith("/git/commits"), + async (next) => { + const response = await next(); + project.branches["kix/setup"] = theirs; + return response; + }, + ), + ); + const response = await post("/setup", { id: "10" }); + expect(response.status).toBe(403); + expect(await response.text()).toContain("changed while the setup was being written"); + expect(project.branches["kix/setup"]).toBe(theirs); + expect(opened().filter((pull) => !pull.closed)).toEqual([]); + }); + + it("keeps a disconnect made while a retry is written", async () => { + await post("/setup", { id: "10" }); + const [first] = opened(); + first!.closed = true; + const earlier = project.branches["kix/setup"]!; + // The maintainer disconnects while the retry's commit is created. + vi.mocked(globalThis.fetch).mockImplementation( + intercept( + github, + (url, method) => method === "POST" && url.pathname.endsWith("/git/commits"), + async (next) => { + const response = await next(); + await db.prepare("DELETE FROM github_project_setups WHERE id=10").run(); + return response; + }, + ), + ); + const response = await post("/setup", { id: "10" }); + expect(response.status).toBe(403); + expect(await response.text()).toContain("changed while its setup was being written"); + expect(await setup()).toBeNull(); + expect(project.branches["kix/setup"]).toBe(earlier); + expect(opened().filter((pull) => !pull.closed)).toEqual([]); + }); + + it("enables from the page when the merge webhook was missed", async () => { + await post("/setup", { id: "10" }); + const [pull] = opened(); + for (const [path, content] of await published()) if (content !== null) project.files.push({ path, content }); + pull!.closed = pull!.merged = true; + const response = await view(); + expect(response.status).toBe(303); + expect(await enrolled()).toMatchObject({ state: "enabled" }); + }); + + it("keeps a disconnect made while a merged setup is being enabled", async () => { + await post("/setup", { id: "10" }); + const [pull] = opened(); + // The maintainer disconnects after the reconciliation read the merged pull request. + vi.mocked(globalThis.fetch).mockImplementation( + intercept( + github, + (url, method) => method === "GET" && url.pathname.endsWith(`/pulls/${pull!.number}`), + async (next) => { + const response = await next(); + await db.prepare("DELETE FROM github_project_setups WHERE id=10").run(); + return response; + }, + ), + ); + expect((await merge(pull!)).status).toBe(202); + expect(await enrolled()).toBeNull(); + expect(await db.prepare("SELECT 1 FROM github_project_members WHERE repository=10").first()).toBeNull(); + }); + + it("lists a project setting up on the picker", async () => { + await post("/setup", { id: "10" }); + const html = await (await app.request(`${origin}/projects`, { headers: { Cookie: session } }, testEnv)).text(); + expect(html).toContain("Setting up"); + }); +}); + +describe("the setup plan on GitHub", () => { + it("checks the goal against the tree as it will be after the merge", () => { + const retired = ".claude/skills/quest-retired/SKILL.md"; + const readme = (link: string) => ({ + path: "quest/README.md", + content: `# Quests\n\n## Goal\n\nReplace [it](/${link}).\n`, + }); + expect(planCheck([readme("AGENTS.md")], ["AGENTS.md"])).toEqual([]); + expect(planCheck([readme(retired), { path: retired, content: null }], [retired])).not.toEqual([]); + }); + + it("refuses to follow a symlink", () => { + const checkout = treeCheckout( + [{ path: ".claude", type: "blob", mode: "120000", sha: "a".repeat(40) }], + new Map(), + ); + expect(() => checkout.stat(".claude/skills")).toThrow(".claude is a symbolic link"); + }); + + it("keeps a goal inside the Goal section", () => { + expect(setupGoal(" One\r\n## Required\n ### Deep\n")).toBe("One\n\\## Required\n \\### Deep"); + expect(setupGoal("Ship a parser\n---\nThen more\n=== ")).toBe("Ship a parser\n\\---\nThen more\n\\==="); + expect(setupGoal("- a list item\n-- not a rule")).toBe("- a list item\n-- not a rule"); + expect(setupGoal("Ship parser\r---")).toBe("Ship parser\n\\---"); + }); +}); diff --git a/cloudflare/tsconfig.json b/cloudflare/tsconfig.json index e921addc..ce8e114f 100644 --- a/cloudflare/tsconfig.json +++ b/cloudflare/tsconfig.json @@ -13,5 +13,5 @@ "jsx": "react-jsx", "jsxImportSource": "hono/jsx" }, - "include": ["src", "scripts", "dev", "test", "vitest.config.ts", "worker-configuration.d.ts"] + "include": ["../src/assets.d.ts", "src", "scripts", "dev", "test", "vitest.config.ts", "worker-configuration.d.ts"] } diff --git a/quest/m1/kix/README.md b/quest/m1/kix/README.md index 0ba6942e..6b4bd4e9 100644 --- a/quest/m1/kix/README.md +++ b/quest/m1/kix/README.md @@ -15,7 +15,6 @@ maintainers, so they need none of this first. ## Required -- [Setup PR](/quest/m1/kix/setup-pr.md) - enrolling a repository without Quest makes the App open its `quest init` pull request - [Read-only CI](/quest/m1/kix/ci-read-only.md) - enrolled repositories keep pull-request CI read-only, checked at enrollment, with every trust boundary written down - [Contributor sandboxing](/quest/m1/kix/sandbox/README.md) - contributors' PR heads build in a VM, on stock Ubuntu and verified on macOS - [Onboarding guide](/quest/m1/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR diff --git a/quest/m1/kix/setup-pr.md b/quest/m1/kix/setup-pr.md deleted file mode 100644 index 4a199154..00000000 --- a/quest/m1/kix/setup-pr.md +++ /dev/null @@ -1,42 +0,0 @@ -# [M] Open a Quest setup PR when enrolling - -## Goal - -A maintainer enrolls a repository that has no Quest files, and the App opens -a pull request with what `quest init` writes, with no runner involved. The -project waits in a setting-up state that links the pull request and is -enabled when it merges. - -## Plan - -Decided 2026-10-27 (user), option A of the onboarding round that also -adopted GitHub App sign-in, chosen over running setup as a runner job, which -needs a runner before a project can enroll: - -- A repository without Quest gets the bot's setup PR. What state is it in - until that PR merges? - - ✅ Enrolled, setting up - - Not enrolled yet - - The merge webhook enables the project with no second visit. A closed pull - request leaves the project in setting up, offering a retry or disconnect. -- Move `init`'s file plan into the core so it returns the files to write - instead of writing them, and have both the CLI and the Worker use it: the - skill stubs, `quest/README.md`, the `Quests: ` line, `.gitattributes`, and - the Claude hook entries in `.claude/settings.json`. The Worker publishes them - through the same path as job branches, on a branch outside `quest/`. -- The enrollment form asks, optionally, what the project is working toward, - and writes the answer as `quest/README.md`'s Goal. Amended 2026-11-01 - (user, /quest-plan): since - [moq-dev/quest#349](https://github.com/moq-dev/quest/pull/349), enrolling - is one **Enable** button on a row of the `/projects` picker, with no form. - On a row with no Quest files, **Enable** opens a short form holding only - this optional question, and submitting it opens the setup PR. Rows that - already have Quest files enable at once, as now. -- An init refusal names the conflicting path on the setup page, as the CLI - does; kix never deletes or renames a repository's files. -- The pull request body says what the PR leaves out, since those steps need - an agent's judgement: pinning `quest` in the repository's tool manager and - adapting existing instructions. kix jobs do not need either, because runners - bring their own `quest`. A missing `flake.nix` still leads to the existing - `bootstrap` job. diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 95c8ad16..843e2217 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,10 +1,7 @@ // `quest init` and `quest uninstall`: install skill stubs and reverse it. // -// Init touches only stubs (writing missing or outdated ones and removing those -// of retired skills), one reference line, one `.gitattributes` line, an empty -// `quest/README.md` when missing, Quest's hook entries in -// `.claude/settings.json`, and the clone's merge driver config. Uninstall never -// deletes the quest tree. +// Init applies the core's file plan (`planInit`) to the checkout, then +// registers the clone's merge driver. Uninstall never deletes the quest tree. import { existsSync, @@ -19,97 +16,66 @@ import { statSync, writeFileSync, } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { + ATTRIBUTES_LINE, + CLAUDE_SETTINGS, + type Checkout, + HOOK_EVENTS, + type Kind, + REFERENCE_MARKER, + SKILL_DIRS, + installedStubs, + isQuestHook, + lines, + newline, + parseSettings, + planInit, + renderSettings, +} from "../core"; import { gitOutput } from "./claims"; import { DRIVER, OLD_DRIVERS } from "./resolve"; -import { PREFIX, SKILLS } from "./skills"; -import { HOOK_EVENTS } from "./worktree-hook"; - -/** - * Marks Quest's line in the root agent instructions, so a repository can - * reword the rest of it without init adding another. - */ -const REFERENCE_MARKER = "Quests: "; - -/** Appended to the root agent instructions when Quest is installed. */ -export const REFERENCE_LINE = "Quests: before starting work, run `quest guide` and follow it."; - -/** The skill directories Claude Code and Codex read; new installs use real directories. */ -const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; - -/** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ -export const ATTRIBUTES_LINE = "quest/** merge=quest"; - -/** Claude Code's shared project settings, where init installs the worktree lifecycle hooks. */ -const CLAUDE_SETTINGS = ".claude/settings.json"; - -/** The one command each installed hook entry runs; the logic lives in the binary, so upgrades never rewrite it. */ -export const HOOK_COMMAND = "quest worktree hook"; +import { SKILLS } from "./skills"; -/** Events whose single hook decides the outcome, so another hook for them conflicts with Quest's. */ -const EXCLUSIVE_EVENTS = new Set(["WorktreeCreate", "WorktreeRemove"]); - -const QUEST_ROOT_README = "# Quests\n\n## Goal\n\nWhat this project is working toward.\n"; +export { ATTRIBUTES_LINE, HOOK_COMMAND, REFERENCE_LINE } from "../core"; /** * Install Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. * A conflict throws before anything is written, so a refused init leaves the repository as it was. */ export function init(root: string): string[] { - const dirs = skillDirs(root); - const written = dirs.flatMap((skills) => - SKILLS.flatMap((skill) => { - const dir = `${skills}/${skill.installed}`; - const path = `${dir}/SKILL.md`; - checkPath(root, path, "file"); - if (isFile(join(root, path))) { - if (!isStub(root, path, skill.installed)) - throw new Error(`${path} is not a Quest stub; remove or rename it before running \`quest init\``); - // An older version's stub is rewritten in place, keeping its newlines; Quest writes no links, so it leaves linked ones. - const content = read(root, path); - if (content.replaceAll("\r\n", "\n") === skill.stub) return []; - if (!isRealDir(join(root, dir)) || kind(join(root, path)) !== "file") return []; - return [{ path, stub: skill.stub.replaceAll("\n", newline(content)) }]; - } else if (existsSync(join(root, dir))) { - throw new Error(`${dir} exists without SKILL.md; remove or rename it before running \`quest init\``); - } - return [{ path, stub: skill.stub }]; - }), - ); - const current = new Set(SKILLS.map((skill) => skill.installed)); - const retired = dirs.flatMap((skills) => stubs(root, skills).filter((dir) => !current.has(basename(dir)))); - const readme = "quest/README.md"; - checkPath(root, readme, "file"); - const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => isFile(join(root, name))) ?? "AGENTS.md"; - checkPath(root, instructions, "file"); - checkPath(root, ".gitattributes", "file"); - checkPath(root, CLAUDE_SETTINGS, "file"); - const settings = installHooks(root); - - const changes: string[] = []; - for (const { path, stub } of written) { - mkdirSync(dirname(join(root, path)), { recursive: true }); - writeFileSync(join(root, path), stub); - changes.push(path); - } - for (const dir of retired) changes.push(removeStub(root, dir)); - - if (!existsSync(join(root, readme))) { - mkdirSync(join(root, "quest"), { recursive: true }); - writeFileSync(join(root, readme), QUEST_ROOT_README); - changes.push(readme); - } - - if (settings !== undefined) { - mkdirSync(join(root, ".claude"), { recursive: true }); - writeFileSync(join(root, CLAUDE_SETTINGS), settings); - changes.push(CLAUDE_SETTINGS); + const changes = planInit(checkout(root), SKILLS); + for (const { path, content } of changes) { + if (content === null) { + rmSync(join(root, path)); + removeIfEmpty(dirname(join(root, path))); + } else { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } } - if (appendLine(root, instructions, REFERENCE_MARKER, REFERENCE_LINE)) changes.push(instructions); - if (appendLine(root, ".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE)) changes.push(".gitattributes"); register(root); - return changes; + return changes.map((change) => change.path); +} + +/** The checkout under `root`, as the core's file plan reads it. */ +function checkout(root: string): Checkout { + return { + lstat: (path) => kind(join(root, path)), + stat: (path) => { + try { + const stat = statSync(join(root, path)); + return stat.isDirectory() ? "dir" : stat.isFile() ? "file" : "other"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return null; + } + }, + read: (path) => read(root, path), + list: (path) => readdirSync(join(root, path)), + real: (path) => realpathSync(join(root, path)), + }; } /** @@ -154,29 +120,13 @@ function unregister(root: string): void { } } -/** Check an install destination and its parents before any writes, following valid symlinks. */ -function checkPath(root: string, path: string, expected: "directory" | "file"): void { - const parent = dirname(path); - if (parent !== ".") checkPath(root, parent, "directory"); - const absolute = join(root, path); - let stat; - try { - stat = statSync(absolute); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - if (kind(absolute) === null) return; - } - if (expected === "directory" ? stat?.isDirectory() : stat?.isFile()) return; - throw new Error(`${path} is not a ${expected}; remove or rename it before running \`quest init\``); -} - /** Remove Quest stubs and markers under `root`. Returns the paths it changed, relative to `root`. */ export function uninstall(root: string): string[] { const changes: string[] = []; if (uninstallHooks(root)) changes.push(CLAUDE_SETTINGS); for (const skills of SKILL_DIRS) { if (!isRealDir(join(root, skills))) continue; - for (const dir of stubs(root, skills)) changes.push(removeStub(root, dir)); + for (const dir of installedStubs(checkout(root), skills)) changes.push(removeStub(root, dir)); removeIfEmpty(join(root, skills)); } // Remove only the conventional link from an older Quest install whose target went away. @@ -203,21 +153,8 @@ export function uninstall(root: string): string[] { return changes; } -/** Validate both destinations before writing; existing links to one tree are installed once. */ -function skillDirs(root: string): string[] { - const seen = new Set(); - return SKILL_DIRS.filter((dir) => { - checkPath(root, dir, "directory"); - const path = join(root, dir); - const destination = existsSync(path) ? realpathSync(path) : resolve(path); - if (seen.has(destination)) return false; - seen.add(destination); - return true; - }); -} - /** What sits at `path` without following a symlink, or `null` for nothing. */ -function kind(path: string): "dir" | "file" | "symlink" | "other" | null { +function kind(path: string): Kind | null { try { const stat = lstatSync(path); if (stat.isDirectory()) return "dir"; @@ -252,43 +189,6 @@ function read(root: string, path: string): string { return readFileSync(join(root, path), "utf8"); } -/** - * Any version's stub: frontmatter naming the skill, then the pointer to - * `quest skill`. Descriptions and the setup link change between versions, so - * only this shape identifies a file Quest wrote. Skill frontmatter has only - * ever held `name` and `description`, so any other field marks an edited stub. - */ -const STUB = - /^---\nname: (\S+)\n(?:(?:name|description): .*\n)*---\n\nRun `quest skill (\S+)` and follow its output\.\nIf `quest` is not installed, follow \S+ first\.\n$/; - -/** - * Whether `path` holds a Quest stub of any version for the skill installed as - * `installed`. Git may check out unchanged stubs with CRLF in an adopting repository. - */ -function isStub(root: string, path: string, installed: string): boolean { - const match = STUB.exec(read(root, path).replaceAll("\r\n", "\n")); - return match?.[1] === installed && `${PREFIX}${match[2]}` === installed; -} - -/** - * The skill directories under `skills`, relative to `root`, that hold a Quest - * stub of any version, including skills this version no longer ships. Quest - * writes neither links nor other files there, so it skips symlinks. - */ -function stubs(root: string, skills: string): string[] { - if (kind(join(root, skills)) === null) return []; - return readdirSync(join(root, skills)) - .filter((name) => name.startsWith(PREFIX)) - .map((name) => `${skills}/${name}`) - .filter( - (dir) => - isRealDir(join(root, dir)) && - kind(join(root, dir, "SKILL.md")) === "file" && - isStub(root, `${dir}/SKILL.md`, basename(dir)), - ) - .sort(); -} - /** Delete the stub in skill directory `dir`, and the directory once empty. Returns the stub's path. */ function removeStub(root: string, dir: string): string { const path = `${dir}/SKILL.md`; @@ -297,33 +197,6 @@ function removeStub(root: string, dir: string): string { return path; } -function newline(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -/** Lines without their terminators, as Markdown editors on any platform wrote them. */ -function lines(content: string): string[] { - const out = content.split(/\r?\n/); - if (out[out.length - 1] === "") out.pop(); - return out; -} - -/** - * Append `line` unless a line already starts with `marker`, creating the file - * if missing, separated from existing content by a blank line. - */ -function appendLine(root: string, path: string, marker: string, line: string): boolean { - let content = isFile(join(root, path)) ? read(root, path) : ""; - if (lines(content).some((existing) => existing.startsWith(marker))) return false; - const eol = newline(content); - if (content !== "") { - if (!content.endsWith("\n")) content += eol; - content += eol; - } - writeFileSync(join(root, path), `${content}${line}${eol}`); - return true; -} - /** * Remove every line starting with `marker` and the blank lines it leaves at the * end, deleting the file if nothing else remains. @@ -346,65 +219,6 @@ function removeLine(root: string, path: string, marker: string): boolean { return true; } -/** A settings object as Claude Code reads it: hook groups per event, each with handlers. */ -type Settings = { hooks?: Record } & Record; - -const record = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -function isQuestHook(handler: unknown): boolean { - return record(handler) && handler.type === "command" && handler.command === HOOK_COMMAND; -} - -/** The settings file parsed and checked for the shape init edits, with its formatting. */ -function readSettings(root: string): { settings: Settings; indent: string; eol: string } | undefined { - if (!isFile(join(root, CLAUDE_SETTINGS))) return undefined; - const text = read(root, CLAUDE_SETTINGS); - const invalid = (why: string) => - new Error(`${CLAUDE_SETTINGS} ${why}; fix it before running \`quest init\` or \`quest uninstall\``); - let settings: unknown; - try { - settings = JSON.parse(text); - } catch { - throw invalid("is not valid JSON"); - } - if (!record(settings)) throw invalid("is not a JSON object"); - if (settings.hooks !== undefined) { - if (!record(settings.hooks)) throw invalid("has hooks that are not an object"); - for (const [event, groups] of Object.entries(settings.hooks)) - if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) - throw invalid(`has ${event} hooks that are not a list of hook groups`); - } - return { settings: settings as Settings, indent: /\n([ \t]+)"/.exec(text)?.[1] ?? " ", eol: newline(text) }; -} - -function writeSettings(settings: Settings, indent: string, eol: string): string { - return `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`; -} - -/** - * The settings file with one Quest hook group added for each event that lacks one, or undefined when - * nothing changes. Another hook for an event whose single hook decides the outcome is a conflict. - */ -function installHooks(root: string): string | undefined { - const { settings, indent, eol } = readSettings(root) ?? { settings: {}, indent: " ", eol: "\n" }; - const hooks = (settings.hooks ??= {}); - let changed = false; - for (const event of HOOK_EVENTS) { - const groups = (hooks[event] ??= []); - const handlers = groups.flatMap((group) => group.hooks ?? []); - if (EXCLUSIVE_EVENTS.has(event) && handlers.some((handler) => !isQuestHook(handler))) - throw new Error( - `${CLAUDE_SETTINGS} already has a ${event} hook, and only one can manage Claude's worktrees; ` + - "remove it before running `quest init`", - ); - if (handlers.some(isQuestHook)) continue; - groups.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] }); - changed = true; - } - return changed ? writeSettings(settings, indent, eol) : undefined; -} - /** * Remove exactly Quest's hook handlers, then the groups, events, and hooks object they leave empty, * deleting the file when nothing else remains. A file that is missing or malformed is left alone. @@ -412,7 +226,7 @@ function installHooks(root: string): string | undefined { function uninstallHooks(root: string): boolean { let parsed; try { - parsed = readSettings(root); + parsed = isFile(join(root, CLAUDE_SETTINGS)) ? parseSettings(read(root, CLAUDE_SETTINGS)) : undefined; } catch { return false; } @@ -436,6 +250,6 @@ function uninstallHooks(root: string): boolean { if (!changed) return false; if (Object.keys(hooks).length === 0) delete settings.hooks; if (Object.keys(settings).length === 0) rmSync(join(root, CLAUDE_SETTINGS)); - else writeFileSync(join(root, CLAUDE_SETTINGS), writeSettings(settings, indent, eol)); + else writeFileSync(join(root, CLAUDE_SETTINGS), renderSettings(settings, indent, eol)); return true; } diff --git a/src/cli/skills.ts b/src/cli/skills.ts index ab3caacf..ea0d9d4f 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -6,6 +6,7 @@ // rewrites only the stubs themselves, when their description or setup link // changed and nobody edited them. +import { PREFIX } from "../core"; import guide from "../../assets/AGENTS.md"; import afk from "../../assets/skills/afk.md"; import audit from "../../assets/skills/audit.md"; @@ -29,9 +30,6 @@ export const GUIDE: string = guide; /** Where an agent without the CLI learns to install it. */ const SETUP = "https://github.com/moq-dev/quest/blob/main/SETUP.md"; -/** Installed skills carry this prefix so they don't collide with a repository's own. */ -export const PREFIX = "quest-"; - /** One skill: YAML frontmatter, then the instructions. */ export class Skill { /** The frontmatter, fences excluded. */ diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 189e7c9e..0173587c 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -14,8 +14,8 @@ import { mainCheckout, } from "./worktree"; -/** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ -export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; +// The events this handles are the core's HOOK_EVENTS, which `quest init` installs. +export { HOOK_EVENTS } from "../core"; /** The fields of a Claude Code hook's JSON input that these events read. */ interface HookInput { diff --git a/src/core/index.ts b/src/core/index.ts index e7734092..ca161bd0 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -32,6 +32,30 @@ export { type Blocker, blockers, label, lookup, ready, renderBlocker } from "./r export { type Finding, ROOT, check, formatFinding } from "./rules"; export { type RankedQuest, type Ranking, type WaitingQuest, ranking, unblocked, waiting } from "./unblocked"; export { MAINTAINER, WAITING, type Wait, onMaintainer, parseWait, waits } from "./waiting"; +export { + ATTRIBUTES_LINE, + CLAUDE_SETTINGS, + type Checkout, + HOOK_COMMAND, + HOOK_EVENTS, + type InitChange, + InitRefused, + type Kind, + PREFIX, + REFERENCE_LINE, + REFERENCE_MARKER, + SKILL_DIRS, + type Settings, + type Stub, + installedStubs, + isQuestHook, + lines, + newline, + parseSettings, + planInit, + questReadme, + renderSettings, +} from "./init"; export { type Chunk, type Clean, type Conflict, LABELS, chunks, render, resolveLists } from "./merge"; export { diff --git a/src/core/init.ts b/src/core/init.ts new file mode 100644 index 00000000..76520a99 --- /dev/null +++ b/src/core/init.ts @@ -0,0 +1,300 @@ +// `quest init`'s file plan: which files installing Quest writes or removes in +// a repository, computed from what the repository holds without writing any. +// The CLI applies it to a checkout and the Worker publishes it as a setup +// pull request, so both install the same files. +// +// The plan touches only skill stubs (writing missing or outdated ones and +// removing those of retired skills), one reference line in the root agent +// instructions, one `.gitattributes` line, `quest/README.md` when missing, and +// Quest's hook entries in `.claude/settings.json`. + +/** Installed skills carry this prefix so they don't collide with a repository's own. */ +export const PREFIX = "quest-"; + +/** + * Marks Quest's line in the root agent instructions, so a repository can + * reword the rest of it without init adding another. + */ +export const REFERENCE_MARKER = "Quests: "; + +/** Appended to the root agent instructions when Quest is installed. */ +export const REFERENCE_LINE = "Quests: before starting work, run `quest guide` and follow it."; + +/** The skill directories Claude Code and Codex read; new installs use real directories. */ +export const SKILL_DIRS = [".claude/skills", ".agents/skills"] as const; + +/** Routes quest files through the `quest` merge driver; uninstall removes lines starting with it. */ +export const ATTRIBUTES_LINE = "quest/** merge=quest"; + +/** Claude Code's shared project settings, where init installs the worktree lifecycle hooks. */ +export const CLAUDE_SETTINGS = ".claude/settings.json"; + +/** The one command each installed hook entry runs; the logic lives in the binary, so upgrades never rewrite it. */ +export const HOOK_COMMAND = "quest worktree hook"; + +/** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ +export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; + +/** Events whose single hook decides the outcome, so another hook for them conflicts with Quest's. */ +const EXCLUSIVE_EVENTS = new Set(["WorktreeCreate", "WorktreeRemove"]); + +/** The quest tree's root, with the project's goal or a placeholder for it. */ +export function questReadme(goal?: string): string { + return `# Quests\n\n## Goal\n\n${goal?.trim() || "What this project is working toward."}\n`; +} + +/** What sits at a path: a directory, a regular file, a symbolic link, or anything else. */ +export type Kind = "dir" | "file" | "symlink" | "other"; + +/** + * A repository's files as the plan reads them. Paths are relative to the + * repository root and joined with `/`. Reads may throw an `InitRefused` + * naming a path the caller cannot inspect. + */ +export interface Checkout { + /** What sits at `path` without following a final symlink, or null when nothing does. */ + lstat(path: string): Kind | null; + /** What `path` resolves to, following symlinks, or null when it, or a link's target, is missing. */ + stat(path: string): Exclude | null; + /** The text of the file at `path`, following symlinks. */ + read(path: string): string; + /** The entry names in the directory at `path`. */ + list(path: string): string[]; + /** Where `path` resolves to, so two names for one directory compare equal; a missing path is itself. */ + real(path: string): string; +} + +/** One skill as a repository installs it: its directory name and its `SKILL.md`. */ +export interface Stub { + installed: string; + stub: string; +} + +/** One file the plan sets to `content`, or, with null content, a retired stub it deletes along with its directory once empty. */ +export interface InitChange { + path: string; + content: string | null; +} + +/** Init cannot install because of what sits at `path`; the repository needs a person's judgement. */ +export class InitRefused extends Error { + constructor( + readonly path: string, + message: string, + ) { + super(message); + this.name = "InitRefused"; + } +} + +function refuse(path: string, why: string): never { + throw new InitRefused(path, `${path} ${why}; remove or rename it before running \`quest init\``); +} + +/** + * The changes installing Quest makes in `repo`, in order, with `stubs` as the + * skills to install and `goal` as a new `quest/README.md`'s Goal. Every + * conflict throws an `InitRefused` before any change is returned, so a + * refused init leaves the repository as it was. An empty plan means Quest is + * already installed. + */ +export function planInit(repo: Checkout, stubs: readonly Stub[], options: { goal?: string } = {}): InitChange[] { + const dirs = skillDirs(repo); + const written = dirs.flatMap((skills) => + stubs.flatMap((skill): InitChange[] => { + const dir = `${skills}/${skill.installed}`; + const path = `${dir}/SKILL.md`; + checkPath(repo, path, "file"); + if (repo.stat(path) === "file") { + if (!isStub(repo, path, skill.installed)) refuse(path, "is not a Quest stub"); + // An older version's stub is rewritten in place, keeping its newlines; Quest writes no links, so it leaves linked ones. + const content = repo.read(path); + if (content.replaceAll("\r\n", "\n") === skill.stub) return []; + if (repo.lstat(dir) !== "dir" || repo.lstat(path) !== "file") return []; + return [{ path, content: skill.stub.replaceAll("\n", newline(content)) }]; + } else if (repo.lstat(dir) !== null) { + throw new InitRefused( + dir, + `${dir} exists without SKILL.md; remove or rename it before running \`quest init\``, + ); + } + return [{ path, content: skill.stub }]; + }), + ); + const current = new Set(stubs.map((skill) => skill.installed)); + const retired = dirs.flatMap((skills) => + installedStubs(repo, skills).filter((dir) => !current.has(dir.slice(dir.lastIndexOf("/") + 1))), + ); + const readme = "quest/README.md"; + checkPath(repo, readme, "file"); + const instructions = ["AGENTS.md", "CLAUDE.md"].find((name) => repo.stat(name) === "file") ?? "AGENTS.md"; + checkPath(repo, instructions, "file"); + checkPath(repo, ".gitattributes", "file"); + checkPath(repo, CLAUDE_SETTINGS, "file"); + const settings = installHooks(repo); + + const changes: InitChange[] = [...written, ...retired.map((dir) => ({ path: `${dir}/SKILL.md`, content: null }))]; + if (repo.lstat(readme) === null) changes.push({ path: readme, content: questReadme(options.goal) }); + if (settings !== undefined) changes.push({ path: CLAUDE_SETTINGS, content: settings }); + for (const [path, marker, line] of [ + [instructions, REFERENCE_MARKER, REFERENCE_LINE], + [".gitattributes", ATTRIBUTES_LINE, ATTRIBUTES_LINE], + ]) { + const content = appendLine(repo.stat(path) === "file" ? repo.read(path) : "", marker, line); + if (content !== null) changes.push({ path, content }); + } + return changes; +} + +/** Check an install destination and its parents before any writes, following valid symlinks. */ +function checkPath(repo: Checkout, path: string, expected: "dir" | "file"): void { + const slash = path.lastIndexOf("/"); + if (slash > 0) checkPath(repo, path.slice(0, slash), "dir"); + const found = repo.stat(path); + if (found === null ? repo.lstat(path) === null : found === expected) return; + refuse(path, `is not a ${expected === "dir" ? "directory" : "file"}`); +} + +/** Validate both destinations before writing; existing links to one tree are installed once. */ +function skillDirs(repo: Checkout): string[] { + const seen = new Set(); + return SKILL_DIRS.filter((dir) => { + checkPath(repo, dir, "dir"); + const destination = repo.stat(dir) === null ? dir : repo.real(dir); + if (seen.has(destination)) return false; + seen.add(destination); + return true; + }); +} + +/** + * Any version's stub: frontmatter naming the skill, then the pointer to + * `quest skill`. Descriptions and the setup link change between versions, so + * only this shape identifies a file Quest wrote. Skill frontmatter has only + * ever held `name` and `description`, so any other field marks an edited stub. + */ +const STUB = + /^---\nname: (\S+)\n(?:(?:name|description): .*\n)*---\n\nRun `quest skill (\S+)` and follow its output\.\nIf `quest` is not installed, follow \S+ first\.\n$/; + +/** + * Whether `path` holds a Quest stub of any version for the skill installed as + * `installed`. Git may check out unchanged stubs with CRLF in an adopting repository. + */ +function isStub(repo: Checkout, path: string, installed: string): boolean { + const match = STUB.exec(repo.read(path).replaceAll("\r\n", "\n")); + return match?.[1] === installed && `${PREFIX}${match[2]}` === installed; +} + +/** + * The skill directories under `skills` that hold a Quest stub of any version, + * including skills this version no longer ships. Quest writes neither links + * nor other files there, so it skips symlinks. + */ +export function installedStubs(repo: Checkout, skills: string): string[] { + if (repo.lstat(skills) === null) return []; + return repo + .list(skills) + .filter((name) => name.startsWith(PREFIX)) + .map((name) => `${skills}/${name}`) + .filter( + (dir) => + repo.lstat(dir) === "dir" && + repo.lstat(`${dir}/SKILL.md`) === "file" && + isStub(repo, `${dir}/SKILL.md`, dir.slice(dir.lastIndexOf("/") + 1)), + ) + .sort(); +} + +/** The line ending a file already uses. */ +export function newline(content: string): string { + return content.includes("\r\n") ? "\r\n" : "\n"; +} + +/** Lines without their terminators, as Markdown editors on any platform wrote them. */ +export function lines(content: string): string[] { + const out = content.split(/\r?\n/); + if (out[out.length - 1] === "") out.pop(); + return out; +} + +/** + * `content` with `line` appended, separated from existing content by a blank + * line, or null when a line already starts with `marker`. + */ +function appendLine(content: string, marker: string, line: string): string | null { + if (lines(content).some((existing) => existing.startsWith(marker))) return null; + const eol = newline(content); + if (content !== "") { + if (!content.endsWith("\n")) content += eol; + content += eol; + } + return `${content}${line}${eol}`; +} + +/** A settings object as Claude Code reads it: hook groups per event, each with handlers. */ +export type Settings = { hooks?: Record } & Record; + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export function isQuestHook(handler: unknown): boolean { + return record(handler) && handler.type === "command" && handler.command === HOOK_COMMAND; +} + +/** + * Claude Code's settings file parsed and checked for the shape init edits, + * with its formatting. Throws an `InitRefused` naming the file when it is not. + */ +export function parseSettings(text: string): { settings: Settings; indent: string; eol: string } { + const invalid = (why: string) => + new InitRefused( + CLAUDE_SETTINGS, + `${CLAUDE_SETTINGS} ${why}; fix it before running \`quest init\` or \`quest uninstall\``, + ); + let settings: unknown; + try { + settings = JSON.parse(text); + } catch { + throw invalid("is not valid JSON"); + } + if (!record(settings)) throw invalid("is not a JSON object"); + if (settings.hooks !== undefined) { + if (!record(settings.hooks)) throw invalid("has hooks that are not an object"); + for (const [event, groups] of Object.entries(settings.hooks)) + if (!Array.isArray(groups) || !groups.every((group) => record(group) && Array.isArray(group.hooks ?? []))) + throw invalid(`has ${event} hooks that are not a list of hook groups`); + } + return { settings: settings as Settings, indent: /\n([ \t]+)"/.exec(text)?.[1] ?? " ", eol: newline(text) }; +} + +/** The settings file's text, in the formatting `parseSettings` read. */ +export function renderSettings(settings: Settings, indent: string, eol: string): string { + return `${JSON.stringify(settings, null, indent).replaceAll("\n", eol)}${eol}`; +} + +/** + * The settings file with one Quest hook group added for each event that lacks one, or undefined when + * nothing changes. Another hook for an event whose single hook decides the outcome is a conflict. + */ +function installHooks(repo: Checkout): string | undefined { + const { settings, indent, eol } = + repo.stat(CLAUDE_SETTINGS) === "file" + ? parseSettings(repo.read(CLAUDE_SETTINGS)) + : { settings: {} as Settings, indent: " ", eol: "\n" }; + const hooks = (settings.hooks ??= {}); + let changed = false; + for (const event of HOOK_EVENTS) { + const groups = (hooks[event] ??= []); + const handlers = groups.flatMap((group) => group.hooks ?? []); + if (EXCLUSIVE_EVENTS.has(event) && handlers.some((handler) => !isQuestHook(handler))) + throw new InitRefused( + CLAUDE_SETTINGS, + `${CLAUDE_SETTINGS} already has a ${event} hook, and only one can manage Claude's worktrees; ` + + "remove it before running `quest init`", + ); + if (handlers.some(isQuestHook)) continue; + groups.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] }); + changed = true; + } + return changed ? renderSettings(settings, indent, eol) : undefined; +}