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

The anonymous home page and /agents show the plans and one button to get a forge #11164

Merged opened this in answer to

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

Merged

mason/forge:mason/11139-the-anonymous-home-page-and-agents-show → main 11 commits fork 7 threads · 7 unresolved

  • Merged. mason/11139-the-anonymous-home-page-and-agents-show was merged into main as 2fee2b9.

Review

Sign in to review this change.
20 files changed +427 −92
README.md +1 −1
@@ -20,7 +20,7 @@ and send a clean pull request back. Build your own apps and share them.
2020
2121 The forge is part of [Open Platform](https://www.open-platform.sh), with the
2222 platform for organizations.
23 You run it yourself today. Hosting is coming.
23 You can run it yourself, or have a hosted forge.
outdated

Points to /pricing, which does not exist yet; the README line should wait for the page.

2424
2525 ## Try it
2626
docs/design/areas/home-plans.md added +3
@@ -0,0 +1,3 @@
1 # Home plans row
2
3 `.door-plans` is the anonymous home's compact list of plans (`src/web/plans_row.ts`): rows, no box: name, muted price ("Free", "$19 a month", or "Paid" without a provider), an optional plain "Pay for <plan>" link, and one muted line of what the plan is; one hairline where the list ends. It sits in `#yours`'s single `.stack`, so its spacing is the stack's gap; the section's one large button follows it, and self-hosting is a sentence with a link, never a second button.
docs/features/guests.md +1 −1
@@ -30,7 +30,7 @@ sends a pull across. It arrives as a guest pull.
3030 - **For:** no account on the root forge at all, so nothing new to abuse here;
3131 the pull path and its guards already exist; the person owns their work.
3232 - **Risks:** the highest bar for a newcomer ("run a forge" is not a grandma
33 step); a forge needs a machine or hosting, and hosting is not shipped; the
33 step); a forge needs a machine or hosting, and hosting is offered as plans on the home page; the
3434 forge is unknown to us, so every pull is from an unvetted origin, which
3535 writers must approve one by one.
3636 - **Cost to build:** low. A clear page that says how, plus the one-command
docs/features/home-plans.md added +30
@@ -0,0 +1,30 @@
1 # Plans on the home
2
3 Where a visitor sees what a hosted forge is and how to get one (#11139).
4
5 ## Where
6
7 - Web: the signed-out home, section **Get your own forge** (`/#yours`): one line per plan, then one **Get a forge** button to `/forges/new`, then the free, open-source way to run it yourself (the guide link and the clone commands).
8 - Terminal: the palette's **Get your own forge** (`src/tui/screens/getforge.ts`), offered to everyone, guests included: the same plans, `Get a forge: <origin>/forges/new`, and the clone commands.
9 - `/llms.txt`: the "Get your own forge" line points at the plans on the home and at `/forges/new`.
10
11 ## What a plan line says
12
13 The plan's name, its price, the machine it runs on (CPU, memory, disk) and its own sentence. The words are `src/billing/plan_words.ts`, shared by both doors. A plan whose size has a [buy link](billing-links.md) also shows "Pay for <plan>".
14
15 The plans themselves are the forge's plans setting (`src/settings/plans.ts`; edit them at `/admin/limits`, the palette's *Plans*, or MCP `limits`).
16
17 ## FORGE_BILLING_PROVIDER
18
19 Which payment provider the forge sells through, read on every request (no restart needed):
20
21 | value | what the plans show |
22 | --- | --- |
23 | `stripe` | each paid plan's monthly price, e.g. "$19 a month"; the free plan reads "Free" |
24 | unset, `none`, or anything else | no price: a paid plan reads "Paid", the free plan "Free" |
25
26 It only changes what is shown; it charges nothing by itself.
27
28 ## Cost
29
30 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.
src/billing/plan_words.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * A plan in plain words, the same on the home page and in the terminal: what
3 * it costs, what it is (the machine it runs on and what it says), and where to
4 * pay for it when a buy link is set (`settings/billing.ts`). With provider
5 * `none` (`billing/provider.ts`) no price is shown: a paid plan reads "Paid",
6 * the free plan still reads "Free", so the two never look the same.
7 */
8
9 import type { BillingLinks } from "../settings/billing.ts";
10 import { sizeWords } from "../settings/billing.ts";
11 import type { Plan } from "../settings/plans.ts";
12
13 export interface PlanWords {
14 readonly name: string;
15 /** "Free", "$19 a month", or "Paid" when prices are not shown. */
16 readonly price: string;
17 /** The machine and the plan's own line: "1 CPU, 2 GB memory, 2 GB disk. A small forge…". */
18 readonly is: string;
19 /** The https buy link for the plan's size, only on a paid plan and only when set. */
20 readonly buy: string | undefined;
21 }
22
23 export function planWords(p: Plan, priced: boolean, links: BillingLinks = {}): PlanWords {
24 const free = p.priceUsd === 0;
25 return {
26 name: p.name,
27 price: free ? "Free" : priced ? `$${p.priceUsd} a month` : "Paid",
28 is: `${sizeWords(p.size)}. ${p.says}`,
29 buy: free ? undefined : links[p.size],
30 };
31 }
32
33 /** Where the home's one button and the terminal's pointer send a person for a hosted forge. */
34 export const GET_A_FORGE_PATH = "/forges/new";
src/billing/provider.ts added +11
@@ -0,0 +1,11 @@
1 /**
2 * Which payment provider this forge sells through: `stripe` or `none`
3 * (`FORGE_BILLING_PROVIDER`; anything else reads as `none`). With `none`
4 * nothing is charged, so pages show the plans without prices.
5 */
6
7 export type BillingProvider = "none" | "stripe";
8
9 export function billingProvider(env: Record<string, string | undefined> = process.env): BillingProvider {
10 return env["FORGE_BILLING_PROVIDER"] === "stripe" ? "stripe" : "none";
11 }
src/settings/billing.ts +5 −24
@@ -9,13 +9,12 @@
99 import type { Database } from "bun:sqlite";
1010 import { err, ok, type Result } from "../kernel/result.ts";
1111 import { SIZES, type Size } from "../spawn/sizes.ts";
12 import { SIGNUP_DEFAULT, parseSignup, type SignupMode } from "./signup.ts";
1312 import { writeSetting } from "./limits.ts";
1413
1514 export const BILLING_SIZES = ["small", "medium", "large"] as const satisfies readonly Size[];
1615 export type BillingLinks = Partial<Record<Size, string>>;
1716
18 const STORE_KEY = "billing.links";
17 export const BILLING_LINKS_KEY = "billing.links";
1918 const MAX_URL = 300;
2019
2120 export const BILLING_WORDS = "billing links are an object of small, medium and large, each an https URL or empty";
@@ -50,11 +49,12 @@ export function parseBillingLinks(raw: unknown): Result<BillingLinks, "invalid_b
5049 export function getBillingLinks(db: Database): BillingLinks {
5150 const row = db
5251 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
53 .get(STORE_KEY);
52 .get(BILLING_LINKS_KEY);
5453 return row === null ? {} : decodeLinks(row.value);
5554 }
5655
57 function decodeLinks(raw: string): BillingLinks {
56 /** A stored links value as links; anything that does not parse reads as none. */
57 export function decodeLinks(raw: string): BillingLinks {
5858 try {
5959 const parsed = parseBillingLinks(JSON.parse(raw));
6060 return parsed.ok ? parsed.value : {};
@@ -71,26 +71,7 @@ export function setBillingLinks(db: Database, value: BillingLinks, actorId: stri
7171 const from = ENCODE(getBillingLinks(db));
7272 const to = ENCODE(value);
7373 if (from === to) return;
74 writeSetting(db, STORE_KEY, to === "{}" ? null : to, from, to, actorId);
75 }
76
77 /**
78 * Who may sign up and the buy links, in one query: the home page needs both
79 * and each read is a statement on a page held to a statement budget.
80 */
81 export function getSignupAndLinks(db: Database): { mode: SignupMode; links: BillingLinks } {
82 let mode: SignupMode = SIGNUP_DEFAULT;
83 let links: BillingLinks = {};
84 const rows = db
85 .query<{ key: string; value: string }, [string, string]>("SELECT key, value FROM forge_setting WHERE key IN (?1, ?2)")
86 .all("signup", STORE_KEY);
87 for (const row of rows) {
88 if (row.key === "signup") {
89 const m = parseSignup(row.value);
90 if (m.ok) mode = m.value;
91 } else links = decodeLinks(row.value);
92 }
93 return { mode, links };
74 writeSetting(db, BILLING_LINKS_KEY, to === "{}" ? null : to, from, to, actorId);
9475 }
9576
9677 /** The size in plain words, from the one table of sizes: "1 CPU, 2 GB memory, 2 GB disk". */
src/settings/door.ts added +38
@@ -0,0 +1,38 @@
1 /**
2 * The settings the signed-out home reads, in one statement: who may sign up,
3 * the plans and the buy links. Each read is a statement on a page held to a
4 * statement budget, so the three come back from one query. A stored value
5 * that does not parse reads as its default, as each setting's own reader does.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { BILLING_LINKS_KEY, decodeLinks, type BillingLinks } from "./billing.ts";
10 import { PLANS_KEY, decodePlans, type Plan } from "./plans.ts";
11 import { SIGNUP_DEFAULT, parseSignup, type SignupMode } from "./signup.ts";
12
13 const SIGNUP_KEY = "signup";
14
15 export interface DoorSettings {
16 readonly mode: SignupMode;
17 readonly plans: Plan[];
18 readonly links: BillingLinks;
19 }
20
21 export function getDoorSettings(db: Database): DoorSettings {
22 let mode: SignupMode = SIGNUP_DEFAULT;
23 let plans: string | undefined;
24 let links: BillingLinks = {};
25 const rows = db
26 .query<{ key: string; value: string }, [string, string, string]>(
27 "SELECT key, value FROM forge_setting WHERE key IN (?1, ?2, ?3)",
28 )
29 .all(SIGNUP_KEY, PLANS_KEY, BILLING_LINKS_KEY);
30 for (const row of rows) {
31 if (row.key === SIGNUP_KEY) {
32 const m = parseSignup(row.value);
33 if (m.ok) mode = m.value;
34 } else if (row.key === PLANS_KEY) plans = row.value;
35 else links = decodeLinks(row.value);
36 }
37 return { mode, plans: decodePlans(plans), links };
38 }
src/settings/plans.ts +10 −5
@@ -100,12 +100,11 @@ export function parsePlans(raw: unknown): Result<Plan[], string> {
100100 return ok(plans);
101101 }
102102
103 /** The plans in force now, read on every use. */
104 export function getPlans(db: Database): Plan[] {
105 const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PLANS_KEY);
106 if (row !== null) {
103 /** A stored plans value as plans; anything that does not parse reads as the defaults. */
104 export function decodePlans(raw: string | undefined): Plan[] {
105 if (raw !== undefined) {
107106 try {
108 const parsed = parsePlans(JSON.parse(row.value));
107 const parsed = parsePlans(JSON.parse(raw));
109108 if (parsed.ok) return parsed.value;
110109 } catch {
111110 // a hand-edited value reads as the defaults
@@ -114,6 +113,12 @@ export function getPlans(db: Database): Plan[] {
114113 return DEFAULT_PLANS.map((p) => ({ ...p }));
115114 }
116115
116 /** The plans in force now, read on every use. */
117 export function getPlans(db: Database): Plan[] {
118 const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PLANS_KEY);
119 return decodePlans(row?.value);
120 }
121
117122 export function getPlan(db: Database, key: string): Plan | undefined {
118123 return getPlans(db).find((p) => p.key === key);
119124 }
src/tui/forge.ts +2
@@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts";
1818 import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
21 import { getForgeCommand } from "./screens/getforge.ts";
2122 import { workCommand } from "./screens/work.ts";
2223 import { shellsScreen } from "./screens/shells.ts";
2324 import { forkTreeScreen } from "./screens/fork_tree.ts";
@@ -241,6 +242,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
241242 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
242243 },
243244 packsCommand(session, ctx),
245 getForgeCommand(session, ctx),
244246 ...dataCommand(session, ctx),
245247 fleetCommand(session, ctx),
246248 {
src/tui/screens/getforge.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * "Get your own forge" in the terminal: the home page's `#yours` for anyone,
3 * guests included. Every plan with its price (or "Paid" while
4 * `FORGE_BILLING_PROVIDER` is not `stripe`), what it is, its buy link when
5 * one is set, then where to get a hosted forge and how to run one yourself.
6 * The words are `billing/plan_words.ts`, the same the web row prints; the
7 * settings are read in one statement as the home reads them.
8 */
9
10 import { billingProvider } from "../../billing/provider.ts";
11 import { GET_A_FORGE_PATH, planWords } from "../../billing/plan_words.ts";
12 import { UPSTREAM_REPO } from "../../core/upstream.ts";
13 import { getDoorSettings } from "../../settings/door.ts";
14 import { publicPlans } from "../../settings/plans.ts";
15 import type { App } from "../../app.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import { style } from "../term/style.ts";
19 import { wrap } from "../term/width.ts";
20 import type { Session } from "../session.ts";
21 import { Scroll } from "./common.ts";
22
23 /** The screen as text lines, read fresh on every draw. */
24 export function getForgeLines(app: App, width = 80, priced = billingProvider() === "stripe"): string[] {
25 const w = Math.max(20, width);
26 const { plans, links } = getDoorSettings(app.db);
27 const origin = (app.config.url ?? "").replace(/\/$/, "");
28 return [
29 "Get your own forge",
30 ...wrap("Hosted for you, or free and open source on a machine you keep.", w),
31 "",
32 "Plans",
33 ...publicPlans(plans).flatMap((p) => {
34 const words = planWords(p, priced, links);
35 return [
36 ` ${words.name} ${words.price}`,
37 ...wrap(words.is, w - 4).map((l) => ` ${l}`),
38 ...(words.buy === undefined ? [] : [` Pay: ${words.buy}`]),
39 ];
40 }),
41 "",
42 `Get a forge: ${origin}${GET_A_FORGE_PATH}`,
43 "",
44 "Or run it yourself, free and open source",
45 ` git clone ${UPSTREAM_REPO}.git`,
46 " cd forge",
47 " bun install",
48 " bun run src/main.ts",
49 ];
50 }
51
52 export function getForgeScreen(session: Session, ctx: Ctx): Screen {
53 const scroll = new Scroll();
54 let lines: string[] = [];
55 return {
56 title: "Get your own forge",
57 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
58 render(area, s) {
59 const c = ctx.theme.c;
60 lines = getForgeLines(session.app, area.w - 4);
61 const from = scroll.window(area.h, lines.length);
62 for (let i = 0; i < area.h; i++) {
63 const line = lines[from + i];
64 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
65 }
66 },
67 key(e) {
68 if (e.key === "esc" || e.key === "q") {
69 ctx.pop();
70 return true;
71 }
72 return scroll.key(e, lines.length);
73 },
74 };
75 }
76
77 /** Offered to everyone, guests included, as the packs command is. */
78 export const getForgeCommand = (session: Session, ctx: Ctx) => ({
79 id: "get-a-forge",
80 title: "Get your own forge",
81 hint: "the plans, where to get a hosted forge, how to run one yourself",
82 run: () => ctx.push(getForgeScreen(session, ctx)),
83 });
src/web/plans_row.ts added +21
@@ -0,0 +1,21 @@
1 /**
2 * The plans in one compact list (name, price, what it is) for the anonymous
3 * home's "Get your own forge". The one button beside it is the home's; a plan
4 * with a buy link set (`settings/billing.ts`) carries a plain "Pay" link, not
5 * a second button. Words come from `billing/plan_words.ts`, the terminal's too.
6 */
7
8 import { billingProvider } from "../billing/provider.ts";
9 import { planWords } from "../billing/plan_words.ts";
10 import type { BillingLinks } from "../settings/billing.ts";
11 import type { Plan } from "../settings/plans.ts";
12 import { html, type Html } from "./html.ts";
13
14 export function plansRow(plans: readonly Plan[], links: BillingLinks = {}, priced = billingProvider() === "stripe"): Html {
15 return html`<ul class="door-plans">${plans.map((p) => {
16 const w = planWords(p, priced, links);
outdated

Principle 2/8: with provider none the row hides price entirely, so the 'actual rows' show names and prose only; the Free plan and paid plans look the same. Say plainly in the row what a plan is, and test the priced variant on the home page.

17 return html`<li><strong>${w.name}</strong> <span class="muted">${w.price}</span>${
18 w.buy === undefined ? "" : html` <a href="${w.buy}" rel="noopener noreferrer" target="_blank">Pay for ${w.name}</a>`
19 }<span class="muted small">${w.is}</span></li>`;
20 })}</ul>`;
21 }
src/web/standards.ts +4 −6
@@ -48,7 +48,6 @@ import { apiCatalog, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPlu
4848 import { forgeDocument } from "../core/standards_index.ts";
4949 import { TAGS } from "../contract/served.ts";
5050 import { running } from "../kernel/version.ts";
51 import { getBillingLinks } from "../settings/billing.ts";
5251 import { timePhase, timePhaseAsync } from "../core/route_timing.ts";
5352 import { shippedReleases, type Shipped } from "../release/shipped.ts";
5453
@@ -399,11 +398,10 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } {
399398 const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db));
400399 // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart.
401400 const signedIn = ctx.auth.user !== undefined;
402 const sold = Object.keys(getBillingLinks(ctx.app.db)).length > 0;
403 const key = `${o} ${signedIn} ${sold}`;
401 const key = `${o} ${signedIn}`;
404402 let kept = llmsBodies.get(key);
405403 if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins) {
406 const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins, sold));
404 const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins));
407405 const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`);
408406 kept = { signedIn, repos, running, tell, plugins, body, etag };
409407 // Origins come from the Host header: keep a handful, not one per guess.
@@ -423,7 +421,7 @@ function pluginLines(o: string, plugins: string | undefined): string[] {
423421 return plugins === undefined ? [...lines, "- Claude Code plugin: not offered here (no public source)"] : lines;
424422 }
425423
426 function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined, sold: boolean): string {
424 function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: string, repos: LlmsListing, signedIn: boolean, plugins: string | undefined): string {
427425 const mcp = mcpPointers(o);
428426 return (
429427 [
@@ -439,7 +437,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
439437 "",
440438 `- Build now, no account: \`curl -sX POST ${o}/api/v1/start -d '{"name":"my-app"}'\` -> {user, token, repo, git, app, claim}. Clone git.url (user:token@), \`git push\` to main: app.url is live. Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.`,
441439 `- Or with an account: sign up at ${o}/signup, then follow "Offer a change" below. By request, POST ${o}/api/v1/users gives 202 {state:"waiting"}. If /signup says 403, use "Report a problem".`,
442 `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one${sold ? " (paid plans are on the home page; this way is free within limits)" : ", free within limits while hosting is not sold"}: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`,
440 `- Get your own forge: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs the claude CLI and a model credential. Or a hosted one, plans on ${o}: sign in and open ${o}/forges/new (or call the MCP \`spawn\` tool with the person's consent). It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.`,
443441 `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`,
444442 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
445443 "",
src/web/studio/home.ts +10 −25
@@ -24,8 +24,10 @@ import { YOURE_IN_MESSAGE } from "../../actions/users.ts";
2424 import { accountsClosedIn } from "../../core/users.ts";
2525 import { markTold, untoldAnswer } from "../../core/requests.ts";
2626 import { requestsCard } from "../requests_card.ts";
27 import { getSignupAndLinks, BILLING_SIZES, sizeWords } from "../../settings/billing.ts";
28 import { getPlans } from "../../settings/plans.ts";
27 import { plansRow } from "../plans_row.ts";
28 import { publicPlans } from "../../settings/plans.ts";
29 import { getDoorSettings } from "../../settings/door.ts";
30 import { GET_A_FORGE_PATH } from "../../billing/plan_words.ts";
2931 import { appsForRepos } from "../../core/apps.ts";
3032 import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts";
3133 import { LOOK_SIZES } from "../../core/looks.ts";
@@ -88,7 +90,6 @@ import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts";
8890 import { spawnedOrg } from "../../spawn/claim.ts";
8991 import { clientApps } from "./client_home.ts";
9092 import { passkeyJs } from "../passkey-client.ts";
91 import { SITE_LINE } from "../site_line.ts";
9293
9394 /** The events that can change what is on you. */
9495 const ON_YOU_LIVE =
@@ -759,7 +760,7 @@ export function studioDoor(ctx: Ctx): Response {
759760 const featured = front.featured;
760761 const all = [...(featured === undefined ? [] : [featured]), ...front.shelf];
761762 const made = all.slice(0, Math.max(DOOR_TILES, all.filter((e) => e.pinned).length));
762 const { mode, links } = getSignupAndLinks(ctx.app.db);
763 const { mode, plans: allPlans, links } = getDoorSettings(ctx.app.db);
763764 const accounts = !accountsClosedIn(ctx.app.db, mode);
764765 timePhase(ctx.request, "tiles", () => {
765766 tiles = made.map((e, at) =>
@@ -777,10 +778,6 @@ export function studioDoor(ctx: Ctx): Response {
777778 const ssh = sshCommand(ctx.app);
778779 const signup = offersSignup(ctx) && accounts;
779780 // Closed is a door with nothing behind it; invite still lands an invite link's holder.
780 // The plans setting is read only when a link is set: no link, no extra statement on the home.
781 const priced = BILLING_SIZES.some((z) => links[z] !== undefined) ? getPlans(ctx.app.db).filter((p) => p.priceUsd > 0) : [];
782 const plans = BILLING_SIZES.filter((z) => links[z] !== undefined && priced.some((p) => p.size === z));
783 const planOf = (z: (typeof BILLING_SIZES)[number]) => priced.find((p) => p.size === z)!;
784781 const origin = (ctx.app.config.url ?? ctx.url.origin).replace(/\/$/, "");
785782 const prompt = agentPrompt(origin);
786783 const asked = encodeURIComponent(prompt);
@@ -880,27 +877,15 @@ export function studioDoor(ctx: Ctx): Response {
880877
881878 <div class="door-section">${homeMilestones(ctx)}</div>
882879
outdated

Principle 1/7: #yours now has a lead sentence, a plans list, a button, then a second 'Run it yourself' h3 plus a second large button and the clone block. Two large buttons in one section compete; make 'Run it yourself' a plain link row or drop the second lg button so there is one confident action.

883 <section class="door-section door-split" id="yours">
880 <section class="door-section" id="yours">
884881 <div class="stack">
885882 <h2>Get your own forge</h2>
886883 <p class="door-support">
887 ${SITE_LINE} Free and open source. Run it on your own computer or server today.${plans.length > 0 ? "" : " Hosted forges are coming."}${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."}
884 Hosted for you, or free and open source on a machine you keep.${accounts ? "" : mode === "invite" ? " New accounts on this forge are by invitation." : " This forge is not taking new accounts."}
888885 </p>
outdated

Principle 1 / dead link: /pricing is 404 in this preview. Do not ship the primary button until the page exists.

889 ${when(plans.length > 0, () => html`<div class="stack-tight" id="plans">
890 <ul class="list">
891 ${plans.map((z) =>
892 row({
893 title: html`<strong>${z[0]!.toUpperCase()}${z.slice(1)}</strong> <span class="muted small">${sizeWords(z)}</span>`,
894 meta: html`$${String(planOf(z).priceUsd)} a month, with $${String(planOf(z).creditUsd)} of crew credit`,
895 trail: btn("Get a forge", { href: links[z]!, external: true, size: "sm", variant: "primary" }),
896 }),
897 )}
898 </ul>
899 <p class="muted small">Your forge is made after payment; the link to claim it arrives by email, usually within a few hours.</p>
900 </div>`)}
901 <div class="bar">${btn("Read the step-by-step guide", { href: `${FORGE_REPO}`, size: "lg" })}</div>
902 </div>
903 <div class="stack">
886 ${plansRow(publicPlans(allPlans), links)}
887 <div class="bar">${btn("Get a forge", { href: GET_A_FORGE_PATH, variant: "primary", size: "lg" })}</div>
888 <p class="door-support">Or run it yourself, free and open source: <a href="${FORGE_REPO}">read the step-by-step guide</a>.</p>
904889 <pre class="door-code"><code>git clone ${FORGE_REPO}.git
905890 cd forge
outdated

Principle 1: this heading and sentence repeat what the section above already said (hosted or free and open source). Say it once.

906891 bun install
src/web/style/content.ts +2
@@ -296,6 +296,8 @@ form.inline { display: inline; margin-left: auto; }
296296 .door-steps p, .door-points dd { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-fg); text-wrap: pretty; }
297297 .door-split { display: grid; gap: 32px; }
298298 .door-split > * { min-width: 0; }
299 .door-plans { list-style: none; margin: 0; padding: 0; border-bottom: 1px solid var(--border); }

Principle 6: a bottom hairline under the list is fine, but the margin-top 40px on #yours > .door-split is a hand-rolled magic number; use the rhythm tokens from docs/DESIGN.md (principle 3).

300 .door-plans li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; padding: 10px 0; }
outdated

Principles 6 and 7: a bordered box around every plan is a card grid. Make these rows: no border or radius, a hairline at most where the list ends.

299301 .door-pulse { margin: 72px 0 0; }
300302 .door-pulse svg { display: block; width: 100%; height: clamp(72px, 10vw, 120px); fill: var(--fg); }
301303 .door-pulse-quiet { fill: var(--border-strong); }
test/agents-page.test.ts +1 −1
@@ -102,7 +102,7 @@ test("above the fold the page is the form and three steps; the whole guide is be
102102 expect(inside).toContain(guideHtml(plain).toString());
103103 for (const sentence of [
104104 "Give claim.url to the person who takes it over. Unclaimed: asleep in 7 days, archived in 30.",
105 "free within limits while hosting is not sold",
105 "Or a hosted one, plans on",
106106 "It sleeps when idle and wakes on its next page load, push or API call; the person brings their own model credential, or its crew stays off.",
107107 ]) {
108108 expect(plain).toContain(sentence);
test/palette-roster.test.ts +4
@@ -51,6 +51,7 @@ test("a site-admin person is offered the whole roster", () => {
5151 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5252 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5353 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
54 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
5455 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
5556 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5657 ["profile", "Open a profile", "username or org"],
@@ -193,6 +194,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
193194 ["explore", "Explore", "on the web"],
194195 ["explore-feed", "Explore feed", "Atom address"],
195196 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
197 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
196198 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
197199 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
198200 ["profile", "Open a profile", "username or org"],
@@ -275,6 +277,7 @@ test("a member person is offered the signed-in roster without the admin entries"
275277 ["explore", "Explore", "on the web"],
276278 ["explore-feed", "Explore feed", "Atom address"],
277279 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
280 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
278281 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
279282 ["profile", "Open a profile", "username or org"],
280283 ["repos", "Repositories", "the repositories you may write"],
@@ -355,6 +358,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
355358 ["explore", "Explore", "on the web"],
356359 ["explore-feed", "Explore feed", "Atom address"],
357360 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
361 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
358362 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
359363 ["profile", "Open a profile", "username or org"],
360364 ["repo", "Open a repository", "owner/name"],
test/plans-row.test.ts added +145
@@ -0,0 +1,145 @@
1 /**
2 * The anonymous home's plans (#11139): each plan says what it costs and what
3 * it is; with provider `none` a paid plan reads "Paid" and the free plan
4 * "Free", with `stripe` the prices show; one button to /forges/new. The home
5 * reads signup, plans and buy links in one statement (`settings/door.ts`),
6 * each falling back to its default on a stored value that does not parse.
7 * The terminal's "Get your own forge" prints the same words to a guest.
8 */
9 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { billingProvider } from "../src/billing/provider.ts";
16 import { planWords } from "../src/billing/plan_words.ts";
17 import { DEFAULT_PLANS } from "../src/settings/plans.ts";
18 import { getDoorSettings } from "../src/settings/door.ts";
19 import { sizeWords } from "../src/settings/billing.ts";
20 import { SIGNUP_DEFAULT } from "../src/settings/signup.ts";
21 import { plansRow } from "../src/web/plans_row.ts";
22 import { getForgeLines } from "../src/tui/screens/getforge.ts";
23 import { forgeCommands, Session } from "../src/tui/forge.ts";
24
25 const FREE = DEFAULT_PLANS.find((p) => p.priceUsd === 0)!;
26 const SMALL = DEFAULT_PLANS.find((p) => p.key === "small")!;
27
28 test("provider none: a paid plan reads Paid, the free plan Free, no dollar price; each says what it is", () => {
29 const none = plansRow(DEFAULT_PLANS, {}, false).toString();
30 for (const p of DEFAULT_PLANS) {
31 expect(none).toContain(p.name);
32 expect(none).toContain(sizeWords(p.size));
33 }
34 expect(none).not.toContain("$");
35 expect(none).not.toContain("a month");
36 expect(planWords(FREE, false).price).toBe("Free");
37 expect(planWords(SMALL, false).price).toBe("Paid");
38 });
39
40 test("provider stripe: the prices show; the free plan is still Free", () => {
41 const priced = plansRow(DEFAULT_PLANS, {}, true).toString();
42 expect(priced).toContain("$19 a month");
43 expect(priced).toContain("$149 a month");
44 expect(planWords(FREE, true).price).toBe("Free");
45 });
46
47 test("a buy link shows on a paid plan of its size, never on the free plan", () => {
48 const links = { small: "https://buy.example/small" };
49 expect(planWords(SMALL, false, links).buy).toBe(links.small);
50 expect(planWords(FREE, false, links).buy).toBeUndefined();
51 });
52
53 test("the provider is none unless stripe is named", () => {
54 expect(billingProvider({})).toBe("none");
55 expect(billingProvider({ FORGE_BILLING_PROVIDER: "x" })).toBe("none");
56 expect(billingProvider({ FORGE_BILLING_PROVIDER: "stripe" })).toBe("stripe");
57 });
58
59 let dataDir: string;
60 let app: App;
61 let servers: Servers;
62 const saved = process.env["FORGE_BILLING_PROVIDER"];
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-plans-row-"));
66 app = createApp({ dataDir, port: 0 });
67 servers = await serve(app);
68 });
69
70 afterEach(() => {
71 if (saved === undefined) delete process.env["FORGE_BILLING_PROVIDER"];
72 else process.env["FORGE_BILLING_PROVIDER"] = saved;
73 app.db.run("DELETE FROM forge_setting WHERE key IN ('signup', 'plans', 'billing.links')");
74 });
75
76 afterAll(async () => {
77 await servers.stop?.();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 const put = (key: string, value: string): void => {
83 app.db.run("INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, 'test', '2026-11-11')", [key, value]);
84 };
85
86 const yours = async (): Promise<string> => {
87 const body = await (await fetch(`${servers.url}/`)).text();
88 return body.slice(body.indexOf('id="yours"'), body.indexOf('class="door-org"'));
89 };
90
91 test("the signed-out home shows each plan and one button to a page that exists", async () => {
92 delete process.env["FORGE_BILLING_PROVIDER"];
93 const section = await yours();
94 for (const p of DEFAULT_PLANS) expect(section).toContain(p.name);
95 expect(section).toContain('href="/forges/new"');
96 expect(section).not.toContain("/pricing");
97 expect(section).not.toContain("$19 a month");
98 expect(section).toContain("Paid");
99 expect(section.match(/data-size="lg"/g)).toHaveLength(1);
100 expect((await fetch(`${servers.url}/forges/new`)).status).toBe(200);
101 });
102
103 test("with FORGE_BILLING_PROVIDER=stripe the real home shows the prices, read per request", async () => {
104 process.env["FORGE_BILLING_PROVIDER"] = "stripe";
105 const section = await yours();
106 expect(section).toContain("$19 a month");
107 expect(section).toContain("$49 a month");
108 expect(section).toContain("$149 a month");
109 expect(section).not.toContain(">Paid<");
110 });
111
112 test("getDoorSettings reads signup, plans and links in one statement", () => {
113 put("signup", "invite");
114 put("billing.links", JSON.stringify({ medium: "https://buy.example/medium" }));
115 const plans = [{ key: "free", name: "Gratis", says: "Free.", size: "small", priceUsd: 0, creditUsd: 0, forges: 1 }];
116 put("plans", JSON.stringify(plans));
117 const read = getDoorSettings(app.db);
118 expect(read.mode).toBe("invite");
119 expect(read.plans.map((p) => p.name)).toEqual(["Gratis"]);
120 expect(read.links).toEqual({ medium: "https://buy.example/medium" });
121 });
122
123 test("getDoorSettings: nothing stored, or a stored value that does not parse, reads as the defaults", () => {
124 const empty = getDoorSettings(app.db);
125 expect(empty).toEqual({ mode: SIGNUP_DEFAULT, plans: DEFAULT_PLANS.map((p) => ({ ...p })), links: {} });
126 put("signup", "sometimes");
127 put("plans", "{not json");
128 put("billing.links", "[1]");
129 expect(getDoorSettings(app.db)).toEqual(empty);
130 });
131
132 test("the terminal's Get your own forge: plans, the pointer to /forges/new and self-hosting", () => {
133 const lines = getForgeLines(app, 80, false).join("\n");
134 for (const p of DEFAULT_PLANS) expect(lines).toContain(p.name);
135 expect(lines).toContain("Paid");
136 expect(lines).not.toContain("$19 a month");
137 expect(lines).toContain("/forges/new");
138 expect(lines).toContain("git clone");
139 expect(getForgeLines(app, 80, true).join("\n")).toContain("$19 a month");
140 });
141
142 test("a guest in the terminal is offered Get your own forge", () => {
143 const guest = new Session(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: undefined });
144 expect(forgeCommands(guest)({} as never).map((c) => c.id)).toContain("get-a-forge");
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs