diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index d239758b..18efb185 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -145,7 +145,7 @@ jobs: run: | set -euo pipefail site=$(mktemp -d) - curl -fsSL "https://$DOMAIN/install.sh" | bash -s -- --dir "$site" install --local --no-prompt + curl -fsSL "https://$DOMAIN/install.sh" | bash -s -- --dir "$site" --local --no-prompt version=$(jq -r .stack.version "$site/.ghost-docker.json") channel=$(jq -r .channel "$site/.ghost-docker.json") [[ $version == "$TAG" && $channel == beta ]] || { diff --git a/README.md b/README.md index 34fe068b..441d80f4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Configuration to run Ghost and its services with Docker Compose. ```sh # Linux, macOS, and Windows through WSL2; or --domain example.com --email ops@example.com -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local ./ghost-docker check ./ghost-docker self-update ``` diff --git a/docs/architecture.md b/docs/architecture.md index 58601651..16a3e48e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,6 +59,7 @@ use native clients; bulk database operations use version-matched MySQL tools. would refer to a different machine. - External import bundles and restore backups are mounted read-only at their own paths. Prompts use the terminal even when the launcher is piped from curl. +- Piped from curl with no command word, the launcher runs `install`. - `doctor` writes a file and reads it through a sibling container mounting the same host path. Seeing the directory inside the manager alone cannot establish that the daemon sees the same files. diff --git a/docs/install.md b/docs/install.md index 138f7bfe..3d9c3636 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2,13 +2,17 @@ ```bash mkdir ~/my-site && cd ~/my-site -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local # or: --domain example.com +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local # or: --domain example.com ``` `install.sh` is the launcher, `ghost-docker`, from the newest release. Piped -into bash it installs the newest release on the `beta` channel; `install` -writes a copy of it into the site, and every later command is -`./ghost-docker ...` from there. +into bash with no command, it runs `install` with the options given, from the +newest release on the `beta` channel. As with Ghost-CLI's `ghost install`, a +site is a production one unless `--local` is given: plain `curl -fsSL +https://docker.ghost.org/install.sh | bash` asks for its domain. `install` +writes a copy of the launcher into the site, and every later command is +`./ghost-docker ...` from there. Other commands can still be piped by name: +`bash -s -- self-update`. ## Releases @@ -26,7 +30,7 @@ a beta. | The development image | `GD_CHANNEL=edge` | `edge`, built from every push to `next-docker`. Not a release. | These go to the launcher with the command: `curl -fsSL -https://docker.ghost.org/install.sh | bash -s -- install --local --release +https://docker.ghost.org/install.sh | bash -s -- --local --release v0.1.0-beta.1`. A site always runs one image digest, never a channel: `install` pins the site's launcher to the digest it ran as, and records the channel the site follows, which `self-update` uses. @@ -61,7 +65,7 @@ ghost-docker install --import BUNDLE [--domain DOMAIN] [--admin-domain DOMAIN] [ | Option | Meaning | | --- | --- | | `--local` | Ghost and MySQL, published on `127.0.0.1:PORT`. `NODE_ENV=development`, `RESTART_POLICY=no`. | -| `--domain DOMAIN` | Production: Ghost, MySQL and Caddy with HTTPS on that domain. | +| `--domain DOMAIN` | The production site's domain: Ghost, MySQL and Caddy with HTTPS on it. Production is the default; asked for at a terminal when omitted. | | `--admin-domain DOMAIN` | A separate Ghost Admin domain. Production only. | | `--email EMAIL` | The ACME account email. Production only; see below. | | `--port PORT` | The loopback port Ghost is published on, in both modes. Omitted: the first at or above 2368 that is free (see [Ports](#ports-and-your-existing-proxy)). | @@ -73,11 +77,11 @@ ghost-docker install --import BUNDLE [--domain DOMAIN] [--admin-domain DOMAIN] [ | `--no-start` | Write the configuration and routes; create no containers. | | `--import BUNDLE` | Import a local or production Ghost-CLI site from the bundle `ghost migrate-export` made; see [Importing a Ghost-CLI site](#importing-a-ghost-cli-site). | -With neither `--local` nor `--domain`, `install` asks which kind of site, and -for a production site its domain. It asks only at a terminal, including when -the launcher was piped from curl. Without one, or with `--no-prompt`, a missing -answer is a usage error naming the option that supplies it; nothing has a -silent default. Every question has an option, so a script never needs to answer +Without `--local`, the site is a production one, as with Ghost-CLI's +`ghost install`, and `install` asks for the domain `--domain` would give. It +asks only at a terminal, including when the launcher was piped from curl. +Without one, or with `--no-prompt`, a missing answer is a usage error naming +the option that supplies it. Every question has an option, so a script never needs to answer one. Exit statuses: `0` installed, `1` failed, `2` a usage error. Options the plan @@ -341,7 +345,7 @@ cd ~/sites/my-blog ghost stop ghost migrate-export --output ../my-blog-bundle mkdir ../my-blog-docker && cd ../my-blog-docker -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ../my-blog-bundle --port 2368 +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ../my-blog-bundle --port 2368 ``` - **Stop first.** The exporter stops a running site while it copies, and @@ -399,7 +403,7 @@ ghost stop ghost migrate-export --output ~/my-site-bundle sudo systemctl stop nginx mkdir ~/my-site && cd ~/my-site -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle ``` - **Stop nginx yourself.** Nothing in the import stops it; it may serve other @@ -426,7 +430,7 @@ scp ~/my-site-bundle.tgz new-host: # On the new host mkdir ~/my-site && cd ~/my-site -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle.tgz +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle.tgz ``` Then point the domain's DNS at the new host. Until it moves, `check` reports @@ -448,7 +452,7 @@ domain of its own (the source's admin domain is not carried to it; name the copy's own with `--admin-domain` if it needs one): ```bash -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle.tgz --domain staging.example.com --no-start +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle.tgz --domain staging.example.com --no-start ``` **A copied database sends real mail.** Before starting the copy, remove the diff --git a/ghost-docker b/ghost-docker index 044864e8..3ee83ea3 100755 --- a/ghost-docker +++ b/ghost-docker @@ -3,6 +3,9 @@ # # ghost-docker [--dir PATH] [options] # ghost-docker help +# curl -fsSL https://docker.ghost.org/install.sh | bash -s -- [install options] +# +# Piped into bash with no command, the command is `install`. # # Everything ghost-docker does happens in a container, the manager image. This # script is the only part that runs on the host, and it does three things: @@ -139,6 +142,22 @@ while (($#)); do shift done +# Empty when this script is being read from a pipe (`curl ... | bash`): there is +# no file, so there is no checkout to find beside it. +script_dir="" +if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then + script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +fi + +# Piped with no command, it installs: `curl ... | bash` and +# `curl ... | bash -s -- --local` mean `install`, the only command that has no +# site's own copy of this script to run instead. +if [[ -z $script_dir && -z $command_word ]]; then + command_word=install + args=(install ${args[@]+"${args[@]}"}) + [[ -z $bundle_at ]] || bundle_at=$((bundle_at + 1)) +fi + # Only the release formats that are published; the manager checks them again. case $channel in '' | stable | beta) ;; @@ -241,12 +260,6 @@ fi # --- Which image ------------------------------------------------------------ -# Empty when this script is being read from a pipe (`curl ... | bash`): there is -# no file, so there is no checkout to find beside it. -script_dir="" -if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then - script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) -fi source_kind=image # A channel is a tag that moves, and `docker run` would otherwise keep using # whichever copy of it this host pulled first. Docker's progress goes to diff --git a/manager/src/commands/install.ts b/manager/src/commands/install.ts index b0befc22..859590f9 100644 --- a/manager/src/commands/install.ts +++ b/manager/src/commands/install.ts @@ -123,10 +123,14 @@ const hostname = z const options = z .object({ - local: flag('A local site: Ghost and MySQL on 127.0.0.1:PORT.'), + local: flag( + 'A local site: Ghost and MySQL on 127.0.0.1:PORT. Without it, the site is a production one.', + ), domain: hostname .optional() - .describe('A production site on this domain: Ghost, MySQL and Caddy with HTTPS.'), + .describe( + "The production site's domain: Ghost, MySQL and Caddy with HTTPS. Asked for at a terminal.", + ), adminDomain: hostname .optional() .describe('Serve Ghost Admin on a separate domain. Production only.'), @@ -202,24 +206,17 @@ interface Plan { /** What the options ask for, and at a terminal, what they leave out. */ async function plan(flags: Flags, prompt: Prompter | null): Promise { const ask = flags.noPrompt ? null : prompt; - let mode: SiteMode | null = flags.local - ? 'local' - : flags.domain !== undefined - ? 'production' - : null; - if (mode === null) { - if (ask === null) { - throw new UsageError('choose a site mode: --local, or --domain example.com'); - } - mode = await ask.choose('What kind of site?', [ - { name: 'Local: for themes and trying Ghost, on 127.0.0.1', value: 'local' }, - { name: 'Production: on a domain, with HTTPS', value: 'production' }, - ]); - } + // As Ghost-CLI's `ghost install`: production unless --local says otherwise. + const mode: SiteMode = flags.local ? 'local' : 'production'; let domain = flags.domain ?? ''; if (mode === 'production' && domain === '') { + if (ask === null) { + throw new UsageError( + 'a production site needs --domain example.com; for a local site, use --local', + ); + } domain = ( - await ask!.text('Its domain (example.com):', (answer) => + await ask.text('Its domain (example.com):', (answer) => isHostname(answer) ? null : 'a hostname such as example.com, not a URL', ) ).toLowerCase(); diff --git a/manager/test/install.test.ts b/manager/test/install.test.ts index 7dc93c70..454f7b54 100644 --- a/manager/test/install.test.ts +++ b/manager/test/install.test.ts @@ -46,7 +46,7 @@ const siteFiles = () => describe('refusals that change nothing', () => { test('usage errors exit 2', async () => { for (const [args, message] of [ - [[], /choose a site mode: --local, or --domain example.com/], + [[], /a production site needs --domain example\.com; for a local site, use --local/], [['--local', '--domain', 'example.com'], /not both/], [['--local', '--with', 'analytics'], /set up after installation.*\n.*TINYBIRD\.md/], [['--local', '--without', 'redis'], /without/], @@ -86,10 +86,10 @@ describe('refusals that change nothing', () => { assert.deepEqual(siteFiles(), []); }); - test('at a terminal, the mode and the domain are asked for', async () => { - h.answers = ['production', 'example.com']; + test('without --local, it is a production site, and at a terminal the domain is asked for', async () => { + h.answers = ['example.com']; assert.equal((await install('--no-start')).code, 0); - assert.deepEqual(h.asked, ['What kind of site?', 'Its domain (example.com):']); + assert.deepEqual(h.asked, ['Its domain (example.com):']); assert.equal( env.get(readFileSync(join(h.dir, '.env'), 'utf8'), 'URL'), 'https://example.com', @@ -97,10 +97,10 @@ describe('refusals that change nothing', () => { }); test('--no-prompt never asks, even at a terminal', async () => { - h.answers = ['local']; + h.answers = ['example.com']; const result = await install('--no-prompt'); assert.equal(result.code, 2); - assert.match(result.stderr, /choose a site mode/); + assert.match(result.stderr, /a production site needs --domain/); assert.deepEqual(h.asked, []); }); diff --git a/pages/index.html b/pages/index.html index 4df25483..b06a0daf 100644 --- a/pages/index.html +++ b/pages/index.html @@ -76,13 +76,13 @@

Requirements

A production site

Ports 80 and 443 must be free. Make an empty directory for the site and run, from inside it:

-
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --domain example.com --email you@example.com
+
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --domain example.com --email you@example.com

Then point the domain's DNS at this host. Caddy obtains a certificate once it does, and ./ghost-docker check reports it.

A local site

For themes, and for trying Ghost, on 127.0.0.1:

mkdir ~/my-site && cd ~/my-site
-curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local
+curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local

Moving a Ghost-CLI site

A local Ghost-CLI site (one made with ghost install local) moves to Docker with its posts, members, staff accounts, settings, themes, images and configuration. You need Ghost-CLI 1.33.4 or later, and Ghost 6.61.0 or later in the site: the import runs the exact version the site did, and 6.61.0 is the first with a Docker image the import can use. Older sites are refused, so update first.

@@ -93,7 +93,7 @@

Moving a Ghost-CLI site

ghost stop ghost migrate-export --output ../my-blog-bundle mkdir ../my-blog-docker && cd ../my-blog-docker -curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ../my-blog-bundle --port 2368 +curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ../my-blog-bundle --port 2368
  • The Docker site runs the exact Ghost version the source did, at the same http://localhost:2368. Use the source's port: server.port in its config.development.json.
  • Staff sign in with their existing passwords.
  • diff --git a/tests/e2e/launcher.sh b/tests/e2e/launcher.sh index 7b340f6e..41534599 100755 --- a/tests/e2e/launcher.sh +++ b/tests/e2e/launcher.sh @@ -344,6 +344,25 @@ expect_status 0 expect_run_arg "ghcr.io/tryghost/ghost-docker:beta" ok "runs with no file of its own, and uses the published image" +mkdir -p "$WORK/piped" +: >"$WORK/piped/piped.tgz" +set +e +OUT=$(cd "$WORK" && FAKE_DOCKER=ok PATH="$FAKE:$PATH" env ${fake_socket_env[@]+"${fake_socket_env[@]}"} \ + "$BASH_BIN" -s -- --dir "$SITE" --channel stable --local --import piped/piped.tgz <"$LAUNCHER" 2>&1) +RC=$? +set -e +expect_status 0 +[[ $(run_args | tail -7) == $'ghcr.io/tryghost/ghost-docker:stable\ninstall\n--channel\nstable\n--local\n--import\n'"$WORK/piped/piped.tgz" ]] || + fail "a piped launcher with no command did not run install" "$(run_args)" +expect_run_arg "$WORK/piped/piped.tgz:$WORK/piped/piped.tgz:ro" +rm -rf "$WORK/piped" +cp "$LAUNCHER" "$WORK/no-command-launcher" +with_fake ok env ${fake_socket_env[@]+"${fake_socket_env[@]}"} GD_IMAGE=example/manager:1 \ + "$BASH_BIN" "$WORK/no-command-launcher" --dir "$SITE" --local +[[ $(run_args | tail -2) == $'example/manager:1\n--local' ]] || + fail "a launcher run from a file was given a command" "$(run_args)" +ok "with no command, piped means install, and the bundle is still mounted; a file is not given one" + step "The manager's exit status" for status in 0 1 2 3; do : >"$FAKE_CALLS"