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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7270-the-terminal-s-repository-screen-makes-a → main 5 commits fork 6 threads · 6 unresolved
docs/APPS.md +1 −1
| @@ -148,7 +148,7 @@ Under the box, four starter sentences are plain links (no script): | ||
| 148 | 148 | screen: *Working now* (while an agent run is live, left out when it is on work |
| 149 | 149 | you may not see), *Tell the crew*, *Report a problem* (one text, an issue |
| 150 | 150 | 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* | |
| 152 | 152 | (docs/VISION.md on the default branch: the purpose, each pillar numbered, |
| 153 | 153 | the rules, through the one read MCP `vision` makes; when there is none the |
| 154 | 154 | 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), | ||
| 469 | 469 | and words to match. An agent's working fork is how the crew builds, not a |
| 470 | 470 | find, and is left out; a private app's hours are told only to those who may |
| 471 | 471 | 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` | |
| 473 | 473 | (`actions/explore.ts`). It is in the account menu and the palette, and the |
| 474 | 474 | front page's "All public apps" leads here. |
| 475 | 475 |
src/tui/screens/workspace.ts +9 −2
| @@ -43,7 +43,7 @@ import { isPinned } from "../../core/pins.ts"; | ||
| 43 | 43 | import { setDecisionsBy } from "../../actions/repos.ts"; |
| 44 | 44 | import { stopMirroring } from "../../actions/mirror_target.ts"; |
| 45 | 45 | 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"; | |
| 47 | 47 | import type { TreeEntry } from "../../git/read.ts"; |
| 48 | 48 | import { fileKind, NO_BYTES } from "../../web/render/kinds.ts"; |
| 49 | 49 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| @@ -122,7 +122,7 @@ import { reportCommands } from "./report.ts"; | ||
| 122 | 122 | import { reportsCommands } from "./reports.ts"; |
| 123 | 123 | import { tellCommands } from "./tell.ts"; |
| 124 | 124 | import { workingCommands } from "./working.ts"; |
| 125 | import { yoursCommands } from "./yours.ts"; | |
| 125 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; | |
| 126 | 126 | import { visionCommands } from "./vision.ts"; |
| 127 | 127 | import { appEnvCommands } from "./appenv.ts"; |
| 128 | 128 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| @@ -1340,6 +1340,9 @@ export function workspaceScreen( | ||
| 1340 | 1340 | { disabled: !f.signedIn }, |
| 1341 | 1341 | ), |
| 1342 | 1342 | ]), |
| 1343 | ...(repo === undefined || canWrite(f.db, repo, f.user) | |
| 1344 | ? [] | |
| 1345 | : [binding(["Y"], "make it yours", { disabled: !f.signedIn })]), | |
| 1343 | 1346 | ...(repo === undefined |
| 1344 | 1347 | ? [] |
| 1345 | 1348 | : [ |
| @@ -1727,6 +1730,10 @@ export function workspaceScreen( | ||
| 1727 | 1730 | ctx.invalidate(); |
| 1728 | 1731 | return true; |
| 1729 | 1732 | } |
| 1733 | if (plain && e.key === "Y" && f.signedIn && repo !== undefined) { | |
| 1734 | ctx.push(makeItYoursScreen(f, ctx, repo)); | |
| 1735 | return true; | |
| 1736 | } | |
| 1730 | 1737 | if (plain && e.key === "F" && f.signedIn) { |
| 1731 | 1738 | // A repository that lives at an origin is forked *there* — a local |
| 1732 | 1739 | // 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 @@ | ||
| 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"; | |
| 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"; | |
| 7 | 11 | 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"; | |
| 9 | 14 | import type { Command } from "../app/palette.ts"; |
| 10 | 15 | 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 => { | |
| 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: |
||
| 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 | } | |
| 12 | 149 | |
| 13 | 150 | export function yoursCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { |
| 14 | 151 | 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 | ||
| 17 | 154 | id: "make-it-yours", |
| 18 | 155 | title: `${repo.ownerName}/${repo.name} — Make it yours`, |
| 19 | 156 | hint: "fork it", |
| 20 | run: () => ctx.push(forkScreen(f, ctx, repo)), | |
| 157 | run: () => ctx.push(makeItYoursScreen(f, ctx, repo)), | |
| 21 | 158 | }, |
| 22 | 159 | ]; |
| 23 | 160 | } |
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
|
||
| 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 |
||
| 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: |
||
| 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", () => { | ||
| 125 | 125 | expect(await palette(bob, [":", "Make it"], "Make it yours")).toContain("alice/lobby — Make it yours"); |
| 126 | 126 | }); |
| 127 | 127 | |
| 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"); | |
| 131 | 131 | }); |
| 132 | 132 | }); |
The
existingbranch ("You already have …: it opens.", Open →make()→ the copy opens with no second repository) has no test. Add one: copy once, pressYagain, assert the wording, confirm, and assert there is still exactly one fork.