fuzzyworld-tools
Tooling for Pavel's self-hosted code and apps on DreamHost. It replaces GitLab (code, CI, Pages) and WordPress (the blog) with static files plus a few helper machines.
The one rule: DreamHost only serves files. There is no app server. Pages are generated when you push, and Apache just sends them. Work that needs a real machine (CI, screenshots) runs on a runner (a home TrueNAS box, or the laptop when it is idle), which pulls jobs from DreamHost over ssh. No runner is a normal state: jobs wait.
The roadmap and backlog are in PLAN.md.
What it provides
| Site | What it is | Details |
|---|---|---|
| code.fuzzyworld.net | Browse pages and read-only git clone for public repos: every unmerged branch, syntax highlighting, history, blame, CI results, bug-report issues, AI-usage charts |
docs/code-site.md |
| apps.fuzzyworld.net | Deployed web apps, one per path (/bubbles/, /slide/, ...), published with fw deploy, with CI screenshots |
docs/apps.md |
| writing.fuzzyworld.net | The writing blog: a static site built from Markdown by fw blog, with comments by email |
docs/blog.md |
| VS Code in the browser | Any public repo, read-only, at apps.fuzzyworld.net/vscode/?repo=<name> |
docs/operations.md |
| CI runners | Run each push's pipeline (.fw-ci.yml) in Docker, like a GitLab runner |
docs/ci.md |
| Admin panel | fw admin: a local web page for bug reports, blog comments and deploy state |
docs/admin.md |
Philosophy
Things made with AI should be shared freely with the world. The code and apps here were made with AI, so they are MIT-licensed: use them, change them, share them.
My conversations with the AI are different. My words and my way of working are mine. I publish a conversation only when I choose to.
The same text is on /why-i-left-gitlab/.
Hosting tiers
Each feature needs a certain kind of host. A tier includes the ones above it, so you can pick what your hosting allows.
| Tier | The host must offer | Features |
|---|---|---|
| T0, files only (ftp only) | serving static files | the built blog, Atom feeds, the landing page, the generated code and CI pages, VS Code in the browser |
| T1, files + jobs (ftp + python) | ssh, cron, git hooks, a mailbox reachable by IMAP (or on the same host); nothing runs when a page is requested | page generation on push, the CI queue and runner API, cleanup jobs, comments and bug reports by email, the fw commands |
| T2, files + an endpoint (ftp + php/cgi) | a script that answers web requests | the comment form (post.php): written, not enabled, not installed |
| T3, always-on process | a long-running server | nothing; out of scope |
T2 is new attack surface (code that runs on a web request, as the same user as everything else on the account),
so comments are accepted by email only. To use the form you would host server/comments/post.php on its own
subdomain and set comments_form in the blog config.
Quick start
First create the repo on DreamHost (private unless --public):
fw new my-game --public --description "One line."
Then make it an app (details and every option: docs/apps.md):
// package.json of the app repo
"scripts": { "deploy": "fw deploy" },
"fuzzyworld": { "app": "my-game", "title": "My Game", "description": "One line." },
"devDependencies": { "fuzzyworld-tools": "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#semver:^1.0.0" }
git push dh:git/my-game.git # `dh` is the SSH alias in ~/.ssh/config
npm run deploy # clone main, install, check, build, upload, print the URL
Use fw outside an app repo (for example on a second laptop):
npm install -g "git+https://code.fuzzyworld.net/fuzzyworld-tools.git#semver:^1.0.0"
Commands
bin/fw runs some commands on the laptop (deploy, repoint, usage, admin, blog) and forwards the rest to
DreamHost (ssh dh ~/bin/fw ...).
Repos
| Command | What it does |
|---|---|
fw list |
every repo, its visibility and last commit |
fw new <name> [--public] [--description TEXT] |
create ~/git/<name>.git on DreamHost; private unless --public |
fw public <name> / fw private <name> |
publish the source on code.fuzzyworld.net right away / unpublish it (pages, clone URL and index entry are removed) |
fw describe <name> TEXT |
set the description shown on the index and summary page |
fw publish <name> |
refresh one repo's public copy and pages (the push hook does this) |
fw publish-all |
refresh every repo, printing [n/total] name (seconds), then the index and the CI dashboard |
fw repoint [DIR] [--https] [--dry-run] |
for each repo in DIR whose origin is on gitlab.com: rename it gitlab, add origin on DreamHost (or the read-only https URL with --https, public repos only), fetch, retarget branch tracking. Changes only remotes, and checks that uncommitted work, HEAD and stashes are unchanged |
Apps and deploys
| Command | What it does |
|---|---|
fw deploy [--ref B] [--as NAME] [--here] [--dry-run] |
build and publish this repo's app (docs/apps.md) |
fw deploys [--json] |
what is deployed where: the deploy ledger and each deployed app against its repo's default branch. Known gap: bin/fw does not forward it yet (see Known gaps); until then run ssh dh '~/bin/fw deploys'. |
CI
| Command | What it does |
|---|---|
fw ci <name> [branch] |
queue a CI run of a branch's latest commit (default: the default branch) |
fw ci-list |
queued and running CI jobs |
fw ci-watch |
follow CI in the terminal (private repos too): running jobs with each job's state, the queue, the last 5 results; ends when nothing is queued or running |
Bug reports and comments
| Command | What it does |
|---|---|
fw issues <repo> public\|reviewed\|private |
set who sees the repo's bug-report pages (default private) and republish them |
fw issues list\|show\|record\|approve\|hide\|close\|reopen\|publish\|publish-all\|ingest\|imap-check |
moderate bug reports (docs/issues.md) |
fw comments list\|show\|approve\|reject\|remove\|untrust\|publish\|export\|ingest\|imap-check |
moderate blog comments (docs/blog.md) |
Laptop-side tools
| Command | What it does |
|---|---|
fw admin [--port N] [--no-open] [--code-dir DIR] |
the local admin web page (docs/admin.md) |
fw blog list\|build\|preview\|deploy <config> |
the static blog (docs/blog.md) |
fw blog watch <config> [--once] [--dry-run], fw blog watch install\|uninstall <config> |
mirror the blog source into git and auto-deploy safe edits (one machine only) |
fw blog import-wp <config> --wp URL, fw blog check <config> |
one-time WordPress import / compare the built site with the WordPress text |
fw usage [--all\|--now\|--trailers [--session ID]] |
Claude Code token use per session; --trailers prints commit-message trailers (docs/usage.md) |
npm run scripts in this repo: dev (preview the pages locally), deploy and deploy-status (deploy this repo's own
server by default; -- all or a target name adds the runner and VS Code web; docs/operations.md), list-repos
(fw list) and prepare-usage-for-pr (fw usage --trailers).
Visibility
Each bare repo stores its own setting: git config fuzzyworld.visibility public|private.
Anything other than exactly public is private, including a missing value, so a repo is never published by
accident. A private repo has no pages, no clone URL and no index entry, and pushing to it publishes nothing.
Other settings in the repo config:
| Key | Meaning |
|---|---|
fuzzyworld.issues |
public, reviewed, or anything else = private: who sees bug-report pages (docs/issues.md) |
fuzzyworld.description |
the one-line description |
fuzzyworld.ci |
off: pushes queue no CI jobs |
fuzzyworld.gitlab-id, fuzzyworld.gitlab-path |
for repos migrated from GitLab |
Repository layout
| Folder | Contents |
|---|---|
bin/ |
fw (laptop side) and deploy-tools |
lib/ |
laptop-side programs: deploy.mjs, admin.py, usage.py, wp_import.py, repoint.sh |
server/ |
everything installed on DreamHost: fw, gen.py (page generator), issues.py, comments.py, blog.py, blog_watch.py, deploys.py, runner-api, hooks, jobs, site templates |
runner/ |
the CI runner (fw-runner.py), its installers and the screenshot script |
vite/ |
the vite plugin for apps (fuzzyworld-tools/vite) |
vscode-web/ |
VS Code in the browser: installer and the fw-fs extension |
tests/ |
the test suite |
Where things live on DreamHost: docs/operations.md.
Security in one paragraph
The site never executes anything from a repo: pages are pre-generated HTML with all repo content escaped, only
image files are copied raw, scripts are denied by .htaccess, and the code site sends
Content-Security-Policy: script-src 'none'. The full list is in docs/code-site.md.
Documentation
| File | Covers |
|---|---|
| docs/code-site.md | page URLs, branches, licences, local preview, security |
| docs/apps.md | package.json keys, fw deploy, the vite plugin, the apps index, screenshots |
| docs/ci.md | queue, runners, .fw-ci.yml, secrets, watching CI |
| docs/issues.md | bug reports by email: modes, privacy rules, run records, replay links |
| docs/admin.md | fw admin: security model and pages |
| docs/blog.md | fw blog, comments by email, the watcher, the WordPress import |
| docs/usage.md | Claude Code usage trailers, cost estimates, usage charts |
| docs/operations.md | DreamHost layout, scheduled jobs, installing and updating, VS Code in the browser, requirements |
| PLAN.md | roadmap, decisions, backlog |
Tests
python3 -m unittest discover tests # the whole suite: pages, blog, comments, issues, runner, admin, deploys
node --test tests/deploy.test.mjs # fw deploy's config, rsync filters and replay-template checks
Known gaps
fw deploysis documented above butbin/fwon the laptop rejects it ("unknown command").fw adminis not affected.- The npm package does not ship
server/(filesinpackage.json), butfw blog(includingimport-wpandcheck) needs it, andfw usage --trailersreadsserver/prices.json(without it the trailers lose the@over100ksplit). So these work fully only from a checkout of this repo.
Other problems found are tracked in the global queue, not here.