Skip to content

feat: S2 — local and single-site production installer - #281

Draft
acburdine wants to merge 5 commits into
claude/s1-docker-compose-foundation-b1e9b9from
claude/s2-installer
Draft

feat: S2 — local and single-site production installer#281
acburdine wants to merge 5 commits into
claude/s1-docker-compose-foundation-b1e9b9from
claude/s2-installer

Conversation

@acburdine

@acburdine acburdine commented Sep 3, 2026

Copy link
Copy Markdown
Member

Implements S2 from docs/ghost-cli-replacement.md (§2.8, §2.2, §2.10): a release-selecting bootstrap and a checkout-owned installer for fresh local and single-site production installs. Stacked on S1 (#280).

What landed

  • bootstrap.sh — curl-able shim, bootstrap logic only. Resolves the newest release on a channel by full semver order (prereleases included), clones at that tag, and execs that checkout's installer.
  • install.sh — checkout-owned; one checkout is one site. Exact Ghost version resolution (asks the pulled image for GHOST_VERSION/GHOST_CONTENT/GHOST_INSTALL, records the digest), stable project identity, private .env/ghost.env, generated passwords, mode-aware preflight, free-port selection (busy explicit port is an error), optional analytics/activitypub setup, Caddy rendering, readiness + ingress verification, useful summary. Honors --no-prompt/--no-start; prompts read /dev/tty with a flag equivalent for every required input. --import/supervisor/infra flags exit 3 naming the step, never as unknown options.
  • .ghost-docker.json reader/writer (scripts/lib/meta.sh) — S1 deferred this here as its first writer. Schema v1 per §2.2; a missing file is "pre-metadata", not broken.
  • scripts/lib/preflight.sh — host-shell preflight/doctor primitives that work when Docker is missing, stopped, or wedged (bounded probes).
  • scripts/lib/install.sh — identity, secrets, image resolution, /dev/tty prompts, readiness/ingress verification.
  • scripts/site.shlist, check/doctor, info.
  • Never stops or reconfigures an existing proxy: a port conflict (incl. 80/443) fails clearly, leaving the operator's proxy running.

Verification

  • tests/install-e2e.test.mjs (GD_TEST_INSTALL=1): 21/21 against real containers — local via bootstrap, two independent local sites running together, explicit port conflict, existing proxy on 80/443, production HTTPS through Caddy, --no-start, and an install with a PATH of only the recorded minimum tools.
  • Fast suite 134/134; shellcheck clean.

Two real bugs surfaced and fixed in the follow-up commit: a set -e abort from S1's ((n++)) on the first multi-line env write, and production verification failing on Ghost's by-design http→https loopback redirect. See the commit body.

Contract change

Amends §2.10: when the manager image lands (S4, first tenant backup/restore) and why env validation, Caddy generation, preflight and version resolution stay host shell.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@acburdine acburdine changed the title claude/s2 installer feat: S2 — local and single-site production installer Sep 3, 2026
acburdine and others added 4 commits September 3, 2026 13:32
Implements S2 of docs/ghost-cli-replacement.md: a release-selecting bootstrap
and a checkout-owned installer, plus the `.ghost-docker.json` reader and writer
that S1 deferred to this step.

- bootstrap.sh selects a release in semver order (never lexically), clones it,
  and execs that checkout's install.sh. Bootstrap logic only.
- install.sh installs into its own directory: mode-aware preflight, stable
  project identity, generated passwords, exact Ghost version resolution from the
  image's own GHOST_VERSION/GHOST_CONTENT/GHOST_INSTALL, private config files,
  Caddy rendering, readiness verification through the site's real ingress, and a
  final summary.
- Installation never stops or reconfigures anything already running. A chosen
  port moves out of the way; an explicitly requested busy port, or an occupied
  80/443 in production, is an error naming what holds it.
- Prompts read /dev/tty and every required input has a flag or environment
  variable, so --no-prompt is fully scriptable. --no-start starts nothing.
- Options for steps that have not landed (--import, --with supervisor,
  --image-registry, --ghost-channel, --without) exit 3 naming the step rather
  than being reported as unknown options.
- scripts/lib/meta.sh reads and writes .ghost-docker.json at schema v1, refuses
  a newer schema rather than misreading it, and treats a missing file as a
  pre-metadata install rather than a broken site.
- scripts/site.sh adds list, check/doctor and info. Doctor degrades to useful
  host-level output when Docker is unreachable.
- Read-only Docker probes have deadlines: a wedged daemon answers nothing rather
  than returning an error, and the check that exists to report that must not be
  able to hang on it.
- Host tool contract recorded in GD_HOST_UTILITIES and enforced by an install
  run with a PATH built from exactly that list. Docker access is established by
  asking the daemon, never from docker-group membership.

Adds tests/meta.test.mjs, tests/install.test.mjs (no daemon needed) and
tests/install-e2e.test.mjs, which installs local and production sites from a
candidate release built out of the working tree, runs two independent local
sites, and covers port conflicts, an existing proxy, --no-start and --no-prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reconciliation

Surfaced by the S2 install e2e suite (now 21/21 against real containers).

- env.sh/compose.sh: `((n++))`/`((count++))` return exit 1 on the first
  iteration (post-increment from 0), aborting under `set -e`. install.sh is the
  first `set -e` caller to write a multi-line env file, so this latent S1 bug
  only surfaced now. Use `n=$((n + 1))`. Without this, every install died at
  "Writing configuration".
- install_verify_ingress: a plain http probe straight to the loopback Ghost is
  301-redirected to HTTPS by design in production (canonical URL is https), so
  the direct-loopback check now accepts a 3xx there and sends the site's Host
  header. Caddy's own checks remain the authoritative production verification.
  Without this, every production install failed its final verification.
- install.sh: a release and its prerelease can share a commit, so
  `git describe --exact-match` can report a different tag than the one bootstrap
  pinned. Accept --ref when it is among `git tag --points-at HEAD`.
- preflight.sh: record `sysctl` in the host-utility contract (macOS memory probe).

Test harness:
- Central teardown so two local sites stay running together for the
  simultaneous-answer and distinct-port assertions; no site is downed while a
  sibling suite still asserts.
- makeCandidateRelease tags each release on its own commit and exposes a file://
  URL, so shallow clone is honoured without the local-clone warning and
  `git describe` is unambiguous.
- HTTP-redirect check drives the installer's /dev/tcp helper, since Node fetch
  forbids overriding Host and Caddy would otherwise redirect to 127.0.0.1.
- minimum-tools wraps docker/jq so their own subprocess needs run with the full
  PATH while the shell's coreutil lookups stay restricted to the contract.
- realpathSync for the recorded site.dir (macOS /var -> /private/var).

docs/ghost-cli-replacement.md: amend §2.10 with when the manager image lands
(S4, first tenant backup/restore) and why env/caddy/preflight stay host shell;
note the deferred operation lock and the manager-image timing in the S4 step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CLI at S4

Corrects the earlier "env/caddy never move into the image" framing. The
dividing line is daemon-dependency, not config-vs-stateful:

- Move onto the manager CLI at S4 (image + dispatcher exist there anyway):
  env.sh, meta.sh, and caddy_render — pure functions of on-disk files, a
  straight bash-deletion substitution.
- Stay host shell permanently: bootstrap shim, preflight, site.sh check/doctor
  (must run without the image), and config validate + caddy validate/reload/
  verify (they derive answers by asking `docker compose config`; moving them
  means bundling the Docker CLI in the manager or reimplementing Compose
  interpolation — the drift "ask Compose" exists to avoid).

Records the accepted split (config get/set in the CLI, validate host shell) and
why the move waits for S4 rather than S2: net line count is a wash once the
Dockerfile, publish pipeline and privileged dispatcher are counted, so it only
pays off riding a PR that builds them anyway. Updates the S4 step accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's shellcheck (Ubuntu 0.9.0) flags what local 0.11 does not:
- site.sh: `(($#)) && shift || true` (SC2015) -> `if (($#)); then shift; fi`
- docker_responsive: its SECONDS arg is optional and bare calls are the norm,
  so silence SC2120/SC2119 at the definition rather than every call site.

Verified with koalaman/shellcheck:v0.9.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On the Linux CI runner, `docker info --format '{{.ServerVersion}}'` prints
"Cannot connect to the Docker daemon ..." and still exits 0, so preflight_docker
took the version path and reported the connect-error string as an old engine
version instead of "docker daemon not reachable".

Decide reachability from the output: the server version is a dotted number when
the daemon answers, so extract it and treat its absence as unreachable, whatever
the exit code. Keeps the timeout (124) path for a wedged daemon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant