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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10713-every-app-in-the-explorer-says-make-it-y → main 5 commits fork 4 threads · 4 unresolved

  • Merged. mason/10713-every-app-in-the-explorer-says-make-it-y was merged into main as 49eeb81.

Review

Sign in to review this change.
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";
4444 import { requireVerified } from "./verify.ts";
4545 import { requireAbility } from "./roles.ts";
4646 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";
4950
5051 export interface MakeItYoursPlan {
5152 readonly dryRun: boolean;
@@ -263,3 +264,66 @@ export async function makeItYours(
263264 });
264265 return ok({ ...plan, made: { repo: copy, app: enabled.value } });
265266 }
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 });

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.

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 @@
11 import { APP_ALREADY_YOURS } from "../../../actions/origin_app_words.ts";
22 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";
49 import { PROVE_FIRST } from "../../../actions/verify_words.ts";
510 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
611 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
712
813 export const tool: Tool = {
914 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",
1116 description:
1217 "Fork an app you can see: code and history, following the original.",
1318 inputSchema: {
@@ -17,6 +22,7 @@ export const tool: Tool = {
1722 name: { type: "string" },
1823 visibility: { type: "string", enum: ["private", "public"] },
1924 dryRun: { type: "boolean" },
25 undo: { type: "boolean" },
2026 },
2127 required: ["repo"],
2228 },
@@ -25,6 +31,17 @@ export const tool: Tool = {
2531 async run(ctx, user, args) {
2632 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.

2733 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 }
2845 const name = str(args["name"]);
2946 const visibility = str(args["visibility"]);
3047 if (visibility !== "" && visibility !== "public" && visibility !== "private")
@@ -52,7 +69,9 @@ export const tool: Tool = {
5269 : bad("not_found", "no such app or repository (owner/name)");
5370 const { made: done, ...plan } = made.value;
5471 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}` },
5675 );
5776 },
5877 };
src/http/server.ts +4
@@ -290,6 +290,8 @@ import { isPacksMirror, MIRROR_PUSH_REFUSED } from "../packs/owner.ts";
290290 import { packPage, packsMirror, packsPage } from "../web/pages/packs.ts";
291291 import {
292292 makeItYoursPage,
293 makeItYoursReceipt,
294 makeItYoursUndo,
293295 makeItYoursSubmit,
294296 } from "../web/studio/make_it_yours.ts";
295297 import {
@@ -1437,6 +1439,8 @@ export function buildRouter(): Router<Handler> {
14371439 // Make it yours (#606): a fork with its app, planned, then made.
14381440 router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage);
14391441 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);
14401444 // The toolbar's actions (#810): the bar links here, the forms post here.
14411445 router.add("GET", "/:owner/:name/toolbar", toolbarPage);
14421446 router.add("POST", "/:owner/:name/toolbar/comment", toolbarComment);
src/tui/screens/yours.ts +61 −2
@@ -4,7 +4,13 @@
44 * repository. It is the fork screen (#606) the `F` key opens.
55 */
66
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";
814 import { APP_ALREADY_YOURS } from "../../actions/origin_app_words.ts";
915 import { PROVE_FIRST } from "../../actions/verify_words.ts";
1016 import { NOT_A_MEMBER } from "../../core/roles.ts";
@@ -65,7 +71,12 @@ export function makeItYoursScreen(f: Session, ctx: Ctx, source: Repo): Screen {
6571 if (!made.ok) return refuse(made.error);
6672 const done = made.value.made;
6773 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 );
6980 ctx.replace(repoScreen(f, ctx, local(f, done.repo)));
7081 });
7182
@@ -147,9 +158,57 @@ export function makeItYoursScreen(f: Session, ctx: Ctx, source: Repo): Screen {
147158 return self;
148159 }
149160
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
150196 export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
151197 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);
152201 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 ]),
153212 {
154213 id: "make-it-yours",
155214 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 } {
4848 };
4949 }
5050
51 function rowHtml(row: ExploreRow): Html {
51 function rowHtml(row: ExploreRow, signedIn: boolean): Html {
5252 const { apps, rest } = numbers(row);
5353 // One tap from a person to their apps; with none, the count is plain text.
5454 const theirs = href({ kind: "app", sort: "score", text: "", archived: false, updated: "all", owner: row.name });
@@ -64,11 +64,16 @@ function rowHtml(row: ExploreRow): Html {
6464 ...(parts.length === 0 ? {} : { meta: metaHtml }),
6565 ...(row.copyable === true && row.repo !== undefined
6666 ? {
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 }),
7277 }
7378 : {}),
7479 });
@@ -217,7 +222,10 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
217222 </div>
218223 ${rows.length === 0
219224 ? 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>`;
221229 const drawn = page({ ctx, title: "Explore", body });
222230 // An unknown filter is a 422 here as on the API and the MCP; the page still draws the note and the results.
223231 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 @@
55 * `actions/make_it_yours.ts`.
66 */
77
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";
99 import type { Ctx } from "../../http/context.ts";
1010 import {
1111 loadRepo,
@@ -96,6 +96,10 @@ export async function makeItYoursPage(ctx: Ctx): Promise<Response> {
9696 action="${repoBase(repo)}/make-it-yours"
9797 class="stack"
9898 >
99 ${when(
100 ctx.url.searchParams.get("receipt") === "1",
101 () => html`<input type="hidden" name="receipt" value="1" />`,
102 )}
99103 <ul class="list setting-list">
100104 ${row({ title: "Yours as", trail: html`<span class="mono">${p.repo}</span>` })}
101105 ${row({ title: "Its address", trail: html`<span class="mono">${p.slug}</span>` })}
@@ -179,11 +183,17 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
179183 : made.error.kind === "unverified"
180184 ? "Prove your email first: the link is in your inbox."
181185 : 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.`
183188 : "That could not be made yours."
184189 : "That could not be made yours.";
185190 return flashTo(ctx, repoBase(repo), { tone: "danger", message });
186191 }
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 });
187197 return redirect(repoBase(made.value.made.repo), {
188198 url: ctx.url,
189199 forgeUrl: ctx.app.config.url,
@@ -194,3 +204,50 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
194204 },
195205 });
196206 }
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", () => {
241241 headers: { authorization: `Bearer ${bobToken}` },
242242 })
243243 ).text();
244 expect(signedIn).toContain('href="/alice/loved/make-it-yours"');
244 expect(signedIn).toContain('action="/alice/loved/make-it-yours"');
245245 const res = await fetch(`${base}/mcp`, {
246246 method: "POST",
247247 headers: {
@@ -303,9 +303,9 @@ describe("explore", () => {
303303 const asBob = await (
304304 await fetch(`${base}/explore`, { headers: { authorization: `Bearer ${bobToken}` } })
305305 ).text();
306 expect(asBob).toContain('href="/alice/loved/make-it-yours"');
306 expect(asBob).toContain('action="/alice/loved/make-it-yours"');
307307 // 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"');
309309 const rows = (await api("kind:app", bobToken)).items;
310310 expect(rows.find((r) => r.name === "alice/gated").copyable).toBe(false);
311311 expect(rows.find((r) => r.name === "alice/loved").copyable).toBe(true);
@@ -313,7 +313,7 @@ describe("explore", () => {
313313 const asAlice = await (
314314 await fetch(`${base}/explore`, { headers: { authorization: `Bearer ${aliceToken}` } })
315315 ).text();
316 expect(asAlice).not.toContain('href="/alice/loved/make-it-yours"');
316 expect(asAlice).not.toContain('action="/alice/loved/make-it-yours"');
317317 });
318318
319319 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", () => {
324324 expect(rows.find((r) => r.name === "alice/gated").copyable).toBe(false);
325325 expect(rows.find((r) => r.name === "alice/secret")).toBeUndefined();
326326 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"');
328328 expect(page).not.toContain('href="/alice/gated/make-it-yours"');
329329 expect(page).not.toContain("alice/secret");
330330 });
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([
4141 "/alice/forge/changes",
4242 "/alice/forge/files",
4343 "/alice/forge/following",
44 "/alice/forge/make-it-yours/receipt",
4445 "/alice/forge/settings/autonomy",
4546 "/alice/forge/settings/crew",
4647 "/alice/forge/tells",
test/tui-working-yours.test.ts +51 −4
@@ -7,9 +7,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 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";
1113 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";
1315 import { createIssue } from "../src/core/issues.ts";
1416 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1517 import { createUser, type User } from "../src/core/users.ts";
@@ -19,6 +21,7 @@ import { local } from "../src/tui/screens/where.ts";
1921 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2022 import { quietLogs } from "./helpers/globals.ts";
2123 import { settleApp } from "./helpers/tui.ts";
24 import { promote } from "./helpers/members.ts";
2225 import { proveAll } from "./helpers/verified.ts";
2326
2427 quietLogs();
@@ -38,7 +41,7 @@ const SHOW_MS = 2000;
3841 * caller expects a row (`shows`) that row is on screen. A negative assertion
3942 * made on the result therefore reads a drawn list, not an empty frame.
4043 */
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> {
4245 const terminal = headlessTerminal(120, 40);
4346 const { app: tui, session } = createForgeApp(
4447 app,
@@ -46,7 +49,7 @@ async function palette(user: User, keys: string[], shows?: string): Promise<stri
4649 { terminal, exitHooks: false },
4750 );
4851 tui.start();
49 tui.ctx.push(repoScreen(session, tui.ctx, local(session, repo)));
52 tui.ctx.push(repoScreen(session, tui.ctx, local(session, on)));
5053 await settleApp(tui);
5154 for (const k of keys) {
5255 terminal.feed(k);
@@ -131,3 +134,47 @@ describe("make it yours in the terminal", () => {
131134 expect(text).toContain("Make alice/lobby your own");
132135 });
133136 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs