Skip to content

Add 'ghost upgrade' command to self-upgrade the CLI#5

Merged
murrayju merged 4 commits intomainfrom
murrayju/ghost-update-command
Apr 28, 2026
Merged

Add 'ghost upgrade' command to self-upgrade the CLI#5
murrayju merged 4 commits intomainfrom
murrayju/ghost-update-command

Conversation

@murrayju
Copy link
Copy Markdown
Member

@murrayju murrayju commented Apr 20, 2026

Adds a new ghost upgrade command (alias update) that downloads the latest published release archive and replaces the currently running binary in place, using the same CloudFront-hosted artifacts (${releases_url}/latest.txt and ${releases_url}/releases/<version>/<archive>) as scripts/install.sh and scripts/install.ps1.

Behavior

  • Target version resolution: defaults to the latest release (via the existing common.CheckVersion plumbing), or --version vX.Y.Z to pin.
  • Package-manager refusal: if the binary was installed via Homebrew / apt / yum-dnf, the command refuses and points the user at the appropriate package-manager command (e.g. brew upgrade ghost). Pass --force to override — a warning is printed and the install proceeds anyway.
  • Dev-build refusal: refuses to replace a local dev build without --force, and reports which release version would be installed so the user knows what they're switching to.
  • Idempotent: short-circuits with ghost is already at version X when current == target (unless --force).
  • Fail fast: pre-flight write check on the install directory so we don't download a ~15 MB archive only to discover we lack permission.
  • Integrity: downloads the archive + its .sha256, verifies with crypto/sha256, and extracts just the ghost / ghost.exe entry using archive/tar + compress/gzip (Linux/macOS) or archive/zip (Windows). No new dependencies.

Cross-OS binary replacement

OS Mechanism
Linux / macOS Atomic os.Rename onto the current path. POSIX keeps the running inode alive via the process's open handle, so overwriting a running executable is safe and the running process keeps running off the old inode.
Windows The loader refuses to delete or overwrite a running .exe, but renaming is permitted. We move the live binary to ghost.exe.old.<unixNano>, then os.Rename the staged binary into place, with a rollback if the second rename fails. Leftover .old.* files are opportunistically cleaned up on subsequent upgrades — they stay locked until the previous process exits, after which the next ghost upgrade run can delete them.

Symlinks are resolved via filepath.EvalSymlinks first, so we touch the real binary rather than clobbering a symlink.

Tests

internal/cmd/upgrade_test.go covers:

  • --version argument validation rejects non-semver input (for both upgrade and update aliases), before any network work.
  • The new dev-build refusal, using the fact that config.Version == "dev" in the test binary.
  • The wrapped failed to check for latest version: network error.

Full end-to-end install success isn't covered in tests because it would require simulating real binary replacement of the test process itself; the extraction, checksum, and replacement helpers are pure-io functions that are easy to exercise manually.

Manual testing

Confirmed locally on darwin/arm64:

$ mkdir -p /tmp/homebrew/bin && go build -o /tmp/homebrew/bin/ghost ./cmd/ghost
$ /tmp/homebrew/bin/ghost upgrade
Error: ghost appears to have been installed via homebrew; upgrade it with:
    brew update && brew upgrade ghost
Or re-run with --force to overwrite the binary from the release archive

$ /tmp/homebrew/bin/ghost upgrade --force
Warning: ghost appears to have been installed via homebrew; overwriting from release archive because --force was set
Updating ghost dev → <latest>
Downloading …

Dev-build refusal (binary built outside of any package-managed path):

$ ./ghost upgrade
Error: ghost is a local dev build, not a released version; re-run with --force to replace it with version <latest>

Docs

  • README.md — new row in the Commands table.
  • CLAUDE.mdupgrade added to the internal/cmd/ command list.
  • docs/cli/ regenerated (new ghost_upgrade.md, updated ghost.md).

Comment thread internal/cmd/upgrade.go
Comment thread internal/cmd/upgrade.go
@murrayju murrayju force-pushed the murrayju/ghost-update-command branch 2 times, most recently from 4c16954 to a87770d Compare April 22, 2026 16:25
@murrayju murrayju requested a review from nathanjcochran April 22, 2026 16:25
Copy link
Copy Markdown
Member

@nathanjcochran nathanjcochran left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did not test, but code LGTM (though it looks like there's a merge conflict). Thanks for doing this!

Comment thread internal/cmd/update.go Outdated

cmd := &cobra.Command{
Use: "update",
Aliases: []string{"upgrade"},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are 100% sure we want to take ghost upgrade as an alias for this? Main reason I ask is because it was previously proposed as a way to upgrade a standard/free instance to a dedicated/paid instance (though this was pretty early on, before we actually settled on the "dedicated" terminology). As of right now, you can't do that kind of upgrade (you have to fork to a dedicated instance instead), but I imagine it might be something we want in the future, and ghost upgrade could be a useful command for it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in this Slack thread - we agreed to make upgrade the primary command, with update as an alias.

This is the most common verb used in related CLI commands in our experience, and we can always change it in the future if we really need to.

Adds a new 'ghost update' command (alias 'upgrade') that downloads the
latest published release archive and replaces the currently running
binary in place, using the same CloudFront-hosted artifacts as
scripts/install.sh and scripts/install.ps1.

Key behaviors:

- Fetches the target version via common.CheckVersion (reuses the
  existing version-check plumbing), or accepts --version vX.Y.Z to pin
  a specific release.
- Refuses to run if the binary was installed via a package manager
  (Homebrew / apt / yum-dnf), and tells the user which command to use
  instead. Pass --force to override.
- Refuses to replace a local dev build without --force, and reports
  which version would be installed.
- Short-circuits with 'ghost is already at version X' when the current
  version matches the target (unless --force).
- Pre-flight write check on the install directory so we fail fast
  before downloading a ~15 MB archive.
- Downloads the archive + its .sha256, verifies with crypto/sha256, and
  extracts just the ghost / ghost.exe entry using archive/tar + gzip
  (Linux/macOS) or archive/zip (Windows). No new dependencies.

Cross-OS binary replacement:

- Linux/macOS: atomic os.Rename onto the current path. POSIX keeps the
  running inode alive via the process's open handle, so overwriting a
  running executable is safe.
- Windows: the loader refuses to delete or overwrite a running .exe,
  but renaming is permitted. We move the live binary to
  ghost.exe.old.<unixNano>, then os.Rename the staged binary into
  place, with a rollback if the second rename fails. Leftover .old.*
  files are opportunistically cleaned up on subsequent updates.
- Symlinks are resolved via filepath.EvalSymlinks so we touch the real
  binary rather than clobbering the symlink.

Tests cover --version argument validation (for both 'update' and
'upgrade' aliases), the dev-build refusal, and the wrapped
'failed to check for latest version' network error.
@murrayju murrayju force-pushed the murrayju/ghost-update-command branch from a87770d to ff19965 Compare April 28, 2026 19:36
@murrayju murrayju changed the title Add 'ghost update' command to self-upgrade the CLI Add 'ghost upgrade' command to self-upgrade the CLI Apr 28, 2026
@murrayju murrayju merged commit 1c73b8a into main Apr 28, 2026
2 checks passed
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.

2 participants