The hosted demo
Nothing in Demo/ reimplements the hub. The build runs the real Hub/hub.mjs against a
fictional workspace, crawls every response it produces, and writes those out beside an
unmodified copy of Hub/index.html. That is the entire design, and the property worth
protecting: if the scanner changes, the demo changes with it, or the build fails loudly.
Which is why the demo build is wired into test.yml as an integration test, not just into
the deploy. A red demo build usually means the interface moved, not that the demo broke.
node Demo/build-demo.mjs # Node 18.17+ and Git. --keep leaves Demo/.build to inspect
Both Demo/.build and Demo/site are gitignored. Nothing generated is committed.
1. The five phases
|
Phase |
Output |
| 1 |
Stage Workspace/ and Home/ into .build, write the hub configs |
.build/workspace, .build/home, .build/hub |
| 2 |
git init every entry in REPOS, each with a bare origin |
real branch / state / last-commit columns |
| 3 |
Spawn the real hub on port 4399 with HOME/USERPROFILE pointed at the fixture |
a live hub |
| 4 |
Crawl /api/scan, /api/health, then every node |
site/api/NNNN.json + api/manifest.json + site/files/ |
| 5 |
Copy the modules and demo.js, rewrite index.html literals |
site/index.html |
Two details in phase 1-3 that are load-bearing:
- The configs are written at build time, never committed, so no config in the repo
carries a path that is only true on one machine.
GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM point at an empty file and GIT_AUTHOR_* is
fixture-wide. The fixture repos cannot pick up the build machine's identity, signing keys
or hooks, and no commit in the demo carries a real person's name or address.
HOME/USERPROFILE are overridden because the scanner reads os.homedir() for the user
CLI roots. Without it the published demo would show the build machine's skills.
2. The literal contract with index.html
Phase 5 rewrites Hub/index.html through sub(find, replace, expected), which throws if
the match count is not exactly expected:
| Literal |
× |
Why |
%TITLE% %FAVICON% %PORT% |
1 |
Server-side template values. %PORT% keeps the real default on purpose — the page compares it to location.port and so never opens an event stream on a static host |
nonce="%NONCE%" |
2 |
No server, no per-request nonce |
download.href = '/api/raw?path=' + …&download=1'; |
1 |
Whole statement, replaced with DEMO_ASSET(n.id) |
'/api/raw?path=' + encodeURIComponent( |
4 |
Prefix only, so the call's own closing paren still closes it |
'/api/preview?path=' + encodeURIComponent( |
1 |
Same |
<script type="module"> |
1 |
demo.js is injected ahead of it |
[!IMPORTANT]
expected 4x "…" found 3 is not a demo bug. It means someone changed an asset URL in
index.html, and the assertion caught it before it shipped a demo that half works. Fix the
sub() call in the same commit as the interface change. See the
project-hub-client skill.
Asset URLs cannot go through the shim because they are element attributes, not fetches —
an <img src> never reaches patched fetch. They are rewritten to DEMO_ASSET(id), whose
slug rule ([^A-Za-z0-9._-] → _) is implemented twice, in slug() in the build and in
assetPath() in demo.js, because an <img src> cannot wait for a manifest to load. Change
one and you must change the other; the build throws on a slug collision rather than
silently overwriting a captured file.
3. What the shim answers, and what it refuses
demo.js is a classic script loaded before the app module, so fetch is already patched
by the time the first request runs. It intercepts same-origin /api/* only, keyed by
keyOf(url) — the same key the build recorded, with fresh and download dropped because
neither changes the bytes.
Two endpoints answer honestly rather than failing:
/api/open → 501 with a sentence saying opening files needs a local hub. A hosted page
has no filesystem, and a status nobody can read is worse than a plain refusal.
/api/pictures → 404, because no Pictures root is configured in the fixture.
Keep that pattern for anything new: say what is absent, do not let it look broken. The
demo README's "what is real, and what is not" table is part of the deliverable — update it
whenever this changes.
4. Changing the fixture
Ordinary markdown and config under Demo/Workspace/ and Demo/Home/, read by the same
scanner that reads a user's. Rebuild to see it.
- New workspace → a folder under
Workspace/Projects/ plus an entry in
PROJECT_CONFIGS. There are three on purpose — one server mounting several workspaces is
a thing worth showing, and one scopes by groups while two scope by pathPrefix so both
forms are exercised.
- New repo → the folder plus a row in
REPOS (dir, branch, state, author,
commit). state is genuinely produced: ahead is an extra local commit, behind pushes
one then resets back off it, dirty writes an uncommitted file. The mix is deliberate —
a table where every row says clean demonstrates nothing.
Repos/Draft/ has no .git by convention, so drafts are correctly absent from REPOS.
- Everything invented: every workspace, repo, person and domain is fictional, and commits use
team@example.dev. Keep it that way.
5. Publishing
.github/workflows/demo.yml runs the same command on any push to main touching Hub/ or
Demo/, and deploys Demo/site to Pages (concurrency: pages, a queued run supersedes an
older one). The domain comes from Demo/static/CNAME; Pages must be set to "GitHub
Actions" as its source in repo settings or the workflow has nowhere to publish.
.nojekyll is written because Jekyll would drop underscore-prefixed paths and add nothing.
test.yml's demo-build job additionally asserts the capture is not empty and holds more
than 100 API responses — the failure a green build would otherwise miss is a build that
happily writes an empty site because the fixture stopped being found.
6. Checklist
node Demo/build-demo.mjs locally before pushing anything that touches Hub/ or Demo/.
- A
sub() failure → fix the sub() call, not the assertion count alone; confirm the
rewritten interface still works.
- New file extension in the fixture →
TEXT/BINARY in the build and DOC_FILE /
kindOfFile() in hub.mjs, or the node captures nothing.
- New workspace or repo → the folder and its
PROJECT_CONFIGS / REPOS row.
- Serve
Demo/site with a static server to check it — file:// cannot work, the shim
answers root-relative paths.
--keep to inspect .build when the capture looks wrong.
- Changed what the demo can or cannot do → update the table in
Demo/README.md.
7. Anti-patterns
- Hand-editing anything in
Demo/site. It is generated and gitignored; fix the build.
- Faking a response the hub never produced. The capture's whole value is that it came
from the real scanner.
- Loosening a
sub() count to make the build pass. That assertion is the regression test.
- Letting a missing capability fail silently. 501/404 with a sentence, like
/api/open.
- Real names, real domains, or a real person's commits in the fixture.
- Committing a config with an absolute path. Phase 1 writes them for a reason.
Related
| For |
See |
| Phase-by-phase notes, the manifest format, failure messages |
references/build-and-capture.md |
| The literals this build rewrites, and who owns them |
the project-hub-client skill |
DOC_FILE / kindOfFile — what is capturable at all |
the project-hub-scan skill |
| What the demo claims to be |
Demo/README.md |
1---2name: project-hub-demo3description: Work on Project Hub's hosted demo at project-hub.ai-automation-tools.dev — the capture build in `Demo/build-demo.mjs` that stands the real hub up against the `Demo/Workspace` fixture, crawls every response and writes flat files into `Demo/site`, plus the `Demo/static/demo.js` fetch shim, the `sub()` literal assertions against `Hub/index.html`, and the Pages workflow. Use for "the demo build failed with expected 1x … found 0", "add a workspace / repo / document to the fixture", "the demo shows a stale tree", "an image or PDF 404s on the demo but works locally", "slug collision", "the demo deploy / CNAME / GitHub Pages", "captured N API responses is too low in CI", and any edit to Demo/ or .github/workflows/demo.yml.4---56# The hosted demo78**Nothing in `Demo/` reimplements the hub.** The build runs the real `Hub/hub.mjs` against a9fictional workspace, crawls every response it produces, and writes those out beside an10unmodified copy of `Hub/index.html`. That is the entire design, and the property worth11protecting: **if the scanner changes, the demo changes with it, or the build fails loudly.**1213Which is why the demo build is wired into `test.yml` as an integration test, not just into14the deploy. A red demo build usually means the interface moved, not that the demo broke.1516```sh17node Demo/build-demo.mjs # Node 18.17+ and Git. --keep leaves Demo/.build to inspect18```1920Both `Demo/.build` and `Demo/site` are gitignored. Nothing generated is committed.2122---2324## 1. The five phases2526| | Phase | Output |27|:-:|:---|:---|28| 1 | Stage `Workspace/` and `Home/` into `.build`, write the hub configs | `.build/workspace`, `.build/home`, `.build/hub` |29| 2 | `git init` every entry in `REPOS`, each with a **bare origin** | real branch / state / last-commit columns |30| 3 | Spawn the real hub on port 4399 with `HOME`/`USERPROFILE` pointed at the fixture | a live hub |31| 4 | Crawl `/api/scan`, `/api/health`, then every node | `site/api/NNNN.json` + `api/manifest.json` + `site/files/` |32| 5 | Copy the modules and `demo.js`, rewrite `index.html` literals | `site/index.html` |3334Two details in phase 1-3 that are load-bearing:3536- The configs are **written at build time**, never committed, so no config in the repo37 carries a path that is only true on one machine.38- `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` point at an empty file and `GIT_AUTHOR_*` is39 fixture-wide. The fixture repos cannot pick up the build machine's identity, signing keys40 or hooks, and **no commit in the demo carries a real person's name or address.**41- `HOME`/`USERPROFILE` are overridden because the scanner reads `os.homedir()` for the user42 CLI roots. Without it the published demo would show the build machine's skills.4344## 2. The literal contract with index.html4546Phase 5 rewrites `Hub/index.html` through `sub(find, replace, expected)`, which **throws if47the match count is not exactly `expected`**:4849| Literal | × | Why |50|:---|:-:|:---|51| `%TITLE%` `%FAVICON%` `%PORT%` | 1 | Server-side template values. `%PORT%` keeps the real default on purpose — the page compares it to `location.port` and so never opens an event stream on a static host |52| ` nonce="%NONCE%"` | 2 | No server, no per-request nonce |53| `download.href = '/api/raw?path=' + …&download=1';` | 1 | Whole statement, replaced with `DEMO_ASSET(n.id)` |54| `'/api/raw?path=' + encodeURIComponent(` | 4 | Prefix only, so the call's own closing paren still closes it |55| `'/api/preview?path=' + encodeURIComponent(` | 1 | Same |56| `<script type="module">` | 1 | `demo.js` is injected ahead of it |5758> [!IMPORTANT]59> **`expected 4x "…" found 3` is not a demo bug.** It means someone changed an asset URL in60> `index.html`, and the assertion caught it before it shipped a demo that half works. Fix the61> `sub()` call in the same commit as the interface change. See the62> [`project-hub-client`](../project-hub-client/SKILL.md) skill.6364Asset URLs cannot go through the shim because they are **element attributes, not fetches** —65an `<img src>` never reaches patched `fetch`. They are rewritten to `DEMO_ASSET(id)`, whose66slug rule (`[^A-Za-z0-9._-]` → `_`) is implemented **twice**, in `slug()` in the build and in67`assetPath()` in `demo.js`, because an `<img src>` cannot wait for a manifest to load. Change68one and you must change the other; the build throws on a slug collision rather than69silently overwriting a captured file.7071## 3. What the shim answers, and what it refuses7273`demo.js` is a classic script loaded **before** the app module, so `fetch` is already patched74by the time the first request runs. It intercepts same-origin `/api/*` only, keyed by75`keyOf(url)` — the same key the build recorded, with `fresh` and `download` dropped because76neither changes the bytes.7778Two endpoints answer honestly rather than failing:7980- `/api/open` → **501** with a sentence saying opening files needs a local hub. A hosted page81 has no filesystem, and a status nobody can read is worse than a plain refusal.82- `/api/pictures` → **404**, because no Pictures root is configured in the fixture.8384Keep that pattern for anything new: **say what is absent, do not let it look broken.** The85demo README's "what is real, and what is not" table is part of the deliverable — update it86whenever this changes.8788## 4. Changing the fixture8990Ordinary markdown and config under `Demo/Workspace/` and `Demo/Home/`, read by the same91scanner that reads a user's. Rebuild to see it.9293- **New workspace** → a folder under `Workspace/Projects/` **plus** an entry in94 `PROJECT_CONFIGS`. There are three on purpose — one server mounting several workspaces is95 a thing worth showing, and one scopes by `groups` while two scope by `pathPrefix` so both96 forms are exercised.97- **New repo** → the folder **plus** a row in `REPOS` (`dir`, `branch`, `state`, `author`,98 `commit`). `state` is genuinely produced: `ahead` is an extra local commit, `behind` pushes99 one then resets back off it, `dirty` writes an uncommitted file. **The mix is deliberate —100 a table where every row says `clean` demonstrates nothing.**101- `Repos/Draft/` has no `.git` by convention, so drafts are correctly absent from `REPOS`.102- Everything invented: every workspace, repo, person and domain is fictional, and commits use103 `team@example.dev`. Keep it that way.104105## 5. Publishing106107`.github/workflows/demo.yml` runs the same command on any push to `main` touching `Hub/` or108`Demo/`, and deploys `Demo/site` to Pages (`concurrency: pages`, a queued run supersedes an109older one). The domain comes from `Demo/static/CNAME`; **Pages must be set to "GitHub110Actions" as its source** in repo settings or the workflow has nowhere to publish.111`.nojekyll` is written because Jekyll would drop underscore-prefixed paths and add nothing.112113`test.yml`'s `demo-build` job additionally asserts the capture is not empty and holds **more114than 100 API responses** — the failure a green build would otherwise miss is a build that115happily writes an empty site because the fixture stopped being found.116117---118119## 6. Checklist1201211. `node Demo/build-demo.mjs` locally before pushing anything that touches `Hub/` or `Demo/`.1222. A `sub()` failure → fix the `sub()` call, not the assertion count alone; confirm the123 rewritten interface still works.1243. New file extension in the fixture → `TEXT`/`BINARY` in the build **and** `DOC_FILE` /125 `kindOfFile()` in `hub.mjs`, or the node captures nothing.1264. New workspace or repo → the folder **and** its `PROJECT_CONFIGS` / `REPOS` row.1275. Serve `Demo/site` with a static server to check it — `file://` cannot work, the shim128 answers root-relative paths.1296. `--keep` to inspect `.build` when the capture looks wrong.1307. Changed what the demo can or cannot do → update the table in `Demo/README.md`.131132## 7. Anti-patterns133134- **Hand-editing anything in `Demo/site`.** It is generated and gitignored; fix the build.135- **Faking a response the hub never produced.** The capture's whole value is that it came136 from the real scanner.137- **Loosening a `sub()` count to make the build pass.** That assertion is the regression test.138- **Letting a missing capability fail silently.** 501/404 with a sentence, like `/api/open`.139- **Real names, real domains, or a real person's commits in the fixture.**140- **Committing a config with an absolute path.** Phase 1 writes them for a reason.141142---143144## Related145146| For | See |147|:---|:---|148| Phase-by-phase notes, the manifest format, failure messages | [`references/build-and-capture.md`](references/build-and-capture.md) |149| The literals this build rewrites, and who owns them | the `project-hub-client` skill |150| `DOC_FILE` / `kindOfFile` — what is capturable at all | the `project-hub-scan` skill |151| What the demo claims to be | `Demo/README.md` |