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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8384-new-app-s-name-from-sentence-loop-is-one → main 2 commits fork
5 files
changed +165
−50
src/actions/apps.ts +56
| @@ -9,6 +9,9 @@ | ||
| 9 | 9 | import { pullDir } from "../core/pulls.ts"; |
| 10 | 10 | import { getIssue } from "../core/issues.ts"; |
| 11 | 11 | 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"; | |
| 12 | 15 | import { requireVerified } from "./verify.ts"; |
| 13 | 16 | import { requireAppRoom } from "./app_limit.ts"; |
| 14 | 17 | import type { App } from "../app.ts"; |
| @@ -436,6 +439,59 @@ export async function newApp( | ||
| 436 | 439 | }); |
| 437 | 440 | } |
| 438 | 441 | |
| 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 | ||
| 439 | 495 | export interface AppView { |
| 440 | 496 | readonly app: AppRecord; |
| 441 | 497 | readonly environments: EnvStatus[]; |
src/web/pages/apps.ts +29 −48
| @@ -14,13 +14,15 @@ | ||
| 14 | 14 | |
| 15 | 15 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 16 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../../actions/repo_words.ts"; | |
| 18 | 17 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 19 | import { NO_SUCH_AGENT } from "../../actions/crew_limit_words.ts"; | |
| 20 | 18 | import { getUserByName } from "../../core/users.ts"; |
| 21 | 19 | import { |
| 20 | NEW_APP_ERRORS, | |
| 21 | NO_NAME_ERROR, | |
| 22 | 22 | appView, |
| 23 | 23 | newApp, |
| 24 | newAppFromSentence, | |
| 25 | type NewAppResult, | |
| 24 | 26 | openEnvUrl, |
| 25 | 27 | redeployEnv, |
| 26 | 28 | stopEnv, |
| @@ -28,7 +30,7 @@ import { | ||
| 28 | 30 | } from "../../actions/apps.ts"; |
| 29 | 31 | import { listAllAgents } from "../../actions/crew.ts"; |
| 30 | 32 | import { planningAvailable } from "../../crew/plan.ts"; |
| 31 | import { appNameJs, deriveAppName } from "./apps-name.ts"; | |
| 33 | import { appNameJs } from "./apps-name.ts"; | |
| 32 | 34 | import { STARTERS, starterFromQuery, starterHref } from "./apps-starters.ts"; |
| 33 | 35 | import { previewEnv, type EnvName } from "../../apps/data.ts"; |
| 34 | 36 | import { envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts"; |
| @@ -443,26 +445,6 @@ export function newAppPage(ctx: Ctx): Response { | ||
| 443 | 445 | return response; |
| 444 | 446 | } |
| 445 | 447 | |
| 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 | ||
| 466 | 448 | // POST /apps/new |
| 467 | 449 | export async function newAppSubmit(ctx: Ctx): Promise<Response> { |
| 468 | 450 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -482,8 +464,30 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 482 | 464 | // names the app — and the server derives that name itself (never trusting |
| 483 | 465 | // whatever the field carried in): the field is only a client-side preview. |
| 484 | 466 | 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") { | |
| 487 | 491 | return page( |
| 488 | 492 | { |
| 489 | 493 | ctx, |
| @@ -494,29 +498,6 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 494 | 498 | 422, |
| 495 | 499 | ); |
| 496 | 500 | } |
| 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 | } | |
| 520 | 501 | if (!made.ok) { |
| 521 | 502 | if (made.error === "forbidden_owner" || made.error === "unauthorized") |
| 522 | 503 | 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"); | ||
| 19 | 19 | const readers: Record<string, Record<string, string>> = { |
| 20 | 20 | "src/actions/migrate.ts": { AT_REPOSITORY_LIMIT, NO_SUCH_AGENT }, |
| 21 | 21 | "src/actions/repos.ts": { AT_REPOSITORY_LIMIT }, |
| 22 | "src/web/pages/apps.ts": { NO_SUCH_AGENT }, | |
| 22 | "src/actions/apps.ts": { NO_SUCH_AGENT }, | |
| 23 | 23 | "src/fleet/verbs.ts": { AGENT_NOT_ON_CREW }, |
| 24 | 24 | "src/tui/screens/common.ts": { AGENT_NOT_ON_CREW }, |
| 25 | 25 | "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"; | ||
| 10 | 10 | import { REPO_INIT_FAILED, REPO_NAME_RULES } from "../src/actions/repo_words.ts"; |
| 11 | 11 | |
| 12 | 12 | 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"]; | |
| 14 | 14 | |
| 15 | 15 | describe("a repository's refusal words", () => { |
| 16 | 16 | for (const [name, words] of Object.entries(WORDS)) { |
new above
offline