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

Describe an app in one sentence in the terminal (palette `new-app`) and it is made with the crew on it, through newAppFromSentence #8415

Merged opened this

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

Merged

scout/forge:scout/8385-describe-an-app-in-one-sentence-in-the-t → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/8385-describe-an-app-in-one-sentence-in-the-t was merged into main as 68ba1fc.

Review

Sign in to review this change.
6 files changed +258 −1
docs/features/new-app.md added +24
@@ -0,0 +1,24 @@
1 # New app, from the terminal
2
3 Open the palette in the forge's terminal and choose **New app** ("say what it
4 does in a sentence; the crew starts on it"). It is offered to signed-in people,
5 never to agents.
6
7 1. Type one sentence, e.g. `a shared shopping list for the flat`, and press
8 Enter. The app is named from it (`shared-shopping`, then `-2`, `-3` if the
9 owner already has that name) by `newAppFromSentence`, the same action the
10 `/apps/new` form calls with its name left blank. A sentence with nothing to
11 name from (empty, punctuation only) keeps the prompt open with the no-name
12 words.
13 2. If you are in organizations, a second **Owner** prompt lists them with your
14 own username (prefilled). An owner not on the list keeps the prompt open.
15 3. A working line ("Making your app…") shows while the starter is committed and
16 production starts; other sessions are not blocked. On success the new
17 repository's screen opens and a note names the app and its address.
18
19 Refusals are shown as a toast in the same words as the web form:
20 `NEW_APP_ERRORS` for the action's codes, and the repository, storage and app
21 limit sentences. Nothing is made and no screen is pushed.
22
23 Try it: `ssh` to the forge (`$FORGE_APP_SSH` on an app), press the palette key,
24 pick **New app**.
src/tui/forge.ts +9
@@ -79,6 +79,7 @@ import { newPullScreen } from "./screens/newpull.ts";
7979 import { newForgeScreen } from "./screens/newforge.ts";
8080 import { newOrgScreen } from "./screens/neworg.ts";
8181 import { migrateScreen } from "./screens/migrate.ts";
82 import { newAppScreen } from "./screens/newapp.ts";
8283 import { newRepoScreen } from "./screens/newrepo.ts";
8384 import { repoScreen } from "./screens/repo.ts";
8485 import { signInScreen } from "./screens/signin.ts";
@@ -214,6 +215,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
214215 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
215216 },
216217 );
218 // An app from a sentence, as /apps/new makes it: a person's, never an agent's.
219 if (session.user !== undefined && session.user.kind !== "agent")
220 commands.push({
221 id: "new-app",
222 title: "New app",
223 hint: "say what it does in a sentence; the crew starts on it",
224 run: () => ctx.push(newAppScreen(session, ctx, blank("New app"))),
225 });
217226 // An organization you own, as /organizations/new and MCP `org_create` make it: a person's, never an agent's (#7260).
218227 if (session.user !== undefined && session.user.kind !== "agent")
219228 commands.push({
src/tui/screens/newapp.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * "New app" in the terminal: say what it does in a sentence and the app is
3 * made from it, as the web form's blank-name path makes it —
4 * `newAppFromSentence` names it, scaffolds it, starts production and puts the
5 * crew on the first issue. When the person is in organizations a second
6 * prompt asks who owns it. The scaffold runs under `runAsync`, so no other
7 * session waits on it; success lands on the new repository.
8 */
9
10 import { appLimitWords } from "../../actions/app_limit.ts";
11 import { NEW_APP_ERRORS, NO_NAME_ERROR, newAppFromSentence } from "../../actions/apps.ts";
12 import { repositoryLimitMessage } from "../../actions/repos.ts";
13 import { appsRuntime } from "../../apps/runtime.ts";
14 import { listOrgsForUser } from "../../core/orgs.ts";
15 import { storageLimitMessage } from "../../core/storage.ts";
16 import { deriveAppName } from "../../web/pages/apps-name.ts";
17 import type { Ctx, Screen } from "../app/app.ts";
18 import type { Session } from "../session.ts";
19 import { later, promptScreen, runAsync } from "./common.ts";
20 import { repoScreen } from "./repo.ts";
21 import { local } from "./where.ts";
22
23 /** The words for a refusal: the action's, and the limits' own sentences. */
24 function refusal(f: Session, code: string, ownerId: string): string {
25 if (code === "repository_limit") return `Not made: ${repositoryLimitMessage(f.app, f.user!)}.`;
26 if (code === "storage_limit") return `Not made: ${storageLimitMessage(f.app, ownerId)}.`;
27 if (code === "app_limit") return appLimitWords(f.app, ownerId);
28 return NEW_APP_ERRORS[code] ?? "That app could not be created.";
29 }
30
31 export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen {
32 const owners = f.user
33 ? [f.user.username, ...listOrgsForUser(f.db, f.user.id).map((o) => o.username)]
34 : [];
35
36 const make = (sentence: string, owner: string): void => {
37 ctx.toast("Making your app… the crew starts on it next.");
38 runAsync(ctx, async () => {
39 const made = await newAppFromSentence(f.app, f.user, { sentence, owner });
40 if (!made.ok) {
41 ctx.toast(refusal(f, made.code, f.user!.id), "danger");
42 return;
43 }
44 const url = appsRuntime(f.app)?.urlFor(made.app, "production");
45 ctx.toast(
46 url === undefined
47 ? `${made.repo.name} is an app, but nothing is serving apps on this forge.`
48 : `${made.repo.name} is starting at ${url}.`,
outdated

Owner prompt and the failure toast path (lines 44-48) have no test. Add both.

49 "success",
50 );
51 ctx.push(repoScreen(f, ctx, local(f, made.repo)));
52 });
53 };
54
55 return promptScreen(ctx, parent, {
56 title: "New app",
57 label: "What should it do?",
58 placeholder: "a shared shopping list for the flat",
59 hint: "One sentence; the app is named from it.",
60 onSubmit: (sentence) => {
outdated

A punctuation-only sentence passes this check and fails later in a toast after "Making your app…". Validate with deriveAppName here so the prompt stays open with the no-name words.

61 if (deriveAppName(sentence) === undefined) return NO_NAME_ERROR;
62 if (owners.length < 2) {
63 make(sentence, owners[0] ?? "");
64 return undefined;
65 }
66 return later(ctx, () =>
67 promptScreen(ctx, parent, {
68 title: "Owner",
69 label: `Under which owner? ${owners.join(", ")}`,
70 value: owners[0]!,
71 onSubmit: (owner) => {
72 if (!owners.includes(owner)) return `Pick one of: ${owners.join(", ")}.`;
73 make(sentence, owner);
74 return undefined;
75 },
76 }),
77 );
78 },
79 });
80 }
test/palette-roster.test.ts +2
@@ -57,6 +57,7 @@ test("a site-admin person is offered the whole roster", () => {
5757 ["new-pull", "New pull request", "owner/name"],
5858 ["settings", "Repository settings", "owner/name"],
5959 ["new-repo", "New repository", undefined],
60 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
6061 ["new-org", "New organization", "an organization you own, for a client's apps"],
6162 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
6263 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
@@ -190,6 +191,7 @@ test("a member person is offered the signed-in roster without the admin entries"
190191 ["new-pull", "New pull request", "owner/name"],
191192 ["settings", "Repository settings", "owner/name"],
192193 ["new-repo", "New repository", undefined],
194 ["new-app", "New app", "say what it does in a sentence; the crew starts on it"],
193195 ["new-org", "New organization", "an organization you own, for a client's apps"],
194196 ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"],
195197 ["new-forge", "New forge", "spawn a client's forge and get its claim link"],
test/tui-apps.test.ts +1 −1
@@ -463,7 +463,7 @@ describe("terminal: apps, runs and the crew", () => {
463463 await h.until("On you");
464464 // alice is the site admin, so the crew entry would have been there.
465465 const ids = forgeCommands(h.session)(h.app.ctx).map((c) => c.id);
466 for (const gone of ["apps", "app", "runs", "new-app", "crew"])
466 for (const gone of ["apps", "app", "runs", "crew"])
467467 expect(ids).not.toContain(gone);
468468 expect(ids).toContain("repo");
469469 expect(ids).toContain("pulls");
test/tui-newapp.test.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * "New app" in the terminal: a sentence makes an app named from it, an empty
3 * one gets the no-name words, and an agent is never offered the entry.
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 { NO_NAME_ERROR } from "../src/actions/apps.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import { createOrg } from "../src/actions/orgs.ts";
12 import { getOrg } from "../src/core/orgs.ts";
13 import { getRepo, listReposOwnedBy } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
16 import { blank } from "../src/tui/screens/common.ts";
17 import { newAppScreen } from "../src/tui/screens/newapp.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 nell: User;
28
29 const identity = (user?: User) => ({
30 sshUsername: user?.username ?? "git",
31 remoteAddress: "127.0.0.1",
32 fingerprint: "SHA256:test",
33 user,
34 });
35
36 beforeAll(async () => {
37 dir = await mkdtemp(join(tmpdir(), "forge-tui-newapp-"));
38 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
39 const made = await createUser(app.db, {
40 origin: "seed",
41 trusted: true,
42 username: "alice",
43 adminIfFirst: true,
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 plain = await createUser(app.db, {
50 origin: "seed",
51 username: "nell",
52 email: "[email protected]",
53 password: "a-good-passphrase",
54 });
55 if (!plain.ok) throw new Error(plain.error);
56 nell = plain.value;
57 const org = createOrg(app, alice, "acme");
58 if (!org.ok) throw new Error(org.error);
59 });
60 afterAll(async () => {
61 app.db.close();
62 await rm(dir, { recursive: true, force: true });
63 });
64
65 async function until(done: () => boolean): Promise<void> {
66 const deadline = Date.now() + 5000;
67 while (!done() && Date.now() < deadline) await Bun.sleep(20);
68 }
69
70 async function withScreen(
71 user: User,
72 body: (h: { text: () => string; feed: (s: string) => Promise<void> }) => Promise<void>,
73 ) {
74 const terminal = headlessTerminal(120, 30);
75 const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false });
76 tui.start();
77 tui.ctx.push(newAppScreen(session, tui.ctx, blank("New app")));
78 try {
79 await settleApp(tui);
80 await body({
81 text: () => tui.screen.toText(),
82 feed: async (s) => {
83 terminal.feed(s);
84 await settleApp(tui);
85 },
86 });
87 } finally {
88 tui.stop();
89 }
90 }
91
92 describe("the new app screen", () => {
93 test("the palette offers it to a signed-in person, never to an agent or a guest", () => {
94 const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id);
95 expect(ids(alice)).toContain("new-app");
96 expect(ids({ ...alice, kind: "agent" })).not.toContain("new-app");
97 expect(ids()).not.toContain("new-app");
98 });
99
100 test("a sentence makes an app named from it, and lands on its repository", () =>
101 withScreen(alice, async ({ text, feed }) => {
102 await feed("a shared shopping list\r");
103 await feed("\r"); // alice is in an organization: accept the prefilled owner
104 await until(() => getRepo(app.db, "alice", "shared-shopping") !== undefined);
105 await feed("");
106 const repo = getRepo(app.db, "alice", "shared-shopping");
107 expect(repo).toBeDefined();
108 expect(text()).toContain("shared-shopping");
109 }));
110
111 test("an empty sentence shows the no-name words and makes nothing", () =>
112 withScreen(alice, async ({ text, feed }) => {
113 await feed("\r");
114 expect(text()).toContain(NO_NAME_ERROR.slice(0, 40));
115 await feed("!!!\r");
116 expect(text()).toContain(NO_NAME_ERROR.slice(0, 40));
117 }));
118
119 test("a person in an organization is asked for the owner, refused one not in the list, and the app is made under the pick", () =>
120 withScreen(alice, async ({ text, feed }) => {
121 await feed("a recipe box for the bakery\r");
122 expect(text()).toContain("Under which owner? alice, acme");
123 for (let i = 0; i < "alice".length; i++) await feed("\x7f");
124 await feed("mallory\r");
125 expect(text()).toContain("Pick one of: alice, acme.");
126 expect(getRepo(app.db, "mallory", "recipe-box-bakery")).toBeUndefined();
127 for (let i = 0; i < "mallory".length; i++) await feed("\x7f");
128 await feed("acme\r");
129 await until(() => listReposOwnedBy(app.db, getOrg(app.db, "acme")!.id).length > 0);
130 expect(listReposOwnedBy(app.db, getOrg(app.db, "acme")!.id).length).toBe(1);
131 expect(listReposOwnedBy(app.db, alice.id).some((r) => r.name.includes("recipe"))).toBe(false);
132 }));
133
134 test("a refusal shows its words and makes nothing", () =>
135 withScreen(nell, async ({ text, feed }) => {
136 await feed("a plant waterer\r");
137 await until(() => text().includes("That app could not be created."));
138 expect(text()).toContain("That app could not be created.");
139 expect(listReposOwnedBy(app.db, nell.id)).toEqual([]);
140 expect(text()).not.toContain("is starting at");
141 }));
142 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs