Status: plan drafted, awaiting review
Driver: a bare git clone + composer install does not produce a
working Scriptor install today. The schema is auto-created on first
boot (iManager's DefaultBootstrap runs SchemaManager::migrate()),
but no rows are inserted, so the first frontend request throws:
RuntimeException: Category with slug "pages" not found in the
iManager database
at boot/Frontend/PageRepository.php:34
The Pages category, its fields, and the admin user only exist
inside docker/seed-demo.sql, which the Docker entrypoint replays on
first start. Outside Docker, there is no documented or supported
path. This plan introduces bin/scriptor install, a CLI-only,
idempotent initial-setup command, and retires the SQL-dump-as-seed
pattern in favour of a single source of truth.
Implementation lives in this repo under
boot/Cli/(new) plus thebin/scriptorentry point. iManager remains untouched: the install command consumes existing iManager primitives (CategoryRepository::ensure(),FieldRepository::ensure(),ItemRepository::save()) rather than adding Scriptor-specific knowledge to the library.
A first-time Scriptor operator can run:
git clone git@github.com:bigin/Scriptor.git
cd Scriptor
composer install
php bin/scriptor installand end up with a working / plus a working /editor/auth/,
without copying SQL files around. The install command must be safe
to run on an existing install (refuses to overwrite), must not be
reachable over HTTP, and must not ship a default admin password.
- Schema migrations. iManager owns its schema and already auto-
applies migrations during boot (
DefaultBootstrap::boot()→SchemaManager::migrate()).bin/scriptor installonly seeds data; it does not touch DDL. - A web-based setup wizard. No
public/install.php, no first- run modal in/editor/. CLI only. Cf. §4. - Multi-tenant or per-site config. Single Scriptor install per DB, as today.
- Plugin-managed install steps. Plugins do not get a setup hook in this PR. If a plugin needs setup, it ships its own CLI.
- Reinstall / reset. Out of scope for v1. To reset, the
operator deletes
data/imanager.dband re-runs the command.
| Path | Today | Outcome |
|---|---|---|
composer install + browser |
iManager auto-migrates schema only | 500 on / (missing Pages category), no admin user, login impossible |
docker compose up -d --build |
docker/entrypoint.sh runs sqlite3 < docker/seed-demo.sql if no DB exists |
Pages + Users + admin user + 1 example Home page, demo password gT5nLazzyBob |
Shared hosting (docs/install-shared-hosting.md) |
Same as composer install path, no seed step documented | Same 500 |
The seed dump is a working snapshot but has three problems:
- It encodes Scriptor's data shape in
.sqlinstead of code, so schema drift between dump and live install is invisible until a migration breaks. - It hard-codes a known admin password, fine for the Docker demo, dangerous if any operator runs the same SQL on a public host.
- There is no non-Docker setup path. The Build-a-Theme tutorial (Phase D) implicitly assumes a running Scriptor install, but the README does not say how to get one without Docker.
iManager is a library. It must not know about Scriptor's Pages
category convention, the slug/parent/pagetype/menu_title/...
field shape, or what an admin user looks like in this app. Putting
that knowledge into iManager violates the one-way dependency
(Scriptor → iManager) the project deliberately maintains.
Anything iManager could legitimately do here is generic ("create a
category with these N fields"), and iManager already exposes that
through CategoryRepository::ensure() / FieldRepository::ensure()
since 2.1.0. The Scriptor-shaped composition belongs in Scriptor.
-
Connect to the same iManager container the request lifecycle uses (
ImanagerBootstrap::create(__DIR__)), so the same migrations run that a request would have run. -
Refuse to proceed if
findBySlug('pages')returns non-null (already installed, abort with a clear message). Cf. §4. -
ensure()the Pages category + its 9 fields, exactly the set indocker/seed-demo.sqlso the editor's existing forms keep working without changes:slug, parent, pagetype, menu_title, content, template, images, summary, position -
ensure()the Users category + its 4 fields:username, email, password, role. -
Read the admin password from one of three sources (precedence in order):
--password=...CLI flag,SCRIPTOR_ADMIN_PASSWORDenvironment variable,- Interactive prompt (TTY-only) with confirmation.
Reject passwords shorter than 12 characters or in a small blacklist (
admin,password,gT5nLazzyBob). -
Hash via
password_hash($password, PASSWORD_BCRYPT)and store asImanager\PasswordFieldValueJSON, the shape the live editor already reads. -
Create a minimal Home page:
slug=home,template=home,position=1,name=Home,content=<welcome blurb>. This makes/render immediately so a first-time user sees the site work, rather than a 404 on an empty page tree. -
Print a summary block: the admin username, the editor URL, and a reminder that the password was not echoed.
Auto-installers have historically been a soft spot in PHP CMS ecosystems. Two failure modes account for most of the well-known incidents:
- Web-reachable installer that does not lock itself after first run. Attacker triggers a second run, overwriting admin credentials or the database.
- Default credentials in the seed (
admin/admin,admin/password, …) that the operator forgot to change before the box was indexed.
The plan addresses both, plus four secondary patterns.
| Rule | Mechanism in code |
|---|---|
| G1. CLI only. The install command never executes from a request. | First statement of the command: if (\PHP_SAPI !== 'cli') { exit(1); }. Belt-and-suspenders: bin/scriptor is outside public/, so the webroot does not expose it even if \PHP_SAPI were spoofed. |
| G2. Idempotent via real-state check, not a lock-file. A re-run never overwrites. | CategoryRepository::findBySlug('pages') is the gate. If it returns non-null, the command aborts with: "Scriptor already installed. To start over, delete data/imanager.db explicitly." No .installed marker (which an attacker could remove). |
| G3. No default password. | Password is mandatory, read from flag / env / TTY prompt. There is no fallback. |
| G4. Password complexity baseline. | Minimum length 12. Hard-coded blacklist of the obvious defaults including this project's old demo password. Rejected with a clear error. |
| G5. Explicit confirmation step. | TTY-only prompt: "About to seed <absolute DB path>. Type INSTALL to proceed." Prevents reflex <Enter> on the wrong shell. The --yes flag is the only way to skip this, for CI / Docker. |
| G6. Single seed for both paths. | The Docker entrypoint shells to bin/scriptor install with --password "$SCRIPTOR_ADMIN_PASSWORD" and --yes. docker/seed-demo.sql is retired in a follow-up PR. One code path = one audit surface. |
- Shell access on the host. If an attacker has shell access, they can read the DB directly; install is not the weakest link.
- Plain HTTP between operator and the box during password prompt. Out of scope; the operator should run the command locally on the host, not over an untrusted SSH session. (Though SSH itself is encrypted, the message is: do not paste passwords through brittle channels.)
- Compromised composer dependencies. Lock-file pinning is the defence there, not this CLI.
php bin/scriptor install [options]
Options:
--password=<value> Admin password. Overrides SCRIPTOR_ADMIN_PASSWORD env.
--username=<name> Admin username (default: admin).
--email=<addr> Admin email (default: admin@example.com).
--yes Skip the "type INSTALL to proceed" confirmation.
Required when stdin is not a TTY (CI, Docker).
--db=<path> Override database path (default: data/imanager.db).
--help Print this message.
0success1already installed (G2)2invalid password (G3 / G4)3non-CLI invocation rejected (G1)4unexpected error (any uncaught exception during seed)
$ php bin/scriptor install
About to seed /var/www/Scriptor/data/imanager.db.
Type INSTALL to proceed: INSTALL
Enter admin password (12+ chars):
Confirm admin password:
[1/4] Pages category + 9 fields created.
[2/4] Users category + 4 fields created.
[3/4] Admin user 'admin' created.
[4/4] Home page seeded (slug=home, template=home).
Scriptor is ready.
Frontend: http://your-host/
Editor: http://your-host/editor/
Admin user: admin
bin/scriptor is structured to grow more subcommands later:
bin/scriptor user:add, bin/scriptor user:passwd,
bin/scriptor backup, bin/scriptor cache:clear. The dispatcher
is a simple switch in bin/scriptor; no Symfony Console dependency
is added until the surface justifies it.
| Path | Purpose |
|---|---|
bin/scriptor |
Executable PHP entry. Argv parsing, subcommand dispatch, exit codes. ~80 lines. |
boot/Cli/InstallCommand.php |
The actual install logic. Pure PHP class, no Symfony Console. ~250 lines. |
boot/Cli/PasswordPrompt.php |
TTY password reader (stty -echo), confirmation, blacklist check. ~80 lines. |
tests/Cli/InstallCommandTest.php |
Unit + integration tests (cf. §8). |
docs/install.md |
New: setup walkthrough (composer install + bin/scriptor install). README links here. |
| Path | Change |
|---|---|
README.md |
Add the php bin/scriptor install step in the Installation section. Drop the misleading "creates the DB on first request" sentence; replace with a pointer to docs/install.md. |
composer.json |
Add "bin": ["bin/scriptor"]. Lets Composer mark it executable when Scriptor is consumed as a Composer package. |
.gitignore |
No change. data/imanager.db already ignored. |
| Path | Reason |
|---|---|
docker/seed-demo.sql |
Replaced by bin/scriptor install --yes --password=$SCRIPTOR_ADMIN_PASSWORD in the entrypoint. |
docker/seed-demo-uploads.tar.gz |
Same. The uploads tarball stays only as long as the Docker demo wants to ship example images; if it does, the entrypoint runs a separate bin/scriptor demo:seed-uploads step (designed but out of scope here). |
docker/entrypoint.sh (seed branch only) |
Re-pointed at the CLI. |
The retirement PR ships after the install CLI is merged + smoked in the demo container, so we never leave the Docker setup in a half-migrated state.
Tempting to split into "v1: Scriptor CLI seeds data" and "v2: Docker entrypoint uses CLI". But:
- The Docker entrypoint is the only existing call site of the seed
artefact. Leaving
docker/seed-demo.sqlalive after the CLI lands means schema drift can still hide there. - Tests for the install command (§8) exercise the same surface the Docker entrypoint hits, so retiring the SQL has no extra QA cost.
That said, the retirement of the SQL dump is a follow-up PR, not part of the install-CLI PR itself. Splitting on that boundary because the dump change touches the demo image, which has its own smoke + tag cycle.
InstallCommandrefuses non-CLI invocation (mocks\PHP_SAPI).InstallCommandrefuses to run when Pages category exists.InstallCommandrejects passwords < 12 chars.InstallCommandrejects blacklisted passwords (gT5nLazzyBob,admin, …).PasswordPromptreads from--password, falls back to env, then to TTY prompt; rejects mismatched confirmation.- Successful run creates Pages with 9 fields, Users with 4 fields,
1 admin user, 1 Home page. Field config matches
seed-demo.sqlbyte for byte where applicable.
A new test under tests/Cli/InstallCommandIntegrationTest.php:
- Boots a fresh container against a temp SQLite file.
- Runs
InstallCommandprogrammatically with a fixed password. - Boots a
Siteinstance against the same DB. VerifiesPageRepositoryresolves Home,findActiveByParent(0)returns one page, and a synthetic login attempt against the seeded admin credentials succeeds via iManager's authentication path.
| Step | Expected |
|---|---|
Fresh clone of Scriptor, composer install, php bin/scriptor install (interactive). |
Pages + Users + admin + Home created; / returns 200 with "Home" rendered. |
| Re-run the command on the same DB. | Exits with code 1 and the "already installed" message. No DB mutation. |
php bin/scriptor install --password=short. |
Exits with code 2, error printed. |
cat foo.txt | php bin/scriptor install (no TTY, no --yes). |
Exits with code 4 (cannot confirm), prints hint about --yes. |
docker compose up -d --build (after the follow-up PR). |
Container boots, demo seed runs through the CLI, the same login works. |
These are explicitly deferred unless a reviewer pushes back:
- Symfony Console vs. hand-rolled dispatch. Today's iManager
CLI uses Symfony Console. Adding it as a Scriptor dependency for
one command feels heavy; the hand-rolled switch in
bin/scriptorstays simple until the subcommand list grows past three or four. Revisit whenuser:addandcache:clearland. - Should
bin/scriptor installalso accept a config file likeinstall.ymlso a CI pipeline can declaratively describe the target state? Postpone; the env-var / flag pattern is enough for the use cases we have today, and YAML adds a parsing surface. - What happens on the shared-hosting setup where the operator
cannot run a shell command on the box?
docs/install-shared-hosting.mdcurrently assumes the operator can at least upload + curl-trigger a script. The install CLI itself works fine from a CGI/CLI hybrid if the host allowsphp bin/scriptor installthrough their SSH panel. If they cannot, we revisit a web installer with the same security guarantees applied (lock-file + IP-token + delete-after-run). Marking out of scope here. - i18n for prompts. All prompt strings are English in v1.
Editor language strings live under
editor/lang/*.php; the install CLI does not pull from there yet. Trivial to add later if needed.
| PR | Title | Scope |
|---|---|---|
| 1 (this) | docs(plan): scriptor install CLI design |
This document only. |
| 2 | feat(cli): bin/scriptor install + InstallCommand |
All of §6 except the Docker entrypoint. |
| 3 | chore(docker): seed via bin/scriptor instead of seed-demo.sql |
Switch entrypoint, retire SQL dump, retag demo image. |
| 4 | docs(install): rewrite install.md + README pointer |
If §6's README/docs change has not landed in PR 2; otherwise skip. |
PR 2 is the load-bearing one; 3 and 4 are mechanical follow-ups.