From 43c2b5c9ec78d5b6ed46b64c5935550933f4ac96 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 11:12:17 -0700 Subject: [PATCH 1/7] Update commands to use new `--required-only` flag --- cmd/containersBuild.go | 2 +- cmd/install.go | 5 +++-- cmd/update.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/containersBuild.go b/cmd/containersBuild.go index 40ac6a3..6b3e743 100644 --- a/cmd/containersBuild.go +++ b/cmd/containersBuild.go @@ -61,7 +61,7 @@ func buildContainers(cmd *cobra.Command, args []string) { for { if dockerInterface.WaitForDjango() { fmt.Println("[+] Re-seeding database in case initial values were added or adjusted...") - seedErr := dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data") + seedErr := dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data", "--required-only") if seedErr != nil { log.Fatalf("Error trying to seed the database: %v\n", seedErr) } diff --git a/cmd/install.go b/cmd/install.go index 7cdbbb4..6de0ec2 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -107,7 +107,7 @@ func fetchAndWriteComposeFile(mode internal.DockerMode, version string) error { } // Performs common setup -func updateContainers(dockerInterface internal.DockerInterface) error { +func updateContainers(dockerInterface internal.DockerInterface, seedArgs ...string) error { var err error if dockerInterface.ManageComposeFile { fmt.Println("[+] Pulling containers...") @@ -139,7 +139,8 @@ func updateContainers(dockerInterface internal.DockerInterface) error { } fmt.Println("[+] Seeding database with initial data...") - err = dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data") + seedCmd := append([]string{"run", "--rm", "django", "/seed_data"}, seedArgs...) + err = dockerInterface.RunComposeCmd(seedCmd...) if err != nil { return fmt.Errorf("Could not seed database: %w", err) } diff --git a/cmd/update.go b/cmd/update.go index 8b1a89d..abc046e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -65,7 +65,7 @@ func updateGhostwriter(cmd *cobra.Command, args []string) { log.Fatalf("Could not tear down containers: %v", err) } - err = updateContainers(*dockerInterface) + err = updateContainers(*dockerInterface, "--required-only") if err != nil { log.Fatalf("%v\n", err) } From ccebebc3b92bf136fb8353636f7db702e7c0975a Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 11:16:13 -0700 Subject: [PATCH 2/7] Fixed help message --- cmd/containersBuild.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/containersBuild.go b/cmd/containersBuild.go index 6b3e743..5519cd7 100644 --- a/cmd/containersBuild.go +++ b/cmd/containersBuild.go @@ -30,7 +30,7 @@ func init() { &skipseed, "skip-seed", false, - `Skip (re-)seeding the database. This is useful when upgrading an existing and you know there are no new or adjusted values.`, + `Skip (re-)seeding the database. This is useful when upgrading an existing installation and you know there are no new or adjusted values.`, ) } From 928527464e4fac58bfab1ac1a37161799068d7b3 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 11:16:22 -0700 Subject: [PATCH 3/7] Updated for changes --- CHANGELOG.md | 14 +++++++++++++- README.md | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69302a2..ed30f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-rc1] - 2026-02-24 +## [1.0.1] - 2026-07-01 + +### Added + +* Added a `build` command that is a shortcut for `containers build` + +### Changed + +* Adjusted the `build` and `update` commands to use Ghostwriter v7.2.1's `--required-only` flag for database fixtures + * Ghostwriter CLI will now skip re-seeding database entries marked as `required: false` + * For now, the starter templates are flagged as non-required so they will not return with updates if an admin deletes them + +## [1.0.0] - 2026-04-09 ### Added diff --git a/README.md b/README.md index eb49f41..7ae14bd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Usage: Available Commands: backup Creates a backup of the PostgreSQL database + build Shortcut for `containers build` completion Generate the autocompletion script for the specified shell config Display or adjust the configuration containers Manage Ghostwriter containers with subcommands From e4b0e6d7401a62b39c577e78abf4f8aba87cb568 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 11:17:40 -0700 Subject: [PATCH 4/7] Added `build` alias --- cmd/build.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 cmd/build.go diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..056f937 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// buildCmd represents the build command +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Shortcut for `containers build`", + Run: func(cmd *cobra.Command, args []string) { + containersBuildCmd.Run(cmd, args) + }, +} + +func init() { + rootCmd.AddCommand(buildCmd) + buildCmd.Flags().BoolVar( + &skipseed, + "skip-seed", + false, + `Skip (re-)seeding the database. This is useful when upgrading an existing installation and you know there are no new or adjusted values.`, + ) +} From fe01bd716ac6b71df453b4ec0f1a9848f6d37b22 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 11:35:08 -0700 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cmd/build.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 056f937..16a18e5 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -8,9 +8,7 @@ import ( var buildCmd = &cobra.Command{ Use: "build", Short: "Shortcut for `containers build`", - Run: func(cmd *cobra.Command, args []string) { - containersBuildCmd.Run(cmd, args) - }, + Run: buildContainers, } func init() { From 46711f3677f3882af7c2ea5dba0f25f1ecbcb3af Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 12:43:27 -0700 Subject: [PATCH 6/7] Added guardrails for older versions of Ghostwriter --- cmd/containersBuild.go | 3 +- cmd/install.go | 4 +-- cmd/internal/docker.go | 23 +++++++++++++++ cmd/internal/seed.go | 60 +++++++++++++++++++++++++++++++++++++++ cmd/internal/seed_test.go | 31 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 cmd/internal/seed.go create mode 100644 cmd/internal/seed_test.go diff --git a/cmd/containersBuild.go b/cmd/containersBuild.go index 5519cd7..1ecd324 100644 --- a/cmd/containersBuild.go +++ b/cmd/containersBuild.go @@ -61,7 +61,8 @@ func buildContainers(cmd *cobra.Command, args []string) { for { if dockerInterface.WaitForDjango() { fmt.Println("[+] Re-seeding database in case initial values were added or adjusted...") - seedErr := dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data", "--required-only") + // SeedDatabase retries without optional args when older Ghostwriter builds reject them. + seedErr := internal.SeedDatabase(*dockerInterface, "--required-only") if seedErr != nil { log.Fatalf("Error trying to seed the database: %v\n", seedErr) } diff --git a/cmd/install.go b/cmd/install.go index 6de0ec2..e804663 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -139,8 +139,8 @@ func updateContainers(dockerInterface internal.DockerInterface, seedArgs ...stri } fmt.Println("[+] Seeding database with initial data...") - seedCmd := append([]string{"run", "--rm", "django", "/seed_data"}, seedArgs...) - err = dockerInterface.RunComposeCmd(seedCmd...) + // SeedDatabase retries without optional args when older Ghostwriter builds reject them. + err = internal.SeedDatabase(dockerInterface, seedArgs...) if err != nil { return fmt.Errorf("Could not seed database: %w", err) } diff --git a/cmd/internal/docker.go b/cmd/internal/docker.go index 9e6e8f9..51bb4e6 100644 --- a/cmd/internal/docker.go +++ b/cmd/internal/docker.go @@ -234,12 +234,35 @@ func (this *DockerInterface) RunCmdWithOutput(args ...string) (string, error) { return output, err } +// RunCmdWithCombinedOutput runs docker/podman and returns stdout and stderr. +// Use this when callers need to inspect command errors instead of streaming +// output directly to the terminal. +func (this *DockerInterface) RunCmdWithCombinedOutput(args ...string) (string, error) { + path, err := exec.LookPath(this.command) + if err != nil { + log.Fatalf("`%s` is not installed or not available in the current PATH variable", this.command) + } + command := exec.Command(path, args...) + command.Dir = this.Dir + command.Stdin = os.Stdin + out, err := command.CombinedOutput() + output := string(out[:]) + return output, err +} + // Runs a `docker compose` subcommand, pointing to the configured compose file, with additional arguments. func (this *DockerInterface) RunComposeCmd(args ...string) error { args = append([]string{"compose", "-f", this.ComposeFile}, args...) return this.RunCmd(args...) } +// RunComposeCmdWithCombinedOutput runs a docker compose subcommand and returns +// stdout and stderr so callers can inspect command failures. +func (this *DockerInterface) RunComposeCmdWithCombinedOutput(args ...string) (string, error) { + args = append([]string{"compose", "-f", this.ComposeFile}, args...) + return this.RunCmdWithCombinedOutput(args...) +} + // Bring all containers up func (this *DockerInterface) Up() error { fmt.Printf("[+] Running `%s` to bring up the containers with %s...\n", this.command, this.ComposeFile) diff --git a/cmd/internal/seed.go b/cmd/internal/seed.go new file mode 100644 index 0000000..300e81c --- /dev/null +++ b/cmd/internal/seed.go @@ -0,0 +1,60 @@ +package internal + +import ( + "fmt" + "strings" +) + +// SeedDatabase runs Ghostwriter's /seed_data helper with optional arguments. +// +// Some older Ghostwriter images and local source checkouts do not support newer +// /seed_data flags, and the CLI cannot reliably infer support from VERSION or +// the network. When an optional argument is rejected by the command-line parser, +// retry once without optional arguments so updates can proceed against older +// builds. Other seed failures still return as hard errors. +func SeedDatabase(dockerInterface DockerInterface, seedArgs ...string) error { + output, err := runSeedData(dockerInterface, seedArgs...) + fmt.Print(output) + if err == nil { + return nil + } + + if len(seedArgs) > 0 && seedArgsUnsupported(output, seedArgs...) { + fmt.Println("[!] This Ghostwriter build does not support the requested seed option(s); retrying without them...") + output, retryErr := runSeedData(dockerInterface) + fmt.Print(output) + if retryErr == nil { + return nil + } + return fmt.Errorf("optional seed arguments are unsupported, and retrying without them failed: %w", retryErr) + } + + return err +} + +// runSeedData builds and runs the docker compose command for /seed_data. +func runSeedData(dockerInterface DockerInterface, seedArgs ...string) (string, error) { + seedCmd := append([]string{"run", "--rm", "django", "/seed_data"}, seedArgs...) + return dockerInterface.RunComposeCmdWithCombinedOutput(seedCmd...) +} + +// seedArgsUnsupported reports whether Django's loaddata command rejected one +// of the optional seed arguments. This matches the observed invalid-argument +// output from Ghostwriter's local loaddata command: +// +// manage.py loaddata: error: unrecognized arguments: --unknown-option +func seedArgsUnsupported(output string, seedArgs ...string) bool { + lowerOutput := strings.ToLower(output) + loaddataInvalidArgs := "manage.py loaddata: error: unrecognized arguments:" + if !strings.Contains(lowerOutput, loaddataInvalidArgs) { + return false + } + + for _, seedArg := range seedArgs { + if strings.Contains(lowerOutput, strings.ToLower(seedArg)) { + return true + } + } + + return false +} diff --git a/cmd/internal/seed_test.go b/cmd/internal/seed_test.go new file mode 100644 index 0000000..19e5118 --- /dev/null +++ b/cmd/internal/seed_test.go @@ -0,0 +1,31 @@ +package internal + +import "testing" + +func TestSeedArgsUnsupportedDetectsLoaddataInvalidArgument(t *testing.T) { + output := `usage: manage.py loaddata [-h] [--force] + fixture [fixture ...] +manage.py loaddata: error: unrecognized arguments: --required-only` + + if !seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected loaddata invalid-argument output to be detected") + } +} + +func TestSeedArgsUnsupportedIgnoresOtherParserErrors(t *testing.T) { + output := `usage: manage.py loaddata [-h] [--force] + fixture [fixture ...] +manage.py loaddata: error: the following arguments are required: fixture` + + if seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected non-invalid-argument parser error to be ignored") + } +} + +func TestSeedArgsUnsupportedIgnoresOtherSeedFailures(t *testing.T) { + output := "django.db.utils.IntegrityError: duplicate key value violates unique constraint" + + if seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected unrelated seed failure to be ignored") + } +} From 675f6e89d02cff9e81b6b57e9a3a7beda9f828f1 Mon Sep 17 00:00:00 2001 From: Christopher Maddalena Date: Wed, 1 Jul 2026 13:07:50 -0700 Subject: [PATCH 7/7] Streams stdout/stderr live to the terminal while capturing --- cmd/internal/docker.go | 68 ++++++++++++++++++++++++++---- cmd/internal/docker_output_test.go | 18 ++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 cmd/internal/docker_output_test.go diff --git a/cmd/internal/docker.go b/cmd/internal/docker.go index 51bb4e6..021628f 100644 --- a/cmd/internal/docker.go +++ b/cmd/internal/docker.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "log" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "regexp" "slices" "strings" + "sync" "time" "github.com/adrg/xdg" @@ -234,20 +236,70 @@ func (this *DockerInterface) RunCmdWithOutput(args ...string) (string, error) { return output, err } -// RunCmdWithCombinedOutput runs docker/podman and returns stdout and stderr. -// Use this when callers need to inspect command errors instead of streaming -// output directly to the terminal. +const commandOutputTailLimit = 64 * 1024 + +type commandOutputTail struct { + mu sync.Mutex + data []byte + limit int +} + +func newCommandOutputTail(limit int) *commandOutputTail { + return &commandOutputTail{limit: limit} +} + +func (tail *commandOutputTail) Write(p []byte) (int, error) { + tail.mu.Lock() + defer tail.mu.Unlock() + + if len(p) >= tail.limit { + tail.data = append(tail.data[:0], p[len(p)-tail.limit:]...) + return len(p), nil + } + + tail.data = append(tail.data, p...) + if len(tail.data) > tail.limit { + tail.data = tail.data[len(tail.data)-tail.limit:] + } + return len(p), nil +} + +func (tail *commandOutputTail) String() string { + tail.mu.Lock() + defer tail.mu.Unlock() + return string(tail.data) +} + +type commandOutputTee struct { + terminal io.Writer + tail *commandOutputTail +} + +func (tee commandOutputTee) Write(p []byte) (int, error) { + if _, err := tee.tail.Write(p); err != nil { + return 0, err + } + if _, err := tee.terminal.Write(p); err != nil { + return 0, err + } + return len(p), nil +} + +// RunCmdWithCombinedOutput streams docker/podman stdout and stderr to the +// terminal while returning a bounded combined tail for error inspection. func (this *DockerInterface) RunCmdWithCombinedOutput(args ...string) (string, error) { path, err := exec.LookPath(this.command) if err != nil { log.Fatalf("`%s` is not installed or not available in the current PATH variable", this.command) } + outputTail := newCommandOutputTail(commandOutputTailLimit) command := exec.Command(path, args...) command.Dir = this.Dir command.Stdin = os.Stdin - out, err := command.CombinedOutput() - output := string(out[:]) - return output, err + command.Stdout = commandOutputTee{terminal: os.Stdout, tail: outputTail} + command.Stderr = commandOutputTee{terminal: os.Stderr, tail: outputTail} + err = command.Run() + return outputTail.String(), err } // Runs a `docker compose` subcommand, pointing to the configured compose file, with additional arguments. @@ -256,8 +308,8 @@ func (this *DockerInterface) RunComposeCmd(args ...string) error { return this.RunCmd(args...) } -// RunComposeCmdWithCombinedOutput runs a docker compose subcommand and returns -// stdout and stderr so callers can inspect command failures. +// RunComposeCmdWithCombinedOutput streams a docker compose subcommand and +// returns a bounded combined output tail so callers can inspect failures. func (this *DockerInterface) RunComposeCmdWithCombinedOutput(args ...string) (string, error) { args = append([]string{"compose", "-f", this.ComposeFile}, args...) return this.RunCmdWithCombinedOutput(args...) diff --git a/cmd/internal/docker_output_test.go b/cmd/internal/docker_output_test.go new file mode 100644 index 0000000..883f855 --- /dev/null +++ b/cmd/internal/docker_output_test.go @@ -0,0 +1,18 @@ +package internal + +import "testing" + +func TestCommandOutputTailKeepsBoundedTail(t *testing.T) { + tail := newCommandOutputTail(10) + + if _, err := tail.Write([]byte("first-")); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + if _, err := tail.Write([]byte("second")); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + + if got := tail.String(); got != "rst-second" { + t.Fatalf("expected bounded tail %q, got %q", "rst-second", got) + } +}