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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8385-describe-an-app-in-one-sentence-in-the-t → main 5 commits fork 2 threads · 2 unresolved
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"; | ||
| 79 | 79 | import { newForgeScreen } from "./screens/newforge.ts"; |
| 80 | 80 | import { newOrgScreen } from "./screens/neworg.ts"; |
| 81 | 81 | import { migrateScreen } from "./screens/migrate.ts"; |
| 82 | import { newAppScreen } from "./screens/newapp.ts"; | |
| 82 | 83 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 83 | 84 | import { repoScreen } from "./screens/repo.ts"; |
| 84 | 85 | import { signInScreen } from "./screens/signin.ts"; |
| @@ -214,6 +215,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 214 | 215 | ctx.push(signInScreen(session, ctx, () => ctx.invalidate())), |
| 215 | 216 | }, |
| 216 | 217 | ); |
| 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 | }); | |
| 217 | 226 | // An organization you own, as /organizations/new and MCP `org_create` make it: a person's, never an agent's (#7260). |
| 218 | 227 | if (session.user !== undefined && session.user.kind !== "agent") |
| 219 | 228 | 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}.`, | |
| 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", () => { | ||
| 57 | 57 | ["new-pull", "New pull request", "owner/name"], |
| 58 | 58 | ["settings", "Repository settings", "owner/name"], |
| 59 | 59 | ["new-repo", "New repository", undefined], |
| 60 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], | |
| 60 | 61 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 61 | 62 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 62 | 63 | ["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" | ||
| 190 | 191 | ["new-pull", "New pull request", "owner/name"], |
| 191 | 192 | ["settings", "Repository settings", "owner/name"], |
| 192 | 193 | ["new-repo", "New repository", undefined], |
| 194 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], | |
| 193 | 195 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 194 | 196 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| 195 | 197 | ["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", () => { | ||
| 463 | 463 | await h.until("On you"); |
| 464 | 464 | // alice is the site admin, so the crew entry would have been there. |
| 465 | 465 | 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"]) | |
| 467 | 467 | expect(ids).not.toContain(gone); |
| 468 | 468 | expect(ids).toContain("repo"); |
| 469 | 469 | 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 | }); |
new above
offline
Owner prompt and the failure toast path (lines 44-48) have no test. Add both.