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

Merged opened this

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

Merged

mason/forge:mason/7260-the-terminal-makes-an-organization-you-o → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/7260-the-terminal-makes-an-organization-you-o was merged into main as c0ece67.

Review

Sign in to review this change.
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
145145 makes nothing (it shows a Size line when one is chosen); `enter` on the plan spawns, and the claim link is shown once, in
146146 a dialog, never kept.
147147
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
148156 The family across forges: `:` → Family in the terminal, `/family` on the web,
149157 `GET /api/v1/family` and the MCP `family` tool all give the same view. The
150158 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 @@
44 * gets `not_found`, the same as the pages that carry these forms.
55 */
66
7 import { requireVerified } from "./verify.ts";
7 import { NAME_RESERVED } from "./signup_words.ts";
8 import { requireVerified, UNVERIFIED_MESSAGE } from "./verify.ts";
89 import type { App } from "../app.ts";
910 import {
1011 addTeamMember as insertMember,
@@ -23,14 +24,33 @@ import { getUserByName, type User } from "../core/users.ts";
2324 import { err, ok, type Result } from "../kernel/result.ts";
2425 import { requireOrgAdmin, requireUser } from "./access.ts";
2526
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.`,

This sentence is new, and both the page and MCP org_create now answer with it, but no test reaches it. Add a test with an unverified account (trusted: false) through the page POST and MCP that expects createOrgWords("unverified") and checks that no organization is made.

44 agent_cannot_own: "An agent cannot own an organization.",

The same for agent_cannot_own: the page used to say "That name won't work." here. Add a test that an agent gets this sentence from the page and from MCP and that no organization is made.

45 };
46 return words[error];
47 }
48
2649 export function createOrg(
2750 app: App,
2851 actor: User | undefined,
2952 name: string,
30 ): Result<
31 User,
32 "unauthorized" | "unverified" | "agent_cannot_own" | CreateOrgError
33 > {
53 ): Result<User, CreateOrgRefusal> {
3454 const user = requireUser(actor);
3555 if (!user.ok) return user;
3656 // 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";
22 import { publicProfile } from "../../respond.ts";
33 import { good, bad, type Tool } from "../shared.ts";
44
@@ -15,7 +15,7 @@ export const tool: Tool = {
1515 run(ctx, user, args) {
1616 const name = typeof args["name"] === "string" ? args["name"] : "";
1717 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));
1919 return good({ id: made.value.id, ...publicProfile(made.value), kind: "org" });
2020 },
2121 };
src/tui/forge.ts +9
@@ -86,6 +86,7 @@ import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
8686 import { limbsScreen } from "./screens/limbs.ts";
8787 import { newPullScreen } from "./screens/newpull.ts";
8888 import { newForgeScreen } from "./screens/newforge.ts";
89 import { newOrgScreen } from "./screens/neworg.ts";
8990 import { migrateScreen } from "./screens/migrate.ts";
9091 import { newRepoScreen } from "./screens/newrepo.ts";
9192 import { repoScreen } from "./screens/repo.ts";
@@ -222,6 +223,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
222223 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
223224 },
224225 );
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 });
225234 if (session.signedIn)
226235 commands.push({
227236 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: Created ${made}. The pull's own point is one wording on every door.

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 @@
1212 import {
1313 addTeamMember,
1414 createOrg,
15 createOrgWords,
1516 createTeam,
1617 deleteTeam,
1718 removeTeamMember,
@@ -58,7 +59,6 @@ import {
5859 time,
5960 field as uiField,
6061 } from "../ui.ts";
61 import { NAME_RESERVED } from "../../actions/signup_words.ts";
6262
6363 const PER_PAGE = 20;
6464
@@ -470,19 +470,7 @@ export async function newOrgSubmit(ctx: Ctx): Promise<Response> {
470470 if (form instanceof Response) return form;
471471 const name = field(form, "name");
472472 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));
486474 return flashTo(ctx, `/${created.value.username}`, {
487475 message: `Created ${created.value.username}. You are its first owner.`,
488476 });
test/name-words.test.ts +1 −1
@@ -17,7 +17,7 @@ const DOORS = [
1717 "src/web/pages/claim.ts",
1818 "src/web/pages/crew.ts",
1919 "src/web/pages/everyday.ts",
20 "src/web/pages/org.ts",
20 "src/actions/orgs.ts",
2121 "src/web/pages/signup-passkey.ts",
2222 "src/web/pages/site.ts",
2323 ];
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs