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

New app's name-from-sentence loop is one exported action, newAppFromSentence in src/actions/apps.ts, and the web form calls it #8405

Merged opened this

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

Merged

torque/forge:torque/8384-new-app-s-name-from-sentence-loop-is-one → main 2 commits fork

  • Merged. torque/8384-new-app-s-name-from-sentence-loop-is-one was merged into main as 9247ba4.

Review

Sign in to review this change.
5 files changed +165 −50
src/actions/apps.ts +56
@@ -9,6 +9,9 @@
99 import { pullDir } from "../core/pulls.ts";
1010 import { getIssue } from "../core/issues.ts";
1111 import { mayView } from "../core/sealed.ts";
12 import { deriveAppName } from "../web/pages/apps-name.ts";
13 import { REPO_INIT_FAILED, REPO_NAME_RULES } from "./repo_words.ts";
14 import { NO_SUCH_AGENT } from "./crew_limit_words.ts";
1215 import { requireVerified } from "./verify.ts";
1316 import { requireAppRoom } from "./app_limit.ts";
1417 import type { App } from "../app.ts";
@@ -436,6 +439,59 @@ export async function newApp(
436439 });
437440 }
438441
442 /** How many suffixed names (`x-2`, `x-3`, …) a derived name will try before giving up. */
443 const MAX_NAME_ATTEMPTS = 30;
444
445 /** Nothing in the sentence a slug can carry: punctuation only, or a script `[a-z0-9]` cannot. */
446 export const NO_NAME_ERROR =
447 "That doesn't leave anything to make a name from — type one below.";
448
449 /** Error code → the words a person is shown, for the web form and the terminal alike. */
450 export const NEW_APP_ERRORS: Record<string, string> = {
451 no_name: NO_NAME_ERROR,
452 name_taken: "That owner already has a repository by that name.",
453 invalid_name: REPO_NAME_RULES,
454 git_init_failed: REPO_INIT_FAILED,
455 invalid_slug:
456 "That name makes no usable hostname — start it with a letter or digit.",
457 slug_taken:
458 "Another app already answers to that hostname. Pick another name.",
459 already_app: "That repository is already an app.",
460 scaffold_failed: "The starter app could not be committed.",
461 no_such_agent: NO_SUCH_AGENT,
462 };
463
464 export interface NewAppFromSentenceInput extends Omit<NewAppInput, "name" | "description"> {
465 /** The sentence the app is named from; it is also the first issue's description. */
466 sentence: string;
467 }
468
469 export type NewAppFromSentence =
470 | ({ readonly ok: true; readonly name: string } & NewAppResult)
471 | { readonly ok: false; readonly code: string };
472
473 /**
474 * `newApp` with the name derived from a sentence: the derived name first,
475 * then `name-2`, `name-3`, … while the owner already has it, up to
476 * MAX_NAME_ATTEMPTS. A sentence with nothing to name from is `no_name`.
477 */
478 export async function newAppFromSentence(
479 app: App,
480 actor: User | undefined,
481 { sentence, ...rest }: NewAppFromSentenceInput,
482 onPhase?: (name: string, ms: number) => void,
483 ): Promise<NewAppFromSentence> {
484 const base = deriveAppName(sentence);
485 if (base === undefined) return { ok: false, code: "no_name" };
486 let name = base;
487 let made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase);
488 for (let n = 2; !made.ok && made.error === "name_taken" && n <= MAX_NAME_ATTEMPTS; n++) {
489 name = `${base}-${n}`;
490 made = await newApp(app, actor, { ...rest, description: sentence, name }, onPhase);
491 }
492 return made.ok ? { ok: true, name, ...made.value } : { ok: false, code: made.error };
493 }
494
439495 export interface AppView {
440496 readonly app: AppRecord;
441497 readonly environments: EnvStatus[];
src/web/pages/apps.ts +29 −48
@@ -14,13 +14,15 @@
1414
1515 import { storageLimitMessage } from "../../core/storage.ts";
1616 import { repositoryLimitMessage } from "../../actions/repos.ts";
17 import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../../actions/repo_words.ts";
1817 import { appLimitWords } from "../../actions/app_limit.ts";
19 import { NO_SUCH_AGENT } from "../../actions/crew_limit_words.ts";
2018 import { getUserByName } from "../../core/users.ts";
2119 import {
20 NEW_APP_ERRORS,
21 NO_NAME_ERROR,
2222 appView,
2323 newApp,
24 newAppFromSentence,
25 type NewAppResult,
2426 openEnvUrl,
2527 redeployEnv,
2628 stopEnv,
@@ -28,7 +30,7 @@ import {
2830 } from "../../actions/apps.ts";
2931 import { listAllAgents } from "../../actions/crew.ts";
3032 import { planningAvailable } from "../../crew/plan.ts";
31 import { appNameJs, deriveAppName } from "./apps-name.ts";
33 import { appNameJs } from "./apps-name.ts";
3234 import { STARTERS, starterFromQuery, starterHref } from "./apps-starters.ts";
3335 import { previewEnv, type EnvName } from "../../apps/data.ts";
3436 import { envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
@@ -443,26 +445,6 @@ export function newAppPage(ctx: Ctx): Response {
443445 return response;
444446 }
445447
446 /** Nothing in the sentence a slug can carry: punctuation only, or a script `[a-z0-9]` cannot. */
447 const NO_NAME_ERROR =
448 "That doesn't leave anything to make a name from — type one below.";
449
450 /** How many suffixed names (`x-2`, `x-3`, …) a derived name will try before giving up. */
451 const MAX_NAME_ATTEMPTS = 30;
452
453 const NEW_APP_ERRORS: Record<string, string> = {
454 name_taken: "That owner already has a repository by that name.",
455 invalid_name: REPO_NAME_RULES,
456 git_init_failed: REPO_INIT_FAILED,
457 invalid_slug:
458 "That name makes no usable hostname — start it with a letter or digit.",
459 slug_taken:
460 "Another app already answers to that hostname. Pick another name.",
461 already_app: "That repository is already an app.",
462 scaffold_failed: "The starter app could not be committed.",
463 no_such_agent: NO_SUCH_AGENT,
464 };
465
466448 // POST /apps/new
467449 export async function newAppSubmit(ctx: Ctx): Promise<Response> {
468450 if (!ctx.auth.user) return signInFirst(ctx);
@@ -482,8 +464,30 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
482464 // names the app — and the server derives that name itself (never trusting
483465 // whatever the field carried in): the field is only a client-side preview.
484466 const explicitName = values.name.trim();
485 const base = explicitName === "" ? deriveAppName(values.description) : explicitName;
486 if (base === undefined) {
467 const rest = {
468 owner: values.owner,
469 visibility: values.visibility === "public" ? "public" : "private",
470 ...(pickAgent
471 ? { pickAgent: true }
472 : values.agent === ""
473 ? {}
474 : { agent: values.agent }),
475 } as const;
476 const made: { ok: true; value: NewAppResult } | { ok: false; error: string } =
477 explicitName === ""
478 ? await newAppFromSentence(
479 ctx.app,
480 ctx.auth.user,
481 { ...rest, sentence: values.description },
482 phase,
483 ).then((r) => (r.ok ? { ok: true as const, value: r } : { ok: false as const, error: r.code }))
484 : await newApp(
485 ctx.app,
486 ctx.auth.user,
487 { ...rest, description: values.description, name: explicitName },
488 phase,
489 );
490 if (!made.ok && made.error === "no_name") {
487491 return page(
488492 {
489493 ctx,
@@ -494,29 +498,6 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
494498 422,
495499 );
496500 }
497 const rest = {
498 owner: values.owner,
499 description: values.description,
500 visibility: values.visibility === "public" ? "public" : "private",
501 ...(pickAgent
502 ? { pickAgent: true }
503 : values.agent === ""
504 ? {}
505 : { agent: values.agent }),
506 } as const;
507 let made = await newApp(ctx.app, ctx.auth.user, { ...rest, name: base }, phase);
508 // A derived name that collides tries `base-2`, `base-3`, … — the same
509 // `name_taken` check an explicit name gets, just run more than once.
510 for (
511 let n = 2;
512 explicitName === "" && !made.ok && made.error === "name_taken" && n <= MAX_NAME_ATTEMPTS;
513 n++
514 ) {
515 made = await newApp(ctx.app, ctx.auth.user, {
516 ...rest,
517 name: `${base}-${n}`,
518 }, phase);
519 }
520501 if (!made.ok) {
521502 if (made.error === "forbidden_owner" || made.error === "unauthorized")
522503 return new Response("forbidden", { status: 403 });
test/actions-newapp.test.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * `newAppFromSentence` (`actions/apps.ts`) called directly: the sentence
3 * names the app, a repeat gets `-2`, nothing to name from is `no_name`, and
4 * the verified-account and app-limit refusals pass through unchanged.
5 */
6 import { afterAll, expect, setDefaultTimeout, 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 { NEW_APP_ERRORS, newAppFromSentence } from "../src/actions/apps.ts";
11 import { setRole } from "../src/actions/roles.ts";
12 import { createApp } from "../src/app.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { setQuota } from "../src/settings/quotas.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18 setDefaultTimeout(30_000);
19
20 const dirs: string[] = [];
21 afterAll(async () => {
22 for (const d of dirs) await rm(d, { recursive: true, force: true });
23 });
24
25 async function world() {
26 const dir = await mkdtemp(join(tmpdir(), "forge-newapp-"));
27 dirs.push(dir);
28 const app = createApp({ dataDir: dir, port: 0 });
29 const mk = async (username: string, trusted: boolean, isAdmin = false) => {
30 const made = await createUser(app.db, {
31 origin: "seed", trusted, username, email: `${username}@x.test`, isAdmin,
32 });
33 if (!made.ok) throw new Error(made.error);
34 return made.value;
35 };
36 const admin = await mk("boss", true, true);
37 const user = await mk("maker", true);
38 const fresh = await mk("fresh", false);
39 for (const who of [user, fresh]) setRole(app, admin, who.username, "member");
40 return { app, admin, user, fresh };
41 }
42
43 test("a sentence makes an app named from it; again makes name-2", async () => {
44 const { app, user } = await world();
45 const sentence = "A tracker for tide tables";
46 const first = await newAppFromSentence(app, user, { sentence });
47 expect(first).toMatchObject({ ok: true });
48 if (!first.ok) return;
49 expect(first.repo.name).toBe(first.name);
50 const second = await newAppFromSentence(app, user, { sentence });
51 expect(second.ok).toBe(true);
52 if (second.ok) expect(second.name).toBe(`${first.name}-2`);
53 });
54
55 test("a sentence with nothing to name from is no_name", async () => {
56 const { app, user } = await world();
57 for (const sentence of ["", " ... "]) {
58 const made = await newAppFromSentence(app, user, { sentence });
59 expect(made).toEqual({ ok: false, code: "no_name" });
60 }
61 expect(NEW_APP_ERRORS.no_name).toBeString();
62 });
63
64 test("an unverified account is refused", async () => {
65 const { app, fresh } = await world();
66 const made = await newAppFromSentence(app, fresh, { sentence: "tide tables" });
67 expect(made).toEqual({ ok: false, code: "unverified" });
68 });
69
70 test("at the app limit a non-admin is refused", async () => {
71 const { app, admin, user } = await world();
72 setQuota(app.db, "apps", 1, admin.id);
73 expect((await newAppFromSentence(app, user, { sentence: "tide tables" })).ok).toBe(true);
74 expect(await newAppFromSentence(app, user, { sentence: "moon phases" })).toEqual({
75 ok: false,
76 code: "app_limit",
77 });
78 });
test/crew-limit-words.test.ts +1 −1
@@ -19,7 +19,7 @@ const read = (file: string): string => readFileSync(join(root, file), "utf8");
1919 const readers: Record<string, Record<string, string>> = {
2020 "src/actions/migrate.ts": { AT_REPOSITORY_LIMIT, NO_SUCH_AGENT },
2121 "src/actions/repos.ts": { AT_REPOSITORY_LIMIT },
22 "src/web/pages/apps.ts": { NO_SUCH_AGENT },
22 "src/actions/apps.ts": { NO_SUCH_AGENT },
2323 "src/fleet/verbs.ts": { AGENT_NOT_ON_CREW },
2424 "src/tui/screens/common.ts": { AGENT_NOT_ON_CREW },
2525 "src/http/mcp/tools/assign.ts": { AGENT_NOT_ON_CREW },
test/repo-words.test.ts +1 −1
@@ -10,7 +10,7 @@ import { join } from "node:path";
1010 import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../src/actions/repo_words.ts";
1111
1212 const WORDS = { REPO_NAME_RULES, REPO_INIT_FAILED };
13 const READERS = ["src/actions/migrate.ts", "src/web/pages/apps.ts", "src/web/pages/site.ts"];
13 const READERS = ["src/actions/migrate.ts", "src/actions/apps.ts", "src/web/pages/site.ts"];
1414
1515 describe("a repository's refusal words", () => {
1616 for (const [name, words] of Object.entries(WORDS)) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs