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

Merged opened this

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

Merged

mason/forge:mason/11240-a-new-free-forge-with-no-crew-credit-say → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/11240-a-new-free-forge-with-no-crew-credit-say was merged into main as 23848a8.

Review

Sign in to review this change.
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`,
9494 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
9595 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
9696 - `/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.
9798 - `/first-admin` — `pages/first-admin.ts` · the first person becomes admin · none · none seen.
9899 - `/fleet/graph` — `pages/fleet-graph.ts` · the fleet as a graph · `/family`, `/forges` · none seen.
99100 - `/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
2121 ## Cost
2222
2323 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[] = [
3636 "agent",
3737 "agents",
3838 "privacy",
39 "pricing",
3940 "bot",
4041 "steward",
4142 "anonymous",
src/http/server.ts +2
@@ -34,6 +34,7 @@ import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3434 import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3535 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3636 import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
37 import { pricingPage } from "../web/pricing.ts";
3738 import { plansSet } from "../web/settings/plans.ts";
3839 import { allFlowsPage } from "../web/pages/flows-all.ts";
3940 import { measureSubmit } from "./measure.ts";
@@ -1014,6 +1015,7 @@ export function buildRouter(): Router<Handler> {
10141015 router.add("GET", "/live/moving", liveMovingFragment);
10151016 router.add("GET", "/live/chips", liveChipsFragment);
10161017 router.add("GET", "/feed", feedPage);
1018 router.add("GET", "/pricing", pricingPage);
10171019 // The home's late regions, each drawn alone (#7660).
10181020 for (const name of HOME_REGIONS) router.add("GET", `/live/home/${name}`, (ctx) => liveHomeFragment(ctx, name));
10191021 // 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";
2727 import { nextStepsFor } from "../../core/next-steps.ts";
2828 import { keepThought } from "../../actions/thought.ts";
2929 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";
3131 import { agentsLine } from "../../web/studio/client_home.ts";
3232 import { lastVisit } from "../../core/home_visit.ts";
3333 import { sinceDigest } from "../../web/studio/since.ts";
@@ -292,7 +292,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
292292 firstTell = steps.some((s) => s.key === "tell" && !s.done);
293293 everyday = everydayHead();
294294 if (firstTell) {
295 clientLines = [FIRST_TELL_LINE, crewNote(f.app)];
295 clientLines = [FIRST_TELL_LINE, crewFirstText(f.app)];
296296 ctx.invalidate();
297297 return;
298298 }
src/web/pages/next-steps.ts +2 −3
@@ -26,8 +26,7 @@ import { badge, btn, row } from "../ui.ts";
2626 import { tellHref } from "../studio/nouns.ts";
2727 import { everydayAsk } from "./everyday.ts";
2828 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";
3130 import { FORGE_TARGET } from "../studio/feed.ts";
3231 import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts";
3332
@@ -72,7 +71,7 @@ function firstTell(ctx: Ctx, user: User, crew: Html[]): Html {
7271 <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>
7372 <div class="bar">${btn("Tell the crew", { variant: "primary" })}</div>
7473 </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>
7675 ${crew.length > 0 ? html`<ul class="list">${crew}</ul>` : null}
7776 </section>`;
7877 }
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";
3333 import { isLocalPath } from "../flash.ts";
3434 import { html } from "../html.ts";
3535 import { flashOrJson, flashTo, page } from "../layout.ts";
36 import { crewCanRun, NO_CREDIT_WORDS } from "./crew_key.ts";
3637 import { FORGE_TARGET } from "./feed.ts";
3738 import { changeHref } from "./nouns.ts";
3839
@@ -95,6 +96,8 @@ async function tellFrom(
9596 if (from === "/" && field(form, "ask") === "1") return flashOrJson(ctx, back, { message: SENT_WORDS });

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.

9697 // The tell's own page, for a caller that asked for JSON (the palette's row).
9798 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);
98101 if (routed === undefined)
99102 return flashOrJson(
100103 ctx,
@@ -115,6 +118,15 @@ async function tellFrom(
115118 );
116119 }
117120
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
118130 /** POST /:owner/:name/tell { text } — a sentence becomes work. */
119131 export async function tellSubmit(ctx: Ctx): Promise<Response> {
120132 const repo = loadRepo(ctx);
@@ -213,11 +225,12 @@ async function composerTell(ctx: Ctx, form: Form, target: string, home: string,
213225 return flashOrJson(ctx, home, { tone: "danger", message: SAY_SOMETHING_WORDS });
214226 const kept = keepThought(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), "composer");
215227 if (kept.ok) onTold();
228 // The first screen's Build: kept, and never silently nothing when the crew cannot run.
216229 return flashOrJson(
217230 ctx,
218231 home,
219232 kept.ok
220 ? { message: "Received." }
233 ? crewCanRun(ctx.app) ? { message: "Received." } : noCreditFlash(ctx)
221234 : {
222235 tone: "danger",
223236 message:
src/web/studio/crew_key.ts +16 −1
@@ -59,7 +59,22 @@ export function crewNote(app: App): string {
5959 if (credit !== undefined && credit.leftUsd > 0)
6060 return `The crew is building on ${usdWords(credit.leftUsd)} of ${usdWords(credit.capUsd)} included credit.`;
6161 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>.`;
6378 }
6479
6580 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> = {
4545 "/opensearch.xml": "the browser search description, XML",
4646 "/sw.js": "the service worker script",
4747 "/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",
4849 "/maintenance": "no picture: the cap of 24 looks is spent; an admin's page, covered by test/maintenance.test.ts",
4950 "/claim": "answers 404 to an admin: it exists only for a link-holder",
5051 "/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", () => {
7171 const file = join(forge.child.config.dataDir, "parent.json");
7272 const before = readFileSync(file, "utf8");
7373 const lines: Record<string, string> = {
74 "": "The crew needs a model key to start.",
74 "": "The crew cannot build until you",
7575 credit: "included credit.",
7676 };
7777 try {
@@ -82,7 +82,18 @@ describe("the next-steps card of a spawned forge", () => {
8282 const home = await page(owner);
8383 const main = home.slice(home.indexOf("<main"), home.indexOf("</main>"));
8484 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 }
8697 expect(main).not.toContain('action="/settings/crew/key"');
8798 expect(main).not.toContain('type="password"');
8899 expect([...main.matchAll(/<form[^>]*>/g)].length).toBe(1);
@@ -93,6 +104,53 @@ describe("the next-steps card of a spawned forge", () => {
93104 }
94105 });
95106
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
96154 test("the tell box sends a tell, and the everyday offer appears once one exists", async () => {
97155 const told = await post(owner, "/marias/bakery/tell", { text: "Add a weekly menu page." });
98156 expect(told.status).toBeLessThan(400);
test/spawn-child-apps.test.ts +2 −1
@@ -178,8 +178,9 @@ describe("a spawned child's apps", () => {
178178 ).replace(/\s+/g, " ");
179179 expect(home).toContain("bakery");
180180 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");
182182 expect(home).toContain('href="/settings/subscriptions"');
183 expect(home).toContain('href="/pricing"');
183184 const appPage = await (
184185 await fetch(`${servers.url}/marias/bakery`, { headers: { cookie } })
185186 ).text();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs