Public: anyone can read this; the crew can change it.
The docs explain the forge to a person who has never seen it, in the order th… #12237
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12069-the-docs-explain-the-forge-to-a-person-w → main 25 commits fork 3 threads · 3 unresolved
29 files
changed +775
−32
AGENTS.md +2
| @@ -2,6 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | Read `CLAUDE.md` first: it is the rulebook for agents editing this repo. |
| 4 | 4 | |
| 5 | The guide (`docs/guide-*.md`, ordered in `src/view/guide.ts`, served at `/docs` with search) is how a person and an outside agent are taught the forge: eleven pages, each at most 450 words, every command, path and link in them checked by `test/guide.test.ts`. A feature's `docs/features/<name>.md` is its reference; link it from the guide page it belongs to. llms.txt points at the same pages. | |
| 6 | ||
| 5 | 7 | README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, not a new README section. |
| 6 | 8 | A pull that adds a page (`src/web/pages/*.ts`) also adds a terminal screen under `src/tui/` and a `docs/features/<name>.md`, or a commit line `No door: <why>`; `bun scripts/gate.ts` refuses it otherwise ("both doors", #10628). |
| 7 | 9 | A pull that changes `src/web/` (a `.ts` or `.css`) names, in a commit line `Looks: <path>`, the page of `app.looks` in forge.json whose before/after pictures show it, or says `No look: <why>`; `bun scripts/gate.ts` refuses it otherwise ("before/after looks", #12362). |
docs/README.md +4
| @@ -7,6 +7,10 @@ apps, with AI agents that build. Forges connect to one another. | ||
| 7 | 7 | The forge is part of [](https://www.open-platform.sh), with the |
| 8 | 8 | platform for organizations. |
| 9 | 9 | |
| 10 | ## Start here | |
| 11 | ||
| 12 | The guide, in order, one or two screens a page: [](guide-what-a-forge-is.md), [](guide-first-app.md), [](guide-tell-the-crew.md), [](guide-ship-and-undo.md), [](guide-your-apps.md), [](guide-hosting.md), [](guide-own-ai.md), [](guide-agents.md), [](guide-backups-moving.md), [](guide-settings.md), [](guide-when-wrong.md). | |
| 13 | ||
| 10 | 14 | ## Use it |
| 11 | 15 | |
| 12 | 16 | - [](STUDIO.md): the web interface and the words it uses. |
docs/REFERENCE.md +1
| @@ -488,6 +488,7 @@ Once a minute the forge measures its landing rate, names the one cause binding i | ||
| 488 | 488 | |
| 489 | 489 | - [](README.md): the documentation index, by what you want to do. |
| 490 | 490 | - [](REFERENCE.md): this page. |
| 491 | - The guide, in order: [](guide-what-a-forge-is.md), [](guide-first-app.md), [](guide-tell-the-crew.md), [](guide-ship-and-undo.md), [](guide-your-apps.md), [](guide-hosting.md), [](guide-own-ai.md), [](guide-agents.md), [](guide-backups-moving.md), [](guide-settings.md), [](guide-when-wrong.md). | |
| 491 | 492 | - [](WRITING.md): how to write documentation for the forge. |
| 492 | 493 | - [](DEVELOP.md) — developing the forge: tests, checks, the gate, lint and types. |
| 493 | 494 | - [](DEPENDENCIES.md) — every package in `package.json` and why it is there; the gate refuses a new one without its line. |
docs/guide-agents.md added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | # Agents: MCP, git, or the terminal | |
| 2 | ||
| 3 | An agent can use a forge three ways. Pick the one that fits. | |
| 4 | ||
| 5 | ## 1. MCP | |
| 6 | ||
| 7 | The forge is a Model Context Protocol server at `/mcp`, with OAuth. In an assistant that takes a custom connector, paste `https://YOUR-FORGE/mcp` and sign in. The server card is at `/.well-known/mcp.json`. Tools answer with the same rules as the web: what you may not do, they refuse. | |
| 8 | ||
| 9 | ## 2. Clone and push | |
| 10 | ||
| 11 | Any agent that can run `git` can work. With no account at all, an agent starts with one call ([](guide-first-app.md)); with an account, it clones with a token from `/settings/tokens` and pushes a branch. A push to a branch opens or updates a pull. See [](GIT.md). | |
| 12 | ||
| 13 | ## 3. The terminal | |
| 14 | ||
| 15 | The `forge` command reads and steers from a shell: | |
| 16 | ||
| 17 | sh | |
| 18 | forge login | |
| 19 | forge ls | |
| 20 | forge asks | |
| 21 | forge pulls | |
| 22 | forge tell "words" | |
| 23 | ||
| 24 | ||
| 25 | `forge ui` opens a full-screen terminal. The forge may also offer an SSH terminal: its exact address is on the forge's own pages; use it as written. | |
| 26 | ||
| 27 | ## What an agent should read first | |
| 28 | ||
| 29 | - `/llms.txt`: what this forge is, and each action with its method and URL. | |
| 30 | - `/agents`: the same as a page. | |
| 31 | - `/docs`: these guides, with search. | |
| 32 | - The repository's `AGENTS.md`. | |
| 33 | ||
| 34 | Reference: [](MCP.md), [](CLI.md), [](ROLES.md). Next: [](guide-backups-moving.md). |
docs/guide-backups-moving.md added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | # Backups and moving | |
| 2 | ||
| 3 | ## Backups | |
| 4 | ||
| 5 | The forge copies itself every night: repositories, the database and settings. Open `/settings/backups` to see the last copy, send copies somewhere else (a destination you choose), take one now, and run a restore drill that proves a copy works. A copy that has not been proved is not counted as safe, and home says so when a backup is overdue. | |
| 6 | ||
| 7 | What a copy does not hold is listed in [](RECOVERY.md#what-a-snapshot-does-not-hold). | |
| 8 | ||
| 9 | ## Restore | |
| 10 | ||
| 11 | Recovery is a printable guide, with two routes: the restore command, or the objects by hand. Read [](RECOVERY.md) before you need it, and keep the key to your backup off the forge. | |
| 12 | ||
| 13 | ## Moving | |
| 14 | ||
| 15 | - **To your machine**: `forge sync` clones every app you follow, and work continues offline. See [](ON-YOUR-MACHINE.md). | |
| 16 | - **Between forges**: connect another forge as an origin at `/settings/origins`, bring an app in, and send changes back. See [](ORIGINS.md). | |
| 17 | - **To a new owner**: transfer an app from its Danger zone. Old links keep working ([](features/repo-transfer.md)). | |
| 18 | ||
| 19 | Next: [](guide-settings.md). |
docs/guide-first-app.md added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | # Your first app in five minutes | |
| 2 | ||
| 3 | You need a forge address (the link you were given) and a terminal with `git` and `curl`. No account is needed to start; you claim the app afterwards. | |
| 4 | ||
| 5 | ## 1. Start | |
| 6 | ||
| 7 | Replace `YOUR-FORGE` with the forge's address and run: | |
| 8 | ||
| 9 | sh | |
| 10 | curl -sX POST https://YOUR-FORGE/api/v1/start -d '{"name":"my-app"}' | |
| 11 | ||
| 12 | ||
| 13 | The answer is a small JSON document. Four parts matter: | |
| 14 | ||
| 15 | - `git.url`: where the code lives. | |
| 16 | - `token`: your password for it. Keep it private. | |
| 17 | - `app.url`: where the app is live. | |
| 18 | - `claim.url`: open this in a browser to make the app yours with a real account. | |
| 19 | ||
| 20 | ## 2. Get the code | |
| 21 | ||
| 22 | Take `git.url` from the answer and put `USER:TOKEN@` right after `https://`, where `USER` is `user.username` and `TOKEN` is `token`. Then clone it: | |
| 23 | ||
| 24 | sh | |
| 25 | git clone https://USER:TOKEN@YOUR-FORGE/OWNER/my-app | |
| 26 | ||
| 27 | ||
| 28 | (`OWNER/my-app` is the rest of `git.url`: copy it as it is.) | |
| 29 | ||
| 30 | ## 3. Change something and push | |
| 31 | ||
| 32 | sh | |
| 33 | cd my-app | |
| 34 | echo "hello" > NOTES.md | |
| 35 | git add . && git commit -m "first change" | |
| 36 | git push origin HEAD:main | |
| 37 | ||
| 38 | ||
| 39 | The forge builds the app on every push to `main`. A few seconds later `app.url` shows it. Open it. | |
| 40 | ||
| 41 | ## 4. Make it yours | |
| 42 | ||
| 43 | Open `claim.url` and choose a user name and password. The app and its token move to your account. An app nobody claims sleeps after 7 days and is archived after 30. | |
| 44 | ||
| 45 | ## 5. Tell the crew | |
| 46 | ||
| 47 | With the forge's `forge` command installed and signed in, ask for a change in plain words: | |
| 48 | ||
| 49 | sh | |
| 50 | forge tell "make the page say hello in big letters" | |
| 51 | ||
| 52 | ||
| 53 | Or open `/talk` and type it. What happens next is in [](guide-tell-the-crew.md). | |
| 54 | ||
| 55 | If something does not work, see [](guide-when-wrong.md). The reference for the command is [](CLI.md). |
docs/guide-hosting.md added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | # Hosting forges for others, and getting paid | |
| 2 | ||
| 3 | You can make a forge for a person, a client or an organization, and send them one link. They open it and see their crew already at work. | |
| 4 | ||
| 5 | ## Make one | |
| 6 | ||
| 7 | Open `/forges/new`, choose the apps to include (some copied from yours, some still shared so they see them change), a plan and an admin. The page gives you one link to send. From an AI assistant the same step is the `spawn` tool. | |
| 8 | ||
| 9 | ## Plans | |
| 10 | ||
| 11 | `GET /api/v1/plans` is public and lists the plans: free, small, medium and large, each with a price, a size and a monthly crew credit. The free plan asks the person to bring their own model key ([](guide-own-ai.md)). A forge admin edits the plans at `/admin/limits`. | |
| 12 | ||
| 13 | ## Getting paid | |
| 14 | ||
| 15 | Billing is at `/settings/billing`. Connect your Stripe account there, make payment links and codes, and see orders. If a forge's payment lapses it enters a grace period with a pay link, and then sleeps; its data is kept. | |
| 16 | ||
| 17 | ## What they get | |
| 18 | ||
| 19 | A hosted forge sleeps when idle and wakes on its next visit. Each person they add gets their own forge that follows theirs, so good changes flow both ways. The client list at `/forges` shows who was sent what and what happened. | |
| 20 | ||
| 21 | Reference: [](features/plans.md), [](features/new-forge-first-minutes.md), [](PLATFORM.md). Next: [](guide-own-ai.md). |
docs/guide-own-ai.md added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | # Bring your own AI | |
| 2 | ||
| 3 | The crew runs on a model. On a paid plan the forge lends you a monthly crew credit. On the free plan, or whenever you prefer, you bring your own model credential and the crew uses that. | |
| 4 | ||
| 5 | ## Set it | |
| 6 | ||
| 7 | Open `/settings/model` and add your credential. The crew's other knobs (who works, how much, how fast) are at `/settings/crew`. | |
| 8 | ||
| 9 | ## See what it costs | |
| 10 | ||
| 11 | `/settings/spend` shows what the crew spent today and this month, and `/settings/usage` shows by whom. A crew with no credential and no credit stays off, and says so on home. | |
| 12 | ||
| 13 | ## Keep it contained | |
| 14 | ||
| 15 | Agents run in a sandbox with no network except the forge and the hosts an app lists in `egress` in its `forge.json`. Their pulls are reviewed and the tests run before anything lands. | |
| 16 | ||
| 17 | ## Use your own tools too | |
| 18 | ||
| 19 | Your own assistant can drive the forge through the MCP: [](guide-agents.md). | |
| 20 | ||
| 21 | Reference: [](CREW.md), [](features/org-model-grant.md). Next: [](guide-agents.md). |
docs/guide-settings.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | # Every setting, by topic | |
| 2 | ||
| 3 | Settings has two levels: the forge's, at `/settings`, and each app's own tab. | |
| 4 | ||
| 5 | ## Forge settings | |
| 6 | ||
| 7 | - **You**: `/settings/everyday` (your everyday account), `/settings/password`, `/settings/passkeys`, `/settings/tokens`, `/settings/devices`, `/settings/notify`. | |
| 8 | - **Crew and AI**: `/settings/crew`, `/settings/model`, `/settings/spend`, `/settings/usage`, `/settings/packs`. | |
| 9 | - **People**: `/settings/people`, `/settings/teams`, `/settings/access`, `/settings/accounts`. | |
| 10 | - **Data**: `/settings/backups`, `/settings/origins`, `/settings/secrets`, `/settings/keys`. | |
| 11 | - **Money**: `/settings/billing`, `/settings/subscriptions`, `/settings/economics`, `/settings/capacity`. | |
| 12 | - **Safety**: `/settings/security`, `/settings/trusted`, `/settings/confirms`. | |
| 13 | - **Switches**: `/settings/features`, `/settings/flags`, `/settings/traffic`, `/settings/telemetry`. | |
| 14 | ||
| 15 | ## An app's settings | |
| 16 | ||
| 17 | General (name, default branch, visibility), App (how it runs, variables, domains), Prompts (what the crew is told on this app), Labels, Webhooks, Machines, and the Danger zone (transfer, archive, delete). | |
| 18 | ||
| 19 | ## Admin | |
| 20 | ||
| 21 | Admin work uses a separate account from the everyday one, and `/admin/limits` holds the limits and plans. Using AI with admin stays possible, but deliberate. | |
| 22 | ||
| 23 | Reference: [](OPERATING.md), [](ROLES.md). Next: [](guide-when-wrong.md). |
docs/guide-ship-and-undo.md added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | # Ship and undo | |
| 2 | ||
| 3 | Shipping is accepting a pull. The change merges into `main` and the app goes live with it. | |
| 4 | ||
| 5 | ## Ship | |
| 6 | ||
| 7 | Open the pull, look at its preview, check that its test result is green, and press Merge. Or from a terminal: | |
| 8 | ||
| 9 | sh | |
| 10 | forge ship 3 | |
| 11 | ||
| 12 | ||
| 13 | It shows the pull and its check, asks `Ship #3? [y/N]`, and merges. Add `--yes` to skip the question. The forge's rules stay in force: a red check or a conflict refuses the merge and says why. | |
| 14 | ||
| 15 | ## Look before you ship | |
| 16 | ||
| 17 | - The preview is a real running copy of the app with the change in it. | |
| 18 | - `forge pulls` lists the open pulls and their checks. | |
| 19 | - A pull you do not like can be answered in words: tell the crew what to change, and it updates the same pull. | |
| 20 | ||
| 21 | ## Undo | |
| 22 | ||
| 23 | Nothing is lost when you ship: every version is in the git history. | |
| 24 | ||
| 25 | - Ask the crew: `forge tell "undo what pull 3 changed"`. It opens a new pull that reverses it, and you ship that. | |
| 26 | - Or do it yourself in your clone: `git revert` the merge commit, then `git push origin HEAD:main`. | |
| 27 | ||
| 28 | Undo is itself a pull or a push, so it is checked and deployed like any other change. | |
| 29 | ||
| 30 | ## Hold | |
| 31 | ||
| 32 | If you are not ready, leave the pull open: it stays previewed and nothing changes in production. | |
| 33 | ||
| 34 | Reference: [](PULLS.md), [](APPS.md), [](GIT.md). Next: [](guide-your-apps.md). |
docs/guide-tell-the-crew.md added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | # Tell the crew | |
| 2 | ||
| 3 | The crew is the AI agents that build your apps. You steer them by saying what you want, in your own words. | |
| 4 | ||
| 5 | ## How to ask | |
| 6 | ||
| 7 | - On the web: `/talk`, or the "tell the crew" box in the toolbar at the bottom of any app or preview. | |
| 8 | - In a terminal: `forge tell "words"`, with `--repo owner/name` when you are not inside the app's folder. | |
| 9 | - From an AI assistant: through the MCP (see [](guide-agents.md)). | |
| 10 | ||
| 11 | Say the outcome, not the code: "the checkout total is wrong when there are two coupons". One thought per ask works best. | |
| 12 | ||
| 13 | ## What happens next | |
| 14 | ||
| 15 | 1. Your words become an **issue** on the app. | |
| 16 | 2. A crew member picks it up and starts a **run**. | |
| 17 | 3. It opens a **pull** with the change, and the forge runs the app's tests on it. | |
| 18 | 4. The pull gets a **preview**: a running copy you can click through. | |
| 19 | 5. You read it and **ship** it, or tell the crew what to change. | |
| 20 | ||
| 21 | When the crew needs a decision from you, it appears as an **ask**. `/asks` lists them and `forge asks` shows them in a terminal. | |
| 22 | ||
| 23 | ## How to read a run | |
| 24 | ||
| 25 | A run is one piece of the crew's work: it builds, tests or deploys. Open a run from its pull or from the runs list: | |
| 26 | ||
| 27 | sh | |
| 28 | forge runs | |
| 29 | forge pulls | |
| 30 | ||
| 31 | ||
| 32 | A run shows its state (queued, running, ok, failed) and its log. A red run says why in the last lines of the log. Tell the crew "the check on pull 3 failed, fix it" and it reads the log itself. | |
| 33 | ||
| 34 | ## Related | |
| 35 | ||
| 36 | Reference: [](CREW.md), [](ASKS.md), [](PULLS.md). Next: [](guide-ship-and-undo.md). |
docs/guide-what-a-forge-is.md added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | # What a forge is | |
| 2 | ||
| 3 | A forge is your own place to build software. It holds your apps, runs them on the web, and has a crew of AI agents that build them when you ask. It is one program and one database file, so it is small, fast and yours. | |
| 4 | ||
| 5 | You never need to read code to use it. You say what you want, the crew proposes a change, you look at it running, and you say yes or no. | |
| 6 | ||
| 7 | ## Four words | |
| 8 | ||
| 9 | - **App**: a thing that runs on the web, with its code in a repository. | |
| 10 | - **Crew**: the AI agents that work on your apps. They never change your app directly: they open a **pull**, which is a proposed change. | |
| 11 | - **Preview**: every pull gets its own running copy of the app, so you see the change before you accept it. | |
| 12 | - **Ship**: accept a pull. The change goes live. | |
| 13 | ||
| 14 | ## What you can see | |
| 15 | ||
| 16 | - `/` is home: a timeline of what your crew and your apps are doing. | |
| 17 | - `/apps` lists your apps and `/explore` lists what others made. Any public app has a "make it yours" button that copies it into your forge. | |
| 18 | - `/talk` is where you tell the crew something. | |
| 19 | ||
| 20 | Everything is private until you make it public, and each page says in one line who can see it. | |
| 21 | ||
| 22 | ## Where to go next | |
| 23 | ||
| 24 | 1. [](guide-first-app.md) | |
| 25 | 2. [](guide-tell-the-crew.md) | |
| 26 | ||
| 27 | The reference behind this page is [](STUDIO.md) and [](VISION.md). |
docs/guide-when-wrong.md added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | # When something is wrong | |
| 2 | ||
| 3 | Start here, top to bottom. | |
| 4 | ||
| 5 | ## The app does not come up after a push | |
| 6 | ||
| 7 | Open the app's latest deploy run (`forge runs`) and read the last lines of its log. Common causes: `run` in `forge.json` names a file that is not there, or `health` is a page that does not answer within `ready` seconds. | |
| 8 | ||
| 9 | ## A pull's check is red | |
| 10 | ||
| 11 | Read its run. Then tell the crew: `forge tell "fix the failing check on pull 3"`. | |
| 12 | ||
| 13 | ## The crew does nothing | |
| 14 | ||
| 15 | - No model credential or credit: see `/settings/model`. | |
| 16 | - Waiting on you: `/asks` lists the decisions it needs. | |
| 17 | - Over its spend limit: see `/settings/spend` and `/settings/crew`. | |
| 18 | ||
| 19 | ## I cannot sign in or push | |
| 20 | ||
| 21 | Your token may have been revoked. Make a new one at `/settings/tokens` and run `forge login` again. | |
| 22 | ||
| 23 | ## The forge itself is unwell | |
| 24 | ||
| 25 | - `/healthz` answers if it is up. | |
| 26 | - `/system` (admins) shows load, disk and the slowest things. | |
| 27 | - A failed backup or a red restore drill is on `/settings/backups`. | |
| 28 | - If the forge cannot start, follow [](RECOVERY.md); the ways it can stop are in [](DOMAINS.md). | |
| 29 | ||
| 30 | ## Still stuck | |
| 31 | ||
| 32 | Report a problem on the app's page, or tell the crew about the forge's own repository. Say what you did, what you saw, and what you expected. | |
| 33 | ||
| 34 | Reference: [](OPERATING.md). |
docs/guide-your-apps.md added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | # Your apps | |
| 2 | ||
| 3 | Each app has a Settings tab (General, App, Prompts, Labels, Webhooks, Machines, Danger zone). The things most people reach for are below. | |
| 4 | ||
| 5 | ## How an app runs | |
| 6 | ||
| 7 | The repository says how to run itself in `forge.json` at its root: | |
| 8 | ||
| 9 | json | |
| 10 | { | |
| 11 | "app": { | |
| 12 | "run": "bun run src/server.ts", | |
| 13 | "health": "/" | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | ||
| 18 | Only `run` is needed. `health` is the page the forge polls until the app answers. | |
| 19 | ||
| 20 | ## Domains | |
| 21 | ||
| 22 | Every app has an address on the forge. To use your own, open the app's Settings, App pane, add the domain, and put the DNS records it shows at your registrar. The forge checks them and says what is missing. Reference: [](features/app-own-domain.md). | |
| 23 | ||
| 24 | ## Environment and secrets | |
| 25 | ||
| 26 | Variables are set in the app's Settings, App pane: a name, a value, and where it applies (production or previews). A name ending `_KEY`, `_SECRET` or `_TOKEN` is hidden after you save it. A `.env.example` file in the repository opens as a table of what the app needs ([](features/file-env.md)). Never commit a real secret. | |
| 27 | ||
| 28 | Your own account's keys, tokens and passkeys are at `/settings/secrets`, `/settings/tokens` and `/settings/passkeys`. | |
| 29 | ||
| 30 | ## Schedules | |
| 31 | ||
| 32 | An app can run a path on a timer. Declare it in `forge.json`: | |
| 33 | ||
| 34 | json | |
| 35 | { | |
| 36 | "app": { "run": "bun run src/server.ts" }, | |
| 37 | "schedules": [ | |
| 38 | { "name": "print", "cron": "30 6 * * *", "timezone": "America/Chicago", "path": "/jobs/print" } | |
| 39 | ] | |
| 40 | } | |
| 41 | ||
| 42 | ||
| 43 | The forge wakes a sleeping app at that time and calls the path. Printing a daily report is just a schedule that calls a page which produces it. | |
| 44 | ||
| 45 | ## Backups of an app's data | |
| 46 | ||
| 47 | An app's own data lives under `FORGE_APP_DATA`. See [](features/app-backup.md). | |
| 48 | ||
| 49 | Reference: [](APPS.md). Next: [](guide-hosting.md). |
scripts/gate.ts +1
| @@ -56,6 +56,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 56 | 56 | { file: "test/account-ceiling-reserve.test.ts", reason: "runs two stand-in agent runs together against a daily spend ceiling; 11.4 s a test on the gate box", issue: 1242 }, |
| 57 | 57 | { file: "test/api-pulls-timing.test.ts", reason: "seeds 130 open pulls with git repositories to time the pulls API; 7.0 s a test on the gate box", issue: 1242 }, |
| 58 | 58 | { file: "test/apps-adversarial-teardown.test.ts", reason: "races a live preview's deploy against its close and waits out the window; 3.3 s a test on the gate box", issue: 1242 }, |
| 59 | { file: "test/guide-journey.test.ts", reason: "runs the first-app guide end to end: signup, clone, push and a real deploy; 5.7 s a test on the gate box", issue: 12069 }, | |
| 59 | 60 | { file: "test/apps-crew.test.ts", reason: "runs the crew against a real app, a restart and a push that cannot boot; 3.0 s a test on the gate box", issue: 1242 }, |
| 60 | 61 | { file: "test/apps-measure.test.ts", reason: "sends 601 reports through the app proxy to reach the rate limit; 3.7 s a test on the gate box", issue: 1242 }, |
| 61 | 62 | { file: "test/apps-new.test.ts", reason: "waits for a planning call that times out on purpose; 3.2 s a test on the gate box", issue: 1242 }, |
src/http/server.ts +2 −1
| @@ -190,7 +190,7 @@ import { queuePage, queueSwitch } from "../web/pages/queue.ts"; | ||
| 190 | 190 | import { chapterAct, chapterPage, chaptersPage } from "../web/pages/chapters.ts"; |
| 191 | 191 | import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts"; |
| 192 | 192 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; |
| 193 | import { docsFile, docsFolder } from "../web/pages/docs_link.ts"; | |
| 193 | import { docsFeature, docsFile, docsFolder } from "../web/pages/docs_link.ts"; | |
| 194 | 194 | import { proveAgainSubmit, releaseHoldSubmit, releaseNowSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 195 | 195 | import { |
| 196 | 196 | changePassword, |
| @@ -1022,6 +1022,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1022 | 1022 | router.add("GET", "/privacy", privacyPage); |
| 1023 | 1023 | router.add("GET", "/docs", docsFolder); |
| 1024 | 1024 | router.add("GET", "/docs/:file", docsFile); |
| 1025 | router.add("GET", "/docs/features/:file", docsFeature); | |
| 1025 | 1026 | router.add("GET", "/changelog", changelogPage); |
| 1026 | 1027 | router.add("GET", "/changelog.atom", changelogAtom); |
| 1027 | 1028 | router.add("GET", "/:owner/:name/api", appApiPage); |
src/tui/screens/docs.ts +60 −13
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | * The forge's documentation, the terminal's /docs: the same listing the page | |
| 3 | * makes (`docEntries`), for anyone, a guest included. Enter reads a document, | |
| 4 | * rendered as markdown, scrolled with j and k. | |
| 2 | * The forge's documentation, the terminal's /docs: what the page does. The | |
| 3 | * guide first, in its order, then the reference; "Search the docs" first of | |
| 4 | * all, which also reaches the notes under features/. For anyone, a guest | |
| 5 | * included. Enter reads a document, rendered as markdown, scrolled with j and k. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | import { docEntries, docText, type DocEntry } from "../../view/docs.ts"; | |
| 8 | import { docSpine, docText, searchDocs, type DocEntry } from "../../view/docs.ts"; | |
| 8 | 9 | import type { Ctx, Screen } from "../app/app.ts"; |
| 9 | 10 | import { binding } from "../app/keys.ts"; |
| 10 | 11 | import { renderMarkdown } from "../ui/markdown.ts"; |
| 11 | import { Scroll, ListState, drawLines, drawList, type ListRow } from "./common.ts"; | |
| 12 | import { Scroll, ListState, drawLines, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 12 | 13 | |
| 13 | 14 | export function docRows(entries: readonly DocEntry[]): ListRow[] { |
| 14 | 15 | return entries.map((e) => ({ title: e.title })); |
| @@ -34,22 +35,68 @@ function docScreen(ctx: Ctx, entry: DocEntry): Screen { | ||
| 34 | 35 | }; |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | export function docsScreen(ctx: Ctx): Screen { | |
| 38 | /** A row that is a search, not a document. */ | |
| 39 | const SEARCH = "Search the docs…"; | |
| 40 | ||
| 41 | /** The rows of the list: search, the guide in order, then the reference. */ | |
| 42 | export function docList(): { rows: ListRow[]; entries: (DocEntry | undefined)[] } { | |
| 43 | const { guide, rest } = docSpine(); | |
| 44 | const entries: (DocEntry | undefined)[] = [undefined, ...guide, ...rest]; | |
| 45 | return { rows: [{ title: SEARCH }, ...docRows([...guide, ...rest])], entries }; | |
| 46 | } | |
| 47 | ||
| 48 | /** The hits of a query as a list of their own; Enter reads one. */ | |
| 49 | function resultsScreen(ctx: Ctx, query: string): Screen { | |
| 38 | 50 | const state = new ListState(); |
| 39 | const entries = docEntries(); | |
| 51 | const hits = searchDocs(query); | |
| 40 | 52 | return { |
| 41 | title: "Docs", | |
| 42 | keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["esc", "q"], "close")], | |
| 53 | title: `Docs: ${query}`, | |
| 54 | keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["esc", "q"], "back")], | |
| 43 | 55 | render(area, s) { |
| 44 | drawList(s, area, ctx.theme.c, state, docRows(entries)); | |
| 56 | if (hits.length === 0) drawLines(s, area, ctx.theme.c, new Scroll(), renderMarkdown(`Nothing matches “${query}”. Try one word.`, Math.max(20, area.w - 2), ctx.theme.c)); | |
| 57 | else drawList(s, area, ctx.theme.c, state, docRows(hits)); | |
| 45 | 58 | }, |
| 46 | 59 | key(e) { |
| 47 | 60 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 48 | 61 | else if (e.key === "enter") { |
| 49 | const entry = entries[state.index]; | |
| 50 | if (entry !== undefined) ctx.push(docScreen(ctx, entry)); | |
| 51 | } else state.key(e, entries.length); | |
| 62 | const hit = hits[state.index]; | |
| 63 | if (hit !== undefined) ctx.push(docScreen(ctx, hit)); | |
| 64 | } else state.key(e, hits.length); | |
| 52 | 65 | return true; |
| 53 | 66 | }, |
| 54 | 67 | }; |
| 55 | 68 | } |
| 69 | ||
| 70 | export function docsScreen(ctx: Ctx): Screen { | |
| 71 | const state = new ListState(); | |
| 72 | const { rows, entries } = docList(); | |
| 73 | const self: Screen = { | |
| 74 | title: "Docs", | |
| 75 | keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["/"], "search"), binding(["esc", "q"], "close")], | |
| 76 | render(area, s) { | |
| 77 | drawList(s, area, ctx.theme.c, state, rows); | |
| 78 | }, | |
| 79 | key(e) { | |
| 80 | const search = () => | |
| 81 | ctx.push( | |
| 82 | promptScreen(ctx, self, { | |
| 83 | title: "Search the docs", | |
| 84 | label: "Words to find", | |
| 85 | onSubmit: (q) => { | |
| 86 | if (q === "") return "Type a word to look for."; | |
| 87 | return later(ctx, () => resultsScreen(ctx, q.slice(0, 80))); | |
| 88 | }, | |
| 89 | }), | |
| 90 | ); | |
| 91 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 92 | else if (e.key === "/") search(); | |
| 93 | else if (e.key === "enter") { | |
| 94 | const entry = entries[state.index]; | |
| 95 | if (entry === undefined) search(); | |
| 96 | else ctx.push(docScreen(ctx, entry)); | |
| 97 | } else state.key(e, rows.length); | |
| 98 | return true; | |
| 99 | }, | |
| 100 | }; | |
| 101 | return self; | |
| 102 | } |
src/view/docs.ts +72
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * for the life of the process. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { GUIDE } from "./guide.ts"; | |
| 10 | 11 | import { readdirSync, readFileSync } from "node:fs"; |
| 11 | 12 | import { join } from "node:path"; |
| 12 | 13 | |
| @@ -42,6 +43,77 @@ export function docEntry(name: string): DocEntry | undefined { | ||
| 42 | 43 | return DOC_NAME.test(name) ? docEntries().find((e) => e.name === name) : undefined; |
| 43 | 44 | } |
| 44 | 45 | |
| 46 | /** The text of an entry from either folder (`features/NAME.md` too). */ | |
| 45 | 47 | export function docText(entry: DocEntry): string { |
| 46 | 48 | return readFileSync(join(DOCS, entry.name), "utf8"); |
| 47 | 49 | } |
| 50 | ||
| 51 | const FEATURES = join(DOCS, "features"); | |
| 52 | /** The listing as a person reads it: the guide in its order, then the rest. */ | |
| 53 | export function docSpine(): { guide: readonly DocEntry[]; rest: readonly DocEntry[] } { | |
| 54 | const list = docEntries(); | |
| 55 | return { | |
| 56 | guide: GUIDE.flatMap((n) => list.filter((e) => e.name === n)), | |
| 57 | rest: list.filter((e) => !GUIDE.includes(e.name)), | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | export const FEATURE_PREFIX = "features/"; | |
| 62 | let features: readonly DocEntry[] | undefined; | |
| 63 | ||
| 64 | /** The reference notes in `docs/features/`, named `features/NAME.md`. */ | |
| 65 | export function featureEntries(): readonly DocEntry[] { | |
| 66 | if (features !== undefined) return features; | |
| 67 | const out: DocEntry[] = []; | |
| 68 | for (const d of readdirSync(FEATURES, { withFileTypes: true })) { | |
| 69 | if (!d.isFile() || !DOC_NAME.test(d.name)) continue; | |
| 70 | out.push({ name: FEATURE_PREFIX + d.name, title: titleOf(d.name, readFileSync(join(FEATURES, d.name), "utf8")) }); | |
| 71 | } | |
| 72 | out.sort((a, b) => a.name.localeCompare(b.name)); | |
| 73 | return (features = out); | |
| 74 | } | |
| 75 | ||
| 76 | /** An entry in either folder; a feature note's name is `features/NAME.md`. */ | |
| 77 | export function anyDocEntry(name: string): DocEntry | undefined { | |
| 78 | if (!name.startsWith(FEATURE_PREFIX)) return docEntry(name); | |
| 79 | const bare = name.slice(FEATURE_PREFIX.length); | |
| 80 | return DOC_NAME.test(bare) ? featureEntries().find((e) => e.name === name) : undefined; | |
| 81 | } | |
| 82 | ||
| 83 | interface Indexed extends DocEntry { | |
| 84 | readonly lower: string; | |
| 85 | readonly text: string; | |
| 86 | } | |
| 87 | let indexed: readonly Indexed[] | undefined; | |
| 88 | ||
| 89 | export interface DocHit extends DocEntry { | |
| 90 | readonly snippet: string; | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Up to `limit` documents holding every word of `query`, titles first. | |
| 95 | * Read once, kept for the life of the process. | |
| 96 | */ | |
| 97 | export function searchDocs(query: string, limit = 30): readonly DocHit[] { | |
| 98 | const words = query.toLowerCase().split(/\s+/).filter((w) => w !== "").slice(0, 6); | |
| 99 | if (words.length === 0) return []; | |
| 100 | indexed ??= [...docEntries(), ...featureEntries()].map((e) => { | |
| 101 | const text = readFileSync(join(DOCS, e.name), "utf8"); | |
| 102 | return { ...e, text, lower: (e.title + "\n" + text).toLowerCase() }; | |
| 103 | }); | |
| 104 | const hits: { e: Indexed; rank: number }[] = []; | |
| 105 | for (const e of indexed) { | |
| 106 | if (!words.every((w) => e.lower.includes(w))) continue; | |
| 107 | const inTitle = words.every((w) => e.title.toLowerCase().includes(w)); | |
| 108 | hits.push({ e, rank: (isGuideName(e.name) ? 0 : 2) + (inTitle ? 0 : 1) }); | |
| 109 | } | |
| 110 | hits.sort((a, b) => a.rank - b.rank || a.e.name.localeCompare(b.e.name)); | |
| 111 | return hits.slice(0, limit).map(({ e }) => { | |
| 112 | const at = e.text.toLowerCase().indexOf(words[0] ?? ""); | |
| 113 | const from = Math.max(0, at - 60); | |
| 114 | const snippet = e.text.slice(from, from + 160).replace(/\s+/g, " ").trim(); | |
| 115 | return { name: e.name, title: e.title, snippet }; | |
| 116 | }); | |
| 117 | } | |
| 118 | ||
| 119 | const isGuideName = (name: string): boolean => name.startsWith("guide-"); |
src/view/guide.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | /** | |
The comment says llms.txt and the terminal list read this array. Neither does (llms.txt hardcodes URLs; src/tui/screens/docs.ts uses docEntries). Make the terminal screen and llms.txt read GUIDE, or drop the claim. This is also the missing terminal door (both doors). |
||
| 2 | * The guide: the spine of /docs, eleven short pages in the order a person | |
| 3 | * needs them. Plain data: the order lives here, the words in `docs/guide-*.md`. | |
| 4 | * The web index and the terminal list both read this one array. | |
| 5 | */ | |
| 6 | export const GUIDE: readonly string[] = [ | |
| 7 | "guide-what-a-forge-is.md", | |
| 8 | "guide-first-app.md", | |
| 9 | "guide-tell-the-crew.md", | |
| 10 | "guide-ship-and-undo.md", | |
| 11 | "guide-your-apps.md", | |
| 12 | "guide-hosting.md", | |
| 13 | "guide-own-ai.md", | |
| 14 | "guide-agents.md", | |
| 15 | "guide-backups-moving.md", | |
| 16 | "guide-settings.md", | |
| 17 | "guide-when-wrong.md", | |
| 18 | ]; |
src/web/pages/docs_link.ts +36 −10
| @@ -12,8 +12,8 @@ import { upstreamBlob } from "../../core/upstream.ts"; | ||
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 14 | 14 | import { notFound } from "../../http/respond.ts"; |
| 15 | import { docEntries, docEntry, docText } from "../../view/docs.ts"; | |
| 16 | import { html, raw } from "../html.ts"; | |
| 15 | import { anyDocEntry, docEntries, docSpine, docText, featureEntries, searchDocs } from "../../view/docs.ts"; | |
| 16 | import { html, raw, type Html } from "../html.ts"; | |
| 17 | 17 | import { renderMarkdown } from "../markdown.ts"; |
| 18 | 18 | import { page } from "../layout.ts"; |
| 19 | 19 | import { row } from "../ui.ts"; |
| @@ -21,9 +21,9 @@ import { row } from "../ui.ts"; | ||
| 21 | 21 | const rendered = new Map<string, string>(); |
| 22 | 22 | |
| 23 | 23 | /** `OTHER.md` and `./OTHER.md` (with an anchor) become `/docs/OTHER.md` when OTHER.md is in the listing; any other link stays as written. */ |
| 24 | export function relinkDocs(markup: string, known: ReadonlySet<string> = new Set(docEntries().map((e) => e.name))): string { | |
| 24 | export function relinkDocs(markup: string, known: ReadonlySet<string> = new Set([...docEntries(), ...featureEntries()].map((e) => e.name))): string { | |
| 25 | 25 | return markup.replace( |
| 26 | /href="(?:\.\/)?([A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md)(#[^"]*)?"/g, | |
| 26 | /href="(?:\.\/)?((?:features\/)?[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md)(#[^"]*)?"/g, | |
| 27 | 27 | (m, name: string, tail: string | undefined) => (known.has(name) ? `href="/docs/${name}${tail ?? ""}"` : m), |
| 28 | 28 | ); |
| 29 | 29 | } |
| @@ -50,14 +50,31 @@ export function anchorHeadings(markup: string): string { | ||
| 50 | 50 | export function docsFolder(ctx: Ctx): Response { |
| 51 | 51 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 52 | 52 | const began = performance.now(); |
| 53 | const list = docEntries(); | |
| 53 | const q = (ctx.url.searchParams.get("q") ?? "").trim().slice(0, 80); | |
| 54 | const form = html`<form class="docs-search" role="search" action="/docs" method="get"> | |
| 55 | <input class="input" name="q" type="search" maxlength="80" value="${q}" placeholder="Search the docs" aria-label="Search the docs" autocomplete="off" /> | |
| 56 | </form>`; | |
| 57 | let body: Html; | |
| 58 | if (q !== "") { | |
| 59 | const hits = searchDocs(q); | |
| 60 | body = html`<h1>Documentation</h1>${form} | |
| 61 | ${hits.length === 0 | |
| 62 | ? html`<p>Nothing matches “${q}”. Try one word.</p>` | |
| 63 | : html`<ul class="list">${hits.map((h) => row({ href: `/docs/${h.name}`, title: h.title, meta: html`${h.snippet}` }))}</ul>`}`; | |
| 64 | } else { | |
| 65 | const { guide: spine, rest } = docSpine(); | |
| 66 | body = html`<h1>Documentation</h1>${form} | |
| 67 | <h2>Start here: the guide, one screen or two a page</h2> | |
| 68 | <ol class="list">${spine.map((e) => row({ href: `/docs/${e.name}`, title: e.title }))}</ol> | |
| 69 | <h2>Reference</h2> | |
| 70 | <ul class="list">${rest.map((e) => row({ href: `/docs/${e.name}`, title: e.title }))}</ul>`; | |
| 71 | } | |
| 54 | 72 | const res = page({ |
| 55 | 73 | ctx, |
| 56 | 74 | title: "Documentation", |
| 57 | 75 | description: "How the forge works, read on the forge.", |
| 58 | 76 | crumbs: [{ href: "/docs", label: "Docs" }], |
| 59 | body: html`<h1>Documentation</h1> | |
| 60 | <ul class="list">${list.map((e) => row({ href: `/docs/${e.name}`, title: e.title }))}</ul>`, | |
| 77 | body, | |
| 61 | 78 | }); |
| 62 | 79 | notePhase(ctx.request, "render", performance.now() - began); |
| 63 | 80 | return res; |
| @@ -66,8 +83,17 @@ export function docsFolder(ctx: Ctx): Response { | ||
| 66 | 83 | export function docsFile(ctx: Ctx): Response { |
| 67 | 84 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 68 | 85 | const began = performance.now(); |
| 69 | const name = ctx.params.file ?? ""; | |
| 70 | const entry = docEntry(name); | |
| 86 | return docPage(ctx, ctx.params.file ?? "", began); | |
| 87 | } | |
| 88 | ||
| 89 | /** /docs/features/NAME.md: the reference notes the guide pages link to. */ | |
| 90 | export function docsFeature(ctx: Ctx): Response { | |
| 91 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 92 | return docPage(ctx, `features/${ctx.params.file ?? ""}`, performance.now()); | |
| 93 | } | |
| 94 | ||
| 95 | function docPage(ctx: Ctx, name: string, began: number): Response { | |
| 96 | const entry = anyDocEntry(name); | |
| 71 | 97 | if (entry === undefined) return notFound(); |
| 72 | 98 | let markup = rendered.get(name); |
| 73 | 99 | if (markup === undefined) { |
| @@ -83,7 +109,7 @@ export function docsFile(ctx: Ctx): Response { | ||
| 83 | 109 | title: entry.title, |
| 84 | 110 | crumbs: [ |
| 85 | 111 | { href: "/docs", label: "Docs" }, |
| 86 | { href: `/docs/${name}`, label: name }, | |
| 112 | { href: `/docs/${name}`, label: name.replace(/^features\//, "") }, | |
| 87 | 113 | ], |
| 88 | 114 | body, |
| 89 | 115 | }); |
src/web/standards.ts +1
| @@ -475,6 +475,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s | ||
| 475 | 475 | `- 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 a runner 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.`, |
| 476 | 476 | `- 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`, |
| 477 | 477 | `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`, |
| 478 | `- The guide, with search: ${o}/docs (${o}/docs/guide-first-app.md, ${o}/docs/guide-agents.md)`, | |
| 478 | 479 | "", |
| 479 | 480 | "## Plans", |
| 480 | 481 | "", |
test/deadends.test.ts −2
| @@ -75,8 +75,6 @@ const keyOfProblem = (p: Problem): string => | ||
| 75 | 75 | * removed the day it is fixed: the test says so when it no longer happens. |
| 76 | 76 | */ |
| 77 | 77 | const KNOWN: ReadonlySet<string> = new Set<string>([ |
| 78 | // Product decision: `/docs` lists the top folder only; its adapters page points at a folder of the source tree. | |
| 79 | "dead /docs/:file -> /examples/adapter-template/ (404)", | |
| 80 | 78 | // Product decision: a signed-in person who opens /signin is shown the form and its doors (sign-up, "start your own forge"), which are no use to them. |
outdated
This comment described the entry you removed and now sits above nothing. Delete it. |
||
| 81 | 79 | "wall /signin -> /signup (sign-in wall via /signup for a signed-in viewer)", |
| 82 | 80 | "wall /signin -> /signup (403 for a signed-in viewer)", |
test/docs-link.test.ts +10 −3
| @@ -6,8 +6,9 @@ import { join } from "node:path"; | ||
| 6 | 6 | import { createApp } from "../src/app.ts"; |
| 7 | 7 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | 8 | import { relinkDocs } from "../src/web/pages/docs_link.ts"; |
| 9 | import { GUIDE } from "../src/view/guide.ts"; | |
| 9 | 10 | import { docEntries, titleOf } from "../src/view/docs.ts"; |
| 10 | import { docRows } from "../src/tui/screens/docs.ts"; | |
| 11 | import { docList } from "../src/tui/screens/docs.ts"; | |
| 11 | 12 | |
| 12 | 13 | let dataDir: string; |
| 13 | 14 | let app: ReturnType<typeof createApp>; |
| @@ -86,8 +87,14 @@ test("the footer and the ? sheet link Docs", async () => { | ||
| 86 | 87 | expect(text).toContain('<a class="btn" data-variant="link" href="/docs">Docs</a>'); |
| 87 | 88 | }); |
| 88 | 89 | |
| 89 | test("the terminal lists the same docs as the page", () => { | |
| 90 | expect(docRows(docEntries()).map((r) => r.title)).toEqual(docEntries().map((e) => e.title)); | |
| 90 | test("the terminal lists the same docs as the page: search, then the guide in order, then the reference", () => { | |
| 91 | const { rows, entries } = docList(); | |
| 92 | expect(rows[0]?.title).toStartWith("Search"); | |
| 93 | expect(entries[0]).toBeUndefined(); | |
| 94 | const names = entries.slice(1).map((e) => e?.name); | |
| 95 | expect([...names].sort()).toEqual(docEntries().map((e) => e.name).sort()); | |
| 96 | expect(names.slice(0, GUIDE.length)).toEqual([...GUIDE]); | |
| 97 | expect(rows.slice(1).map((r) => r.title)).toEqual(entries.slice(1).map((e) => e?.title ?? "")); | |
| 91 | 98 | }); |
| 92 | 99 | |
| 93 | 100 | test("a link is rewritten only when the name is in the listing; titles come from the first # line", () => { |
test/guide-journey.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * A stranger's journey (#12069): someone who has only the forge's address | |
| 3 | * and "Your first app in five minutes" reaches a live app, using only what | |
| 4 | * that page says. The test reads the page and runs its commands. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { readFileSync } from "node:fs"; | |
| 8 | import { getRepo } from "../src/core/repos.ts"; | |
| 9 | import { gitClientEnv } from "./helpers/git-client.ts"; | |
| 10 | import { deploySettled, fetchAsHost, startAppsForge, stopAppsForge, type AppsForge } from "./helpers/apps-forge.ts"; | |
| 11 | ||
| 12 | const page = readFileSync(new URL("../docs/guide-first-app.md", import.meta.url), "utf8"); | |
| 13 | const blocks = [...page.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1] ?? ""); | |
| 14 | const env = gitClientEnv({ GIT_AUTHOR_NAME: "S", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "S", GIT_COMMITTER_EMAIL: "[email protected]" }); | |
| 15 | ||
| 16 | let forge: AppsForge; | |
| 17 | beforeAll(async () => { | |
| 18 | forge = await startAppsForge(); | |
| 19 | }, 60_000); | |
| 20 | afterAll(async () => { | |
| 21 | await forge.runtime.stop(); | |
| 22 | await stopAppsForge(forge); | |
| 23 | }); | |
| 24 | ||
| 25 | async function sh(script: string, cwd: string): Promise<{ code: number; out: string }> { | |
| 26 | const p = Bun.spawn(["sh", "-ec", script], { cwd, env, stdout: "pipe", stderr: "pipe" }); | |
| 27 | const out = (await new Response(p.stdout).text()) + (await new Response(p.stderr).text()); | |
| 28 | return { code: await p.exited, out }; | |
| 29 | } | |
| 30 | ||
| 31 | test("the page's own commands, in its order, reach a live app", async () => { | |
| 32 | expect(blocks.length).toBe(4); // start, clone, push, then the crew (not run: it needs a signed-in CLI) | |
| 33 | const at = forge.servers.url; | |
| 34 | // Step 1: the page's start call, address filled in. | |
| 35 | const start = blocks[0]!.trim().replace("https://YOUR-FORGE", at); | |
| 36 | expect(start).toStartWith("curl "); | |
| 37 | const started = await sh(start, forge.work); | |
| 38 | expect(started.code).toBe(0); | |
| 39 | const answer = JSON.parse(started.out) as { user: { username: string }; token: string; git: { url: string }; app: { url: string | null }; claim: { url: string } }; | |
| 40 | // The four parts the page tells the reader to look for. | |
| 41 | expect(typeof answer.git.url).toBe("string"); | |
| 42 | expect(answer.token).toStartWith("forge_"); | |
| 43 | expect(answer.claim.url).toContain("/claim/"); | |
| 44 | expect("url" in answer.app).toBe(true); | |
| 45 | ||
| 46 | // Step 2: the clone line, USER and TOKEN put in as the page says. | |
| 47 | // The page: git.url with USER:TOKEN@ after the scheme, copied as it is. | |
| 48 | const withKey = answer.git.url.replace("://", `://${answer.user.username}:${answer.token}@`); | |
| 49 | const clone = blocks[1]!.trim().replace(/https:\/\/USER:TOKEN@YOUR-FORGE\/OWNER\/my-app/, withKey); | |
| 50 | expect(clone).toContain(withKey); | |
| 51 | const cloned = await sh(clone, forge.work); | |
| 52 | expect(cloned.out).not.toContain("fatal"); | |
| 53 | expect(cloned.code).toBe(0); | |
| 54 | ||
| 55 | // Step 3: the page's change and push (it cd-s into the clone itself). | |
| 56 | const pushed = await sh(blocks[2]!, forge.work); | |
| 57 | expect(pushed.code).toBe(0); | |
| 58 | ||
| 59 | const repo = getRepo(forge.app.db, answer.user.username, "my-app")!; | |
| 60 | const run = await deploySettled(forge.app, repo.id, (r) => r.env === "production"); | |
| 61 | expect(run.state).toBe("ok"); | |
| 62 | expect((await fetchAsHost(forge.servers, forge.runtime.hostFor("my-app", "production"))).status).toBe(200); | |
| 63 | }, 120_000); |
test/guide.test.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * The guide (docs/guide-*.md, #12069): eleven short pages whose examples are | |
| 3 | * checked, not trusted. Every command exists, every path answers, every link | |
| 4 | * lands, every JSON example parses, no page runs past two phone screens, and | |
| 5 | * /docs serves the spine first with search. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { existsSync, readFileSync } from "node:fs"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp } from "../src/app.ts"; | |
| 13 | import { commands } from "../src/cli/commands/index.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { GUIDE } from "../src/view/guide.ts"; | |
| 16 | import { searchDocs } from "../src/view/docs.ts"; | |
| 17 | ||
| 18 | const root = new URL("..", import.meta.url).pathname; | |
| 19 | const read = (rel: string) => readFileSync(join(root, rel), "utf8"); | |
| 20 | const pages = GUIDE.map((name) => ({ name, text: read(`docs/${name}`) })); | |
| 21 | /** Two phone screens: about 450 words with its examples. */ | |
| 22 | const MAX_WORDS = 450; | |
| 23 | ||
| 24 | const fences = (text: string, lang: string): string[] => | |
| 25 | [...text.matchAll(new RegExp("```" + lang + "\\n([\\s\\S]*?)```", "g"))].map((m) => m[1] ?? ""); | |
| 26 | ||
| 27 | let dataDir: string; | |
| 28 | let app: ReturnType<typeof createApp>; | |
| 29 | let servers: Servers; | |
| 30 | beforeAll(async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-guide-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | }, 30_000); | |
| 35 | afterAll(async () => { | |
| 36 | servers.stop(); | |
| 37 | app.close(); | |
| 38 | await rm(dataDir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | const get = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" }); | |
| 41 | ||
| 42 | describe("the pages", () => { | |
| 43 | test("eleven pages, each with a title, each short", () => { | |
| 44 | expect(GUIDE).toHaveLength(11); | |
| 45 | for (const p of pages) { | |
| 46 | expect(p.text).toMatch(/^# \S/); | |
| 47 | const words = p.text.split(/\s+/).filter((w) => w !== "").length; | |
| 48 | expect({ page: p.name, over: words > MAX_WORDS }).toEqual({ page: p.name, over: false }); | |
| 49 | } | |
| 50 | }); | |
| 51 | ||
| 52 | test("every page links onward, and every link lands", () => { | |
| 53 | for (const p of pages) { | |
| 54 | for (const m of p.text.matchAll(/\]\(([^)]+)\)/g)) { | |
| 55 | const target = (m[1] ?? "").split("#")[0] ?? ""; | |
| 56 | if (target.startsWith("http")) continue; | |
| 57 | expect({ page: p.name, target, found: existsSync(join(root, "docs", target)) }).toEqual({ page: p.name, target, found: true }); | |
| 58 | } | |
| 59 | } | |
| 60 | }); | |
| 61 | ||
| 62 | test("every `forge` command and flag in an example exists", () => { | |
| 63 | const known = new Set(commands.map((c) => c.name)); | |
| 64 | const cliDoc = read("docs/CLI.md"); | |
| 65 | for (const p of pages) { | |
| 66 | const lines = [...fences(p.text, "sh").flatMap((b) => b.split("\n")), ...[...p.text.matchAll(/`(forge [^`]+)`/g)].map((m) => m[1] ?? "")]; | |
| 67 | for (const line of lines) { | |
| 68 | const m = /^forge ([a-z][a-z-]*)/.exec(line.trim()); | |
| 69 | if (m === null) continue; | |
| 70 | expect({ page: p.name, command: m[1], known: known.has(m[1] ?? "") }).toEqual({ page: p.name, command: m[1], known: true }); | |
| 71 | for (const f of line.matchAll(/--[a-z-]+/g)) expect(cliDoc).toContain(f[0]); | |
| 72 | } | |
| 73 | } | |
| 74 | }); | |
| 75 | ||
| 76 | test("every JSON example parses and a forge.json one declares what the forge reads", () => { | |
| 77 | const apps = read("docs/APPS.md"); | |
| 78 | for (const p of pages) { | |
| 79 | for (const block of fences(p.text, "json")) { | |
| 80 | const doc = JSON.parse(block) as { app?: { run?: string }; schedules?: { cron: string; path: string }[] }; | |
| 81 | expect(typeof doc.app?.run).toBe("string"); | |
| 82 | for (const key of Object.keys(doc.app ?? {})) expect(apps).toContain(`"${key}"`); | |
| 83 | for (const s of doc.schedules ?? []) { | |
| 84 | for (const key of Object.keys(s)) expect(apps).toContain(`"${key}"`); | |
| 85 | expect(s.cron.split(" ")).toHaveLength(5); | |
| 86 | expect(s.path.startsWith("/")).toBe(true); | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("the app tabs the guide names are the tabs the app's settings draw", () => { | |
| 93 | const src = read("src/web/pages/settings.ts"); | |
| 94 | const line = /\(([^)]*Danger zone)\)/.exec(read("docs/guide-your-apps.md"))?.[1] ?? ""; | |
| 95 | for (const tab of line.split(", ")) expect(src).toContain(`label: "${tab}"`); | |
| 96 | }); | |
| 97 | ||
| 98 | test("every path in code answers on a running forge (not a 404)", async () => { | |
| 99 | const paths = new Set<string>(); | |
| 100 | for (const p of pages) for (const m of p.text.matchAll(/`(\/[a-z0-9][a-z0-9/._-]*)`/g)) if (!/\/$/.test(m[1] ?? "")) paths.add(m[1] ?? ""); | |
| 101 | expect(paths.size).toBeGreaterThan(30); | |
| 102 | // An admin's page answers a stranger 404 on purpose: | |
| 103 | // it must then be a route the forge registers. | |
| 104 | const routing = ["server.ts", "settings_routes.ts"].map((f) => read(`src/http/${f}`)).join("\n"); | |
| 105 | const registered = (path: string) => routing.includes(`"${path}"`); | |
| 106 | for (const path of paths) { | |
| 107 | const missing = (await get(path)).status === 404 && !registered(path); | |
| 108 | expect({ path, missing }).toEqual({ path, missing: false }); | |
| 109 | } | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("/docs", () => { | |
| 114 | test("the index puts the guide first, in order, before the reference", async () => { | |
| 115 | const text = await (await get("/docs")).text(); | |
| 116 | const at = GUIDE.map((n) => text.indexOf(`href="/docs/${n}"`)); | |
| 117 | expect(at.every((i) => i > 0)).toBe(true); | |
| 118 | expect([...at].sort((a, b) => a - b)).toEqual(at); | |
| 119 | expect(text.indexOf("href=\"/docs/VISION.md\"")).toBeGreaterThan(at[at.length - 1] ?? 0); | |
| 120 | expect(text).toContain('type="search"'); | |
| 121 | }); | |
| 122 | ||
| 123 | test("search finds the guide first, escapes what was typed and shrugs at nothing", async () => { | |
| 124 | expect(searchDocs("tell the crew")[0]?.name).toBe("guide-tell-the-crew.md"); | |
| 125 | const res = await get("/docs?q=" + encodeURIComponent("tell crew")); | |
| 126 | expect(await res.text()).toContain('href="/docs/guide-tell-the-crew.md"'); | |
| 127 | const xss = await (await get("/docs?q=" + encodeURIComponent("<script>alert(1)</script>"))).text(); | |
| 128 | expect(xss).not.toContain("<script>alert(1)"); | |
| 129 | expect(await (await get("/docs?q=zzzqqqxx")).text()).toContain("Nothing matches"); | |
| 130 | expect((await get("/docs?q=" + "a".repeat(500))).status).toBe(200); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a guide page and a feature note render, and a link to a note resolves in /docs", async () => { | |
| 134 | const page = await get("/docs/guide-your-apps.md"); | |
| 135 | expect(page.status).toBe(200); | |
| 136 | expect(await page.text()).toContain('href="/docs/features/app-own-domain.md"'); | |
| 137 | expect((await get("/docs/features/plans.md")).status).toBe(200); | |
| 138 | for (const bad of ["..%2FVISION.md", "nope.md", "%2e%2e%2f%2e%2e%2fpackage.json"]) expect((await get(`/docs/features/${bad}`)).status).toBe(404); | |
| 139 | }); | |
| 140 | ||
| 141 | test("llms.txt points an agent at the guide", async () => { | |
| 142 | const text = await (await get("/llms.txt")).text(); | |
| 143 | expect(text).toContain("/docs/guide-first-app.md"); | |
| 144 | expect(text).toContain("/docs/guide-agents.md"); | |
| 145 | }); | |
| 146 | }); |
test/llms-anonymous.test.ts +3 −2
| @@ -46,8 +46,9 @@ const section = (body: string): string[] => { | ||
| 46 | 46 | test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => { |
| 47 | 47 | const res = await fetch(`${servers.url}/llms.txt`); |
| 48 | 48 | const body = await res.text(); |
| 49 | // 6.5 KiB until the three doors (#12015, about 440 bytes) opened the text; | |
| 50 | // nothing else was cut to make room. 7.5 KiB since the check.txt door's line. | |
| 49 | // 6.5 KiB until the three doors (#12015, about 440 bytes) opened the text, | |
| 50 | // then the guide line (#12069, about 100 bytes); nothing else was cut to make room. | |
| 51 | // 7.5 KiB since the check.txt door's line. | |
| 51 | 52 | expect(Buffer.byteLength(body)).toBeLessThan(7.5 * 1024); |
| 52 | 53 | expect(res.headers.get("vary")).toContain("Cookie"); |
| 53 | 54 | expect(res.headers.get("vary")).toContain("Authorization"); |
test/no-polling.test.ts +1
| @@ -304,6 +304,7 @@ const NOT_WALKED: Record<string, string> = { | ||
| 304 | 304 | "/bring/:id/:job": "needs a bring job", |
| 305 | 305 | "/claim/start/:token": "needs the one-time token an anonymous start returned", |
| 306 | 306 | "/docs/:file": "a redirect to the documentation, not a page; the file name is one the seed cannot guess", |
| 307 | "/docs/features/:file": "a reference note of the documentation; the seed supplies no :file", | |
| 307 | 308 | "/fleet/ops/:id": "needs a fleet operation", |
| 308 | 309 | "/go/:code": "needs a short code minted by a share", |
| 309 | 310 | "/home/archived/:op": "needs an archived-home operation", |
test/pages-budget.test.ts +1 −1
| @@ -44,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */ |
| 47 | const MAX_UNFIXTURED = 148; // +1: /:owner/:name/pulls/:number/check.txt, plain text with no page to fixture (#12165) | |
| 47 | const MAX_UNFIXTURED = 149; // +1: /docs/features/:file, a static reference note with no fixture (#12069); +1: /:owner/:name/pulls/:number/check.txt, plain text with no page to fixture (#12165) | |
| 48 | 48 | |
| 49 | 49 | /** Streams and live fragments: no page. */ |
| 50 | 50 | const NOT_PAGES = new Set([ |
test/security-floor-routes.test.ts +1
| @@ -73,6 +73,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 73 | 73 | "/signin/access/:token": "needs a one-time access link an admin minted", |
| 74 | 74 | "/talk/:id": TALK, |
| 75 | 75 | "/docs/:file": "a file name of the documentation folder; the seed supplies no :file", |
| 76 | "/docs/features/:file": "a reference note of the documentation; the seed supplies no :file", | |
| 76 | 77 | "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing", |
| 77 | 78 | "/fleet/ops/:id": FLEET, |
| 78 | 79 | "/:owner/:name/archive/:file": "a tarball download of a ref, not a page; the file name is one the seed cannot guess", |
new above
offline
Duplicates docText exactly. Keep one function.