Public: anyone can read this; the crew can change it.
New app asks for the sentence first, and offers the name it would pick #85
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/60-new-app-asks-for-the-sentence-first-and → main 1 commit fork
4 files
changed +213
−21
docs/APPS.md +6 −5
| @@ -113,11 +113,12 @@ around it. None is advisory. | ||
| 113 | 113 | |
| 114 | 114 | What a person sees, in order: |
| 115 | 115 | |
| 116 | 1. **New app.** A name, and — optionally — a sentence about what it should | |
| 117 | do. The forge creates the repository with a small working app in it, | |
| 118 | makes it an app, and starts production. Seconds later there is a URL. | |
| 119 | If a sentence was given, it becomes the first issue, assigned to an agent | |
| 120 | on the crew, and the agent starts. | |
| 116 | 1. **New app.** A sentence about what it should do — that's the one thing | |
| 117 | that's required. The forge derives a name from it (shown, editable, | |
| 118 | before you submit) unless you set one yourself, creates the repository | |
| 119 | with a small working app in it, makes it an app, and starts production. | |
| 120 | Seconds later there is a URL. The sentence becomes the first issue, | |
| 121 | assigned to an agent on the crew, and the agent starts. | |
| 121 | 122 | 2. **A pull appears**, opened by the agent, with a preview. Open the preview |
| 122 | 123 | (a link, or a QR code from a phone), use it. Leave feedback as review |
| 123 | 124 | comments and a verdict. Request changes and the agent picks it up in the |
src/web/pages/apps-name.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * The name a sentence suggests, for the New app form: server-side (the | |
| 3 | * boundary that counts) and, in `appNameJs`, the same rule in the browser | |
| 4 | * so the field shows a live preview before anyone submits anything. | |
| 5 | */ | |
| 6 | ||
| 7 | import { SLUG_MAX } from "../../core/apps.ts"; | |
| 8 | ||
| 9 | const MAX_WORDS = 6; | |
| 10 | ||
| 11 | /** | |
| 12 | * A lowercase, hyphenated slug of the sentence's first few words — the same | |
| 13 | * shape a hostname label takes (`core/apps.ts`'s `SLUG_MAX`). Undefined | |
| 14 | * when nothing survives: punctuation only, or a script this ASCII slug | |
| 15 | * cannot carry. | |
| 16 | */ | |
| 17 | export function deriveAppName(sentence: string): string | undefined { | |
| 18 | const words = sentence.trim().split(/\s+/).slice(0, MAX_WORDS).join(" "); | |
| 19 | const slug = words | |
| 20 | .toLowerCase() | |
| 21 | .replace(/[^a-z0-9]+/g, "-") | |
| 22 | .replace(/^-+|-+$/g, "") | |
| 23 | .slice(0, SLUG_MAX) | |
| 24 | .replace(/-+$/g, ""); | |
| 25 | return slug === "" ? undefined : slug; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Mirrors `deriveAppName` in the browser, live, as the sentence is typed — | |
| 30 | * a preview only. The name actually used is derived again on the server | |
| 31 | * (`deriveAppName`), which never trusts what this put in the field: the | |
| 32 | * script blanks it on submit unless the person edited it themselves. | |
| 33 | */ | |
| 34 | export const appNameJs = /* js */ ` | |
| 35 | (function () { | |
| 36 | var desc = document.getElementById('f-description'); | |
| 37 | var name = document.getElementById('f-name'); | |
| 38 | var form = name ? name.closest('form') : null; | |
| 39 | if (!desc || !name || !form) return; | |
| 40 | var touched = name.value.trim() !== ''; | |
| 41 | name.addEventListener('input', function () { touched = true; }); | |
| 42 | function slugify(s) { | |
| 43 | var words = s.trim().split(/\\s+/).slice(0, ${MAX_WORDS}).join(' '); | |
| 44 | return words | |
| 45 | .toLowerCase() | |
| 46 | .replace(/[^a-z0-9]+/g, '-') | |
| 47 | .replace(/^-+|-+$/g, '') | |
| 48 | .slice(0, ${SLUG_MAX}) | |
| 49 | .replace(/-+$/g, ''); | |
| 50 | } | |
| 51 | function preview() { | |
| 52 | if (touched) return; | |
| 53 | name.value = slugify(desc.value); | |
| 54 | } | |
| 55 | desc.addEventListener('input', preview); | |
| 56 | form.addEventListener('submit', function () { | |
| 57 | if (!touched) name.value = ''; | |
| 58 | }); | |
| 59 | preview(); | |
| 60 | })(); | |
| 61 | `; |
src/web/pages/apps.ts +54 −16
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | } from "../../actions/apps.ts"; |
| 22 | 22 | import { listAllAgents, listCrew } from "../../actions/crew.ts"; |
| 23 | 23 | import { planningAvailable } from "../../crew/plan.ts"; |
| 24 | import { appNameJs, deriveAppName } from "./apps-name.ts"; | |
| 24 | 25 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 25 | 26 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; |
| 26 | 27 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; |
| @@ -237,10 +238,20 @@ function newAppForm(ctx: Ctx, values: NewAppValues, error?: string): Html { | ||
| 237 | 238 | |
| 238 | 239 | return html`<div class="narrow-wide"> |
| 239 | 240 | ${pageHead("New app", { |
| 240 | sub: "A repository with a small working app in it, running in production a few seconds from now.", | |
| 241 | sub: "A repository with a small working app in it: a first issue from your sentence, a crew member on it, production at its hostname, and a preview for every change.", | |
| 241 | 242 | })} |
| 242 | 243 | ${card( |
| 243 | 244 | html`<form method="post" action="/apps/new"> |
| 245 | ${formField({ | |
| 246 | label: "What should it do?", | |
| 247 | name: "description", | |
| 248 | type: "textarea", | |
| 249 | rows: 3, | |
| 250 | value: values.description, | |
| 251 | required: true, | |
| 252 | autofocus: true, | |
| 253 | hint: "A sentence is enough — it becomes the first issue, and the agent you pick below starts on it.", | |
| 254 | })} | |
| 244 | 255 | ${selectField({ |
| 245 | 256 | label: "Owner", |
| 246 | 257 | name: "owner", |
| @@ -254,23 +265,13 @@ function newAppForm(ctx: Ctx, values: NewAppValues, error?: string): Html { | ||
| 254 | 265 | label: "Name", |
| 255 | 266 | name: "name", |
| 256 | 267 | value: values.name, |
| 257 | required: true, | |
| 258 | autofocus: true, | |
| 259 | 268 | pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", |
| 260 | 269 | hint: |
| 261 | 270 | domain === undefined |
| 262 | ? "Letters, digits, dots, dashes and underscores. The hostname is made from it." | |
| 263 | : `Letters, digits, dots, dashes and underscores. It runs at <name>${appHostSuffix(domain)}.`, | |
| 271 | ? "Left blank, it's made from your sentence above. Letters, digits, dots, dashes and underscores; the hostname is made from it." | |
| 272 | : `Left blank, it's made from your sentence above. Letters, digits, dots, dashes and underscores; it runs at <name>${appHostSuffix(domain)}.`, | |
| 264 | 273 | ...(error ? { error } : {}), |
| 265 | 274 | })} |
| 266 | ${formField({ | |
| 267 | label: "What should it do?", | |
| 268 | name: "description", | |
| 269 | type: "textarea", | |
| 270 | rows: 3, | |
| 271 | value: values.description, | |
| 272 | hint: "Optional. A sentence is enough — it becomes the first issue, and the agent you pick below starts on it.", | |
| 273 | })} | |
| 274 | 275 | ${selectField({ |
| 275 | 276 | label: "Visibility", |
| 276 | 277 | name: "visibility", |
| @@ -352,9 +353,17 @@ export function newAppPage(ctx: Ctx): Response { | ||
| 352 | 353 | ? PICK_AGENT |
| 353 | 354 | : "", |
| 354 | 355 | }), |
| 356 | scriptJs: appNameJs, | |
| 355 | 357 | }); |
| 356 | 358 | } |
| 357 | 359 | |
| 360 | /** Nothing in the sentence a slug can carry: punctuation only, or a script `[a-z0-9]` cannot. */ | |
| 361 | const NO_NAME_ERROR = | |
| 362 | "That doesn't leave anything to make a name from — type one below."; | |
| 363 | ||
| 364 | /** How many suffixed names (`x-2`, `x-3`, …) a derived name will try before giving up. */ | |
| 365 | const MAX_NAME_ATTEMPTS = 30; | |
| 366 | ||
| 358 | 367 | const NEW_APP_ERRORS: Record<string, string> = { |
| 359 | 368 | name_taken: "That owner already has a repository by that name.", |
| 360 | 369 | invalid_name: "Names start with a letter or digit; no spaces, no .git.", |
| @@ -381,8 +390,23 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 381 | 390 | agent: field(form, "agent"), |
| 382 | 391 | }; |
| 383 | 392 | const pickAgent = values.agent === PICK_AGENT; |
| 384 | const made = await newApp(ctx.app, ctx.auth.user, { | |
| 385 | name: values.name, | |
| 393 | // An explicit name is never second-guessed; left blank, the sentence | |
| 394 | // names the app — and the server derives that name itself (never trusting | |
| 395 | // whatever the field carried in): the field is only a client-side preview. | |
| 396 | const explicitName = values.name.trim(); | |
| 397 | const base = explicitName === "" ? deriveAppName(values.description) : explicitName; | |
| 398 | if (base === undefined) { | |
| 399 | return page( | |
| 400 | { | |
| 401 | ctx, | |
| 402 | title: "New app", | |
| 403 | body: newAppForm(ctx, values, NO_NAME_ERROR), | |
| 404 | scriptJs: appNameJs, | |
| 405 | }, | |
| 406 | 422, | |
| 407 | ); | |
| 408 | } | |
| 409 | const rest = { | |
| 386 | 410 | owner: values.owner, |
| 387 | 411 | description: values.description, |
| 388 | 412 | visibility: values.visibility === "public" ? "public" : "private", |
| @@ -391,7 +415,20 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 391 | 415 | : values.agent === "" |
| 392 | 416 | ? {} |
| 393 | 417 | : { agent: values.agent }), |
| 394 | }); | |
| 418 | } as const; | |
| 419 | let made = await newApp(ctx.app, ctx.auth.user, { ...rest, name: base }); | |
| 420 | // A derived name that collides tries `base-2`, `base-3`, … — the same | |
| 421 | // `name_taken` check an explicit name gets, just run more than once. | |
| 422 | for ( | |
| 423 | let n = 2; | |
| 424 | explicitName === "" && !made.ok && made.error === "name_taken" && n <= MAX_NAME_ATTEMPTS; | |
| 425 | n++ | |
| 426 | ) { | |
| 427 | made = await newApp(ctx.app, ctx.auth.user, { | |
| 428 | ...rest, | |
| 429 | name: `${base}-${n}`, | |
| 430 | }); | |
| 431 | } | |
| 395 | 432 | if (!made.ok) { |
| 396 | 433 | if (made.error === "forbidden_owner" || made.error === "unauthorized") |
| 397 | 434 | return new Response("forbidden", { status: 403 }); |
| @@ -404,6 +441,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 404 | 441 | values, |
| 405 | 442 | NEW_APP_ERRORS[made.error] ?? "That app could not be created.", |
| 406 | 443 | ), |
| 444 | scriptJs: appNameJs, | |
| 407 | 445 | }, |
| 408 | 446 | 422, |
| 409 | 447 | ); |
test/apps.test.ts +92
| @@ -1206,3 +1206,95 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build a notes app\ | ||
| 1206 | 1206 | expect(made.value.issue).toBeUndefined(); |
| 1207 | 1207 | }); |
| 1208 | 1208 | }); |
| 1209 | ||
| 1210 | describe("New app: the form asks for the sentence, and names it (issue #60)", () => { | |
| 1211 | let dataDir: string; | |
| 1212 | let app: App; | |
| 1213 | let servers: Servers; | |
| 1214 | let base: string; | |
| 1215 | let cookie: string; | |
| 1216 | ||
| 1217 | beforeAll(async () => { | |
| 1218 | dataDir = await mkdtemp(join(tmpdir(), "forge-appname-")); | |
| 1219 | app = createApp({ dataDir, port: 0 }); | |
| 1220 | servers = await serve(app); | |
| 1221 | base = servers.url; | |
| 1222 | const created = await createUser(app.db, { | |
| 1223 | username: "nadia", | |
| 1224 | email: "[email protected]", | |
| 1225 | password: "a-good-passphrase", | |
| 1226 | adminIfFirst: true, | |
| 1227 | }); | |
| 1228 | if (!created.ok) throw new Error(created.error); | |
| 1229 | cookie = ( | |
| 1230 | ( | |
| 1231 | await fetch(`${base}/signin`, { | |
| 1232 | method: "POST", | |
| 1233 | body: new URLSearchParams({ | |
| 1234 | username: "nadia", | |
| 1235 | password: "a-good-passphrase", | |
| 1236 | }), | |
| 1237 | redirect: "manual", | |
| 1238 | }) | |
| 1239 | ).headers.get("set-cookie") ?? "" | |
| 1240 | ).split(";")[0]!; | |
| 1241 | }); | |
| 1242 | ||
| 1243 | afterAll(async () => { | |
| 1244 | servers.stop(); | |
| 1245 | app.close(); | |
| 1246 | await rm(dataDir, { recursive: true, force: true }); | |
| 1247 | }); | |
| 1248 | ||
| 1249 | function submitNewApp(fields: Record<string, string>) { | |
| 1250 | return fetch(`${base}/apps/new`, { | |
| 1251 | method: "POST", | |
| 1252 | headers: { cookie }, | |
| 1253 | body: new URLSearchParams({ | |
| 1254 | owner: "", | |
| 1255 | name: "", | |
| 1256 | visibility: "private", | |
| 1257 | agent: "", | |
| 1258 | ...fields, | |
| 1259 | }), | |
| 1260 | redirect: "manual", | |
| 1261 | }); | |
| 1262 | } | |
| 1263 | ||
| 1264 | test("a sentence alone creates an app named its slug", async () => { | |
| 1265 | const res = await submitNewApp({ | |
| 1266 | description: "Keep a shared grocery list", | |
| 1267 | }); | |
| 1268 | expect(res.status).toBe(303); | |
| 1269 | expect(res.headers.get("location")).toBe( | |
| 1270 | "/nadia/keep-a-shared-grocery-list", | |
| 1271 | ); | |
| 1272 | }); | |
| 1273 | ||
| 1274 | test("a second app from the same sentence gets a distinct name", async () => { | |
| 1275 | const res = await submitNewApp({ | |
| 1276 | description: "Keep a shared grocery list", | |
| 1277 | }); | |
| 1278 | expect(res.status).toBe(303); | |
| 1279 | expect(res.headers.get("location")).toBe( | |
| 1280 | "/nadia/keep-a-shared-grocery-list-2", | |
| 1281 | ); | |
| 1282 | }); | |
| 1283 | ||
| 1284 | test("a sentence a slug cannot carry asks for a name, sentence intact", async () => { | |
| 1285 | const res = await submitNewApp({ description: "!!! ??? ..." }); | |
| 1286 | expect(res.status).toBe(422); | |
| 1287 | const text = await res.text(); | |
| 1288 | expect(text).toContain("make a name from"); | |
| 1289 | expect(text).toContain("!!! ??? ..."); | |
| 1290 | }); | |
| 1291 | ||
| 1292 | test("an explicit name wins over the derived one", async () => { | |
| 1293 | const res = await submitNewApp({ | |
| 1294 | name: "groceries", | |
| 1295 | description: "Keep a shared grocery list", | |
| 1296 | }); | |
| 1297 | expect(res.status).toBe(303); | |
| 1298 | expect(res.headers.get("location")).toBe("/nadia/groceries"); | |
| 1299 | }); | |
| 1300 | }); |
new above
offline