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

Merged opened this

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

Merged

scout/forge:scout/60-new-app-asks-for-the-sentence-first-and → main 1 commit fork

  • Merged. scout/60-new-app-asks-for-the-sentence-first-and was merged into main as 312ed15.

Review

Sign in to review this change.
4 files changed +213 −21
docs/APPS.md +6 −5
@@ -113,11 +113,12 @@ around it. None is advisory.
113113
114114 What a person sees, in order:
115115
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.
121122 2. **A pull appears**, opened by the agent, with a preview. Open the preview
122123 (a link, or a QR code from a phone), use it. Leave feedback as review
123124 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 {
2121 } from "../../actions/apps.ts";
2222 import { listAllAgents, listCrew } from "../../actions/crew.ts";
2323 import { planningAvailable } from "../../crew/plan.ts";
24 import { appNameJs, deriveAppName } from "./apps-name.ts";
2425 import { listRepoRuns } from "../../actions/runs.ts";
2526 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
2627 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
@@ -237,10 +238,20 @@ function newAppForm(ctx: Ctx, values: NewAppValues, error?: string): Html {
237238
238239 return html`<div class="narrow-wide">
239240 ${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.",
241242 })}
242243 ${card(
243244 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 })}
244255 ${selectField({
245256 label: "Owner",
246257 name: "owner",
@@ -254,23 +265,13 @@ function newAppForm(ctx: Ctx, values: NewAppValues, error?: string): Html {
254265 label: "Name",
255266 name: "name",
256267 value: values.name,
257 required: true,
258 autofocus: true,
259268 pattern: "[A-Za-z0-9][A-Za-z0-9._-]*",
260269 hint:
261270 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)}.`,
264273 ...(error ? { error } : {}),
265274 })}
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 })}
274275 ${selectField({
275276 label: "Visibility",
276277 name: "visibility",
@@ -352,9 +353,17 @@ export function newAppPage(ctx: Ctx): Response {
352353 ? PICK_AGENT
353354 : "",
354355 }),
356 scriptJs: appNameJs,
355357 });
356358 }
357359
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
358367 const NEW_APP_ERRORS: Record<string, string> = {
359368 name_taken: "That owner already has a repository by that name.",
360369 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> {
381390 agent: field(form, "agent"),
382391 };
383392 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 = {
386410 owner: values.owner,
387411 description: values.description,
388412 visibility: values.visibility === "public" ? "public" : "private",
@@ -391,7 +415,20 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
391415 : values.agent === ""
392416 ? {}
393417 : { 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 }
395432 if (!made.ok) {
396433 if (made.error === "forbidden_owner" || made.error === "unauthorized")
397434 return new Response("forbidden", { status: 403 });
@@ -404,6 +441,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
404441 values,
405442 NEW_APP_ERRORS[made.error] ?? "That app could not be created.",
406443 ),
444 scriptJs: appNameJs,
407445 },
408446 422,
409447 );
test/apps.test.ts +92
@@ -1206,3 +1206,95 @@ printf '%s\\n' '{"is_error":false,"result":"{\\"summary\\":\\"Build a notes app\
12061206 expect(made.value.issue).toBeUndefined();
12071207 });
12081208 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs