Public: anyone can read this; the crew can change it.
A new free forge with no crew credit says so once, with the two ways forward #11302
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11240-a-new-free-forge-with-no-crew-credit-say → main 3 commits fork 2 threads · 2 unresolved
12 files
changed +134
−10
docs/design/audit.md +1
| @@ -94,6 +94,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 94 | 94 | - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1. |
| 95 | 95 | - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1. |
| 96 | 96 | - `/privacy` — `pages/privacy.ts` · what the forge keeps about a visitor, three plain lines, open to anyone · none · none seen. |
| 97 | - `/pricing` — `pricing.ts` · the plans and what each brings, the page the no-credit line points to · that line, `/` · none seen. | |
| 97 | 98 | - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen. |
| 98 | 99 | - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen. |
| 99 | 100 | - `/fleet` — `pages/fleet.ts` · the rows a fleet query line selects, as a table · `/fleet/graph` · none seen. |
docs/features/home-plans.md +4
| @@ -21,3 +21,7 @@ The plans themselves are the forge's plans setting (`src/settings/plans.ts`; edi | ||
| 21 | 21 | ## Cost |
| 22 | 22 | |
| 23 | 23 | The home reads who may sign up, the plans and the buy links in one statement (`src/settings/door.ts`), so the section adds no query to the page's statement budget. A stored value that does not parse reads as its default. |
| 24 | ||
| 25 | ## When the crew has no credit | |
| 26 | ||
| 27 | A forge whose crew has no key and no included credit says so once on its first screen: "The crew cannot build until you add a model key or pick a plan." (`NO_CREDIT_WORDS`, `crewFirstLine` in `src/web/studio/crew_key.ts`; the terminal's first screen says the same line as text, `crewFirstText`, with credit too). The key is a link to `/settings/subscriptions` (admins), the plan a link to `/pricing`, the plans on a page of their own (`src/web/pricing.ts`). With credit the line is the next step: make an app or bring a repository. A tell sent with no credit is kept, and the flash says the crew cannot build yet, with the same two ways forward; that holds for the first screen's own box (`app=forge`, kept as a thought) as for a tell to an app. `/pricing` has no terminal screen: its door there is the palette's *Get your own forge*. |
src/core/reserved.ts +1
| @@ -36,6 +36,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 36 | 36 | "agent", |
| 37 | 37 | "agents", |
| 38 | 38 | "privacy", |
| 39 | "pricing", | |
| 39 | 40 | "bot", |
| 40 | 41 | "steward", |
| 41 | 42 | "anonymous", |
src/http/server.ts +2
| @@ -34,6 +34,7 @@ import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; | ||
| 34 | 34 | import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 35 | 35 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 36 | 36 | import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 37 | import { pricingPage } from "../web/pricing.ts"; | |
| 37 | 38 | import { plansSet } from "../web/settings/plans.ts"; |
| 38 | 39 | import { allFlowsPage } from "../web/pages/flows-all.ts"; |
| 39 | 40 | import { measureSubmit } from "./measure.ts"; |
| @@ -1014,6 +1015,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1014 | 1015 | router.add("GET", "/live/moving", liveMovingFragment); |
| 1015 | 1016 | router.add("GET", "/live/chips", liveChipsFragment); |
| 1016 | 1017 | router.add("GET", "/feed", feedPage); |
| 1018 | router.add("GET", "/pricing", pricingPage); | |
| 1017 | 1019 | // The home's late regions, each drawn alone (#7660). |
| 1018 | 1020 | for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name)); |
| 1019 | 1021 | // Whether a hostname is an app's verified domain — what Caddy's |
src/tui/screens/dashboard.ts +2 −2
| @@ -27,7 +27,7 @@ import { appCrew } from "../../core/app-crew.ts"; | ||
| 27 | 27 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 28 | 28 | import { keepThought } from "../../actions/thought.ts"; |
| 29 | 29 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; |
| 30 | import { crewNote } from "../../web/studio/crew_key.ts"; | |
| 30 | import { crewFirstText } from "../../web/studio/crew_key.ts"; | |
| 31 | 31 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 32 | 32 | import { lastVisit } from "../../core/home_visit.ts"; |
| 33 | 33 | import { sinceDigest } from "../../web/studio/since.ts"; |
| @@ -292,7 +292,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 292 | 292 | firstTell = steps.some((s) => s.key === "tell" && !s.done); |
| 293 | 293 | everyday = everydayHead(); |
| 294 | 294 | if (firstTell) { |
| 295 | clientLines = [FIRST_TELL_LINE, crewNote(f.app)]; | |
| 295 | clientLines = [FIRST_TELL_LINE, crewFirstText(f.app)]; | |
| 296 | 296 | ctx.invalidate(); |
| 297 | 297 | return; |
| 298 | 298 | } |
src/web/pages/next-steps.ts +2 −3
| @@ -26,8 +26,7 @@ import { badge, btn, row } from "../ui.ts"; | ||
| 26 | 26 | import { tellHref } from "../studio/nouns.ts"; |
| 27 | 27 | import { everydayAsk } from "./everyday.ts"; |
| 28 | 28 | import { EVERYDAY_WHY } from "../../core/everyday.ts"; |
| 29 | import { crewNote } from "../studio/crew_key.ts"; | |
| 30 | import { crewHasCredential } from "../../core/subscriptions.ts"; | |
| 29 | import { crewFirstLine } from "../studio/crew_key.ts"; | |
| 31 | 30 | import { FORGE_TARGET } from "../studio/feed.ts"; |
| 32 | 31 | import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts"; |
| 33 | 32 | |
| @@ -72,7 +71,7 @@ function firstTell(ctx: Ctx, user: User, crew: Html[]): Html { | ||
| 72 | 71 | <textarea class="input" name="${COMPOSER_FIELDS.text}" rows="3" maxlength="${String(TELL_MAX)}" required placeholder="A weekly menu page for the bakery" aria-label="Your request" data-draft="${user.id}"></textarea> |
| 73 | 72 | <div class="bar">${btn("Tell the crew", { variant: "primary" })}</div> |
| 74 | 73 | </form> |
| 75 | <p class="small muted" id="crew-credit-note">${crewNote(ctx.app)}${crewHasCredential(ctx.app.db) || !ctx.auth.user?.isAdmin ? null : html` <a href="/settings/subscriptions">Add your own key</a>.`}</p> | |
| 74 | <p class="small muted" id="crew-credit-note">${crewFirstLine(ctx.app, ctx.auth.user?.isAdmin === true)}</p> | |
| 76 | 75 | ${crew.length > 0 ? html`<ul class="list">${crew}</ul>` : null} |
| 77 | 76 | </section>`; |
| 78 | 77 | } |
src/web/pricing.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * GET /pricing: the plans on a page of their own, so a line that says "pick a | |
| 3 | * plan" (the first screen of a forge whose crew has no credit, #11240) has | |
| 4 | * somewhere to point. The words are the home's (`plans_row.ts`); the terminal's | |
| 5 | * door is the palette's "Get your own forge". | |
| 6 | */ | |
| 7 | ||
| 8 | import { notePhase, takeSessionTime, timePhase } from "../core/route_timing.ts"; | |
| 9 | import type { Ctx } from "../http/context.ts"; | |
| 10 | import { GET_A_FORGE_PATH } from "../billing/plan_words.ts"; | |
| 11 | import { getDoorSettings } from "../settings/door.ts"; | |
| 12 | import { publicPlans } from "../settings/plans.ts"; | |
| 13 | import { freePlanName } from "./funnel_door.ts"; | |
| 14 | import { html } from "./html.ts"; | |
| 15 | import { page } from "./layout.ts"; | |
| 16 | import { plansRow } from "./plans_row.ts"; | |
| 17 | import { btn } from "./ui.ts"; | |
| 18 | ||
| 19 | export function pricingPage(ctx: Ctx): Response { | |
| 20 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 21 | const { plans, links } = getDoorSettings(ctx.app.db); | |
| 22 | const body = html`<section id="pricing" class="stack"> | |
| 23 | <h1>Plans</h1> | |
| 24 | <p class="muted">Each plan comes with crew credit. Or add your own model key and the crew builds on it.</p> | |
| 25 | ${plansRow(publicPlans(plans), links)} | |
| 26 | <div class="bar" data-plan="${freePlanName(plans)}">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary" })}</div> | |
| 27 | </section>`; | |
| 28 | return timePhase(ctx.request, "render", () => page({ ctx, title: "Plans", body })); | |
| 29 | } |
src/web/studio/actions.ts +14 −1
| @@ -33,6 +33,7 @@ import { backTo, field, readForm, type Form } from "../form.ts"; | ||
| 33 | 33 | import { isLocalPath } from "../flash.ts"; |
| 34 | 34 | import { html } from "../html.ts"; |
| 35 | 35 | import { flashOrJson, flashTo, page } from "../layout.ts"; |
| 36 | import { crewCanRun, NO_CREDIT_WORDS } from "./crew_key.ts"; | |
| 36 | 37 | import { FORGE_TARGET } from "./feed.ts"; |
| 37 | 38 | import { changeHref } from "./nouns.ts"; |
| 38 | 39 | |
| @@ -95,6 +96,8 @@ async function tellFrom( | ||
| 95 | 96 | if (from === "/" && field(form, "ask") === "1") return flashOrJson(ctx, back, { message: SENT_WORDS }); |
| 96 | 97 | // The tell's own page, for a caller that asked for JSON (the palette's row). |
| 97 | 98 | const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` }; |
| 99 | // "Build" never silently does nothing: with no credit the person is told, with the way forward. | |
| 100 | if (!crewCanRun(ctx.app)) return flashOrJson(ctx, back, noCreditFlash(ctx), at); | |
| 98 | 101 | if (routed === undefined) |
| 99 | 102 | return flashOrJson( |
| 100 | 103 | ctx, |
| @@ -115,6 +118,15 @@ async function tellFrom( | ||
| 115 | 118 | ); |
| 116 | 119 | } |
| 117 | 120 | |
| 121 | /** The flash for words kept while the crew has no credit: saved, the crew cannot build yet, and the way forward. */ | |
| 122 | function noCreditFlash(ctx: Ctx): { tone: "attn"; message: string; link: { href: string; label: string } } { | |
| 123 | return { | |
| 124 | tone: "attn", | |
| 125 | message: `Saved, but ${NO_CREDIT_WORDS.charAt(0).toLowerCase()}${NO_CREDIT_WORDS.slice(1)}`, | |
| 126 | link: ctx.auth.user?.isAdmin === true ? { href: "/settings/subscriptions", label: "Add a model key" } : { href: "/pricing", label: "See the plans" }, | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 118 | 130 | /** POST /:owner/:name/tell { text } — a sentence becomes work. */ |
| 119 | 131 | export async function tellSubmit(ctx: Ctx): Promise<Response> { |
| 120 | 132 | const repo = loadRepo(ctx); |
| @@ -213,11 +225,12 @@ async function composerTell(ctx: Ctx, form: Form, target: string, home: string, | ||
| 213 | 225 | return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS }); |
| 214 | 226 | const kept = keepThought(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), "composer"); |
| 215 | 227 | if (kept.ok) onTold(); |
| 228 | // The first screen's Build: kept, and never silently nothing when the crew cannot run. | |
| 216 | 229 | return flashOrJson( |
| 217 | 230 | ctx, |
| 218 | 231 | home, |
| 219 | 232 | kept.ok |
| 220 | ? { message: "Received." } | |
| 233 | ? crewCanRun(ctx.app) ? { message: "Received." } : noCreditFlash(ctx) | |
| 221 | 234 | : { |
| 222 | 235 | tone: "danger", |
| 223 | 236 | message: |
src/web/studio/crew_key.ts +16 −1
| @@ -59,7 +59,22 @@ export function crewNote(app: App): string { | ||
| 59 | 59 | if (credit !== undefined && credit.leftUsd > 0) |
| 60 | 60 | return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit.`; |
| 61 | 61 | if (credit !== undefined) return "The included credit is spent; the crew needs a model key to go on."; |
| 62 | return "The crew needs a model key to start."; | |
| 62 | return NO_CREDIT_WORDS; | |
| 63 | } | |
| 64 | ||
| 65 | /** What a forge whose crew has nothing to run on says, on the page and in the terminal. */ | |
| 66 | export const NO_CREDIT_WORDS = "The crew cannot build until you add a model key or pick a plan."; | |
| 67 | ||
| 68 | /** The first screen's line as plain text, for the terminal: the same words as `crewFirstLine`, no links. */ | |
| 69 | export function crewFirstText(app: App): string { | |
| 70 | return crewCanRun(app) ? `${crewNote(app)} Next: make an app or bring a repository.` : NO_CREDIT_WORDS; | |
outdated
The terminal's first screen uses crewNote only (dashboard.ts:295), so with credit it lacks the 'Next:' step and the two states differ between doors. Share a plain-text version of this line with the TUI, or fix the doc claim in home-plans.md. |
||
| 71 | } | |
| 72 | ||
| 73 | /** The first screen's one line: with credit the next step, without it the two ways forward, each a link (#11240). */ | |
| 74 | export function crewFirstLine(app: App, admin: boolean): Html { | |
| 75 | if (crewCanRun(app)) | |
| 76 | return html`${crewNote(app)} Next: <a href="/apps/new">make an app</a> or <a href="/new">bring a repository</a>.`; | |
| 77 | return html`The crew cannot build until you ${admin ? html`<a href="/settings/subscriptions">add a model key</a>` : "add a model key"} or <a href="/pricing">pick a plan</a>.`; | |
| 63 | 78 | } |
| 64 | 79 | |
| 65 | 80 | const keyField = (): Html => html`<form class="stack" method="post" action="${KEY_ACTION}" id="crew-key-form"> |
test/forge-looks.test.ts +1
| @@ -45,6 +45,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 45 | 45 | "/opensearch.xml": "the browser search description, XML", |
| 46 | 46 | "/sw.js": "the service worker script", |
| 47 | 47 | "/audit.csv": "a CSV download of /audit", |
| 48 | "/pricing": "no picture: the cap of 24 looks is spent; the page is covered by test/next-steps-card.test.ts", | |
| 48 | 49 | "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts", |
| 49 | 50 | "/claim": "answers 404 to an admin: it exists only for a link-holder", |
| 50 | 51 | "/first-admin": "the setup form for a forge with no admin yet", |
test/next-steps-card.test.ts +60 −2
| @@ -71,7 +71,7 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 71 | 71 | const file = join(forge.child.config.dataDir, "parent.json"); |
| 72 | 72 | const before = readFileSync(file, "utf8"); |
| 73 | 73 | const lines: Record<string, string> = { |
| 74 | "": "The crew needs a model key to start.", | |
| 74 | "": "The crew cannot build until you", | |
| 75 | 75 | credit: "included credit.", |
| 76 | 76 | }; |
| 77 | 77 | try { |
| @@ -82,7 +82,18 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 82 | 82 | const home = await page(owner); |
| 83 | 83 | const main = home.slice(home.indexOf("<main"), home.indexOf("</main>")); |
| 84 | 84 | expect(main).toContain(said); |
| 85 | expect(main).toContain('href="/settings/subscriptions"'); | |
| 85 | if (mode === "") { | |
| 86 | // No credit: the two ways forward, once, each a link. | |
| 87 | expect(main).toContain('href="/settings/subscriptions"'); | |
| 88 | expect(main).toContain('href="/pricing"'); | |
| 89 | expect(main.match(/cannot build/g)?.length).toBe(1); | |
| 90 | } else { | |
| 91 | // Credit: the next step instead. | |
| 92 | expect(main).toContain("Next:"); | |
| 93 | expect(main).toContain('href="/apps/new"'); | |
| 94 | expect(main).toContain('href="/new"'); | |
| 95 | expect(main).not.toContain("cannot build"); | |
| 96 | } | |
| 86 | 97 | expect(main).not.toContain('action="/settings/crew/key"'); |
| 87 | 98 | expect(main).not.toContain('type="password"'); |
| 88 | 99 | expect([...main.matchAll(/<form[^>]*>/g)].length).toBe(1); |
| @@ -93,6 +104,53 @@ describe("the next-steps card of a spawned forge", () => { | ||
| 93 | 104 | } |
| 94 | 105 | }); |
| 95 | 106 | |
| 107 | test("with no credit a tell is kept and says the crew cannot build yet, with the way forward; with credit it does not", async () => { | |
| 108 | const file = join(forge.child.config.dataDir, "parent.json"); | |
| 109 | const before = readFileSync(file, "utf8"); | |
| 110 | const flashOf = (res: Response) => decodeURIComponent(res.headers.get("set-cookie") ?? ""); | |
| 111 | try { | |
| 112 | const doc = JSON.parse(before); | |
| 113 | delete doc.credit; | |
| 114 | writeFileSync(file, JSON.stringify(doc)); | |
| 115 | const none = await post(owner, "/marias/bakery/tell", { text: "Add a contact page." }); | |
| 116 | expect(none.status).toBeLessThan(400); | |
| 117 | expect(flashOf(none)).toContain("cannot build until you add a model key or pick a plan"); | |
| 118 | expect(flashOf(none)).toContain("/settings/subscriptions"); | |
| 119 | writeFileSync(file, before); | |
| 120 | const funded = await post(owner, "/marias/bakery/tell", { text: "Add a gallery page." }); | |
| 121 | expect(flashOf(funded)).not.toContain("cannot build"); | |
| 122 | } finally { | |
| 123 | writeFileSync(file, before); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | test("the first screen's own box (app=forge) with no credit says the crew cannot build, with the link; with credit it says Received", async () => { | |
| 128 | const file = join(forge.child.config.dataDir, "parent.json"); | |
| 129 | const before = readFileSync(file, "utf8"); | |
| 130 | const flashOf = (res: Response) => decodeURIComponent(res.headers.get("set-cookie") ?? ""); | |
| 131 | try { | |
| 132 | const doc = JSON.parse(before); | |
| 133 | delete doc.credit; | |
| 134 | writeFileSync(file, JSON.stringify(doc)); | |
| 135 | const none = await post(owner, "/tell", { app: "forge", text: "A shop for my bakery." }); | |
| 136 | expect(none.status).toBeLessThan(400); | |
| 137 | expect(flashOf(none)).toContain("cannot build until you add a model key or pick a plan"); | |
| 138 | expect(flashOf(none)).toContain("/settings/subscriptions"); | |
| 139 | writeFileSync(file, before); | |
| 140 | const funded = await post(owner, "/tell", { app: "forge", text: "A shop for my bakery." }); | |
| 141 | expect(flashOf(funded)).toContain("Received."); | |
| 142 | expect(flashOf(funded)).not.toContain("cannot build"); | |
| 143 | } finally { | |
| 144 | writeFileSync(file, before); | |
| 145 | } | |
| 146 | }); | |
| 147 | ||
| 148 | test("/pricing answers, signed out, with the plans", async () => { | |
| 149 | const res = await fetch(url("/pricing")); | |
| 150 | expect(res.status).toBe(200); | |
| 151 | expect(await res.text()).toContain('id="pricing"'); | |
| 152 | }); | |
| 153 | ||
| 96 | 154 | test("the tell box sends a tell, and the everyday offer appears once one exists", async () => { |
| 97 | 155 | const told = await post(owner, "/marias/bakery/tell", { text: "Add a weekly menu page." }); |
| 98 | 156 | expect(told.status).toBeLessThan(400); |
test/spawn-child-apps.test.ts +2 −1
| @@ -178,8 +178,9 @@ describe("a spawned child's apps", () => { | ||
| 178 | 178 | ).replace(/\s+/g, " "); |
| 179 | 179 | expect(home).toContain("bakery"); |
| 180 | 180 | expect(home).not.toContain("No apps yet"); |
| 181 | expect(home).toContain("The crew needs a model key to start"); | |
| 181 | expect(home).toContain("The crew cannot build until you"); | |
| 182 | 182 | expect(home).toContain('href="/settings/subscriptions"'); |
| 183 | expect(home).toContain('href="/pricing"'); | |
| 183 | 184 | const appPage = await ( |
| 184 | 185 | await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } }) |
| 185 | 186 | ).text(); |
new above
offline
The first-screen composer posts to /tell with app=FORGE_TARGET and is answered elsewhere ('Received.'); this early return also skips the no-credit check below. The first screen's Build button still does nothing visible with no credit. Check crewCanRun on that path too, and test it.