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

A person or an assistant given only the link knows the next step and sees our pictures, not another product's #11691

Merged opened this in answer to

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

Merged

mason/forge:mason/11654-a-person-or-an-assistant-given-only-the → main 6 commits fork

  • Merged. mason/11654-a-person-or-an-assistant-given-only-the was merged into main as b90478b.

Review

Sign in to review this change.
16 files changed +193 −32
docs/features/start-my-forge.md +4
@@ -20,3 +20,7 @@ The form carries `from=home`. If the door refuses a stranger's start (rate limit
2020 ## Three screens, no link to copy
2121
2222 When the door accepts a start from the home page (`from=home`, signed out), the browser is not shown the claim link. If the new forge already answers, it gets a 303 straight to its `/claim#<code>` form; if it is still booting, a "Making your forge" page polls `GET /forges/<name>/ready` and goes on by itself (`walkOn` in `src/web/pages/new-forge.ts`). On the claim form the person picks a username and password and lands signed in as the forge's admin. Screens: `/`, the claim form, the forge. Test: `test/stranger-three-screens.test.ts` (in the gate, which runs every test file).
23
24 ## One path, one button name
25
26 `SITE_PATH` and `START_BUTTON` (`src/web/site_line.ts`) hold the path as short sentences and the button's one name. The sentence is the meta description of `/`, `/pricing` and `/agents`, and sits in `/llms.txt` beside a Plans table made from the plans setting (`src/web/offer.ts`, also the `Offer` data on `/pricing`); the home page itself keeps one sentence. `/`, `/pricing` (the Free plan's button, posted to `/forges/start`), `/agents` and the terminal command all say "Start my forge", and all tell the person they choose a username and password next. Test: `test/share-path.test.ts`.
src/web/layout.ts +6 −8
@@ -748,7 +748,7 @@ function footer(ctx: Ctx, own?: Html): Html {
748748 function pageDescription(options: PageOptions): string {
749749 const own = options.description?.trim().split(/\r?\n/)[0]?.trim() ?? "";
750750 const line = own === "" ? SITE_LINE : own;
751 return line.length <= 200 ? line : `${line.slice(0, 197).trimEnd()}…`;
751 return line.length <= 360 ? line : `${line.slice(0, 357).trimEnd()}…`;
752752 }
753753
754754 /**
@@ -758,7 +758,7 @@ function pageDescription(options: PageOptions): string {
758758 export function tabTitle(title: string): string {
759759 const own = title.replace(/(?: · forge)+$/, "");
760760 if (own === "" || own === "forge") return "forge";
761 return own.startsWith("forge: ") ? own : `${own} · forge`;
761 return own.startsWith("forge: ") || own.startsWith("Open Platform Forge: ") ? own : `${own} · forge`;
762762 }
763763
764764 export function page(options: PageOptions, status = 200): Response {
@@ -766,6 +766,7 @@ export function page(options: PageOptions, status = 200): Response {
766766 // inline <style> is admitted by the hash of its own text, so the same page
767767 // is the same bytes for everyone and can be cached (docs/web.md).
768768 const { ctx } = options;
769 const shareImage = `${ctx.url.origin}${options.ogImage !== undefined && options.ogImage.startsWith("/og/") ? options.ogImage : "/og/site.png"}`;
769770 const theme = ctx.theme ?? options.theme;
770771 const cookieFlash = readFlash(ctx.request);
771772 const flash = options.flash ?? cookieFlash;
@@ -799,7 +800,7 @@ export function page(options: PageOptions, status = 200): Response {
799800 />
800801 <title>${tabTitle(options.title)}</title>
801802 <meta name="description" content="${pageDescription(options)}" />
802 <meta property="og:site_name" content="forge" />
803 <meta property="og:site_name" content="Open Platform Forge" />
803804 <meta property="og:type" content="website" />
804805 <meta property="og:title" content="${options.title}" />
805806 <meta property="og:description" content="${pageDescription(options)}" />
@@ -820,14 +821,11 @@ export function page(options: PageOptions, status = 200): Response {
820821 }
821822 <meta
822823 property="og:image"
823 content="${ctx.url.origin}${
824 options.ogImage !== undefined && options.ogImage.startsWith("/og/")
825 ? options.ogImage
826 : "/og/site.png"
827 }"
824 content="${shareImage}"
828825 />
829826 <meta property="og:image:width" content="1200" />
830827 <meta property="og:image:height" content="630" />
828 <meta name="twitter:image" content="${shareImage}" />
831829 <meta name="twitter:card" content="summary_large_image" />
832830 <link rel="manifest" href="/manifest.webmanifest" />
833831 <link
src/web/offer.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * The plan facts an assistant quotes (CPU, memory, price, credit), as data:
3 * schema.org `Offer`s for a page and a markdown table for /llms.txt, both made
4 * from the plans setting so they cannot disagree with the pricing page.
5 */
6 import { SIZES } from "../spawn/sizes.ts";
7 import type { Plan } from "../settings/plans.ts";
8
9 const gb = (mib: number): number => mib / 1024;
10
11 /** One schema.org Offer per plan, with its size and monthly crew credit as plain properties. */
12 export function offerData(plans: readonly Plan[], origin: string): object[] {
13 return plans.map((p) => {
14 const s = SIZES[p.size];
15 return {
16 "@type": "Offer",
17 name: p.name,
18 description: p.says,
19 price: String(p.priceUsd),
20 priceCurrency: "USD",
21 url: `${origin}/pricing`,
22 additionalProperty: [
23 { "@type": "PropertyValue", name: "CPUs", value: s.cpu },
24 { "@type": "PropertyValue", name: "Memory (GB)", value: gb(s.memoryMiB) },
25 { "@type": "PropertyValue", name: "Disk (GB)", value: gb(s.diskMiB) },
26 { "@type": "PropertyValue", name: "Crew credit (USD a month)", value: p.creditUsd },
27 ],
28 };
29 });
30 }
31
32 /** The same facts as a markdown table. */
33 export function offerTable(plans: readonly Plan[]): string[] {
34 return [
35 "| Plan | USD/month | CPUs | RAM GB | Disk GB | Credit USD |",
36 "| --- | --- | --- | --- | --- | --- |",
37 ...plans.map((p) => {
38 const s = SIZES[p.size];
39 return `| ${p.name} | ${p.priceUsd} | ${s.cpu} | ${gb(s.memoryMiB)} | ${gb(s.diskMiB)} | ${p.creditUsd} |`;
40 }),
41 ];
42 }
src/web/pages/agents.ts +4 −4
@@ -13,6 +13,7 @@ import { html, type Html } from "../html.ts";
1313 import { btn, disclosure, field as formField } from "../ui.ts";
1414 import { page } from "../layout.ts";
1515 import { renderMarkdown } from "../markdown.ts";
16 import { START_BUTTON, SITE_PATH } from "../site_line.ts";
1617 import { llmsBody } from "../standards.ts";
1718
1819 /** An address in prose, outside code, made a link; a template (`{owner}`) stays words. */
@@ -67,16 +68,15 @@ export function agentsPage(ctx: Ctx): Response {
6768 return timePhase(ctx.request, "render", () => page({
6869 ctx,
6970 title: "For AI assistants",
70 description:
71 "What this forge is and how an AI assistant can help you use it: build one of its public apps, report a problem, or get a forge of your own.",
71 description: SITE_PATH(ctx.url.origin),
7272 body: html`${pageHead("For AI assistants", {
7373 sub: html`People start at <a href="/">home</a>. Assistants: <a href="/llms.txt">/llms.txt</a>`,
7474 })}
7575 <section class="stack" aria-labelledby="agent-forge-h">
7676 <h2 id="agent-forge-h">Get a forge of your own</h2>
77 <form method="post" action="/forges/new" class="stack" id="agent-forge">
77 <form method="post" action="/forges/start" class="stack" id="agent-forge">
7878 ${formField({ label: "Name", name: "name", required: true })}
79 ${btn("Make my forge", { variant: "primary" })}
79 ${btn(START_BUTTON, { variant: "primary" })}
8080 </form>
8181 </section>
8282 <div class="prose break"><ol id="agent-steps" aria-label="Build an app with no account">${firstSteps(body).map((step) => html`<li>${stepLine(step)}</li>`)}</ol></div>
src/web/pages/pricing.ts +12 −2
@@ -20,6 +20,9 @@ import { notFoundPage, pageHead } from "../chrome.ts";
2020 import { field, readForm } from "../form.ts";
2121 import { html, type Html } from "../html.ts";
2222 import { page, redirect } from "../layout.ts";
23 import { START_BUTTON, SITE_PATH } from "../site_line.ts";
24 import { offerData } from "../offer.ts";
25 import { safeJson } from "../structured_data.ts";
2326 import { btn, field as formField, row } from "../ui.ts";
2427 import { claimLinkBody } from "./new-forge.ts";
2528
@@ -43,7 +46,7 @@ function plansView(ctx: Ctx, values: Values, errors: Partial<Record<Refusal["fie
4346 title: html`${p.name}`,
4447 meta: html`${sizeWords(p.size)}. ${p.says}${p.creditUsd > 0 && selling ? html` $${p.creditUsd} of crew credit each month.` : null}`,
4548 trail: free
46 ? html`<span class="muted">Free</span> ${btn("Start free", { name: "plan", value: p.key })}`
49 ? html`<span class="muted">Free</span> ${btn(START_BUTTON, { formaction: "/forges/start" })}`
4750 : paid
4851 ? html`<span>$${p.priceUsd} a month</span> ${btn(`Get ${p.name}`, { name: "plan", value: p.key, variant: "primary" })}`
4952 : html`<span class="muted">Ask the operator</span>`,
@@ -84,7 +87,14 @@ export function pricingPage(ctx: Ctx): Response {
8487 if (!isBuyer(ctx)) return notFoundPage(ctx);
8588 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
8689 const rendering = performance.now();
87 const response = page({ ctx, title: "Get your own forge", body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}) });
90 const plans = getPlans(ctx.app.db);
91 const response = page({
92 ctx,
93 title: "Open Platform Forge: pricing and your own forge",
94 description: SITE_PATH(ctx.url.origin),
95 jsonLd: safeJson({ "@context": "https://schema.org", "@type": "Product", name: "Open Platform Forge", url: `${ctx.url.origin}/`, offers: offerData(plans, ctx.url.origin) }),
96 body: plansView(ctx, { name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), email: "" }, {}),
97 });
8898 notePhase(ctx.request, "render", performance.now() - rendering);
8999 return response;
90100 }
src/web/site_line.ts +10
@@ -1,2 +1,12 @@
11 /** The one sentence for what the forge is: manifest, meta and og:description, front page. */
22 export const SITE_LINE = "Your software and the crew that builds it, in one place.";
3
4 /** The one button a person presses to get a forge; every page that offers one uses this name. */
5 export const START_BUTTON = "Start my forge";
6
7 /** The one path from a link to an app, in one line; no other path is described anywhere. */
8 export const SITE_PATH = (origin: string): string =>
9 `Open ${origin}. Type a name and press ${START_BUTTON}. Choose a username and password. Type what you want built. Your app is live a minute later.`;
10
11 /** What this is, in one sentence, with the full name. */
12 export const SITE_WHAT = "Open Platform Forge gives you a forge of your own where an AI crew builds your apps.";
src/web/standards.ts +15 −6
@@ -42,7 +42,9 @@ import { publicForgeRepo } from "../core/forge_repo.ts";
4242 import type { Ctx } from "../http/context.ts";
4343 import { notFound } from "../http/respond.ts";
4444 import { SHARE_TARGET } from "./share_target.ts";
45 import { SITE_LINE } from "./site_line.ts";
45 import { offerTable } from "./offer.ts";
46 import { getPlans } from "../settings/plans.ts";
47 import { SITE_PATH, SITE_LINE } from "./site_line.ts";
4648 import { holds } from "./feeds.ts";
4749 import { mcpToolsSection } from "./llms_tools.ts";
4850 import { apiCatalog, cardsLine, DISCOVERY, mcpCard, offeredPlugins, mcpPointers, offeredPluginRepo, pluginSource } from "../core/mcp_card.ts";
@@ -386,7 +388,7 @@ function runningSentence(): string {
386388 }
387389
388390 /** llms.txt and its ETag per origin, valid while the listing read, the build, the tell command and the plugin source being public are the same. */
389 const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: string | undefined; body: string; etag: string }>();
391 const llmsBodies = new Map<string, { signedIn: boolean; repos: LlmsListing; running: string; tell: string | undefined; plugins: string | undefined; plans: string; body: string; etag: string }>();
390392
391393 /**
392394 * GET /llms.txt — the forge as an agent should meet it (llmstxt.org): what
@@ -418,11 +420,12 @@ export function llmsBody(ctx: Ctx): { body: string; etag: string } {
418420 // The tools section depends on who asks, so the signed-in and the anonymous body are kept apart.
419421 const signedIn = ctx.auth.user !== undefined;
420422 const key = `${o} ${signedIn}`;
423 const plans = JSON.stringify(getPlans(ctx.app.db));
421424 let kept = llmsBodies.get(key);
422 if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins) {
425 if (kept === undefined || kept.repos !== repos || kept.running !== running || kept.tell !== tell || kept.plugins !== plugins || kept.plans !== plans) {
423426 const body = timePhase(ctx.request, "render", () => renderLlmsTxt(ctx, o, tell, running, repos, signedIn, plugins));
424427 const etag = timePhase(ctx.request, "etag", () => `"${new Bun.CryptoHasher("sha256").update(body).digest("hex").slice(0, 32)}"`);
425 kept = { signedIn, repos, running, tell, plugins, body, etag };
428 kept = { signedIn, repos, running, tell, plugins, plans, body, etag };
426429 // Origins come from the Host header: keep a handful, not one per guess.
427430 if (llmsBodies.size >= 16) llmsBodies.clear();
428431 llmsBodies.set(key, kept);
@@ -448,7 +451,9 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
448451 "",
449452 running,
450453 "",
451 `> ${SITE_LINE} A self-hosted forge in one Bun process and one SQLite file that hosts code and runs apps, with AI agents that build. Forges connect to one another. Part of Open Platform (https://www.open-platform.sh). Anyone may read a public repository, and report a problem in it without an account.`,
454 SITE_PATH(o),
455 "",
456 `> ${SITE_LINE} A self-hosted forge in one Bun process and one SQLite file that hosts code and runs apps, with AI agents that build. Forges connect. Part of Open Platform (https://www.open-platform.sh). Anyone may read a public repository and report a problem in it.`,
452457 "",
453458 "## Start here",
454459 "",
@@ -456,10 +461,14 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
456461 "",
457462 `- 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.`,
458463 `- 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".`,
459 `- 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.`,
464 `- Run it yourself instead: \`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 hosted (plans below): ${o}/forges/new, or MCP \`spawn\` with the person's consent. A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.`,
460465 `- 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`,
461466 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
462467 "",
468 "## Plans",
469 "",
470 ...offerTable(getPlans(ctx.app.db)),
471 "",
463472 "## Report a problem (no account)",
464473 "",
465474 `- Page: ${o}/{owner}/{name}/report`,
src/web/studio/home.ts +3 −2
@@ -45,6 +45,7 @@ import { liveRegionsJs } from "../client.ts";
4545 import { html, when, type Html } from "../html.ts";
4646 import { fragment, page, redirect } from "../layout.ts";
4747 import { START_REFUSALS, startRefusalIn } from "../start_refusal.ts";
48 import { SITE_PATH, SITE_WHAT } from "../site_line.ts";
4849 import { siteStructuredData } from "../structured_data.ts";
4950 import { running } from "../../kernel/version.ts";
5051 import { homeMilestones } from "./milestone_lines.ts";
@@ -927,8 +928,8 @@ bun run src/main.ts</code></pre>
927928 </div>`;
928929 const res = page({
929930 ctx,
930 title: "forge: software for what you're building, yours to keep",
931 description: "Your own forge, with a private AI crew that builds your apps, games and business tools. Open source, and yours to keep.",
931 title: "Open Platform Forge: software for what you're building, yours to keep",
932 description: `${SITE_WHAT} ${SITE_PATH(origin)}`,
932933 body,
933934 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${funnelJs}\n${startNameJs}\n${JS_FLAG_JS}`,
934935 jsonLd: siteStructuredData(origin, running().version),
test/agents-page-language.test.ts +1 −1
@@ -29,7 +29,7 @@ test("/agents has no card, keeps the form, and says what /llms.txt says", async
2929 expect(main).not.toContain('class="card');
3030 expect(main).not.toContain("card-body");
3131 expect(main).toContain('<section class="stack" aria-labelledby="agent-forge-h">');
32 expect(main).toMatch(/<form method="post" action="\/forges\/new"/);
32 expect(main).toMatch(/<form method="post" action="\/forges\/start"/);
3333 expect(main).toContain("<h2 id=\"agent-forge-h\">Get a forge of your own</h2>");
3434 const llms = await (await fetch(servers.url + "/llms.txt")).text();
3535 // Plain prose lines (letters, spaces, commas, periods) must appear verbatim on the page.
test/agents-page.test.ts +2 −3
@@ -78,7 +78,7 @@ test("above the fold the page is the form and three steps; the whole guide is be
7878 expect(open).toBeGreaterThan(0);
7979 const outside = main.slice(0, open);
8080 expect(main.match(/<details/g)?.length).toBe(1);
81 expect(outside).toContain('action="/forges/new"');
81 expect(outside).toContain('action="/forges/start"');
8282 expect(outside).toContain('href="/llms.txt"');
8383 // Real list numbering: an <ol> inside .prose, no hand-written "1." in the items.
8484 const steps = outside.slice(outside.indexOf('<ol id="agent-steps"'));
@@ -102,8 +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 "Or a hosted one, plans on",
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.",
105 "A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.",
107106 ]) {
108107 expect(plain).toContain(sentence);
109108 expect(inside).toContain(sentence);
test/forge-sleep.test.ts +1 −1
@@ -319,7 +319,7 @@ describe("a hosted forge scales to zero", () => {
319319 test("/agents makes a forge in one step: its form posts the name straight to the spawn", async () => {
320320 const aliceToken = tokenFor(parent, "alice", "admin");
321321 const agents = await (await fetch(`${servers.url}/agents`)).text();
322 expect(agents).toContain('<form method="post" action="/forges/new" class="stack" id="agent-forge">');
322 expect(agents).toContain('<form method="post" action="/forges/start" class="stack" id="agent-forge">');
323323 // What that form sends, and nothing else: the forge is made, its claim link in the answer.
324324 const made = await fetch(`${servers.url}/forges/new`, {
325325 method: "POST",
test/home-start-forge.test.ts +1 −1
@@ -39,7 +39,7 @@ test("a visitor with no cookies sees the start form on /", async () => {
3939 expect(form).toContain('autocapitalize="none"');
4040 expect(form).toContain('autocomplete="off"');
4141 expect(form).not.toContain("class=\"hint\"");
42 expect(body.match(/Start my forge/g)).toHaveLength(1);
42 expect(body.match(/Start my forge\s*<\/button>/g)).toHaveLength(1);
4343 });
4444
4545 test("the first view has one primary button, and the first sentence is one", async () => {
test/page-title.test.ts +3 −2
@@ -27,8 +27,8 @@ const page = async (path: string) => (await fetch(`${servers.url}${path}`)).text
2727 test("the front page names the forge once", async () => {
2828 const raw = await page("/");
2929 const title = /<title>([^<]*)<\/title>/.exec(raw)?.[1] ?? "";
30 expect(title).toStartWith("forge");
31 expect(title.match(/forge/g)).toHaveLength(1);
30 expect(title).toStartWith("Open Platform Forge: ");
31 expect(title.match(/forge/gi)).toHaveLength(1);
3232 expect(raw).toContain(`property="og:title" content="${title}"`);
3333 });
3434
@@ -44,6 +44,7 @@ test("no title ends in the suffix twice", async () => {
4444 // A title that opens with the forge's name names it already.
4545 expect(tabTitle("forge: software for what you're building")).toBe("forge: software for what you're building");
4646 expect(tabTitle("forge-x")).toBe("forge-x · forge");
47 expect(tabTitle("Open Platform Forge: pricing")).toBe("Open Platform Forge: pricing");
4748 for (const path of ["/search?q=forge", "/trevato/forge/issues", "/trevato/forge/pulls"])
4849 expect(await page(path)).not.toContain("· forge · forge");
4950 });
test/pricing-order.test.ts +1 −1
@@ -80,7 +80,7 @@ describe("/pricing", () => {
8080 expect(res.status).toBe(200);
8181 const text = await res.text();
8282 expect(text).toContain("$19 a month");
83 expect(text).toContain("Start free");
83 expect(text).toContain("Start my forge");
8484 expect(res.headers.get("content-security-policy")).toBeTruthy();
8585 });
8686
test/pull-quiet.test.ts +2 −1
@@ -66,8 +66,9 @@ afterAll(async () => {
6666 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6767 const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
69 // Share tags with the full name (#11654): 31,983 pull, 25,359 list, budgets that plus ~170.
6970 // Measured ~31,650 (pull: 31,130 on main plus the verdict line and the change above the check) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze. With the Your-dotfiles palette entry (#10548) the list measures 25,260, budget that plus ~170. The palette now also carries the people, three settings rows and the Tell-the-crew action (#10659): 31,869 measured on the pull page, so the pull budget is that plus ~160.
70 const BUDGET = { pull: 32_030, list: 25_430 };
71 const BUDGET = { pull: 32_160, list: 25_530 };
7172
7273 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7374 const text = await get(`/alice/r/pulls/${number}`);
test/share-path.test.ts added +86
@@ -0,0 +1,86 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp as createForge, type App } from "../src/app.ts";
6 import { serve, type Servers } from "../src/http/server.ts";
7 import { getPlans } from "../src/settings/plans.ts";
8 import { SIZES } from "../src/spawn/sizes.ts";
9 import { SITE_PATH, START_BUTTON } from "../src/web/site_line.ts";
10
11 let dataDir: string;
12 let app: App;
13 let servers: Servers;
14 let base: string;
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-sharepath-"));
18 app = createForge({ dataDir, port: 0 });
19 servers = await serve(app);
20 base = servers.url;
21 });
22 afterAll(async () => {
23 await servers.stop();
24 await rm(dataDir, { recursive: true, force: true });
25 });
26
27 const get = async (path: string): Promise<string> => (await fetch(`${base}${path}`)).text();
28
29 test("/, /pricing, /agents and /llms.txt each give the one-line path with the one button name", async () => {
30 const line = SITE_PATH(base);
31 expect(line).toContain(`press ${START_BUTTON}.`);
32 for (const path of ["/", "/pricing", "/agents", "/llms.txt"]) {
33 const text = (await get(path)).replaceAll("&#39;", "'");
34 expect(text, path).toContain(line);
35 expect(text, path).toContain(START_BUTTON);
36 expect(text, path).not.toContain("Spawn");
37 expect(text, path).not.toContain("New app");
38 }
39 const llms = await get("/llms.txt");
40 expect(llms.split("\n").slice(0, 8).join("\n")).toContain(line);
41 for (const path of ["/", "/pricing"]) {
42 const head = await get(path);
43 expect(head).toContain(`<meta name="description" content="${path === "/" ? "Open Platform Forge gives you a forge of your own where an AI crew builds your apps. " : ""}${line}`);
44 }
45 });
46
47 test("/pricing's free start is the home form's one step: a name and the same button, posted to the same door", async () => {
48 const text = await get("/pricing");
49 expect(text).toMatch(/<button[^>]*formaction="\/forges\/start"[^>]*>\s*Start my forge\s*<\/button>/);
50 expect(text).not.toContain("Start free");
51 });
52
53 test("the plan facts in /pricing's Offer data and /llms.txt's table are the plans setting", async () => {
54 const plans = getPlans(app.db);
55 const page = await get("/pricing");
56 const ld = JSON.parse(page.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)![1]!);
57 expect(ld.offers).toHaveLength(plans.length);
58 const llms = await get("/llms.txt");
59 plans.forEach((p, i) => {
60 const s = SIZES[p.size];
61 const offer = ld.offers[i];
62 expect(offer.price).toBe(String(p.priceUsd));
63 const prop = (n: string) => offer.additionalProperty.find((x: { name: string }) => x.name === n).value;
64 expect(prop("CPUs")).toBe(s.cpu);
65 expect(prop("Memory (GB)")).toBe(s.memoryMiB / 1024);
66 expect(prop("Crew credit (USD a month)")).toBe(p.creditUsd);
67 expect(llms).toContain(`| ${p.name} | ${p.priceUsd} | ${s.cpu} | ${s.memoryMiB / 1024} | ${s.diskMiB / 1024} | ${p.creditUsd} |`);
68 });
69 });
70
71 test("the share tags name the full product and the image resolves to a 1200×630 PNG under 300 KB", async () => {
72 for (const path of ["/", "/pricing"]) {
73 const head = await get(path);
74 expect(head).toContain('<meta property="og:site_name" content="Open Platform Forge" />');
75 expect(head).toMatch(/<title>Open Platform Forge: /);
76 const image = head.match(/<meta name="twitter:image" content="([^"]+)"/)![1]!;
77 expect(head).toMatch(new RegExp(`property="og:image"\\s+content="${image}"`));
78 expect(head).toContain(`name="twitter:image" content="${base}/og/site.png"`);
79 const res = await fetch(image);
80 expect(res.status).toBe(200);
81 const bytes = new Uint8Array(await res.arrayBuffer());
82 expect(bytes.length).toBeLessThan(300 * 1024);
83 const dv = new DataView(bytes.buffer);
84 expect([dv.getUint32(16), dv.getUint32(20)]).toEqual([1200, 630]);
85 }
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs