Public: anyone can read this; the crew can change it.

The terminal's repository screen makes an app you can see your own with `Y`, planned first, as MCP `make_it_yours` does #7305

Merged opened this

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

Merged

scout/forge:scout/7270-the-terminal-s-repository-screen-makes-a → main 5 commits fork 6 threads · 6 unresolved

  • Merged. scout/7270-the-terminal-s-repository-screen-makes-a was merged into main as c177ee1.

Review

Sign in to review this change.
6 files changed +340 −10
docs/APPS.md +1 −1
@@ -148,7 +148,7 @@ Under the box, four starter sentences are plain links (no script):
148148 screen: *Working now* (while an agent run is live, left out when it is on work
149149 you may not see), *Tell the crew*, *Report a problem* (one text, an issue
150150 titled by its first line, through `openIssueThrough`) and *Make it yours*
151 (the fork screen, for those who cannot write the repository), and *Vision*
151 (for those who cannot write the repository: `Y` on the repository screen, or the palette row, asks for a name, defaulting to the original's, and a visibility, `tab` to switch, then shows the plan and copies only after a confirm, and the copy opens), and *Vision*
152152 (docs/VISION.md on the default branch: the purpose, each pillar numbered,
153153 the rules, through the one read MCP `vision` makes; when there is none the
154154 terminal says so in one line of its own, where MCP answers an empty `order`); a pull is
docs/STUDIO.md +1 −1
@@ -469,7 +469,7 @@ count of apps links there), `archived:yes` (put-away repositories too),
469469 and words to match. An agent's working fork is how the crew builds, not a
470470 find, and is left out; a private app's hours are told only to those who may
471471 enter it. Every app row offers **Make it yours** (a fork) to someone signed
472 in. The same rows: `GET /api/v1/explore?q=…` and the MCP tool `explore`
472 in (in the terminal, `Y` on a repository screen: name, `tab` for visibility, the plan, then a confirm). The same rows: `GET /api/v1/explore?q=…` and the MCP tool `explore`
473473 (`actions/explore.ts`). It is in the account menu and the palette, and the
474474 front page's "All public apps" leads here.
475475
src/tui/screens/workspace.ts +9 −2
@@ -43,7 +43,7 @@ import { isPinned } from "../../core/pins.ts";
4343 import { setDecisionsBy } from "../../actions/repos.ts";
4444 import { stopMirroring } from "../../actions/mirror_target.ts";
4545 import { getMirrorTarget, mirrorLine } from "../../core/migrate_mirror.ts";
46 import { canAdmin, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts";
46 import { canAdmin, canWrite, DECISIONS_BY, getRepoById, type Repo } from "../../core/repos.ts";
4747 import type { TreeEntry } from "../../git/read.ts";
4848 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
4949 import type { RepoFacts, RepoSource } from "../../view/source.ts";
@@ -122,7 +122,7 @@ import { reportCommands } from "./report.ts";
122122 import { reportsCommands } from "./reports.ts";
123123 import { tellCommands } from "./tell.ts";
124124 import { workingCommands } from "./working.ts";
125 import { yoursCommands } from "./yours.ts";
125 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
126126 import { visionCommands } from "./vision.ts";
127127 import { appEnvCommands } from "./appenv.ts";
128128 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
@@ -1340,6 +1340,9 @@ export function workspaceScreen(
13401340 { disabled: !f.signedIn },
13411341 ),
13421342 ]),
1343 ...(repo === undefined || canWrite(f.db, repo, f.user)
1344 ? []
1345 : [binding(["Y"], "make it yours", { disabled: !f.signedIn })]),
13431346 ...(repo === undefined
13441347 ? []
13451348 : [
@@ -1727,6 +1730,10 @@ export function workspaceScreen(
17271730 ctx.invalidate();
17281731 return true;
17291732 }
1733 if (plain && e.key === "Y" && f.signedIn && repo !== undefined) {
1734 ctx.push(makeItYoursScreen(f, ctx, repo));
1735 return true;
1736 }
17301737 if (plain && e.key === "F" && f.signedIn) {
17311738 // A repository that lives at an origin is forked *there* — a local
17321739 // fork of a mirror would be a copy of a copy, with nowhere to
src/tui/screens/yours.ts +140 −3
@@ -4,11 +4,148 @@
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";
8 import { APP_ALREADY_YOURS } from "../../actions/origin_app_words.ts";
9 import { PROVE_FIRST } from "../../actions/verify_words.ts";
10 import { NOT_A_MEMBER } from "../../core/roles.ts";
711 import { canWrite, type Repo } from "../../core/repos.ts";
8 import type { Ctx } from "../app/app.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
914 import type { Command } from "../app/palette.ts";
1015 import type { Session } from "../session.ts";
11 import { forkScreen } from "./newrepo.ts";
16 import { style } from "../term/style.ts";
17 import { truncate } from "../term/width.ts";
18 import { field } from "../ui/widgets.ts";
19 import { confirmScreen, runAsync } from "./common.ts";
20 import { repoScreen } from "./repo.ts";
21 import { local } from "./where.ts";
22
23 /** A refusal in the words MCP `make_it_yours` uses; a new error kind fails the type check here. */
24 function refusalText(error: MakeItYoursError): string {
25 switch (error.kind) {
26 case "limit":
27 return `at most ${COPIES_PER_DAY} copies a day; another after ${error.resetsAt}`;
28 case "repository_limit":
29 case "storage_limit":
30 return error.message;
31 case "invalid":
32 return error.message;
33 case "own":
34 return APP_ALREADY_YOURS;
35 case "not_member":
36 return NOT_A_MEMBER;
37 case "unverified":
38 return PROVE_FIRST;
39 case "not_found":
40 case "unauthorized":
41 return "no such app or repository (owner/name)";
42 }
43 }
44
45 /**
46 * `Y` on a repository you can see but do not own: a name (the original's) and
47 * a visibility, the plan as MCP `make_it_yours` plans it, and a copy only
48 * after a confirm. The copy opens on success.
49 */
50 export function makeItYoursScreen(f: Session, ctx: Ctx, source: Repo): Screen {
51 let name = source.name;
52 let visibility: "public" | "private" = "private";
53 let error: string | undefined;
54 const from = `${source.ownerName}/${source.name}`;
55 const input = (dryRun: boolean) => ({ dryRun, name, visibility });
56
57 const refuse = (e: MakeItYoursError): void => {
outdated

The existing branch ("You already have …: it opens.", Open → make() → the copy opens with no second repository) has no test. Add one: copy once, press Y again, assert the wording, confirm, and assert there is still exactly one fork.

58 error = refusalText(e);
59 ctx.toast(error, "danger");
60 };
61
62 const make = (): void =>
63 runAsync(ctx, async () => {
64 const made = await makeItYours(f.app, f.user, source, input(false));
65 if (!made.ok) return refuse(made.error);
66 const done = made.value.made;
67 if (done === undefined) return;
68 ctx.toast(`${done.repo.ownerName}/${done.repo.name} is yours`, "success");
69 ctx.replace(repoScreen(f, ctx, local(f, done.repo)));
70 });
71
72 const plan = (): void =>
73 runAsync(ctx, async () => {
74 const planned = await makeItYours(f.app, f.user, source, input(true));
75 if (!planned.ok) return refuse(planned.error);
76 const p = planned.value;
77 ctx.push(
78 confirmScreen(ctx, self, {
79 title: "Make it yours",
80 body: p.existing
81 ? [`You already have ${p.repo}: it opens.`]
82 : [
83 `${from} -> ${p.repo} (${p.visibility})`,
84 `Copied: ${p.copied.join(", ")}.`,
85 `Not copied: ${p.notCopied.join(", ")}.`,
86 "It follows the original.",
87 `Copies left today: ${p.copiesLeft}.`,
88 ],
89 actions: [{ label: p.existing ? "Open" : "Make it yours" }, { label: "Cancel" }],
90 onChoose: (choice) => {
91 if (choice === 0) make();
92 },
93 }),
94 );
95 });
96
97 const self: Screen = {
98 title: "Make it yours",
99 keys: [
100 binding(["enter"], "plan"),
101 binding(["tab"], "visibility"),
102 binding(["esc"], "cancel"),
103 ],
104 render(area, s) {
105 const c = ctx.theme.c;
outdated

Untested: tab to public, and the refusal for a private original ("a copy of a private repository stays private") that comes back through refuse. Add a test that presses tab, sees (public) in the plan, and one where a private original is refused in those words.

106 s.text(
107 area.x,
108 area.y,
109 truncate(`Make ${from} your own`, area.w),
110 style(c.fg, c.bg),
111 area.x + area.w,
112 );
113 const cursor = field(s, { x: area.x, y: area.y + 2, w: Math.min(60, area.w), h: 3 }, c, {
114 label: `Name (${visibility}, tab to change)`,
115 value: name,
116 cursor: [...name].length,
117 focused: true,
118 ...(error === undefined
119 ? { hint: "Code and history are copied, data and secrets are not." }
120 : { error }),
121 });
122 ctx.setCursor(cursor);
123 },
124 key(e) {
125 if (e.key === "enter") {
126 plan();
127 return true;
128 }
129 if (e.key === "tab") {
130 visibility = visibility === "private" ? "public" : "private";
131 error = undefined;
132 return true;
133 }
134 if (e.key === "backspace") {
135 name = [...name].slice(0, -1).join("");
136 error = undefined;
137 return true;
138 }
139 if (!e.ctrl && !e.alt && [...e.key].length === 1) {
140 name += e.key;
141 error = undefined;
142 return true;
143 }
144 return false;
145 },
146 };
147 return self;
148 }
12149
13150 export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
14151 if (repo === undefined || f.user === undefined || canWrite(f.db, repo, f.user)) return [];
@@ -17,7 +154,7 @@ export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Com
17154 id: "make-it-yours",
18155 title: `${repo.ownerName}/${repo.name} — Make it yours`,
19156 hint: "fork it",
20 run: () => ctx.push(forkScreen(f, ctx, repo)),
157 run: () => ctx.push(makeItYoursScreen(f, ctx, repo)),
21158 },
22159 ];
23160 }
test/tui-make-it-yours.test.ts added +186
@@ -0,0 +1,186 @@
1 /** The terminal's repository screen makes an app yours with `Y`: plan, confirm, copy (#7270). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { newApp } from "../src/actions/apps.ts";
7 import { COPIES_PER_DAY, makeItYours } from "../src/actions/make_it_yours.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import { getRepo, type Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { createForgeApp } from "../src/tui/forge.ts";
12 import { repoScreen } from "../src/tui/screens/repo.ts";
13 import { local } from "../src/tui/screens/where.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { promote } from "./helpers/members.ts";
17 import { settleApp } from "./helpers/tui.ts";
18 import { proveAll } from "./helpers/verified.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: Forge;
24 let alice: User;
25 let bob: User;
26 let carol: User;
27 let n = 0;
28
29 const mkUser = async (username: string, first = false): Promise<User> => {
30 const r = await createUser(forge.db, {
31 origin: "seed",
32 trusted: true,
33 username,
34 email: `${username}@forge.test`,
35 password: "a-long-passphrase",
36 adminIfFirst: first,
37 });
38 if (!r.ok) throw new Error(r.error);
39 return r.value;
40 };
41
42 const original = async (name = `orchard${n++}`): Promise<Repo> => {
43 const made = await newApp(forge, alice, { name, visibility: "public" });
44 if (!made.ok) throw new Error(String(made.error));
45 return made.value.repo;
46 };
47
48 beforeAll(async () => {
49 dir = await mkdtemp(join(tmpdir(), "forge-tui-yours-"));
50 forge = createApp({ dataDir: dir, port: 0 });
51 alice = await mkUser("alice", true);
52 bob = await mkUser("bob");
53 carol = await mkUser("carol");
54 promote(forge.db);
55 proveAll(forge);
56 }, 60_000);
57 afterAll(async () => {
58 forge.close?.();
59 await rm(dir, { recursive: true, force: true });
60 });
61
62 async function open(user: User | undefined, repo: Repo) {
63 const { app, session } = createForgeApp(
64 forge,
65 { sshUsername: user?.username ?? "anon", remoteAddress: "127.0.0.1", user },
66 { terminal: headlessTerminal(120, 40), exitHooks: false },
67 );
68 app.start();
69 await settleApp(app);
70 app.push(repoScreen(session, app.ctx, local(session, repo)));
71 await settleApp(app);
72 const press = async (key: string): Promise<string> => {
73 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
74 await settleApp(app);
75 return app.screen.toText();
76 };
77 return { app, press };
78 }
79
80 test("Y plans first: what is copied and what is not, and nothing is made", async () => {
81 const repo = await original();
82 const h = await open(bob, repo);
83 const text0 = await h.press("Y");
84 expect(text0).toContain("Make it yours");
85 const text = await h.press("enter");
86 expect(text).toContain(`alice/${repo.name} -> bob/${repo.name} (private)`);
87 expect(text).toContain("the code, its history");
88 expect(text).toContain("the app's data");
89 expect(text).toContain("follows the original");
90 expect(getRepo(forge.db, "bob", repo.name)).toBeUndefined();
91 h.app.stop();
92 });
93
94 test("a confirm copies, and the copy opens", async () => {
95 const repo = await original();
96 const h = await open(bob, repo);
97 await h.press("Y");
98 await h.press("enter");
99 const text = await h.press("enter");
outdated

bob/<name> is also in the success toast (… is yours), so this passes even if ctx.replace(repoScreen(…)) were deleted. Assert something only the copy's repository screen renders, e.g. its header or key bar, or that the top screen is no longer titled "Make it yours".

100 expect(getRepo(forge.db, "bob", repo.name)).toBeDefined();
101 expect(text).not.toContain("Make it yours");
102 expect(text).toContain(`› bob/${repo.name}`);
103 h.app.stop();
104 });
105
106 test("esc copies nothing", async () => {
107 const repo = await original();
108 const h = await open(bob, repo);
109 await h.press("Y");
110 await h.press("enter");
111 await h.press("esc"); // the confirm
112 await h.press("esc"); // the screen
113 expect(getRepo(forge.db, "bob", repo.name)).toBeUndefined();
114 h.app.stop();
115 });
116
117 test("a sixth copy in a day is refused in MCP's words, and nothing is made", async () => {
118 for (let i = 0; i < COPIES_PER_DAY; i++) {

Non-blocking: this for is indented four spaces too far.

119 const r = await makeItYours(forge, carol, await original(), { dryRun: false });
120 if (!r.ok) throw new Error(JSON.stringify(r.error));
121 }
122 const repo = await original();
123 const h = await open(carol, repo);
124 await h.press("Y");
125 await h.press("enter");
126 const text = await h.press("enter");
127 expect(text).toContain(`at most ${COPIES_PER_DAY} copies a day; another after `);
128 expect(getRepo(forge.db, "carol", repo.name)).toBeUndefined();
129 h.app.stop();
130 });
131
132 test("your own repository is refused as 'already yours'", async () => {
133 const repo = await original();
134 const h = await open(alice, repo);
135 await h.press("Y");
136 const text = await h.press("enter");
137 expect(text).toContain("this app is already yours");
138 h.app.stop();
139 });
140
141 test("tab makes the copy public, and the plan says so", async () => {
outdated

No need for a dynamic import and a raw SQL lookup: mkUser already returns the User. Keep bob/carol in lets the way alice is kept.

142 const repo = await original();
143 const h = await open(bob, repo);
144 await h.press("Y");
145 await h.press("tab");
146 const text = await h.press("enter");
147 expect(text).toContain(`alice/${repo.name} -> bob/${repo.name} (public)`);
148 h.app.stop();
149 });
150
151 test("a private original is never copied public: refused in the action's words", async () => {
152 const made = await newApp(forge, bob, { name: "secret-plot", visibility: "private" });
153 if (!made.ok) throw new Error(String(made.error));
154 const h = await open(alice, made.value.repo); // a forge admin may read it
155 await h.press("Y");
156 await h.press("tab");
157 const text = await h.press("enter");
158 expect(text).toContain("a copy of a private repository stays private");
159 expect(getRepo(forge.db, "alice", "secret-plot")).toBeUndefined();
160 h.app.stop();
161 });
162
163 test("a copy you already have is opened, not made twice", async () => {
164 const repo = await original();
165 const first = await makeItYours(forge, bob, repo, { dryRun: false });
166 if (!first.ok) throw new Error(JSON.stringify(first.error));
167 const h = await open(bob, repo);
168 await h.press("Y");
169 const plan = await h.press("enter");
170 expect(plan).toContain(`You already have bob/${repo.name}: it opens.`);
171 const text = await h.press("enter");
172 expect(text).toContain(`› bob/${repo.name}`);
173 const copies = forge.db
174 .query<{ n: number }, [string, string]>("SELECT count(*) AS n FROM repo WHERE owner_id = ?1 AND fork_parent_id = ?2")
175 .get(bob.id, repo.id)!.n;
176 expect(copies).toBe(1);
177 h.app.stop();
178 });
179
180 test("Y does nothing when not signed in", async () => {
181 const repo = await original();
182 const h = await open(undefined, repo);
183 const text = await h.press("Y");
184 expect(text).not.toContain("Make alice/");
185 h.app.stop();
186 });
test/tui-working-yours.test.ts +3 −3
@@ -125,8 +125,8 @@ describe("make it yours in the terminal", () => {
125125 expect(await palette(bob, [":", "Make it"], "Make it yours")).toContain("alice/lobby — Make it yours");
126126 });
127127
128 test("it opens the fork screen", async () => {
129 const text = await palette(bob, [":", "make it", "\r"], "Fork");
130 expect(text).toContain("Fork");
128 test("it opens the make-it-yours screen", async () => {
129 const text = await palette(bob, [":", "make it", "\r"], "your own");
130 expect(text).toContain("Make alice/lobby your own");
131131 });
132132 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs