Public: anyone can read this; the crew can change it.
The terminal makes an organization you own from the palette, with the same rules and words as MCP `org_create` #7466
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7260-the-terminal-makes-an-organization-you-o → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +334
−22
docs/TOUR.md +8
| @@ -145,6 +145,14 @@ page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and | ||
| 145 | 145 | makes nothing (it shows a Size line when one is chosen); `enter` on the plan spawns, and the claim link is shown once, in |
| 146 | 146 | a dialog, never kept. |
| 147 | 147 | |
| 148 | An organization for a client's apps: `Ctrl+K`, then **New organization** (a | |
| 149 | signed-in person's, never an agent's) asks for a name and calls the same | |
| 150 | `createOrg` as `/organizations/new` and the MCP `org_create`. A taken, reserved | |
| 151 | or invalid name is refused with the same words on all three | |
| 152 | (`createOrgWords`, `src/actions/orgs.ts`) and stays in the field to fix. Once | |
| 153 | made, the screen says where it lives, and `n` opens New repository with it as | |
| 154 | the owner. | |
| 155 | ||
| 148 | 156 | The family across forges: `:` → Family in the terminal, `/family` on the web, |
| 149 | 157 | `GET /api/v1/family` and the MCP `family` tool all give the same view. The |
| 150 | 158 | forge that sent this one is shown to anyone; the forges it sent, only to a |
src/actions/orgs.ts +25 −5
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * gets `not_found`, the same as the pages that carry these forms. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { requireVerified } from "./verify.ts"; | |
| 7 | import { NAME_RESERVED } from "./signup_words.ts"; | |
| 8 | import { requireVerified, UNVERIFIED_MESSAGE } from "./verify.ts"; | |
| 8 | 9 | import type { App } from "../app.ts"; |
| 9 | 10 | import { |
| 10 | 11 | addTeamMember as insertMember, |
| @@ -23,14 +24,33 @@ import { getUserByName, type User } from "../core/users.ts"; | ||
| 23 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 24 | 25 | import { requireOrgAdmin, requireUser } from "./access.ts"; |
| 25 | 26 | |
| 27 | export type CreateOrgRefusal = | |
| 28 | | "unauthorized" | |
| 29 | | "unverified" | |
| 30 | | "agent_cannot_own" | |
| 31 | | CreateOrgError; | |
| 32 | ||
| 33 | /** | |
| 34 | * What `createOrg`'s refusal says, on every door: the new-organization | |
| 35 | * page, MCP `org_create` and the terminal read it from here. | |
| 36 | */ | |
| 37 | export function createOrgWords(error: CreateOrgRefusal): string { | |
| 38 | const words: Record<CreateOrgRefusal, string> = { | |
| 39 | name_taken: "That name is already taken.", | |
| 40 | name_reserved: NAME_RESERVED, | |
| 41 | invalid_name: "Letters, digits, dots and dashes only.", | |
| 42 | unauthorized: "Sign in to create an organization.", | |
| 43 | unverified: `${UNVERIFIED_MESSAGE[0]!.toUpperCase()}${UNVERIFIED_MESSAGE.slice(1)}: an unverified account cannot make an organization.`, | |
| 44 | agent_cannot_own: "An agent cannot own an organization.", | |
The same for |
||
| 45 | }; | |
| 46 | return words[error]; | |
| 47 | } | |
| 48 | ||
| 26 | 49 | export function createOrg( |
| 27 | 50 | app: App, |
| 28 | 51 | actor: User | undefined, |
| 29 | 52 | name: string, |
| 30 | ): Result< | |
| 31 | User, | |
| 32 | "unauthorized" | "unverified" | "agent_cannot_own" | CreateOrgError | |
| 33 | > { | |
| 53 | ): Result<User, CreateOrgRefusal> { | |
| 34 | 54 | const user = requireUser(actor); |
| 35 | 55 | if (!user.ok) return user; |
| 36 | 56 | // Its founder is its first owner, and an agent never owns one. |
src/http/mcp/tools/org_create.ts +2 −2
| @@ -1,4 +1,4 @@ | ||
| 1 | import { createOrg } from "../../../actions/orgs.ts"; | |
| 1 | import { createOrg, createOrgWords } from "../../../actions/orgs.ts"; | |
| 2 | 2 | import { publicProfile } from "../../respond.ts"; |
| 3 | 3 | import { good, bad, type Tool } from "../shared.ts"; |
| 4 | 4 | |
| @@ -15,7 +15,7 @@ export const tool: Tool = { | ||
| 15 | 15 | run(ctx, user, args) { |
| 16 | 16 | const name = typeof args["name"] === "string" ? args["name"] : ""; |
| 17 | 17 | const made = createOrg(ctx.app, user, name); |
| 18 | if (!made.ok) return bad(made.error, made.error); | |
| 18 | if (!made.ok) return bad(made.error, createOrgWords(made.error)); | |
| 19 | 19 | return good({ id: made.value.id, ...publicProfile(made.value), kind: "org" }); |
| 20 | 20 | }, |
| 21 | 21 | }; |
src/tui/forge.ts +9
| @@ -86,6 +86,7 @@ import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; | ||
| 86 | 86 | import { limbsScreen } from "./screens/limbs.ts"; |
| 87 | 87 | import { newPullScreen } from "./screens/newpull.ts"; |
| 88 | 88 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 89 | import { newOrgScreen } from "./screens/neworg.ts"; | |
| 89 | 90 | import { migrateScreen } from "./screens/migrate.ts"; |
| 90 | 91 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 91 | 92 | import { repoScreen } from "./screens/repo.ts"; |
| @@ -222,6 +223,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 222 | 223 | ctx.push(signInScreen(session, ctx, () => ctx.invalidate())), |
| 223 | 224 | }, |
| 224 | 225 | ); |
| 226 | // An organization you own, as /organizations/new and MCP `org_create` make it: a person's, never an agent's (#7260). | |
| 227 | if (session.user !== undefined && session.user.kind !== "agent") | |
| 228 | commands.push({ | |
| 229 | id: "new-org", | |
| 230 | title: "New organization", | |
| 231 | hint: "an organization you own, for a client's apps", | |
| 232 | run: () => ctx.push(newOrgScreen(session, ctx)), | |
| 233 | }); | |
| 225 | 234 | if (session.signedIn) |
| 226 | 235 | commands.push({ |
| 227 | 236 | id: "migrate", |
src/tui/screens/neworg.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * "New organization" in the terminal (#7260): one field, a name, through | |
| 3 | * `createOrg` as the page and MCP `org_create` call it, so every rule | |
| 4 | * (taken, reserved, invalid, an agent cannot own one) is the action's and | |
| 5 | * the refusal is `createOrgWords`. A refused name stays in the field to fix. | |
| 6 | * Once made, the screen says where the organization lives, and **n** opens | |
| 7 | * the new-repository screen with it as the owner. | |
| 8 | */ | |
| 9 | ||
| 10 | import { createOrg, createOrgWords } from "../../actions/orgs.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate } from "../term/width.ts"; | |
| 15 | import { field } from "../ui/widgets.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { newRepoScreen } from "./newrepo.ts"; | |
| 18 | ||
| 19 | export function newOrgScreen(f: Session, ctx: Ctx): Screen { | |
| 20 | let name = ""; | |
| 21 | let error: string | undefined; | |
| 22 | let made: string | undefined; | |
| 23 | ||
| 24 | const where = (org: string): string => { | |
| 25 | const base = f.app.config.url; | |
| 26 | return base === undefined ? `/${org}` : `${base.replace(/\/+$/, "")}/${org}`; | |
| 27 | }; | |
| 28 | ||
| 29 | const submit = (): void => { | |
| 30 | const created = createOrg(f.app, f.user, name.trim()); | |
| 31 | if (!created.ok) { | |
| 32 | error = createOrgWords(created.error); | |
| 33 | ctx.toast(error, "danger"); | |
| 34 | return; | |
| 35 | } | |
| 36 | made = created.value.username; | |
| 37 | ctx.toast(`Created ${made}.`, "success"); | |
outdated
Use the same words as the page's flash: |
||
| 38 | }; | |
| 39 | ||
| 40 | return { | |
| 41 | title: "New organization", | |
| 42 | get keys() { | |
| 43 | return made === undefined | |
| 44 | ? [binding(["enter"], "create"), binding(["esc"], "discard")] | |
| 45 | : [binding(["n"], "new repository under it"), binding(["esc"], "close")]; | |
| 46 | }, | |
| 47 | render(area, s) { | |
| 48 | const c = ctx.theme.c; | |
| 49 | if (made !== undefined) { | |
| 50 | ctx.setCursor(null); | |
| 51 | const lines = [ | |
| 52 | `Made ${made}. You are its first owner.`, | |
| 53 | `It lives at ${where(made)}.`, | |
| 54 | "", | |
| 55 | `n opens a new repository under ${made}.`, | |
| 56 | ]; | |
| 57 | lines.forEach((line, i) => | |
| 58 | s.text(area.x, area.y + i, truncate(line, area.w), style(i < 2 ? c.fg : c.mutedFg, c.bg), area.x + area.w), | |
| 59 | ); | |
| 60 | return; | |
| 61 | } | |
| 62 | const cursor = field(s, { x: area.x, y: area.y, w: Math.min(60, area.w), h: 3 }, c, { | |
| 63 | label: "Name", | |
| 64 | value: name, | |
| 65 | cursor: [...name].length, | |
| 66 | focused: true, | |
| 67 | ...(error === undefined | |
| 68 | ? { hint: "Letters, digits, dots and dashes. You will be its first owner." } | |
| 69 | : { error }), | |
| 70 | }); | |
| 71 | ctx.setCursor(cursor); | |
| 72 | }, | |
| 73 | paste(text) { | |
| 74 | if (made !== undefined) return false; | |
| 75 | name += text.replace(/\s+/g, ""); | |
| 76 | error = undefined; | |
| 77 | return true; | |
| 78 | }, | |
| 79 | key(e) { | |
| 80 | if (made !== undefined) { | |
| 81 | if (e.key !== "n" || e.ctrl || e.alt) return false; | |
| 82 | ctx.replace(newRepoScreen(f, ctx, made)); | |
| 83 | return true; | |
| 84 | } | |
| 85 | if (e.key === "enter") { | |
| 86 | submit(); | |
| 87 | return true; | |
| 88 | } | |
| 89 | if (e.key === "backspace") { | |
| 90 | name = [...name].slice(0, -1).join(""); | |
| 91 | error = undefined; | |
| 92 | return true; | |
| 93 | } | |
| 94 | if (!e.ctrl && !e.alt && [...e.key].length === 1) { | |
| 95 | name += e.key; | |
| 96 | error = undefined; | |
| 97 | return true; | |
| 98 | } | |
| 99 | return false; | |
| 100 | }, | |
| 101 | }; | |
| 102 | } |
src/web/pages/org.ts +2 −14
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | import { |
| 13 | 13 | addTeamMember, |
| 14 | 14 | createOrg, |
| 15 | createOrgWords, | |
| 15 | 16 | createTeam, |
| 16 | 17 | deleteTeam, |
| 17 | 18 | removeTeamMember, |
| @@ -58,7 +59,6 @@ import { | ||
| 58 | 59 | time, |
| 59 | 60 | field as uiField, |
| 60 | 61 | } from "../ui.ts"; |
| 61 | import { NAME_RESERVED } from "../../actions/signup_words.ts"; | |
| 62 | 62 | |
| 63 | 63 | const PER_PAGE = 20; |
| 64 | 64 | |
| @@ -470,19 +470,7 @@ export async function newOrgSubmit(ctx: Ctx): Promise<Response> { | ||
| 470 | 470 | if (form instanceof Response) return form; |
| 471 | 471 | const name = field(form, "name"); |
| 472 | 472 | const created = createOrg(ctx.app, ctx.auth.user, name); |
| 473 | if (!created.ok) { | |
| 474 | const messages: Record<string, string> = { | |
| 475 | name_taken: "That name is already taken.", | |
| 476 | name_reserved: NAME_RESERVED, | |
| 477 | invalid_name: "Letters, digits, dots and dashes only.", | |
| 478 | unauthorized: "Sign in to create an organization.", | |
| 479 | }; | |
| 480 | return orgForm( | |
| 481 | ctx, | |
| 482 | name, | |
| 483 | messages[created.error] ?? "That name won't work.", | |
| 484 | ); | |
| 485 | } | |
| 473 | if (!created.ok) return orgForm(ctx, name, createOrgWords(created.error)); | |
| 486 | 474 | return flashTo(ctx, `/${created.value.username}`, { |
| 487 | 475 | message: `Created ${created.value.username}. You are its first owner.`, |
| 488 | 476 | }); |
test/name-words.test.ts +1 −1
| @@ -17,7 +17,7 @@ const DOORS = [ | ||
| 17 | 17 | "src/web/pages/claim.ts", |
| 18 | 18 | "src/web/pages/crew.ts", |
| 19 | 19 | "src/web/pages/everyday.ts", |
| 20 | "src/web/pages/org.ts", | |
| 20 | "src/actions/orgs.ts", | |
| 21 | 21 | "src/web/pages/signup-passkey.ts", |
| 22 | 22 | "src/web/pages/site.ts", |
| 23 | 23 | ]; |
test/tui-neworg.test.ts added +185
| @@ -0,0 +1,185 @@ | ||
| 1 | /** | |
| 2 | * "New organization" in the terminal (#7260): `createOrg` through one field, | |
| 3 | * refused with MCP `org_create`'s words, and the new-repository screen then | |
| 4 | * offers the organization as an owner. A person's, never an agent's. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createOrgWords, type CreateOrgRefusal } from "../src/actions/orgs.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { getOrg } from "../src/core/orgs.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { tool as orgCreate } from "../src/http/mcp/tools/org_create.ts"; | |
| 15 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 16 | import { newOrgScreen } from "../src/tui/screens/neworg.ts"; | |
| 17 | import { newOrgSubmit } from "../src/web/pages/org.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | let dir: string; | |
| 25 | let app: App; | |
| 26 | let alice: User; | |
| 27 | let unproven: User; | |
| 28 | let robot: User; | |
| 29 | ||
| 30 | const identity = (user?: User) => ({ | |
| 31 | sshUsername: user?.username ?? "git", | |
| 32 | remoteAddress: "127.0.0.1", | |
| 33 | fingerprint: "SHA256:test", | |
| 34 | user, | |
| 35 | }); | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-tui-neworg-")); | |
| 39 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 40 | const made = await createUser(app.db, { | |
| 41 | origin: "seed", | |
| 42 | trusted: true, | |
| 43 | username: "alice", | |
| 44 | email: "[email protected]", | |
| 45 | password: "a-good-passphrase", | |
| 46 | }); | |
| 47 | if (!made.ok) throw new Error(made.error); | |
| 48 | alice = made.value; | |
| 49 | const fresh = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | username: "nell", | |
| 52 | email: "[email protected]", | |
| 53 | password: "a-good-passphrase", | |
| 54 | }); | |
| 55 | if (!fresh.ok) throw new Error(fresh.error); | |
| 56 | unproven = fresh.value; | |
| 57 | const agent = await createUser(app.db, { | |
| 58 | origin: "agent", | |
| 59 | agent: true, | |
| 60 | username: "robo", | |
| 61 | email: "[email protected]", | |
| 62 | }); | |
| 63 | if (!agent.ok) throw new Error(agent.error); | |
| 64 | robot = agent.value; | |
| 65 | }); | |
| 66 | afterAll(async () => { | |
| 67 | app.db.close(); | |
| 68 | await rm(dir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | async function withScreen( | |
| 72 | user: User, | |
| 73 | body: (h: { text: () => string; feed: (s: string) => Promise<void> }) => Promise<void>, | |
| 74 | ) { | |
| 75 | const terminal = headlessTerminal(120, 30); | |
| 76 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 77 | tui.start(); | |
| 78 | tui.ctx.push(newOrgScreen(session, tui.ctx)); | |
| 79 | try { | |
| 80 | await settleApp(tui); | |
| 81 | await body({ | |
| 82 | text: () => tui.screen.toText(), | |
| 83 | feed: async (s) => { | |
| 84 | terminal.feed(s); | |
| 85 | await settleApp(tui); | |
| 86 | }, | |
| 87 | }); | |
| 88 | } finally { | |
| 89 | tui.stop?.(); | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | /** What MCP `org_create` answers for `name`, as `user` (alice by default). */ | |
| 94 | function mcpAnswer(name: string, user: User = alice): { error?: string; message?: string } { | |
| 95 | return orgCreate.run({ app } as never, user, { name }) as { error?: string; message?: string }; | |
| 96 | } | |
| 97 | const mcpWords = (name: string, user?: User): string => mcpAnswer(name, user).message ?? ""; | |
| 98 | ||
| 99 | /** The page's answer to POST /organizations/new with `name`, as `user`. */ | |
| 100 | async function pageAnswer(name: string, user: User): Promise<{ status: number; body: string }> { | |
| 101 | const url = new URL("https://forge.test/organizations/new"); | |
| 102 | const request = new Request(url.href, { | |
| 103 | method: "POST", | |
| 104 | headers: { origin: url.origin, "content-type": "application/x-www-form-urlencoded" }, | |
| 105 | body: new URLSearchParams({ name }), | |
| 106 | }); | |
| 107 | const res = await newOrgSubmit({ | |
| 108 | app, | |
| 109 | request, | |
| 110 | url, | |
| 111 | params: {}, | |
| 112 | auth: { user, scope: "rw", via: "session" }, | |
| 113 | theme: undefined, | |
| 114 | } as never); | |
| 115 | return { status: res.status, body: await res.text() }; | |
| 116 | } | |
| 117 | ||
| 118 | describe("the new organization screen", () => { | |
| 119 | test("the palette offers it to a signed-in person, and never to an agent or a guest", () => { | |
| 120 | const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id); | |
| 121 | expect(ids(alice)).toContain("new-org"); | |
| 122 | expect(ids({ ...alice, kind: "agent" })).not.toContain("new-org"); | |
| 123 | expect(ids()).not.toContain("new-org"); | |
| 124 | }); | |
| 125 | ||
| 126 | test("makes one, says where it lives, and the new-repository screen offers it as owner", () => | |
| 127 | withScreen(alice, async ({ text, feed }) => { | |
| 128 | await feed("acme\r"); | |
| 129 | expect(getOrg(app.db, "acme")).toBeDefined(); | |
| 130 | expect(text()).toContain("Made acme. You are its first owner."); | |
| 131 | expect(text()).toContain("It lives at https://forge.test/acme."); | |
| 132 | await feed("n"); | |
| 133 | expect(text()).toContain("Name (under acme)"); | |
| 134 | })); | |
| 135 | ||
| 136 | test("a taken name is refused with MCP's words, nothing is made, and the name stays", () => | |
| 137 | withScreen(alice, async ({ text, feed }) => { | |
| 138 | await feed("alice\r"); | |
| 139 | expect(getOrg(app.db, "alice")).toBeUndefined(); | |
| 140 | expect(text()).toContain(createOrgWords("name_taken")); | |
| 141 | expect(mcpWords("alice")).toBe(createOrgWords("name_taken")); | |
| 142 | await feed("-co"); | |
| 143 | expect(text()).toContain("alice-co"); | |
| 144 | })); | |
| 145 | ||
| 146 | test("a reserved name is refused with MCP's words, and nothing is made", () => | |
| 147 | withScreen(alice, async ({ text, feed }) => { | |
| 148 | await feed("admin\r"); | |
| 149 | expect(getOrg(app.db, "admin")).toBeUndefined(); | |
| 150 | expect(text()).toContain(createOrgWords("name_reserved")); | |
| 151 | expect(mcpWords("admin")).toBe(createOrgWords("name_reserved")); | |
| 152 | expect(text()).toContain("admin"); | |
| 153 | })); | |
| 154 | ||
| 155 | test("an invalid name is refused with MCP's words", () => | |
| 156 | withScreen(alice, async ({ text, feed }) => { | |
| 157 | await feed("not valid!\r"); | |
| 158 | expect(text()).toContain(createOrgWords("invalid_name")); | |
| 159 | expect(mcpWords("not valid!")).toBe(createOrgWords("invalid_name")); | |
| 160 | })); | |
| 161 | ||
| 162 | test("MCP's message is the sentence, never the bare code", () => { | |
| 163 | const answer = mcpAnswer("alice"); | |
| 164 | expect(answer.error).toBe("name_taken"); | |
| 165 | expect(answer.message).not.toBe("name_taken"); | |
| 166 | expect(answer.message).toBe(createOrgWords("name_taken")); | |
| 167 | }); | |
| 168 | ||
| 169 | const refused: [string, () => User, CreateOrgRefusal][] = [ | |
| 170 | ["an unverified person", () => unproven, "unverified"], | |
| 171 | ["an agent", () => robot, "agent_cannot_own"], | |
| 172 | ]; | |
| 173 | for (const [who, user, code] of refused) | |
| 174 | test(`${who} is told why by the page and by MCP, and nothing is made`, async () => { | |
| 175 | const name = `${code.replace(/_/g, "-")}-co`; | |
| 176 | const page = await pageAnswer(name, user()); | |
| 177 | expect(page.status).toBe(422); | |
| 178 | expect(page.body).toContain(createOrgWords(code)); | |
| 179 | expect(page.body).not.toContain("That name won't work."); | |
| 180 | const answer = mcpAnswer(name, user()); | |
| 181 | expect(answer.error).toBe(code); | |
| 182 | expect(answer.message).toBe(createOrgWords(code)); | |
| 183 | expect(getOrg(app.db, name)).toBeUndefined(); | |
| 184 | }); | |
| 185 | }); |
new above
offline
This sentence is new, and both the page and MCP
org_createnow answer with it, but no test reaches it. Add a test with an unverified account (trusted: false) through the page POST and MCP that expectscreateOrgWords("unverified")and checks that no organization is made.