Public: anyone can read this; the crew can change it.
Every app in the explorer says "make it yours" and one tap forks it, counted by a test #10728
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10713-every-app-in-the-explorer-says-make-it-y → main 5 commits fork 4 threads · 4 unresolved
11 files
changed +505
−25
docs/features/make-it-yours.md added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | # Make it yours | |
| 2 | ||
| 3 | Every app row in the explorer (`/explore`) says **Make it yours**. Signed in, one tap forks the app into your | |
| 4 | account as an app of your own, following the original; code and history are copied, data and secrets are not | |
| 5 | (`makeItYours` in `src/actions/make_it_yours.ts`). A receipt in plain words follows, with **Undo**. Signed out, the | |
| 6 | row sends you to sign in, then to a plan page where one tap confirms and lands on the same receipt. | |
| 7 | ||
| 8 | ## Undo | |
| 9 | ||
| 10 | `undoMakeItYours` removes the copy this tap made, only while it has no commits of its own and no pull was sent from | |
| 11 | it; nothing is switched off before that is known. The receipt of a copy that was already yours says so and offers | |
| 12 | no Undo. The original is never touched. | |
| 13 | ||
| 14 | ## Terminal | |
| 15 | ||
| 16 | On a repository you can see but not write, open the palette and choose **Make it yours** (or press `Y` on the | |
| 17 | repository screen): a name, a visibility, the plan, then a confirm. Once you have a copy, **Undo make it yours** | |
| 18 | appears in the same palette and asks to confirm. A copy with changes or a sent pull is kept, and the toast says why. | |
| 19 | ||
| 20 | ## MCP | |
| 21 | ||
| 22 | json | |
| 23 | { "name": "make_it_yours", "arguments": { "repo": "alice/garden", "dryRun": false } } | |
| 24 | { "name": "make_it_yours", "arguments": { "repo": "alice/garden", "undo": true } } | |
| 25 | ||
| 26 | ||
| 27 | Undo answers `changed`, `unauthorized`, `failed` or `not_found`, each for its own cause. |
src/actions/make_it_yours.ts +66 −2
| @@ -44,8 +44,9 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 44 | 44 | import { requireVerified } from "./verify.ts"; |
| 45 | 45 | import { requireAbility } from "./roles.ts"; |
| 46 | 46 | import { requireAppRoom, appLimitWords } from "./app_limit.ts"; |
| 47 | import { enableApp } from "./apps.ts"; | |
| 48 | import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts"; | |
| 47 | import { disableApp, enableApp } from "./apps.ts"; | |
| 48 | import { forkDrift } from "./forks.ts"; | |
| 49 | import { deleteRepo, FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts"; | |
| 49 | 50 | |
| 50 | 51 | export interface MakeItYoursPlan { |
| 51 | 52 | readonly dryRun: boolean; |
| @@ -263,3 +264,66 @@ export async function makeItYours( | ||
| 263 | 264 | }); |
| 264 | 265 | return ok({ ...plan, made: { repo: copy, app: enabled.value } }); |
| 265 | 266 | } |
| 267 | ||
| 268 | /** The receipt, in the words the page and the MCP both say. */ | |
| 269 | export const receiptWords = (plan: Pick<MakeItYoursPlan, "repo" | "from" | "existing">): string => | |
| 270 | plan.existing | |
| 271 | ? `${plan.repo} was already yours, a copy of ${plan.from}.` | |
| 272 | : `${plan.repo} is yours: a copy of ${plan.from}, following it. Its first deploy is under way. Undo removes it while it has nothing of your own.`; | |
| 273 | ||
| 274 | export const undoWords = (repo: string): string => `${repo} is removed. The original was not touched.`; | |
| 275 | ||
| 276 | export type UndoError = | |
| 277 | | { readonly kind: "not_found" } | |
| 278 | | { readonly kind: "unauthorized" } | |
| 279 | /** The copy has work of its own: removing it would lose that. */ | |
| 280 | | { readonly kind: "changed"; readonly message: string } | |
| 281 | | { readonly kind: "failed" }; | |
| 282 | ||
| 283 | /** | |
| 284 | * Take back a copy made from `source`: the actor's own fork, with its app, | |
| 285 | * removed — only while it has no commits of its own and no pull was sent from | |
| 286 | * it, so nothing a person made is lost. The original is untouched. | |
| 287 | */ | |
| 288 | export async function undoMakeItYours( | |
| 289 | app: App, | |
| 290 | actor: User | undefined, | |
| 291 | source: Repo, | |
| 292 | ): Promise<Result<{ readonly repo: string; readonly says: string }, UndoError>> { | |
| 293 | if (actor === undefined || !isActive(actor)) return err({ kind: "unauthorized" }); | |
| 294 | const row = app.db | |
| 295 | .query<{ id: string }, [string, string]>( | |
| 296 | `SELECT id FROM repo WHERE owner_id = ?1 AND fork_parent_id = ?2`, | |
| 297 | ) | |
| 298 | .get(actor.id, source.id); | |
| 299 | const copy = row === null ? undefined : getRepoById(app.db, row.id); | |
| 300 | if (copy === undefined) return err({ kind: "not_found" }); | |
| 301 | const name = `${copy.ownerName}/${copy.name}`; | |
| 302 | const drift = await forkDrift(app, copy, source); | |
| 303 | if (drift.ahead === null || drift.ahead > 0) | |
| 304 | return err({ | |
| 305 | kind: "changed", | |
| 306 | message: `${name} has changes of your own, so it is kept. Delete it in its settings if you mean to.`, | |
| 307 | }); | |
| 308 | // Refuse before anything is switched off: a refused undo leaves the copy as it was. | |
| 309 | const sentPull = | |
| 310 | app.db | |
| 311 | .query( | |
| 312 | `SELECT 1 FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 313 | WHERE p.head_repo_id = ?1 AND i.repo_id <> ?1 LIMIT 1`, | |
| 314 | ) | |
| 315 | .get(copy.id) !== null; | |
| 316 | if (sentPull) | |
| 317 | return err({ kind: "changed", message: `${name} has a pull sent from it, so it is kept.` }); | |
| 318 | if (getAppByRepo(app.db, copy.id) !== undefined) disableApp(app, actor, copy); | |
| 319 | const gone = await deleteRepo(app, actor, copy); | |
| 320 | if (!gone.ok) | |
| 321 | return err( | |
| 322 | gone.error === "has_pulls" | |
| 323 | ? { kind: "changed", message: `${name} has a pull sent from it, so it is kept.` } | |
| 324 | : gone.error === "not_found" | |
| 325 | ? { kind: "not_found" } | |
| 326 | : { kind: "failed" }, | |
| 327 | ); | |
| 328 | return ok({ repo: name, says: undoWords(name) }); | |
| 329 | } |
src/http/mcp/tools/make_it_yours.ts +22 −3
| @@ -1,13 +1,18 @@ | ||
| 1 | 1 | import { APP_ALREADY_YOURS } from "../../../actions/origin_app_words.ts"; |
| 2 | 2 | import { NOT_A_MEMBER } from "../../../core/roles.ts"; |
| 3 | import { COPIES_PER_DAY, makeItYours } from "../../../actions/make_it_yours.ts"; | |
| 3 | import { | |
| 4 | COPIES_PER_DAY, | |
| 5 | makeItYours, | |
| 6 | receiptWords, | |
| 7 | undoMakeItYours, | |
| 8 | } from "../../../actions/make_it_yours.ts"; | |
| 4 | 9 | import { PROVE_FIRST } from "../../../actions/verify_words.ts"; |
| 5 | 10 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 11 | import { VISIBILITY_WORDS } from "../../../core/arg_words.ts"; |
| 7 | 12 | |
| 8 | 13 | export const tool: Tool = { |
| 9 | 14 | name: "make_it_yours", |
| 10 | doc: "`{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false", | |
| 15 | doc: "`{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false; `undo: true` removes the copy while it has nothing of your own", | |
| 11 | 16 | description: |
| 12 | 17 | "Fork an app you can see: code and history, following the original.", |
| 13 | 18 | inputSchema: { |
| @@ -17,6 +22,7 @@ export const tool: Tool = { | ||
| 17 | 22 | name: { type: "string" }, |
| 18 | 23 | visibility: { type: "string", enum: ["private", "public"] }, |
| 19 | 24 | dryRun: { type: "boolean" }, |
| 25 | undo: { type: "boolean" }, | |
| 20 | 26 | }, |
| 21 | 27 | required: ["repo"], |
| 22 | 28 | }, |
| @@ -25,6 +31,17 @@ export const tool: Tool = { | ||
| 25 | 31 | async run(ctx, user, args) { |
| 26 | 32 | const repo = repoArg(ctx, user, args); |
outdated
Every non-'changed' error (unauthorized, failed) is reported as not_found. Map each kind to its own answer. |
||
| 27 | 33 | if (isAnswer(repo)) return repo; |
| 34 | if (args["undo"] === true) { | |
| 35 | const undone = await undoMakeItYours(ctx.app, user, repo); | |
| 36 | if (undone.ok) return good(undone.value); | |
| 37 | return undone.error.kind === "changed" | |
| 38 | ? bad("changed", undone.error.message) | |
| 39 | : undone.error.kind === "unauthorized" | |
| 40 | ? bad("unauthorized", "sign in to undo") | |
| 41 | : undone.error.kind === "failed" | |
| 42 | ? bad("failed", "the copy could not be removed; try again") | |
| 43 | : bad("not_found", "nothing of yours was made from that app"); | |
| 44 | } | |
| 28 | 45 | const name = str(args["name"]); |
| 29 | 46 | const visibility = str(args["visibility"]); |
| 30 | 47 | if (visibility !== "" && visibility !== "public" && visibility !== "private") |
| @@ -52,7 +69,9 @@ export const tool: Tool = { | ||
| 52 | 69 | : bad("not_found", "no such app or repository (owner/name)"); |
| 53 | 70 | const { made: done, ...plan } = made.value; |
| 54 | 71 | return good( |
| 55 | done === undefined ? plan : { ...plan, url: `/${done.repo.ownerName}/${done.repo.name}` }, | |
| 72 | done === undefined | |
| 73 | ? plan | |
| 74 | : { ...plan, says: receiptWords(plan), url: `/${done.repo.ownerName}/${done.repo.name}` }, | |
| 56 | 75 | ); |
| 57 | 76 | }, |
| 58 | 77 | }; |
src/http/server.ts +4
| @@ -290,6 +290,8 @@ import { isPacksMirror, MIRROR_PUSH_REFUSED } from "../packs/owner.ts"; | ||
| 290 | 290 | import { packPage, packsMirror, packsPage } from "../web/pages/packs.ts"; |
| 291 | 291 | import { |
| 292 | 292 | makeItYoursPage, |
| 293 | makeItYoursReceipt, | |
| 294 | makeItYoursUndo, | |
| 293 | 295 | makeItYoursSubmit, |
| 294 | 296 | } from "../web/studio/make_it_yours.ts"; |
| 295 | 297 | import { |
| @@ -1437,6 +1439,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1437 | 1439 | // Make it yours (#606): a fork with its app, planned, then made. |
| 1438 | 1440 | router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage); |
| 1439 | 1441 | router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit); |
| 1442 | router.add("GET", "/:owner/:name/make-it-yours/receipt", makeItYoursReceipt); | |
| 1443 | router.add("POST", "/:owner/:name/make-it-yours/undo", makeItYoursUndo); | |
| 1440 | 1444 | // The toolbar's actions (#810): the bar links here, the forms post here. |
| 1441 | 1445 | router.add("GET", "/:owner/:name/toolbar", toolbarPage); |
| 1442 | 1446 | router.add("POST", "/:owner/:name/toolbar/comment", toolbarComment); |
src/tui/screens/yours.ts +61 −2
| @@ -4,7 +4,13 @@ | ||
| 4 | 4 | * repository. It is the fork screen (#606) the `F` key opens. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { COPIES_PER_DAY, makeItYours, type MakeItYoursError } from "../../actions/make_it_yours.ts"; | |
| 7 | import { | |
| 8 | COPIES_PER_DAY, | |
| 9 | makeItYours, | |
| 10 | receiptWords, | |
| 11 | undoMakeItYours, | |
| 12 | type MakeItYoursError, | |
| 13 | } from "../../actions/make_it_yours.ts"; | |
| 8 | 14 | import { APP_ALREADY_YOURS } from "../../actions/origin_app_words.ts"; |
| 9 | 15 | import { PROVE_FIRST } from "../../actions/verify_words.ts"; |
| 10 | 16 | import { NOT_A_MEMBER } from "../../core/roles.ts"; |
| @@ -65,7 +71,12 @@ export function makeItYoursScreen(f: Session, ctx: Ctx, source: Repo): Screen { | ||
| 65 | 71 | if (!made.ok) return refuse(made.error); |
| 66 | 72 | const done = made.value.made; |
| 67 | 73 | if (done === undefined) return; |
| 68 | ctx.toast(`${done.repo.ownerName}/${done.repo.name} is yours`, "success"); | |
| 74 | ctx.toast( | |
| 75 | made.value.existing | |
| 76 | ? receiptWords(made.value) | |
| 77 | : `${receiptWords(made.value)} Undo: the palette, "Undo make it yours".`, | |
| 78 | "success", | |
| 79 | ); | |
| 69 | 80 | ctx.replace(repoScreen(f, ctx, local(f, done.repo))); |
| 70 | 81 | }); |
| 71 | 82 | |
| @@ -147,9 +158,57 @@ export function makeItYoursScreen(f: Session, ctx: Ctx, source: Repo): Screen { | ||
| 147 | 158 | return self; |
| 148 | 159 | } |
| 149 | 160 | |
| 161 | /** The palette's undo: confirm, then remove the copy while it has nothing of the person's own. */ | |
| 162 | function undoScreen(f: Session, ctx: Ctx, source: Repo): Screen { | |
| 163 | const self: Screen = { | |
| 164 | title: "Undo make it yours", | |
| 165 | keys: [binding(["esc"], "cancel")], | |
| 166 | render() {}, | |
| 167 | key: () => false, | |
| 168 | }; | |
| 169 | return confirmScreen(ctx, self, { | |
| 170 | title: "Undo make it yours", | |
| 171 | body: [ | |
| 172 | `Remove your copy of ${source.ownerName}/${source.name}?`, | |
| 173 | "Kept if it has changes of your own or a pull sent from it.", | |
| 174 | ], | |
| 175 | actions: [{ label: "Undo", variant: "danger" }, { label: "Cancel" }], | |
| 176 | onChoose: (choice) => { | |
| 177 | if (choice !== 0) return; | |
| 178 | runAsync(ctx, async () => { | |
| 179 | const undone = await undoMakeItYours(f.app, f.user, source); | |
| 180 | if (undone.ok) return ctx.toast(undone.value.says, "success"); | |
| 181 | ctx.toast( | |
| 182 | undone.error.kind === "changed" | |
| 183 | ? undone.error.message | |
| 184 | : undone.error.kind === "unauthorized" | |
| 185 | ? "sign in to undo" | |
| 186 | : undone.error.kind === "failed" | |
| 187 | ? "the copy could not be removed; try again" | |
| 188 | : "nothing of yours was made from that app", | |
| 189 | "danger", | |
| 190 | ); | |
| 191 | }); | |
| 192 | }, | |
| 193 | }); | |
| 194 | } | |
| 195 | ||
| 150 | 196 | export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { |
| 151 | 197 | if (repo === undefined || f.user === undefined || canWrite(f.db, repo, f.user)) return []; |
| 198 | const made = f.db | |
| 199 | .query(`SELECT 1 FROM repo WHERE owner_id = ?1 AND fork_parent_id = ?2`) | |
| 200 | .get(f.user.id, repo.id); | |
| 152 | 201 | return [ |
| 202 | ...(made === null | |
| 203 | ? [] | |
| 204 | : [ | |
| 205 | { | |
| 206 | id: "undo-make-it-yours", | |
| 207 | title: `${repo.ownerName}/${repo.name} — Undo make it yours`, | |
| 208 | hint: "remove your copy", | |
| 209 | run: () => ctx.push(undoScreen(f, ctx, repo)), | |
| 210 | }, | |
| 211 | ]), | |
| 153 | 212 | { |
| 154 | 213 | id: "make-it-yours", |
| 155 | 214 | title: `${repo.ownerName}/${repo.name} — Make it yours`, |
src/web/studio/explore.ts +15 −7
| @@ -48,7 +48,7 @@ function numbers(row: ExploreRow): { apps: string; rest: string } { | ||
| 48 | 48 | }; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | function rowHtml(row: ExploreRow): Html { | |
| 51 | function rowHtml(row: ExploreRow, signedIn: boolean): Html { | |
| 52 | 52 | const { apps, rest } = numbers(row); |
| 53 | 53 | // One tap from a person to their apps; with none, the count is plain text. |
| 54 | 54 | const theirs = href({ kind: "app", sort: "score", text: "", archived: false, updated: "all", owner: row.name }); |
| @@ -64,11 +64,16 @@ function rowHtml(row: ExploreRow): Html { | ||
| 64 | 64 | ...(parts.length === 0 ? {} : { meta: metaHtml }), |
| 65 | 65 | ...(row.copyable === true && row.repo !== undefined |
| 66 | 66 | ? { |
| 67 | trail: btn("Make it yours", { | |
| 68 | href: `/${row.repo.owner}/${row.repo.name}/make-it-yours`, | |
| 69 | size: "sm", | |
| 70 | icon: "fork", | |
| 71 | }), | |
| 67 | trail: signedIn | |
| 68 | ? html`<form method="post" action="/${row.repo.owner}/${row.repo.name}/make-it-yours" data-make-it-yours> | |
| 69 | <input type="hidden" name="receipt" value="1" /> | |
| 70 | ${btn("Make it yours", { size: "sm", icon: "fork" })} | |
| 71 | </form>` | |
| 72 | : btn("Make it yours", { | |
| 73 | href: `/${row.repo.owner}/${row.repo.name}/make-it-yours?receipt=1`, | |
| 74 | size: "sm", | |
| 75 | icon: "fork", | |
| 76 | }), | |
| 72 | 77 | } |
| 73 | 78 | : {}), |
| 74 | 79 | }); |
| @@ -217,7 +222,10 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 217 | 222 | </div> |
| 218 | 223 | ${rows.length === 0 |
| 219 | 224 | ? emptyState(ctx, query) |
| 220 | : card(html`<ul class="list">${rows.map(rowHtml)}</ul>`, { pad: false, extra: "bare" })}</div>`; | |
| 225 | : card(html`<ul class="list">${rows.map((r) => rowHtml(r, ctx.auth.user !== undefined))}</ul>`, { | |
| 226 | pad: false, | |
| 227 | extra: "bare", | |
| 228 | })}</div>`; | |
| 221 | 229 | const drawn = page({ ctx, title: "Explore", body }); |
| 222 | 230 | // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results. |
| 223 | 231 | const response = unknown.length === 0 ? drawn : new Response(drawn.body, { status: 422, headers: drawn.headers }); |
src/web/studio/make_it_yours.ts +59 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * `actions/make_it_yours.ts`. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { COPIES_PER_DAY, makeItYours } from "../../actions/make_it_yours.ts"; | |
| 8 | import { COPIES_PER_DAY, makeItYours, receiptWords, undoMakeItYours } from "../../actions/make_it_yours.ts"; | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | 10 | import { |
| 11 | 11 | loadRepo, |
| @@ -96,6 +96,10 @@ export async function makeItYoursPage(ctx: Ctx): Promise<Response> { | ||
| 96 | 96 | action="${repoBase(repo)}/make-it-yours" |
| 97 | 97 | class="stack" |
| 98 | 98 | > |
| 99 | ${when( | |
| 100 | ctx.url.searchParams.get("receipt") === "1", | |
| 101 | () => html`<input type="hidden" name="receipt" value="1" />`, | |
| 102 | )} | |
| 99 | 103 | <ul class="list setting-list"> |
| 100 | 104 | ${row({ title: "Yours as", trail: html`<span class="mono">${p.repo}</span>` })} |
| 101 | 105 | ${row({ title: "Its address", trail: html`<span class="mono">${p.slug}</span>` })} |
| @@ -179,11 +183,17 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> { | ||
| 179 | 183 | : made.error.kind === "unverified" |
| 180 | 184 | ? "Prove your email first: the link is in your inbox." |
| 181 | 185 | : made.error.kind === "limit" |
| 182 | ? `That is today's copies made (${COPIES_PER_DAY} a day). Another can be made after ${made.error.resetsAt.slice(11, 16)} UTC.` | |
| 186 | ? `That is today's copies made (${COPIES_PER_DAY} a day). ` + | |
| 187 | `Another can be made after ${made.error.resetsAt.slice(11, 16)} UTC.` | |
| 183 | 188 | : "That could not be made yours." |
| 184 | 189 | : "That could not be made yours."; |
| 185 | 190 | return flashTo(ctx, repoBase(repo), { tone: "danger", message }); |
| 186 | 191 | } |
| 192 | if (field(form, "receipt") === "1") | |
| 193 | return redirect(`${repoBase(repo)}/make-it-yours/receipt${made.value.existing ? "" : "?made=1"}`, { | |
| 194 | url: ctx.url, | |
| 195 | forgeUrl: ctx.app.config.url, | |
| 196 | }); | |
| 187 | 197 | return redirect(repoBase(made.value.made.repo), { |
| 188 | 198 | url: ctx.url, |
| 189 | 199 | forgeUrl: ctx.app.config.url, |
| @@ -194,3 +204,50 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> { | ||
| 194 | 204 | }, |
| 195 | 205 | }); |
| 196 | 206 | } |
| 207 | ||
| 208 | // GET /:owner/:name/make-it-yours/receipt — what one tap did, in words, with Undo. | |
| 209 | export async function makeItYoursReceipt(ctx: Ctx): Promise<Response> { | |
| 210 | const repo = loadRepo(ctx); | |
| 211 | if (!repo) return notFoundPage(ctx); | |
| 212 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 213 | const plan = await makeItYours(ctx.app, ctx.auth.user, repo, { dryRun: true }); | |
outdated
Design principles 6 and 7: a card boxed around three buttons. Drop the card. Principle 1: the title and the sub say the same thing. Also Undo shows for an 'already yours' copy, which would delete an older fork. |
||
| 214 | if (!plan.ok || !plan.value.existing) | |
| 215 | return flashTo(ctx, "/explore", { tone: "attn", message: "Nothing of yours was made from that app." }); | |
| 216 | const p = plan.value; | |
| 217 | // Undo is offered for the copy this tap just made, not one made earlier. | |
| 218 | const justMade = ctx.url.searchParams.get("made") === "1"; | |
| 219 | return page({ | |
| 220 | ctx, | |
| 221 | title: p.existing && !justMade ? `${p.repo} was already yours` : `${p.repo} is yours`, | |
| 222 | body: html`<div class="narrow-wide"> | |
| 223 | ${pageHead(justMade ? receiptWords({ ...p, existing: false }) : receiptWords(p))} | |
| 224 | <div class="form-actions"> | |
| 225 | ${btn("Open mine", { href: `/${p.repo}`, variant: "primary", icon: "fork" })} | |
| 226 | ${when( | |
| 227 | justMade, | |
| 228 | () => | |
| 229 | html`<form method="post" action="${repoBase(repo)}/make-it-yours/undo">${btn("Undo", { | |
| 230 | variant: "ghost", | |
| 231 | })}</form>`, | |
| 232 | )} | |
| 233 | ${btn("Back to explore", { href: "/explore", variant: "ghost" })} | |
| 234 | </div> | |
| 235 | </div>`, | |
| 236 | }); | |
| 237 | } | |
| 238 | ||
| 239 | // POST /:owner/:name/make-it-yours/undo | |
| 240 | export async function makeItYoursUndo(ctx: Ctx): Promise<Response> { | |
| 241 | const repo = loadRepo(ctx); | |
| 242 | if (!repo) return notFoundPage(ctx); | |
| 243 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 244 | const form = await readForm(ctx, "rw"); | |
| 245 | if (form instanceof Response) return form; | |
| 246 | const undone = await undoMakeItYours(ctx.app, ctx.auth.user, repo); | |
| 247 | if (!undone.ok) | |
| 248 | return flashTo(ctx, "/explore", { | |
| 249 | tone: "danger", | |
| 250 | message: undone.error.kind === "changed" ? undone.error.message : "There is nothing to undo.", | |
| 251 | }); | |
| 252 | return flashTo(ctx, "/explore", { message: undone.value.says }); | |
| 253 | } |
test/explore.test.ts +5 −5
| @@ -241,7 +241,7 @@ describe("explore", () => { | ||
| 241 | 241 | headers: { authorization: `Bearer ${bobToken}` }, |
| 242 | 242 | }) |
| 243 | 243 | ).text(); |
| 244 | expect(signedIn).toContain('href="/alice/loved/make-it-yours"'); | |
| 244 | expect(signedIn).toContain('action="/alice/loved/make-it-yours"'); | |
| 245 | 245 | const res = await fetch(`${base}/mcp`, { |
| 246 | 246 | method: "POST", |
| 247 | 247 | headers: { |
| @@ -303,9 +303,9 @@ describe("explore", () => { | ||
| 303 | 303 | const asBob = await ( |
| 304 | 304 | await fetch(`${base}/explore`, { headers: { authorization: `Bearer ${bobToken}` } }) |
| 305 | 305 | ).text(); |
| 306 | expect(asBob).toContain('href="/alice/loved/make-it-yours"'); | |
| 306 | expect(asBob).toContain('action="/alice/loved/make-it-yours"'); | |
| 307 | 307 | // alice/gated is a public repository whose app only its writers enter. |
| 308 | expect(asBob).not.toContain('href="/alice/gated/make-it-yours"'); | |
| 308 | expect(asBob).not.toContain('action="/alice/gated/make-it-yours"'); | |
| 309 | 309 | const rows = (await api("kind:app", bobToken)).items; |
| 310 | 310 | expect(rows.find((r) => r.name === "alice/gated").copyable).toBe(false); |
| 311 | 311 | expect(rows.find((r) => r.name === "alice/loved").copyable).toBe(true); |
| @@ -313,7 +313,7 @@ describe("explore", () => { | ||
| 313 | 313 | const asAlice = await ( |
| 314 | 314 | await fetch(`${base}/explore`, { headers: { authorization: `Bearer ${aliceToken}` } }) |
| 315 | 315 | ).text(); |
| 316 | expect(asAlice).not.toContain('href="/alice/loved/make-it-yours"'); | |
| 316 | expect(asAlice).not.toContain('action="/alice/loved/make-it-yours"'); | |
| 317 | 317 | }); |
| 318 | 318 | |
| 319 | 319 | test("Make it yours is offered to a signed-out visitor on public apps only, linking to the make-it-yours path", async () => { |
| @@ -324,7 +324,7 @@ describe("explore", () => { | ||
| 324 | 324 | expect(rows.find((r) => r.name === "alice/gated").copyable).toBe(false); |
| 325 | 325 | expect(rows.find((r) => r.name === "alice/secret")).toBeUndefined(); |
| 326 | 326 | const page = await (await fetch(`${base}/explore`)).text(); |
| 327 | expect(page).toContain('href="/alice/loved/make-it-yours"'); | |
| 327 | expect(page).toContain('href="/alice/loved/make-it-yours?receipt=1"'); | |
| 328 | 328 | expect(page).not.toContain('href="/alice/gated/make-it-yours"'); |
| 329 | 329 | expect(page).not.toContain("alice/secret"); |
| 330 | 330 | }); |
test/explorer-make-it-yours.test.ts added +194
| @@ -0,0 +1,194 @@ | ||
| 1 | /** | |
| 2 | * Every app row in the explorer says "Make it yours", counted; one tap forks | |
| 3 | * it, with a receipt in plain words and an undo; the MCP says the same. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 14 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 17 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 18 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | describe("explorer: make it yours", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let bobCookie: string; | |
| 29 | let bobToken: string; | |
| 30 | ||
| 31 | const mk = async (username: string, first = false): Promise<User> => { | |
| 32 | const r = await createUser(app.db, { | |
| 33 | origin: "seed", | |
| 34 | trusted: true, | |
| 35 | username, | |
| 36 | email: `${username}@forge.local`, | |
| 37 | password: "a-good-passphrase", | |
| 38 | adminIfFirst: first, | |
| 39 | }); | |
| 40 | if (!r.ok) throw new Error(r.error); | |
| 41 | promote(app.db, r.value); | |
| 42 | return r.value; | |
| 43 | }; | |
| 44 | const post = (path: string, body = "") => | |
| 45 | fetch(`${servers.url}${path}`, { | |
| 46 | method: "POST", | |
| 47 | headers: { cookie: bobCookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, | |
| 48 | body, | |
| 49 | redirect: "manual", | |
| 50 | }); | |
| 51 | const explore = (cookie?: string) => | |
| 52 | fetch(`${servers.url}/explore?q=${encodeURIComponent("kind:app")}`, { | |
| 53 | headers: cookie === undefined ? {} : { cookie }, | |
| 54 | }).then((r) => r.text()); | |
| 55 | ||
| 56 | /** One MCP `make_it_yours` call as bob; its answer's text (a refusal reads `kind: message`). */ | |
| 57 | const said = async (args: object): Promise<string> => { | |
| 58 | const res = await fetch(`${servers.url}/mcp`, { | |
| 59 | method: "POST", | |
| 60 | headers: { authorization: `Bearer ${bobToken}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, | |
| 61 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "make_it_yours", arguments: args } }), | |
| 62 | }); | |
| 63 | const j = (await res.json()) as any; | |
| 64 | return j.result.content[0].text; | |
| 65 | }; | |
| 66 | /** The same call, its answer parsed. */ | |
| 67 | const call = async (args: object) => JSON.parse(await said(args)); | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-explorer-yours-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | servers = await serve(app); | |
| 73 | const alice = await mk("alice", true); | |
| 74 | const bob = await mk("bob"); | |
| 75 | for (const name of ["garden", "kiln"]) { | |
| 76 | const made = await newApp(app, alice, { name, description: name, visibility: "public" }); | |
| 77 | if (!made.ok) throw new Error(String(made.error)); | |
| 78 | } | |
| 79 | bobCookie = `forge_session=${createSession(app.db, bob.id).token}`; | |
| 80 | bobToken = createToken(app.db, bob.id, "rw", "rw").token; | |
| 81 | }, 60_000); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | servers.stop(); | |
| 85 | app.close(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("one action per app row, signed in or out", async () => { | |
| 90 | for (const cookie of [undefined, bobCookie]) { | |
| 91 | const page = await explore(cookie); | |
| 92 | const rows = ["garden", "kiln"].filter((n) => page.includes(`href="/alice/${n}"`)).length; | |
| 93 | expect(rows).toBe(2); | |
| 94 | expect(page.match(/Make it yours/g)?.length).toBe(rows); | |
| 95 | } | |
| 96 | // Signed in, the action is a form that posts: one tap. | |
| 97 | expect((await explore(bobCookie)).match(/data-make-it-yours/g)?.length).toBe(2); | |
| 98 | }); | |
| 99 | ||
| 100 | test("one tap forks, the receipt says so, undo takes it back", async () => { | |
| 101 | const tapped = await post("/alice/garden/make-it-yours", "receipt=1"); | |
| 102 | expect(tapped.status).toBe(303); | |
| 103 | expect(tapped.headers.get("location")).toBe("/alice/garden/make-it-yours/receipt?made=1"); | |
| 104 | const copy = getRepo(app.db, "bob", "garden")!; | |
| 105 | expect(copy.forkParentId).not.toBeNull(); | |
| 106 | const receipt = await ( | |
| 107 | await fetch(`${servers.url}/alice/garden/make-it-yours/receipt?made=1`, { headers: { cookie: bobCookie } }) | |
| 108 | ).text(); | |
| 109 | expect(receipt).toContain("bob/garden is yours"); | |
| 110 | expect(receipt).toContain("Undo"); | |
| 111 | const undone = await post("/alice/garden/make-it-yours/undo"); | |
| 112 | expect(undone.status).toBe(303); | |
| 113 | expect(getRepo(app.db, "bob", "garden")).toBeUndefined(); | |
| 114 | expect(getRepo(app.db, "alice", "garden")).toBeDefined(); | |
| 115 | }); | |
| 116 | ||
| 117 | test("a cross-origin tap is refused", async () => { | |
| 118 | const res = await fetch(`${servers.url}/alice/kiln/make-it-yours`, { | |
| 119 | method: "POST", | |
| 120 | headers: { cookie: bobCookie, origin: "https://evil.example" }, | |
| 121 | redirect: "manual", | |
| 122 | }); | |
| 123 | expect(res.status).toBe(403); | |
| 124 | expect(getRepo(app.db, "bob", "kiln")).toBeUndefined(); | |
| 125 | }); | |
| 126 | ||
| 127 | test("the MCP says the same words and undoes", async () => { | |
| 128 | const made = await call({ repo: "alice/kiln", dryRun: false }); | |
| 129 | expect(made.says).toContain("bob/kiln is yours"); | |
| 130 | expect(getRepo(app.db, "bob", "kiln")).toBeDefined(); | |
| 131 | const undone = await call({ repo: "alice/kiln", undo: true }); | |
| 132 | expect(undone.says).toBe("bob/kiln is removed. The original was not touched."); | |
| 133 | expect(getRepo(app.db, "bob", "kiln")).toBeUndefined(); | |
| 134 | // Nothing is left to undo. | |
| 135 | expect(await said({ repo: "alice/kiln", undo: true })).toBe( | |
| 136 | "not_found: nothing of yours was made from that app", | |
| 137 | ); | |
| 138 | }); | |
| 139 | ||
| 140 | test("signed-out visitors cannot undo, and the receipt of an older copy offers no Undo", async () => { | |
| 141 | await post("/alice/garden/make-it-yours", "receipt=1"); | |
| 142 | const out = await fetch(`${servers.url}/alice/garden/make-it-yours/undo`, { | |
| 143 | method: "POST", | |
| 144 | headers: { origin: servers.url }, | |
| 145 | redirect: "manual", | |
| 146 | }); | |
| 147 | expect(out.status).toBe(303); | |
| 148 | expect(out.headers.get("location")).toContain("/signin"); | |
| 149 | expect(getRepo(app.db, "bob", "garden")).toBeDefined(); | |
| 150 | const again = await fetch(`${servers.url}/alice/garden/make-it-yours/receipt`, { headers: { cookie: bobCookie } }); | |
| 151 | const text = await again.text(); | |
| 152 | expect(text).toContain("already yours"); | |
| 153 | expect(text).not.toContain("Undo"); | |
| 154 | const fresh = await fetch(`${servers.url}/alice/garden/make-it-yours/receipt?made=1`, { headers: { cookie: bobCookie } }); | |
| 155 | expect(await fresh.text()).toContain("Undo"); | |
| 156 | }); | |
| 157 | ||
| 158 | test("undo is refused when the copy has its own commits, and the app stays on", async () => { | |
| 159 | const copy = getRepo(app.db, "bob", "garden")!; | |
| 160 | const b = getUserByName(app.db, "bob")!; | |
| 161 | const made = await commitToBranch(app, b, copy, { message: "mine", files: [{ path: "mine.txt", content: "x\n" }] }); | |
| 162 | expect(made.ok).toBe(true); | |
| 163 | const res = await post("/alice/garden/make-it-yours/undo"); | |
| 164 | expect(res.status).toBe(303); | |
| 165 | expect(getRepo(app.db, "bob", "garden")).toBeDefined(); | |
| 166 | expect(getAppByRepo(app.db, copy.id)).toBeDefined(); | |
| 167 | }); | |
| 168 | ||
| 169 | test("undo is refused, with the app still on, when a pull was sent from the copy", async () => { | |
| 170 | const bobUser = getUserByName(app.db, "bob")!; | |
| 171 | const orig = getRepo(app.db, "alice", "kiln")!; | |
| 172 | await post("/alice/kiln/make-it-yours", "receipt=1"); | |
| 173 | const copy = getRepo(app.db, "bob", "kiln")!; | |
| 174 | const c = await commitToBranch(app, bobUser, copy, { message: "mine", files: [{ path: "m.txt", content: "x\n" }] }); | |
| 175 | expect(c.ok).toBe(true); | |
| 176 | const pull = await openPull(app, bobUser, orig, { title: "mine", baseBranch: "main", headBranch: "main", headRepo: copy }); | |
| 177 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 178 | // Taken into the original, the copy has nothing ahead: only the pull keeps it. | |
| 179 | const merged = await mergePull(app, getUserByName(app.db, "alice")!, orig, pull.value, "merge"); | |
| 180 | if (!merged.ok) throw new Error(String(merged.error)); | |
| 181 | const res = await post("/alice/kiln/make-it-yours/undo"); | |
| 182 | expect(res.status).toBe(303); | |
| 183 | expect(getRepo(app.db, "bob", "kiln")).toBeDefined(); | |
| 184 | expect(getAppByRepo(app.db, copy.id)).toBeDefined(); | |
| 185 | }); | |
| 186 | ||
| 187 | test("the MCP undo answers `changed` for a copy with commits of its own, and keeps it", async () => { | |
| 188 | // bob/garden has bob's own commit from the test above. | |
| 189 | expect(await said({ repo: "alice/garden", undo: true })).toBe( | |
| 190 | "changed: bob/garden has changes of your own, so it is kept. Delete it in its settings if you mean to.", | |
| 191 | ); | |
| 192 | expect(getRepo(app.db, "bob", "garden")).toBeDefined(); | |
| 193 | }); | |
| 194 | }); |
test/server-timing-sweep.test.ts +1
| @@ -41,6 +41,7 @@ const REDIRECTS = new Set([ | ||
| 41 | 41 | "/alice/forge/changes", |
| 42 | 42 | "/alice/forge/files", |
| 43 | 43 | "/alice/forge/following", |
| 44 | "/alice/forge/make-it-yours/receipt", | |
| 44 | 45 | "/alice/forge/settings/autonomy", |
| 45 | 46 | "/alice/forge/settings/crew", |
| 46 | 47 | "/alice/forge/tells", |
test/tui-working-yours.test.ts +51 −4
| @@ -7,9 +7,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { newApp } from "../src/actions/apps.ts"; | |
| 11 | import { makeItYours } from "../src/actions/make_it_yours.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 13 | 15 | import { createIssue } from "../src/core/issues.ts"; |
| 14 | 16 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 15 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -19,6 +21,7 @@ import { local } from "../src/tui/screens/where.ts"; | ||
| 19 | 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 20 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 21 | 23 | import { settleApp } from "./helpers/tui.ts"; |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 22 | 25 | import { proveAll } from "./helpers/verified.ts"; |
| 23 | 26 | |
| 24 | 27 | quietLogs(); |
| @@ -38,7 +41,7 @@ const SHOW_MS = 2000; | ||
| 38 | 41 | * caller expects a row (`shows`) that row is on screen. A negative assertion |
| 39 | 42 | * made on the result therefore reads a drawn list, not an empty frame. |
| 40 | 43 | */ |
| 41 | async function palette(user: User, keys: string[], shows?: string): Promise<string> { | |
| 44 | async function palette(user: User, keys: string[], shows?: string, on: Repo = repo): Promise<string> { | |
| 42 | 45 | const terminal = headlessTerminal(120, 40); |
| 43 | 46 | const { app: tui, session } = createForgeApp( |
| 44 | 47 | app, |
| @@ -46,7 +49,7 @@ async function palette(user: User, keys: string[], shows?: string): Promise<stri | ||
| 46 | 49 | { terminal, exitHooks: false }, |
| 47 | 50 | ); |
| 48 | 51 | tui.start(); |
| 49 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo))); | |
| 52 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, on))); | |
| 50 | 53 | await settleApp(tui); |
| 51 | 54 | for (const k of keys) { |
| 52 | 55 | terminal.feed(k); |
| @@ -131,3 +134,47 @@ describe("make it yours in the terminal", () => { | ||
| 131 | 134 | expect(text).toContain("Make alice/lobby your own"); |
| 132 | 135 | }); |
| 133 | 136 | }); |
| 137 | ||
| 138 | describe("undo make it yours in the terminal", () => { | |
| 139 | let garden: Repo; | |
| 140 | beforeAll(async () => { | |
| 141 | promote(app.db, bob); | |
| 142 | const made = await newApp(app, alice, { name: "garden", description: "garden", visibility: "public" }); | |
| 143 | if (!made.ok) throw new Error(String(made.error)); | |
| 144 | garden = getRepo(app.db, "alice", "garden")!; | |
| 145 | }, 60_000); | |
| 146 | ||
| 147 | const copy = async (): Promise<Repo> => { | |
| 148 | const made = await makeItYours(app, bob, garden, { dryRun: false }); | |
| 149 | if (!made.ok) throw new Error(made.error.kind); | |
| 150 | return getRepo(app.db, "bob", "garden")!; | |
| 151 | }; | |
| 152 | const undo = (shows: string) => palette(bob, [":", "Undo make", "\r", "\r"], shows, garden); | |
| 153 | ||
| 154 | test("offered only once a copy exists", async () => { | |
| 155 | expect(getRepo(app.db, "bob", "garden")).toBeUndefined(); | |
| 156 | expect(await palette(bob, [":", "Undo make"], undefined, garden)).not.toContain("Undo make it yours"); | |
| 157 | await copy(); | |
| 158 | expect(await palette(bob, [":", "Undo make"], "Undo make it yours", garden)).toContain( | |
| 159 | "alice/garden — Undo make it yours", | |
| 160 | ); | |
| 161 | }); | |
| 162 | ||
| 163 | test("confirming removes the copy and leaves the original", async () => { | |
| 164 | if (getRepo(app.db, "bob", "garden") === undefined) await copy(); | |
| 165 | expect(await undo("is removed")).toContain("bob/garden is removed. The original was not touched."); | |
| 166 | expect(getRepo(app.db, "bob", "garden")).toBeUndefined(); | |
| 167 | expect(getRepo(app.db, "alice", "garden")).toBeDefined(); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a copy with commits of its own is kept, and the toast says why", async () => { | |
| 171 | const mine = await copy(); | |
| 172 | const c = await commitToBranch(app, bob, mine, { | |
| 173 | message: "mine", | |
| 174 | files: [{ path: "mine.txt", content: "x\n" }], | |
| 175 | }); | |
| 176 | expect(c.ok).toBe(true); | |
| 177 | expect(await undo("so it is kept")).toContain("bob/garden has changes of your own, so it is kept."); | |
| 178 | expect(getRepo(app.db, "bob", "garden")).toBeDefined(); | |
| 179 | }); | |
| 180 | }); |
disableApp runs before deleteRepo can fail with has_pulls, so a refused undo leaves the copy with its app disabled. Check pulls first or roll back.