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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11139-the-anonymous-home-page-and-agents-show → main 11 commits fork 7 threads · 7 unresolved
README.md +1 −1
| @@ -20,7 +20,7 @@ and send a clean pull request back. Build your own apps and share them. | ||
| 20 | 20 | |
| 21 | 21 | The forge is part of [](https://www.open-platform.sh), with the |
| 22 | 22 | platform for organizations. |
| 23 | You run it yourself today. Hosting is coming. | |
| 23 | You can run it yourself, or have a hosted forge. | |
| 24 | 24 | |
| 25 | 25 | ## Try it |
| 26 | 26 |
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/billing-links.md +1 −5
| @@ -13,8 +13,4 @@ An address is https, at most 300 bytes, with no credentials, spaces or quotes. O | ||
| 13 | 13 | |
| 14 | 14 | ## What the home shows |
| 15 | 15 | |
| 16 | With no links, the signed-out home says "Hosted forges are coming." and nothing is sold. With any link set, the home's **Get your own forge** section lists one row per size that has a link: the size's CPU, memory and disk, the monthly price and crew credit, and a **Get a forge** button to the link. "Hosted forges are coming" goes. The prices come from `src/settings/plans.ts`; the buyer is told the forge is made after payment and the claim link arrives by email. | |
| 17 | ||
| 18 | ## What llms.txt changes | |
| 19 | ||
| 20 | The "Get your own forge" line says hosted forges are free within limits "while hosting is not sold" when no link is set; with any link set it says paid plans are on the home page and the other way is free within limits. | |
| 16 | The signed-out home's **Get your own forge** lists every plan ([](home-plans.md)) and one **Get a forge** button to `/forges/new`. A paid plan whose size has a link also carries a plain **Pay for <plan>** link to it (opened in a new tab, `noopener`); the free plan never does, and no link adds a second button. The terminal's **Get your own forge** prints the same link as `Pay: <address>`. |
docs/features/guests.md +1 −1
| @@ -30,7 +30,7 @@ sends a pull across. It arrives as a guest pull. | ||
| 30 | 30 | - **For:** no account on the root forge at all, so nothing new to abuse here; |
| 31 | 31 | the pull path and its guards already exist; the person owns their work. |
| 32 | 32 | - **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 | |
| 34 | 34 | forge is unknown to us, so every pull is from an unvetted origin, which |
| 35 | 35 | writers must approve one by one. |
| 36 | 36 | - **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 [](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 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 11 | 11 | import { SIZES, type Size } from "../spawn/sizes.ts"; |
| 12 | import { SIGNUP_DEFAULT, parseSignup, type SignupMode } from "./signup.ts"; | |
| 13 | 12 | import { writeSetting } from "./limits.ts"; |
| 14 | 13 | |
| 15 | 14 | export const BILLING_SIZES = ["small", "medium", "large"] as const satisfies readonly Size[]; |
| 16 | 15 | export type BillingLinks = Partial<Record<Size, string>>; |
| 17 | 16 | |
| 18 | const STORE_KEY = "billing.links"; | |
| 17 | export const BILLING_LINKS_KEY = "billing.links"; | |
| 19 | 18 | const MAX_URL = 300; |
| 20 | 19 | |
| 21 | 20 | 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 | ||
| 50 | 49 | export function getBillingLinks(db: Database): BillingLinks { |
| 51 | 50 | const row = db |
| 52 | 51 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") |
| 53 | .get(STORE_KEY); | |
| 52 | .get(BILLING_LINKS_KEY); | |
| 54 | 53 | return row === null ? {} : decodeLinks(row.value); |
| 55 | 54 | } |
| 56 | 55 | |
| 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 { | |
| 58 | 58 | try { |
| 59 | 59 | const parsed = parseBillingLinks(JSON.parse(raw)); |
| 60 | 60 | return parsed.ok ? parsed.value : {}; |
| @@ -71,26 +71,7 @@ export function setBillingLinks(db: Database, value: BillingLinks, actorId: stri | ||
| 71 | 71 | const from = ENCODE(getBillingLinks(db)); |
| 72 | 72 | const to = ENCODE(value); |
| 73 | 73 | 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); | |
| 94 | 75 | } |
| 95 | 76 | |
| 96 | 77 | /** 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> { | ||
| 100 | 100 | return ok(plans); |
| 101 | 101 | } |
| 102 | 102 | |
| 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) { | |
| 107 | 106 | try { |
| 108 | const parsed = parsePlans(JSON.parse(row.value)); | |
| 107 | const parsed = parsePlans(JSON.parse(raw)); | |
| 109 | 108 | if (parsed.ok) return parsed.value; |
| 110 | 109 | } catch { |
| 111 | 110 | // a hand-edited value reads as the defaults |
| @@ -114,6 +113,12 @@ export function getPlans(db: Database): Plan[] { | ||
| 114 | 113 | return DEFAULT_PLANS.map((p) => ({ ...p })); |
| 115 | 114 | } |
| 116 | 115 | |
| 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 | ||
| 117 | 122 | export function getPlan(db: Database, key: string): Plan | undefined { |
| 118 | 123 | return getPlans(db).find((p) => p.key === key); |
| 119 | 124 | } |
src/tui/forge.ts +2
| @@ -18,6 +18,7 @@ import { sourceFor } from "../view/source.ts"; | ||
| 18 | 18 | import { fleetCommand } from "./screens/fleet.ts"; |
| 19 | 19 | import { dataCommand } from "./screens/data.ts"; |
| 20 | 20 | import { packsCommand } from "./screens/packs.ts"; |
| 21 | import { getForgeCommand } from "./screens/getforge.ts"; | |
| 21 | 22 | import { workCommand } from "./screens/work.ts"; |
| 22 | 23 | import { shellsScreen } from "./screens/shells.ts"; |
| 23 | 24 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| @@ -241,6 +242,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 241 | 242 | run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"), |
| 242 | 243 | }, |
| 243 | 244 | packsCommand(session, ctx), |
| 245 | getForgeCommand(session, ctx), | |
| 244 | 246 | ...dataCommand(session, ctx), |
| 245 | 247 | fleetCommand(session, ctx), |
| 246 | 248 | { |
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 | ||
| 48 | 48 | import { forgeDocument } from "../core/standards_index.ts"; |
| 49 | 49 | import { TAGS } from "../contract/served.ts"; |
| 50 | 50 | import { running } from "../kernel/version.ts"; |
| 51 | import { getBillingLinks } from "../settings/billing.ts"; | |
| 52 | 51 | import { timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 53 | 52 | import { shippedReleases, type Shipped } from "../release/shipped.ts"; |
| 54 | 53 | |
| @@ -399,11 +398,10 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } { | ||
| 399 | 398 | const repos = timePhase(ctx.request, "repos", () => llmsRepos(ctx.app.db)); |
| 400 | 399 | // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart. |
| 401 | 400 | 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}`; | |
| 404 | 402 | let kept = llmsBodies.get(key); |
| 405 | 403 | 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)); | |
| 407 | 405 | const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`); |
| 408 | 406 | kept = { signedIn, repos, running, tell, plugins, body, etag }; |
| 409 | 407 | // 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[] { | ||
| 423 | 421 | return plugins === undefined ? [...lines, "- Claude Code plugin: not offered here (no public source)"] : lines; |
| 424 | 422 | } |
| 425 | 423 | |
| 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 { | |
| 427 | 425 | const mcp = mcpPointers(o); |
| 428 | 426 | return ( |
| 429 | 427 | [ |
| @@ -439,7 +437,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 439 | 437 | "", |
| 440 | 438 | `- 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.`, |
| 441 | 439 | `- 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.`, | |
| 443 | 441 | `- 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`, |
| 444 | 442 | `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`, |
| 445 | 443 | "", |
src/web/studio/home.ts +10 −25
| @@ -24,8 +24,10 @@ import { YOURE_IN_MESSAGE } from "../../actions/users.ts"; | ||
| 24 | 24 | import { accountsClosedIn } from "../../core/users.ts"; |
| 25 | 25 | import { markTold, untoldAnswer } from "../../core/requests.ts"; |
| 26 | 26 | 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"; | |
| 29 | 31 | import { appsForRepos } from "../../core/apps.ts"; |
| 30 | 32 | import { WEIGHT_MAX, WEIGHT_NAMES } from "../../core/front.ts"; |
| 31 | 33 | import { LOOK_SIZES } from "../../core/looks.ts"; |
| @@ -88,7 +90,6 @@ import { nextStepsCard, nextStepsOf } from "../pages/next-steps.ts"; | ||
| 88 | 90 | import { spawnedOrg } from "../../spawn/claim.ts"; |
| 89 | 91 | import { clientApps } from "./client_home.ts"; |
| 90 | 92 | import { passkeyJs } from "../passkey-client.ts"; |
| 91 | import { SITE_LINE } from "../site_line.ts"; | |
| 92 | 93 | |
| 93 | 94 | /** The events that can change what is on you. */ |
| 94 | 95 | const ON_YOU_LIVE = |
| @@ -759,7 +760,7 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 759 | 760 | const featured = front.featured; |
| 760 | 761 | const all = [...(featured === undefined ? [] : [featured]), ...front.shelf]; |
| 761 | 762 | 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); | |
| 763 | 764 | const accounts = !accountsClosedIn(ctx.app.db, mode); |
| 764 | 765 | timePhase(ctx.request, "tiles", () => { |
| 765 | 766 | tiles = made.map((e, at) => |
| @@ -777,10 +778,6 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 777 | 778 | const ssh = sshCommand(ctx.app); |
| 778 | 779 | const signup = offersSignup(ctx) && accounts; |
| 779 | 780 | // 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)!; | |
| 784 | 781 | const origin = (ctx.app.config.url ?? ctx.url.origin).replace(/\/$/, ""); |
| 785 | 782 | const prompt = agentPrompt(origin); |
| 786 | 783 | const asked = encodeURIComponent(prompt); |
| @@ -880,27 +877,15 @@ export function studioDoor(ctx: Ctx): Response { | ||
| 880 | 877 | |
| 881 | 878 | <div class="door-section">${homeMilestones(ctx)}</div> |
| 882 | 879 | |
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"> | |
| 884 | 881 | <div class="stack"> |
| 885 | 882 | <h2>Get your own forge</h2> |
| 886 | 883 | <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."} | |
| 888 | 885 | </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> | |
| 904 | 889 | <pre class="door-code"><code>git clone ${FORGE_REPO}.git |
| 905 | 890 | 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. |
||
| 906 | 891 | bun install |
src/web/style/content.ts +2
| @@ -296,6 +296,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 296 | 296 | .door-steps p, .door-points dd { margin: 0; font-size: 15px; line-height: 1.55; color: var(--muted-fg); text-wrap: pretty; } |
| 297 | 297 | .door-split { display: grid; gap: 32px; } |
| 298 | 298 | .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. |
||
| 299 | 301 | .door-pulse { margin: 72px 0 0; } |
| 300 | 302 | .door-pulse svg { display: block; width: 100%; height: clamp(72px, 10vw, 120px); fill: var(--fg); } |
| 301 | 303 | .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 | ||
| 102 | 102 | expect(inside).toContain(guideHtml(plain).toString()); |
| 103 | 103 | for (const sentence of [ |
| 104 | 104 | "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", | |
| 106 | 106 | "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.", |
| 107 | 107 | ]) { |
| 108 | 108 | expect(plain).toContain(sentence); |
test/billing-links.test.ts +21 −24
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The bridge to paid hosting (#11149): `billing.links` is empty by default and |
| 3 | * the anonymous home reads as it did; with links set, `#yours` shows one row | |
| 4 | * a plan, each button opening its link, and "coming" and "not sold" are gone. | |
| 3 | * the anonymous home shows the plans with no pay link; with links set, each | |
| 4 | * paid plan whose size has a link carries a plain "Pay for <plan>" link to it, | |
| 5 | * beside the home's one "Get a forge" button (#11139). | |
| 5 | 6 | */ |
| 6 | 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 7 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -72,12 +73,13 @@ afterAll(async () => { | ||
| 72 | 73 | await rm(dataDir, { recursive: true, force: true }); |
| 73 | 74 | }); |
| 74 | 75 | |
| 75 | test("no links by default: the home says hosted forges are coming and shows no plan", async () => { | |
| 76 | test("no links by default: the home shows the plans, one button and no pay link", async () => { | |
| 76 | 77 | expect(getBillingLinks(app.db)).toEqual({}); |
| 77 | 78 | const text = await home(); |
| 78 | expect(text).toContain("Hosted forges are"); | |
| 79 | expect(text).not.toContain("Get a forge"); | |
| 80 | expect(await guide()).toContain("while hosting is not sold"); | |
| 79 | expect(text).not.toContain("Hosted forges are"); | |
| 80 | expect(text).not.toContain("Pay for"); | |
| 81 | expect(text.match(/>Get a forge</g)).toHaveLength(1); | |
| 82 | expect(await guide()).not.toContain("while hosting is not sold"); | |
| 81 | 83 | }); |
| 82 | 84 | |
| 83 | 85 | test("only http(s) addresses without credentials, only the three sizes", () => { |
| @@ -106,43 +108,38 @@ test("a bad link is refused and nothing is kept", async () => { | ||
| 106 | 108 | expect(getBillingLinks(app.db)).toEqual({}); |
| 107 | 109 | }); |
| 108 | 110 | |
| 109 | test("with three links the home shows three rows, each a button to its link", async () => { | |
| 111 | const yours = (text: string): string => text.slice(text.indexOf('id="yours"'), text.indexOf('class="door-org"')); | |
| 112 | const payAnchors = (section: string): string[] => [...section.matchAll(/<a\s[^>]*>Pay for [^<]*<\/a\s*>/g)].map((m) => m[0]); | |
| 113 | ||
| 114 | test("with three links each paid plan carries a pay link; the button stays one", async () => { | |
| 110 | 115 | const res = await patch(adminToken, { billingLinks: LINKS }); |
| 111 | 116 | expect(res.status).toBe(200); |
| 112 | 117 | const read = (await (await fetch(`${servers.url}/api/v1/admin/settings`, { headers: { authorization: `Bearer ${adminToken}` } })).json()) as { billingLinks: unknown }; |
| 113 | 118 | expect(read.billingLinks).toEqual(LINKS); |
| 114 | const text = await home(); | |
| 115 | const section = text.slice(text.indexOf('id="plans"'), text.indexOf("Read the step-by-step guide")); | |
| 116 | const anchors = [...section.matchAll(/<a\s[^>]*>Get a forge<\/a\s*>/g)].map((m) => m[0]); | |
| 119 | const section = yours(await home()); | |
| 120 | const anchors = payAnchors(section); | |
| 117 | 121 | expect(anchors).toHaveLength(3); |
| 118 | 122 | for (const [i, size] of (["small", "medium", "large"] as const).entries()) { |
| 119 | 123 | expect(anchors[i]).toContain(`href="${LINKS[size]}"`); |
| 120 | 124 | expect(anchors[i]).toContain("noopener"); |
| 121 | 125 | expect(section).toContain(sizeWords(size)); |
| 122 | 126 | } |
| 123 | expect(section).toContain("$19 a month, with $5 of crew credit"); | |
| 124 | expect(section).toContain("$49 a month, with $15 of crew credit"); | |
| 125 | expect(section).toContain("$149 a month, with $50 of crew credit"); | |
| 126 | expect(section).toContain("Your forge is made after payment; the link to claim it arrives by email, usually within a few hours."); | |
| 127 | expect(text).not.toContain("Hosted forges are"); | |
| 128 | expect(await guide()).not.toContain("while hosting is not sold"); | |
| 127 | expect(section.match(/>Get a forge</g)).toHaveLength(1); | |
| 128 | expect(section).not.toContain("Pay for Free"); | |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | test("a size without a link has no row; clearing every link restores the page", async () => { | |
| 131 | test("a size without a link has no pay link; clearing every link restores the page", async () => { | |
| 132 | 132 | await patch(adminToken, { billingLinks: { medium: LINKS.medium } }); |
| 133 | const one = await home(); | |
| 134 | expect(one.match(/Get a forge/g)).toHaveLength(1); | |
| 135 | expect(one).toContain("$49 a month"); | |
| 136 | expect(one).not.toContain("$19 a month"); | |
| 133 | const one = payAnchors(yours(await home())); | |
| 134 | expect(one).toHaveLength(1); | |
| 135 | expect(one[0]).toContain(LINKS.medium); | |
| 137 | 136 | await patch(adminToken, { billingLinks: {} }); |
| 138 | 137 | // three real changes so far (set, narrowed, cleared); the refused ones kept nothing |
| 139 | 138 | const kept = app.db |
| 140 | 139 | .query<{ n: number }, []>("SELECT count(*) AS n FROM forge_setting_change WHERE key = 'billing.links'") |
| 141 | 140 | .get(); |
| 142 | 141 | expect(kept?.n).toBe(3); |
| 143 | const none = await home(); | |
| 144 | expect(none).toContain("Hosted forges are"); | |
| 145 | expect(none).not.toContain("Get a forge"); | |
| 142 | expect(payAnchors(yours(await home()))).toHaveLength(0); | |
| 146 | 143 | }); |
| 147 | 144 | |
| 148 | 145 | const form = (cookie: string, fields: Record<string, string>, origin: string = servers.url) => |
test/palette-roster.test.ts +4
| @@ -51,6 +51,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 51 | 51 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 52 | 52 | ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"], |
| 53 | 53 | ["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"], | |
| 54 | 55 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 55 | 56 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 56 | 57 | ["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", | ||
| 193 | 194 | ["explore", "Explore", "on the web"], |
| 194 | 195 | ["explore-feed", "Explore feed", "Atom address"], |
| 195 | 196 | ["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"], | |
| 196 | 198 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 197 | 199 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 198 | 200 | ["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" | ||
| 275 | 277 | ["explore", "Explore", "on the web"], |
| 276 | 278 | ["explore-feed", "Explore feed", "Atom address"], |
| 277 | 279 | ["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"], | |
| 278 | 281 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 279 | 282 | ["profile", "Open a profile", "username or org"], |
| 280 | 283 | ["repos", "Repositories", "the repositories you may write"], |
| @@ -355,6 +358,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 355 | 358 | ["explore", "Explore", "on the web"], |
| 356 | 359 | ["explore-feed", "Explore feed", "Atom address"], |
| 357 | 360 | ["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"], | |
| 358 | 362 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 359 | 363 | ["profile", "Open a profile", "username or org"], |
| 360 | 364 | ["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 | }); |
Points to /pricing, which does not exist yet; the README line should wait for the page.