# Installing Convoro without a shell

2026-08-07. The blocker named in `HANDOFF-PC.md`, given a design.

The requirement, restated and unchanged since it was first said:

> Upload a zip to shared hosting, run a web installer. **No Composer at all.**
> Not really needing CLI, because people on shared hosting may have no shell.
> It must install like Invision, XenForo, phpBB, MyBB, SMF — **not** like
> Flarum or Kopling.

---

## What is actually missing

Composer is fine — there is none, and the autoloader is ours. Everything else
is not:

- **There is no installer.** `install/` was three empty directories on the Mac;
  git does not track empty directories, so on this machine it does not exist at
  all. Nothing in `public/index.php` or `Kernel` routes anywhere near one.
- **Two commands block outright.** `migrate` creates the schema — and runs
  again on **every upgrade**, which is the half people forget. `key:generate`
  writes `APP_KEY`, without which there are no sessions.
- **Four more have no web path**: `mail:send`, `privacy:export`,
  `privacy:prune`, `update:check`. Most shared hosts offer cron through a
  control panel, so these are softer — but a forum that silently never sends
  email is indistinguishable from a broken one.
- **The repo is not the release.** No build produces an uploadable artifact.

## The shape

`public/index.php` boots the kernel and dispatches. The installer is not a
separate application bolted beside it — it is a **state the kernel can be in**,
because a second entry point means a second bootstrap, a second autoloader
path, and two ways for a half-installed site to behave.

```
request -> Autoload -> Convoro::boot -> is this site installed?
                                          no  -> installer routes only
                                          yes -> normal routing
```

**Installed is defined as: `.env` exists, and the `migrations` table reports at
least one applied migration.** Either alone lies — a `.env` can be uploaded
from another site, and a schema can exist because somebody pointed a config at
a database by hand. Together they mean somebody completed an install here.

🚨 **`APP_KEY` is deliberately not part of it**, though the first draft of this
note said it was. Ernest asked why Convoro needs a key when Invision and
XenForo do not appear to, and the question found a real bug.

They *do* have one — IPS generates salts into `conf_global.php`, XenForo a
`globalSalt`, WordPress `AUTH_KEY` and friends. Nobody ever sees it, which is
the actual requirement: it is generated silently and never mentioned again.
That much this design already did.

What it got wrong was the check. Gating "installed" on the key means a live
forum whose key got blanked by a botched `.env` edit begins serving **the
installer**, on a site full of real data — the exact takeover this check exists
to prevent, reached from the other direction. And nothing is gained: `Crypt`
refuses to encrypt without a key, and its only consumer, two-factor, asks
`usable()` first and reports itself unavailable. A missing key is a degraded
feature that explains itself, which is what it should be.

That check runs on every request forever, so it is one cached boolean and never
a query once installed.

## The steps

1. **Requirements.** PHP 8.5, the five extensions, and whether `content/` and
   `storage/` are writable. Failures are shown together — a check that reveals
   one problem per reload is a bad afternoon on a slow host.
2. **Database.** Host, name, user, password, prefix. Tested before it is
   written, and the error is the real one: "unknown database" and "access
   denied" send a person to different places, so mapping both to "could not
   connect" is a cost, not a kindness.
3. **Write `.env` and generate the key.** Failing here is the most likely
   failure on shared hosting, so it offers the file contents to paste manually
   rather than dead-ending.
4. **Migrate**, showing progress. This is `migrate` behind a button.
5. **The first administrator**, and the site name.
6. **🚨 Lock itself.** A re-runnable installer on a live site is a way to take
   it over.

## Locking, which is the part worth getting right

**The lock is the installed check itself**, not a flag the installer sets.
Once `.env`, key and schema are all present, the installer's routes stop
existing — so a lock file that got deleted, or a flag somebody flipped, does
not reopen it. There is no separate thing to forget.

Deleting the installer directory is offered afterwards as tidiness, not as the
security control. Security that depends on a person doing a manual cleanup step
is security that fails on the sites least able to afford it.

## Upgrading, which is the half people forget

The same problem one version later: a person uploads a new zip over their site
and the schema is now behind. `migrate` is a shell command they do not have.

So: the admin panel notices pending migrations — it already knows how, since
`migrate:status` answers exactly this — and offers to run them, with the same
progress the installer shows. This happens **every release**, not once, which
is why it belongs in this note rather than a later one.

🚨 A site mid-upgrade must not serve half a schema. Migrations run behind a
maintenance response for everyone except the administrator running them.

## Cron, and the outage it caused before

Real cron stays the recommended path. Where there is none, a pseudo-cron fired
by page views is the traditional answer and the right one here.

🚨 **Tasks running inside visitor requests took a live site down once.** The
remote end got slow, and every request that happened to run the queue got slow
with it. The lesson was **cap every outbound call**, not "never do it" — the
pattern is right for shared hosting, the timeouts are what matter. Read
`docs/design/2026-08-06-in-admin-updates.md` before building it.

Three rules, then:

- Every outbound call has a timeout, and the runner has a wall-clock budget it
  will not exceed regardless of what is queued.
- A task that overruns is abandoned, not retried in the same request.
- The response is already sent before the runner starts, so a slow task cannot
  make a page slow.

## The release zip

`tools/build-release` produces what a person uploads: everything except
`tests/`, `examples/`, `node_modules/`, `.git`, and the Docker files — with
`.env.example` and never `.env`. 🚨 The zip is what the anti-tamper story rests
on later, and it is much cheaper to get the contents right now than to explain
a leaked `.env` afterwards.

## Order

1. The installed check, and the kernel state that follows from it.
2. Steps 1–5, in order, each verified on a fresh database.
3. The upgrader, because it is needed by the next release and not the one
   after.
4. `tools/build-release`.
5. Pseudo-cron, last, because it is the only part with a live outage behind it
   and the only one where being slow to ship costs nothing.

## Not decided here

- **Whether the installer can create the database**, as against requiring one.
  Most shared hosts create it in their own panel and the credentials arrive
  ready; asking for `CREATE DATABASE` rights to save one step is a poor trade.
- **Multi-language installs.** The installer is the one screen a person cannot
  reach the language switcher from, and that needs its own thinking.
