From 041ee50f85c2d47c74bbe44157817bd7304a64bd Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 14:49:50 -0400 Subject: [PATCH 1/6] feat(cli): render command hints with a wrapper-passed tool name Every pasteable command hint the CLI prints starts with the bare binary name, so when the CLI runs behind a wrapper, pasting a hint invokes an unconfigured binary that can't reach the server. A wrapper now passes its own invocation on every call via a hidden global --cli-name flag; every hint site and kong's usage text render through the new cliname package, so printed commands work as pasted. The flag is scanned from the raw args because kong's usage name must be fixed before parsing; an absent flag keeps the default name. Co-Authored-By: Claude Fable 5 --- pkg/cmd/client/config.go | 6 ++- pkg/cmd/commands/apply.go | 7 +-- pkg/cmd/commands/common.go | 13 +++-- pkg/cmd/commands/configure.go | 5 +- pkg/cmd/commands/fixlint.go | 3 +- pkg/cmd/commands/login.go | 5 +- pkg/cmd/commands/plan.go | 3 +- pkg/cmd/commands/progress.go | 3 +- pkg/cmd/commands/watch_tui.go | 3 +- pkg/cmd/commands/watch_tui_view.go | 21 ++++---- pkg/cmd/internal/cliname/cliname.go | 62 ++++++++++++++++++++++++ pkg/cmd/internal/cliname/cliname_test.go | 41 ++++++++++++++++ pkg/cmd/internal/templates/locks.go | 11 +++-- pkg/cmd/internal/templates/plan.go | 3 +- pkg/cmd/internal/templates/progress.go | 17 ++++--- pkg/cmd/main.go | 9 +++- 16 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 pkg/cmd/internal/cliname/cliname.go create mode 100644 pkg/cmd/internal/cliname/cliname_test.go diff --git a/pkg/cmd/client/config.go b/pkg/cmd/client/config.go index d707af091..d6504b547 100644 --- a/pkg/cmd/client/config.go +++ b/pkg/cmd/client/config.go @@ -11,6 +11,8 @@ import ( "github.com/block/spirit/pkg/utils" "gopkg.in/yaml.v3" + + "github.com/block/schemabot/pkg/cmd/internal/cliname" ) // Config represents the global SchemaBot CLI configuration. @@ -330,12 +332,12 @@ func ResolveBearerToken(ctx context.Context, tokenFlag, endpointFlag, profileFla return token, nil } if profile.RefreshToken == "" || profile.OIDC == nil { - return token, fmt.Errorf("token for profile %q is expired or about to expire and cannot be refreshed; run `schemabot login`", profileName) + return token, fmt.Errorf("token for profile %q is expired or about to expire and cannot be refreshed; run `%s login`", profileName, cliname.Name()) } result, err := RefreshToken(ctx, LoginConfig{Issuer: profile.OIDC.Issuer, ClientID: profile.OIDC.ClientID}, profile.RefreshToken) if err != nil { - return token, fmt.Errorf("could not refresh the token for profile %q (run `schemabot login`): %w", profileName, err) + return token, fmt.Errorf("could not refresh the token for profile %q (run `%s login`): %w", profileName, cliname.Name(), err) } profile.Token = result.IDToken diff --git a/pkg/cmd/commands/apply.go b/pkg/cmd/commands/apply.go index 673a46f0b..091b7a1b2 100644 --- a/pkg/cmd/commands/apply.go +++ b/pkg/cmd/commands/apply.go @@ -11,6 +11,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" @@ -70,7 +71,7 @@ func (cmd *ApplyCmd) Run(g *Globals) error { if err != nil { // Ignore status preflight errors; apply is still guarded server-side. } else if active != nil && active.State != "" { - progressCmd := fmt.Sprintf("schemabot status %s", active.ApplyID) + progressCmd := fmt.Sprintf("%s status %s", cliname.Name(), active.ApplyID) var stateMsg string switch { case state.IsState(active.State, state.Apply.WaitingForDeploy): @@ -92,7 +93,7 @@ func (cmd *ApplyCmd) Run(g *Globals) error { fmt.Println(stateMsg) fmt.Println() if state.IsState(active.State, state.Apply.WaitingForDeploy, state.Apply.WaitingForCutover) { - fmt.Printf("To trigger cutover: schemabot cutover -e %s %s\n", cmd.Environment, active.ApplyID) + fmt.Printf("To trigger cutover: %s cutover -e %s %s\n", cliname.Name(), cmd.Environment, active.ApplyID) } fmt.Printf("To watch and manage: %s\n", progressCmd) return fmt.Errorf("schema change already in progress") @@ -708,7 +709,7 @@ func watchApplyProgressLog(endpoint, applyID string, heartbeatInterval time.Dura case state.IsState(curState, state.Apply.RevertWindow): revertWindowStart = time.Now() lastRevertHeartbeat = time.Now() - log.emit("msg", "Revert window open — run 'schemabot revert' to undo or 'schemabot skip-revert' to finalize") + log.emit("msg", fmt.Sprintf("Revert window open — run '%s revert' to undo or '%s skip-revert' to finalize", cliname.Name(), cliname.Name())) } lastGlobalState = globalNorm } diff --git a/pkg/cmd/commands/common.go b/pkg/cmd/commands/common.go index d0e23fd2c..be1072002 100644 --- a/pkg/cmd/commands/common.go +++ b/pkg/cmd/commands/common.go @@ -18,6 +18,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" ) @@ -27,6 +28,10 @@ type Globals struct { Endpoint string `help:"SchemaBot API endpoint (overrides profile)"` Profile string `help:"Configuration profile"` Token string `help:"Bearer token for authenticating to an auth-enabled server (or set SCHEMABOT_TOKEN)"` + // CLIName is declared so kong accepts the flag at any position; main.go + // consumes the value from the raw args before parsing, since it feeds + // kong's own usage text. + CLIName string `name:"cli-name" hidden:"" help:"Tool name rendered in command hints (for CLI wrappers)"` // Build info (set by main.go from ldflags) Version string `kong:"-"` @@ -82,7 +87,7 @@ func LoadCLIConfig(dir string) (*CLIConfig, error) { if err != nil { if os.IsNotExist(err) { absDir, _ := filepath.Abs(dir) - return nil, fmt.Errorf("schemabot.yaml not found in %s\n\nUse -s to specify the schema directory:\n schemabot plan -s ./path/to/schema\n schemabot apply -s ./path/to/schema -e staging", absDir) + return nil, fmt.Errorf("schemabot.yaml not found in %s\n\nUse -s to specify the schema directory:\n %s plan -s ./path/to/schema\n %s apply -s ./path/to/schema -e staging", absDir, cliname.Name(), cliname.Name()) } return nil, fmt.Errorf("read config file: %w", err) } @@ -113,7 +118,7 @@ func resolveEndpoint(endpoint, profile string) (string, error) { return "", fmt.Errorf("resolve endpoint: %w", err) } if ep == "" { - return "", fmt.Errorf("no endpoint configured (run 'schemabot configure' to set up a profile)") + return "", fmt.Errorf("no endpoint configured (run '%s configure' to set up a profile)", cliname.Name()) } return ep, nil } @@ -479,9 +484,9 @@ func buildApplyOptions(planResult *apitypes.PlanResponse, deferCutover, deferDep // printWatchInstructions prints the "To watch and manage" hint. func printWatchInstructions(applyID, database, environment string) { if applyID != "" { - fmt.Printf("To watch and manage: schemabot progress %s\n", applyID) + fmt.Printf("To watch and manage: %s progress %s\n", cliname.Name(), applyID) } else { - fmt.Printf("To watch and manage: schemabot status -d %s -e %s\n", database, environment) + fmt.Printf("To watch and manage: %s status -d %s -e %s\n", cliname.Name(), database, environment) } } diff --git a/pkg/cmd/commands/configure.go b/pkg/cmd/commands/configure.go index 38ff32bbb..2d30e062f 100644 --- a/pkg/cmd/commands/configure.go +++ b/pkg/cmd/commands/configure.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" ) // ConfigureCmd configures CLI settings. @@ -69,10 +70,10 @@ func (cmd *ConfigureSetupCmd) Run(g *Globals) error { if cfg.DefaultProfile == profileName { fmt.Printf("\nThis is your default profile. You can now run:\n") - fmt.Printf(" schemabot plan -s ./schema -e staging\n") + fmt.Printf(" %s plan -s ./schema -e staging\n", cliname.Name()) } else { fmt.Printf("\nTo use this profile:\n") - fmt.Printf(" schemabot plan -s ./schema -e staging --profile %s\n", profileName) + fmt.Printf(" %s plan -s ./schema -e staging --profile %s\n", cliname.Name(), profileName) fmt.Printf("\nOr set as default:\n") fmt.Printf(" export SCHEMABOT_PROFILE=%s\n", profileName) } diff --git a/pkg/cmd/commands/fixlint.go b/pkg/cmd/commands/fixlint.go index c013417de..d12761d9d 100644 --- a/pkg/cmd/commands/fixlint.go +++ b/pkg/cmd/commands/fixlint.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/lint" ) @@ -83,7 +84,7 @@ func (cmd *FixLintCmd) Run(g *Globals) error { if cmd.DryRun && result.TotalFixed > 0 { fmt.Println("Run without --dry-run to apply fixes.") } else if result.TotalFixed > 0 { - fmt.Println("Run 'schemabot plan' to see full validation results.") + fmt.Printf("Run '%s plan' to see full validation results.\n", cliname.Name()) } // Exit with error if there are unfixable issues (for CI) diff --git a/pkg/cmd/commands/login.go b/pkg/cmd/commands/login.go index b701d3f7a..1f055b5ca 100644 --- a/pkg/cmd/commands/login.go +++ b/pkg/cmd/commands/login.go @@ -12,6 +12,7 @@ import ( "time" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" ) // loginTimeout bounds the whole interactive login, including the wait for the @@ -51,10 +52,10 @@ func (cmd *LoginCmd) Run(g *Globals) error { profileName := client.ResolveProfileName(cfg, g.Profile) profile, ok := cfg.Profiles[profileName] if !ok { - return fmt.Errorf("profile %q is not configured; run `schemabot configure` to set its endpoint before logging in", profileName) + return fmt.Errorf("profile %q is not configured; run `%s configure` to set its endpoint before logging in", profileName, cliname.Name()) } if profile.Endpoint == "" { - return fmt.Errorf("profile %q has no endpoint; run `schemabot configure` to set it before logging in", profileName) + return fmt.Errorf("profile %q has no endpoint; run `%s configure` to set it before logging in", profileName, cliname.Name()) } loginCfg, err := resolveLoginConfig(cmd.Issuer, cmd.ClientID, cmd.RedirectPort, &profile) diff --git a/pkg/cmd/commands/plan.go b/pkg/cmd/commands/plan.go index f5c4c32f8..a14fa78a7 100644 --- a/pkg/cmd/commands/plan.go +++ b/pkg/cmd/commands/plan.go @@ -13,6 +13,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" @@ -46,7 +47,7 @@ func (cmd *PlanCmd) Run(g *Globals) error { return fmt.Errorf("resolve endpoint: %w", err) } if ep == "" { - errMsg := "no endpoint configured (run 'schemabot configure' to set up a profile)" + errMsg := fmt.Sprintf("no endpoint configured (run '%s configure' to set up a profile)", cliname.Name()) if cmd.JSON { return client.ExitWithJSON("invalid_request", errMsg) } diff --git a/pkg/cmd/commands/progress.go b/pkg/cmd/commands/progress.go index 3ab9b2e94..62134e189 100644 --- a/pkg/cmd/commands/progress.go +++ b/pkg/cmd/commands/progress.go @@ -5,6 +5,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" ) @@ -18,7 +19,7 @@ type ProgressCmd struct { // Run executes the progress command. func (cmd *ProgressCmd) Run(g *Globals) error { if cmd.ApplyID == "" { - return fmt.Errorf("apply_id is required (use 'schemabot status -d ' to find active applies)") + return fmt.Errorf("apply_id is required (use '%s status -d ' to find active applies)", cliname.Name()) } ep, err := g.Resolve() diff --git a/pkg/cmd/commands/watch_tui.go b/pkg/cmd/commands/watch_tui.go index 189c59b5f..b417611f2 100644 --- a/pkg/cmd/commands/watch_tui.go +++ b/pkg/cmd/commands/watch_tui.go @@ -12,6 +12,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" ) @@ -462,7 +463,7 @@ func formatExitContext(applyID, deployRequestURL, environment string) string { if deployRequestURL != "" { fmt.Fprintf(&b, " Deploy Request: %s\n", deployRequestURL) } - cmd := fmt.Sprintf("schemabot progress %s", applyID) + cmd := fmt.Sprintf("%s progress %s", cliname.Name(), applyID) if environment != "" { cmd += " -e " + environment } diff --git a/pkg/cmd/commands/watch_tui_view.go b/pkg/cmd/commands/watch_tui_view.go index 70525a31a..88bbc85f1 100644 --- a/pkg/cmd/commands/watch_tui_view.go +++ b/pkg/cmd/commands/watch_tui_view.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/ui" @@ -191,9 +192,9 @@ func (m WatchModel) progressView() string { b.WriteString(templates.FormatApplyStopped()) b.WriteString("\n") if m.applyID != "" { - fmt.Fprintf(&b, "Use 'schemabot start -e %s %s' to resume.\n", m.environment, m.applyID) + fmt.Fprintf(&b, "Use '%s start -e %s %s' to resume.\n", cliname.Name(), m.environment, m.applyID) } else { - fmt.Fprintf(&b, "Use 'schemabot status -d %s -e %s' to find the apply ID.\n", m.database, m.environment) + fmt.Fprintf(&b, "Use '%s status -d %s -e %s' to find the apply ID.\n", cliname.Name(), m.database, m.environment) } case isCuttingOver: // During cutover, show minimal footer - no detach/stop allowed @@ -219,9 +220,9 @@ func (m WatchModel) progressView() string { b.WriteString("Press Enter to deploy or proceed via the PlanetScale console (ESC to detach)\n") } else { if m.applyID != "" { - fmt.Fprintf(&b, "To proceed: schemabot start -e %s %s\n", m.environment, m.applyID) + fmt.Fprintf(&b, "To proceed: %s start -e %s %s\n", cliname.Name(), m.environment, m.applyID) } else { - fmt.Fprintf(&b, "To find the apply ID: schemabot status -d %s -e %s\n", m.database, m.environment) + fmt.Fprintf(&b, "To find the apply ID: %s status -d %s -e %s\n", cliname.Name(), m.database, m.environment) } b.WriteString("Watching for deploy... (ESC to detach)\n") } @@ -233,9 +234,9 @@ func (m WatchModel) progressView() string { b.WriteString("Press Enter to proceed with cutover (or ESC to detach)\n") } else { if m.applyID != "" { - fmt.Fprintf(&b, "To proceed: schemabot cutover -e %s %s\n", m.environment, m.applyID) + fmt.Fprintf(&b, "To proceed: %s cutover -e %s %s\n", cliname.Name(), m.environment, m.applyID) } else { - fmt.Fprintf(&b, "To find the apply ID: schemabot status -d %s -e %s\n", m.database, m.environment) + fmt.Fprintf(&b, "To find the apply ID: %s status -d %s -e %s\n", cliname.Name(), m.database, m.environment) } b.WriteString("Watching for cutover... (ESC to detach)\n") } @@ -422,13 +423,13 @@ func (m WatchModel) detachedView() string { } b.WriteString("\n") if m.applyID != "" { - fmt.Fprintf(&b, "To reattach: schemabot progress %s\n", m.applyID) + fmt.Fprintf(&b, "To reattach: %s progress %s\n", cliname.Name(), m.applyID) if state.IsState(m.state, state.Apply.WaitingForDeploy) { - fmt.Fprintf(&b, "To deploy: schemabot start -e %s %s\n", m.environment, m.applyID) + fmt.Fprintf(&b, "To deploy: %s start -e %s %s\n", cliname.Name(), m.environment, m.applyID) } - fmt.Fprintf(&b, "To stop: schemabot stop -e %s %s\n", m.environment, m.applyID) + fmt.Fprintf(&b, "To stop: %s stop -e %s %s\n", cliname.Name(), m.environment, m.applyID) } else { - fmt.Fprintf(&b, "Find apply: schemabot status -d %s -e %s\n", m.database, m.environment) + fmt.Fprintf(&b, "Find apply: %s status -d %s -e %s\n", cliname.Name(), m.database, m.environment) } return b.String() } diff --git a/pkg/cmd/internal/cliname/cliname.go b/pkg/cmd/internal/cliname/cliname.go new file mode 100644 index 000000000..ff0fc8119 --- /dev/null +++ b/pkg/cmd/internal/cliname/cliname.go @@ -0,0 +1,62 @@ +// Package cliname resolves the tool name rendered in CLI command hints. +// +// Every pasteable command the CLI prints ("Force unlock: schemabot unlock +// ...") starts with the tool name. When the CLI runs behind a wrapper, the +// bare binary name is wrong: pasting it invokes an unconfigured binary +// instead of the wrapper the operator actually uses. The wrapper passes its +// own invocation on every call via the --cli-name flag, and every hint +// renders through Name so pasted commands work as printed. +package cliname + +import ( + "strings" + "sync/atomic" +) + +// defaultName is the bare binary name, rendered when no --cli-name is passed. +const defaultName = "schemabot" + +// flagName is the global flag a wrapper uses to pass its invocation. +const flagName = "--cli-name" + +var name atomic.Value + +// Set records the tool name command hints render, typically a wrapper +// invocation such as "sq schemabot". An empty name is ignored so an absent +// --cli-name flag keeps the default rather than clearing the name. +func Set(n string) { + if n == "" { + return + } + name.Store(n) +} + +// Name returns the tool name to render at the start of CLI command hints. +func Name() string { + if v, ok := name.Load().(string); ok { + return v + } + return defaultName +} + +// FromArgs extracts the --cli-name flag value from raw command-line args, +// returning "" when the flag is absent. It scans the args directly because +// the name feeds kong's usage text, which must be fixed before kong parses; +// kong still declares the flag so it is accepted at any position. +func FromArgs(args []string) string { + value := "" + for i := 0; i < len(args); i++ { + if args[i] == "--" { + break + } + if args[i] == flagName && i+1 < len(args) { + value = args[i+1] + i++ + continue + } + if rest, ok := strings.CutPrefix(args[i], flagName+"="); ok { + value = rest + } + } + return value +} diff --git a/pkg/cmd/internal/cliname/cliname_test.go b/pkg/cmd/internal/cliname/cliname_test.go new file mode 100644 index 000000000..5fce642fb --- /dev/null +++ b/pkg/cmd/internal/cliname/cliname_test.go @@ -0,0 +1,41 @@ +package cliname + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestName covers the full lifecycle in one test because the name is +// process-global: the default before any Set, an empty Set leaving the +// default in place, and a wrapper-passed name taking effect. +func TestName(t *testing.T) { + assert.Equal(t, "schemabot", Name(), "default before any Set") + + Set("") + assert.Equal(t, "schemabot", Name(), "empty Set keeps the default") + + Set("sq schemabot") + assert.Equal(t, "sq schemabot", Name(), "wrapper-passed name is rendered") +} + +func TestFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"absent", []string{"status", "-e", "staging"}, ""}, + {"equals form", []string{"--cli-name=sq schemabot", "status"}, "sq schemabot"}, + {"space form", []string{"--cli-name", "sq schemabot", "status"}, "sq schemabot"}, + {"after subcommand", []string{"rollback", "--cli-name", "sq schemabot"}, "sq schemabot"}, + {"missing value at end", []string{"status", "--cli-name"}, ""}, + {"not scanned past double dash", []string{"status", "--", "--cli-name=sq schemabot"}, ""}, + {"last occurrence wins", []string{"--cli-name=first", "--cli-name", "second"}, "second"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FromArgs(tt.args)) + }) + } +} diff --git a/pkg/cmd/internal/templates/locks.go b/pkg/cmd/internal/templates/locks.go index c332782a0..b0c03acbc 100644 --- a/pkg/cmd/internal/templates/locks.go +++ b/pkg/cmd/internal/templates/locks.go @@ -4,6 +4,7 @@ import ( "fmt" "time" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/ui" ) @@ -66,8 +67,8 @@ func WriteLockConflict(data LockConflictData) { fmt.Println() fmt.Println("Options:") fmt.Println(" • Wait for the current schema change to complete") - fmt.Println(" • Ask the lock owner to release: schemabot unlock") - fmt.Printf(" • Force unlock: schemabot unlock -d %s --force\n", data.Database) + fmt.Printf(" • Ask the lock owner to release: %s unlock\n", cliname.Name()) + fmt.Printf(" • Force unlock: %s unlock -d %s --force\n", cliname.Name(), data.Database) fmt.Println() } @@ -94,8 +95,8 @@ func WriteLocksList(locks []LockData) { } fmt.Println("To release a lock:") - fmt.Println(" schemabot unlock -d -t ") - fmt.Println(" schemabot unlock -d -t --force # override ownership") + fmt.Printf(" %s unlock -d -t \n", cliname.Name()) + fmt.Printf(" %s unlock -d -t --force # override ownership\n", cliname.Name()) } // WriteNoLockFound writes the message when a lock doesn't exist. @@ -110,7 +111,7 @@ func WriteNoLockFound(database, dbType string) { // the wrong namespace — point them at the lock that actually exists. func WriteLockExistsUnderOtherType(database, requestedType, foundType string) { fmt.Printf("No lock found for %s (%s), but a %s lock exists for this database.\n", database, requestedType, foundType) - fmt.Printf("Release it with: schemabot unlock -d %s -t %s\n", database, foundType) + fmt.Printf("Release it with: %s unlock -d %s -t %s\n", cliname.Name(), database, foundType) } // WriteLockTypeScanFailed writes the message when the check for locks under diff --git a/pkg/cmd/internal/templates/plan.go b/pkg/cmd/internal/templates/plan.go index 5855203b8..37dd1e2bd 100644 --- a/pkg/cmd/internal/templates/plan.go +++ b/pkg/cmd/internal/templates/plan.go @@ -10,6 +10,7 @@ import ( "golang.org/x/text/language" "github.com/block/schemabot/pkg/apitypes" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/ui" ) @@ -478,7 +479,7 @@ func WriteUnsafeChangesBlocked(changes []UnsafeChange, database, environment, sc } fmt.Println("🚨 To proceed with these destructive changes, re-run with --allow-unsafe:") fmt.Println() - fmt.Printf(" schemabot apply -s %s -e %s --allow-unsafe\n", schemaDir, environment) + fmt.Printf(" %s apply -s %s -e %s --allow-unsafe\n", cliname.Name(), schemaDir, environment) fmt.Println() } diff --git a/pkg/cmd/internal/templates/progress.go b/pkg/cmd/internal/templates/progress.go index a543fc397..903f3c891 100644 --- a/pkg/cmd/internal/templates/progress.go +++ b/pkg/cmd/internal/templates/progress.go @@ -9,6 +9,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/caller" + "github.com/block/schemabot/pkg/cmd/internal/cliname" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/ui" @@ -796,9 +797,9 @@ func WriteStopSuccess(data StopData) { } fmt.Println() if data.ApplyID != "" { - fmt.Printf("%sCheckpoint saved. Use 'schemabot start -e %s %s' to resume.%s\n", ANSIDim, data.Environment, data.ApplyID, ANSIReset) + fmt.Printf("%sCheckpoint saved. Use '%s start -e %s %s' to resume.%s\n", ANSIDim, cliname.Name(), data.Environment, data.ApplyID, ANSIReset) } else { - fmt.Printf("%sCheckpoint saved. Use 'schemabot start' to resume from where you left off.%s\n", ANSIDim, ANSIReset) + fmt.Printf("%sCheckpoint saved. Use '%s start' to resume from where you left off.%s\n", ANSIDim, cliname.Name(), ANSIReset) } } @@ -856,9 +857,9 @@ func WriteStartNoWatch(applyID, database, environment string) { fmt.Printf("%s%s▶️ Schema change resumed%s\n", ANSIBold, ANSIGreen, ANSIReset) fmt.Println() if applyID != "" { - fmt.Printf("To watch and manage: schemabot progress %s\n", applyID) + fmt.Printf("To watch and manage: %s progress %s\n", cliname.Name(), applyID) } else { - fmt.Printf("To watch and manage: schemabot status -d %s -e %s\n", database, environment) + fmt.Printf("To watch and manage: %s status -d %s -e %s\n", cliname.Name(), database, environment) } } @@ -877,7 +878,7 @@ func WriteReleaseSuccess(data ReleaseData) { fmt.Printf("Environment: %s\n", data.Environment) fmt.Println() if data.ApplyID != "" { - fmt.Printf("%sHeld deployments will resume. Use 'schemabot progress %s' to follow them.%s\n", ANSIDim, data.ApplyID, ANSIReset) + fmt.Printf("%sHeld deployments will resume. Use '%s progress %s' to follow them.%s\n", ANSIDim, cliname.Name(), data.ApplyID, ANSIReset) } else { fmt.Printf("%sHeld deployments will resume.%s\n", ANSIDim, ANSIReset) } @@ -1143,7 +1144,7 @@ func writeStatusListFooter(data StatusListData) { } writeStatusListTruncation(data, item) } - fmt.Printf("%sUse 'schemabot status ' to view details%s\n", ANSIDim, ANSIReset) + fmt.Printf("%sUse '%s status ' to view details%s\n", ANSIDim, cliname.Name(), ANSIReset) } func writeFailedStatusList(data StatusListData) { @@ -1162,7 +1163,7 @@ func writeFailedStatusList(data StatusListData) { statusFailureActor(a, data.ShowExternalID), formatFailureTimestamp(a), compactStatusFailureReason(a.ErrorMessage)) - fmt.Printf("schemabot status %s\n", a.ApplyID) + fmt.Printf("%s status %s\n", cliname.Name(), a.ApplyID) } if data.HasMore && data.Limit > 0 { @@ -1326,7 +1327,7 @@ func WriteDatabaseHistory(data DatabaseHistoryData) { } fmt.Println() - fmt.Printf("%sUse 'schemabot status ' to view details%s\n", ANSIDim, ANSIReset) + fmt.Printf("%sUse '%s status ' to view details%s\n", ANSIDim, cliname.Name(), ANSIReset) } // stateColorFunc returns an ANSI color function for the given state. diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index 3cd4d0ccc..f213fe59c 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -15,6 +15,7 @@ import ( "github.com/block/schemabot/pkg/cmd/client" "github.com/block/schemabot/pkg/cmd/commands" + "github.com/block/schemabot/pkg/cmd/internal/cliname" ) // Set by ldflags at build time. @@ -59,9 +60,15 @@ type CLI struct { } func main() { + // Render command hints — and kong's own usage text — with the tool name a + // wrapper passes via --cli-name, so wrapper-invoked runs print commands + // that work as pasted. Scanned from the raw args because kong's usage name + // must be fixed before kong parses; an absent flag keeps the default. + cliname.Set(cliname.FromArgs(os.Args[1:])) + var cli CLI ctx := kong.Parse(&cli, - kong.Name("schemabot"), + kong.Name(cliname.Name()), kong.Description("Declarative schema GitOps orchestrator"), kong.UsageOnError(), kong.Vars{"version": fmt.Sprintf("%s (commit: %s)", version, commit)}, From fb92a0feb7b98d4acf2b78005d4315e6c9e54807 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 16:15:37 -0400 Subject: [PATCH 2/6] test(cli): isolate the cliname default from package test ordering Co-Authored-By: Claude Fable 5 --- pkg/cmd/internal/cliname/cliname.go | 8 ++++---- pkg/cmd/internal/cliname/cliname_test.go | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/internal/cliname/cliname.go b/pkg/cmd/internal/cliname/cliname.go index ff0fc8119..b67e63898 100644 --- a/pkg/cmd/internal/cliname/cliname.go +++ b/pkg/cmd/internal/cliname/cliname.go @@ -19,7 +19,7 @@ const defaultName = "schemabot" // flagName is the global flag a wrapper uses to pass its invocation. const flagName = "--cli-name" -var name atomic.Value +var name atomic.Pointer[string] // Set records the tool name command hints render, typically a wrapper // invocation such as "sq schemabot". An empty name is ignored so an absent @@ -28,13 +28,13 @@ func Set(n string) { if n == "" { return } - name.Store(n) + name.Store(&n) } // Name returns the tool name to render at the start of CLI command hints. func Name() string { - if v, ok := name.Load().(string); ok { - return v + if p := name.Load(); p != nil { + return *p } return defaultName } diff --git a/pkg/cmd/internal/cliname/cliname_test.go b/pkg/cmd/internal/cliname/cliname_test.go index 5fce642fb..90623a32e 100644 --- a/pkg/cmd/internal/cliname/cliname_test.go +++ b/pkg/cmd/internal/cliname/cliname_test.go @@ -8,8 +8,13 @@ import ( // TestName covers the full lifecycle in one test because the name is // process-global: the default before any Set, an empty Set leaving the -// default in place, and a wrapper-passed name taking effect. +// default in place, and a wrapper-passed name taking effect. The global is +// reset to its unset state around the test so ordering against other tests +// in the package cannot change the outcome. func TestName(t *testing.T) { + name.Store(nil) + t.Cleanup(func() { name.Store(nil) }) + assert.Equal(t, "schemabot", Name(), "default before any Set") Set("") From 55a2c71a853944bdb308c02a7216391dd2e60b0f Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 20:07:03 -0400 Subject: [PATCH 3/6] feat(cli): export cliname for embedding wrappers A wrapper that embeds the command packages in its own kong CLI (rather than exec'ing the binary) must call cliname.Set before parsing, which an internal/ package forbids from another module. Co-Authored-By: Claude Fable 5 --- pkg/cmd/client/config.go | 2 +- pkg/cmd/{internal => }/cliname/cliname.go | 8 +++++--- pkg/cmd/{internal => }/cliname/cliname_test.go | 0 pkg/cmd/commands/apply.go | 2 +- pkg/cmd/commands/common.go | 2 +- pkg/cmd/commands/configure.go | 2 +- pkg/cmd/commands/fixlint.go | 2 +- pkg/cmd/commands/login.go | 2 +- pkg/cmd/commands/plan.go | 2 +- pkg/cmd/commands/progress.go | 2 +- pkg/cmd/commands/watch_tui.go | 2 +- pkg/cmd/commands/watch_tui_view.go | 2 +- pkg/cmd/internal/templates/locks.go | 2 +- pkg/cmd/internal/templates/plan.go | 2 +- pkg/cmd/internal/templates/progress.go | 2 +- pkg/cmd/main.go | 2 +- 16 files changed, 19 insertions(+), 17 deletions(-) rename pkg/cmd/{internal => }/cliname/cliname.go (84%) rename pkg/cmd/{internal => }/cliname/cliname_test.go (100%) diff --git a/pkg/cmd/client/config.go b/pkg/cmd/client/config.go index d6504b547..d090135e2 100644 --- a/pkg/cmd/client/config.go +++ b/pkg/cmd/client/config.go @@ -12,7 +12,7 @@ import ( "github.com/block/spirit/pkg/utils" "gopkg.in/yaml.v3" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" ) // Config represents the global SchemaBot CLI configuration. diff --git a/pkg/cmd/internal/cliname/cliname.go b/pkg/cmd/cliname/cliname.go similarity index 84% rename from pkg/cmd/internal/cliname/cliname.go rename to pkg/cmd/cliname/cliname.go index b67e63898..f2e4deed2 100644 --- a/pkg/cmd/internal/cliname/cliname.go +++ b/pkg/cmd/cliname/cliname.go @@ -3,9 +3,11 @@ // Every pasteable command the CLI prints ("Force unlock: schemabot unlock // ...") starts with the tool name. When the CLI runs behind a wrapper, the // bare binary name is wrong: pasting it invokes an unconfigured binary -// instead of the wrapper the operator actually uses. The wrapper passes its -// own invocation on every call via the --cli-name flag, and every hint -// renders through Name so pasted commands work as printed. +// instead of the wrapper the operator actually uses. A wrapper that execs +// the binary passes its own invocation on every call via the --cli-name +// flag; a wrapper that embeds the command packages calls Set directly before +// parsing. Every hint renders through Name so pasted commands work as +// printed. package cliname import ( diff --git a/pkg/cmd/internal/cliname/cliname_test.go b/pkg/cmd/cliname/cliname_test.go similarity index 100% rename from pkg/cmd/internal/cliname/cliname_test.go rename to pkg/cmd/cliname/cliname_test.go diff --git a/pkg/cmd/commands/apply.go b/pkg/cmd/commands/apply.go index 091b7a1b2..fa1d5a0b8 100644 --- a/pkg/cmd/commands/apply.go +++ b/pkg/cmd/commands/apply.go @@ -11,7 +11,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" diff --git a/pkg/cmd/commands/common.go b/pkg/cmd/commands/common.go index be1072002..79a2260aa 100644 --- a/pkg/cmd/commands/common.go +++ b/pkg/cmd/commands/common.go @@ -18,7 +18,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" ) diff --git a/pkg/cmd/commands/configure.go b/pkg/cmd/commands/configure.go index 2d30e062f..60bacf3da 100644 --- a/pkg/cmd/commands/configure.go +++ b/pkg/cmd/commands/configure.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" ) // ConfigureCmd configures CLI settings. diff --git a/pkg/cmd/commands/fixlint.go b/pkg/cmd/commands/fixlint.go index d12761d9d..cd40ef11a 100644 --- a/pkg/cmd/commands/fixlint.go +++ b/pkg/cmd/commands/fixlint.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/lint" ) diff --git a/pkg/cmd/commands/login.go b/pkg/cmd/commands/login.go index 1f055b5ca..a93622230 100644 --- a/pkg/cmd/commands/login.go +++ b/pkg/cmd/commands/login.go @@ -12,7 +12,7 @@ import ( "time" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" ) // loginTimeout bounds the whole interactive login, including the wait for the diff --git a/pkg/cmd/commands/plan.go b/pkg/cmd/commands/plan.go index a14fa78a7..c0d48eed3 100644 --- a/pkg/cmd/commands/plan.go +++ b/pkg/cmd/commands/plan.go @@ -13,7 +13,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" diff --git a/pkg/cmd/commands/progress.go b/pkg/cmd/commands/progress.go index 62134e189..6a8b4522c 100644 --- a/pkg/cmd/commands/progress.go +++ b/pkg/cmd/commands/progress.go @@ -5,7 +5,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" ) diff --git a/pkg/cmd/commands/watch_tui.go b/pkg/cmd/commands/watch_tui.go index b417611f2..1feab4739 100644 --- a/pkg/cmd/commands/watch_tui.go +++ b/pkg/cmd/commands/watch_tui.go @@ -12,7 +12,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/cmd/client" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" ) diff --git a/pkg/cmd/commands/watch_tui_view.go b/pkg/cmd/commands/watch_tui_view.go index 88bbc85f1..355e32f06 100644 --- a/pkg/cmd/commands/watch_tui_view.go +++ b/pkg/cmd/commands/watch_tui_view.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/ui" diff --git a/pkg/cmd/internal/templates/locks.go b/pkg/cmd/internal/templates/locks.go index b0c03acbc..ed833269a 100644 --- a/pkg/cmd/internal/templates/locks.go +++ b/pkg/cmd/internal/templates/locks.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/ui" ) diff --git a/pkg/cmd/internal/templates/plan.go b/pkg/cmd/internal/templates/plan.go index 37dd1e2bd..ef011223f 100644 --- a/pkg/cmd/internal/templates/plan.go +++ b/pkg/cmd/internal/templates/plan.go @@ -10,7 +10,7 @@ import ( "golang.org/x/text/language" "github.com/block/schemabot/pkg/apitypes" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/ui" ) diff --git a/pkg/cmd/internal/templates/progress.go b/pkg/cmd/internal/templates/progress.go index 903f3c891..7f60480a3 100644 --- a/pkg/cmd/internal/templates/progress.go +++ b/pkg/cmd/internal/templates/progress.go @@ -9,7 +9,7 @@ import ( "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/caller" - "github.com/block/schemabot/pkg/cmd/internal/cliname" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/ddl" "github.com/block/schemabot/pkg/state" "github.com/block/schemabot/pkg/ui" diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index f213fe59c..eeda2ecd2 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -14,8 +14,8 @@ import ( "github.com/alecthomas/kong" "github.com/block/schemabot/pkg/cmd/client" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/commands" - "github.com/block/schemabot/pkg/cmd/internal/cliname" ) // Set by ldflags at build time. From 67527d366417d4b391939415afc736a36d5594ec Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 20:17:38 -0400 Subject: [PATCH 4/6] fix(cli): include the database in the lock-release hint The unlock command requires -d, so the pasteable hint must carry it. Co-Authored-By: Claude Fable 5 --- TEMPLATES.md | 4 ++-- pkg/cmd/internal/templates/locks.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TEMPLATES.md b/TEMPLATES.md index 5f202e71d..f11d65bf6 100644 --- a/TEMPLATES.md +++ b/TEMPLATES.md @@ -1534,7 +1534,7 @@ Another schema change is in progress for this database. Options: • Wait for the current schema change to complete - • Ask the lock owner to release: schemabot unlock + • Ask the lock owner to release: schemabot unlock -d testapp • Force unlock: schemabot unlock -d testapp --force @@ -1559,7 +1559,7 @@ Another schema change is in progress for this database. Options: • Wait for the current schema change to complete - • Ask the lock owner to release: schemabot unlock + • Ask the lock owner to release: schemabot unlock -d testapp • Force unlock: schemabot unlock -d testapp --force diff --git a/pkg/cmd/internal/templates/locks.go b/pkg/cmd/internal/templates/locks.go index ed833269a..f2c15b994 100644 --- a/pkg/cmd/internal/templates/locks.go +++ b/pkg/cmd/internal/templates/locks.go @@ -67,7 +67,7 @@ func WriteLockConflict(data LockConflictData) { fmt.Println() fmt.Println("Options:") fmt.Println(" • Wait for the current schema change to complete") - fmt.Printf(" • Ask the lock owner to release: %s unlock\n", cliname.Name()) + fmt.Printf(" • Ask the lock owner to release: %s unlock -d %s\n", cliname.Name(), data.Database) fmt.Printf(" • Force unlock: %s unlock -d %s --force\n", cliname.Name(), data.Database) fmt.Println() } From 640112262c716593b25e7a2cc923959a47e4df02 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 22:18:55 -0400 Subject: [PATCH 5/6] refactor(cli): use a fictional wrapper name in cliname examples Co-Authored-By: Claude Fable 5 --- pkg/cmd/cliname/cliname.go | 2 +- pkg/cmd/cliname/cliname_test.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/cliname/cliname.go b/pkg/cmd/cliname/cliname.go index f2e4deed2..297cdfc8f 100644 --- a/pkg/cmd/cliname/cliname.go +++ b/pkg/cmd/cliname/cliname.go @@ -24,7 +24,7 @@ const flagName = "--cli-name" var name atomic.Pointer[string] // Set records the tool name command hints render, typically a wrapper -// invocation such as "sq schemabot". An empty name is ignored so an absent +// invocation such as "acme schemabot". An empty name is ignored so an absent // --cli-name flag keeps the default rather than clearing the name. func Set(n string) { if n == "" { diff --git a/pkg/cmd/cliname/cliname_test.go b/pkg/cmd/cliname/cliname_test.go index 90623a32e..3778e04b0 100644 --- a/pkg/cmd/cliname/cliname_test.go +++ b/pkg/cmd/cliname/cliname_test.go @@ -20,8 +20,8 @@ func TestName(t *testing.T) { Set("") assert.Equal(t, "schemabot", Name(), "empty Set keeps the default") - Set("sq schemabot") - assert.Equal(t, "sq schemabot", Name(), "wrapper-passed name is rendered") + Set("acme schemabot") + assert.Equal(t, "acme schemabot", Name(), "wrapper-passed name is rendered") } func TestFromArgs(t *testing.T) { @@ -31,11 +31,11 @@ func TestFromArgs(t *testing.T) { want string }{ {"absent", []string{"status", "-e", "staging"}, ""}, - {"equals form", []string{"--cli-name=sq schemabot", "status"}, "sq schemabot"}, - {"space form", []string{"--cli-name", "sq schemabot", "status"}, "sq schemabot"}, - {"after subcommand", []string{"rollback", "--cli-name", "sq schemabot"}, "sq schemabot"}, + {"equals form", []string{"--cli-name=acme schemabot", "status"}, "acme schemabot"}, + {"space form", []string{"--cli-name", "acme schemabot", "status"}, "acme schemabot"}, + {"after subcommand", []string{"rollback", "--cli-name", "acme schemabot"}, "acme schemabot"}, {"missing value at end", []string{"status", "--cli-name"}, ""}, - {"not scanned past double dash", []string{"status", "--", "--cli-name=sq schemabot"}, ""}, + {"not scanned past double dash", []string{"status", "--", "--cli-name=acme schemabot"}, ""}, {"last occurrence wins", []string{"--cli-name=first", "--cli-name", "second"}, "second"}, } for _, tt := range tests { From a5acfc7238d859005bb7286fbef515377356ce12 Mon Sep 17 00:00:00 2001 From: Armand Parajon Date: Sun, 9 Aug 2026 22:33:29 -0400 Subject: [PATCH 6/6] feat(cli): render preview hints and kong help text with the wrapper name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the hint surfaces the first sweep missed: the preview usage/error hints, the demo narration lines in preview templates (so a wrapper-invoked demo no longer mixes tool names), and the kong help tags that embed command hints — now interpolated via a cli_name kong var. FromArgs skips hyphen-leading space-form values so it accepts exactly what kong's own scanner accepts, and a parse test pins the kong flag declaration and the raw-args scan to the same spelling. Co-Authored-By: Claude Fable 5 --- pkg/cmd/cliname/cliname.go | 7 ++-- pkg/cmd/cliname/cliname_test.go | 2 ++ pkg/cmd/commands/apply.go | 2 +- pkg/cmd/commands/cliname_flag_test.go | 32 +++++++++++++++++++ pkg/cmd/commands/preview.go | 18 ++++++----- pkg/cmd/internal/templates/preview_defer.go | 12 ++++--- .../internal/templates/preview_progress.go | 16 ++++++---- .../internal/templates/preview_sequential.go | 4 ++- pkg/cmd/internal/templates/preview_vschema.go | 12 ++++--- pkg/cmd/main.go | 5 ++- pkg/cmd/main_test.go | 1 + 11 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 pkg/cmd/commands/cliname_flag_test.go diff --git a/pkg/cmd/cliname/cliname.go b/pkg/cmd/cliname/cliname.go index 297cdfc8f..25b9fbdd1 100644 --- a/pkg/cmd/cliname/cliname.go +++ b/pkg/cmd/cliname/cliname.go @@ -44,14 +44,17 @@ func Name() string { // FromArgs extracts the --cli-name flag value from raw command-line args, // returning "" when the flag is absent. It scans the args directly because // the name feeds kong's usage text, which must be fixed before kong parses; -// kong still declares the flag so it is accepted at any position. +// kong still declares the flag so it is accepted at any position. The scan +// accepts exactly what kong's own parser accepts — in particular a +// hyphen-leading space-form value is not consumed, matching kong's scanner — +// so the two can never disagree on a successful parse. func FromArgs(args []string) string { value := "" for i := 0; i < len(args); i++ { if args[i] == "--" { break } - if args[i] == flagName && i+1 < len(args) { + if args[i] == flagName && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { value = args[i+1] i++ continue diff --git a/pkg/cmd/cliname/cliname_test.go b/pkg/cmd/cliname/cliname_test.go index 3778e04b0..a9c44a534 100644 --- a/pkg/cmd/cliname/cliname_test.go +++ b/pkg/cmd/cliname/cliname_test.go @@ -35,6 +35,8 @@ func TestFromArgs(t *testing.T) { {"space form", []string{"--cli-name", "acme schemabot", "status"}, "acme schemabot"}, {"after subcommand", []string{"rollback", "--cli-name", "acme schemabot"}, "acme schemabot"}, {"missing value at end", []string{"status", "--cli-name"}, ""}, + {"flag-like space-form value not consumed", []string{"--cli-name", "-e", "staging"}, ""}, + {"flag-like equals-form value accepted", []string{"--cli-name=-x"}, "-x"}, {"not scanned past double dash", []string{"status", "--", "--cli-name=acme schemabot"}, ""}, {"last occurrence wins", []string{"--cli-name=first", "--cli-name", "second"}, "second"}, } diff --git a/pkg/cmd/commands/apply.go b/pkg/cmd/commands/apply.go index fa1d5a0b8..a0923263a 100644 --- a/pkg/cmd/commands/apply.go +++ b/pkg/cmd/commands/apply.go @@ -26,7 +26,7 @@ type ApplyCmd struct { PullRequest int `help:"Pull request number (optional, for tracking)" name:"pull-request"` AutoApprove bool `short:"y" help:"Skip confirmation prompt" name:"auto-approve"` Watch bool `short:"w" help:"Watch progress until completion" default:"true" negatable:""` - DeferCutover bool `help:"Defer cutover until manual trigger (use 'schemabot cutover')" name:"defer-cutover"` + DeferCutover bool `help:"Defer cutover until manual trigger (use '${cli_name} cutover')" name:"defer-cutover"` DeferDeploy bool `help:"Defer deploy until manual trigger (holds at waiting_for_deploy)" name:"defer-deploy"` SkipRevert bool `help:"Skip revert window after completion (Vitess only)" name:"skip-revert"` Branch string `help:"Reuse existing PlanetScale branch (syncs with main, skips branch creation)" name:"branch"` diff --git a/pkg/cmd/commands/cliname_flag_test.go b/pkg/cmd/commands/cliname_flag_test.go new file mode 100644 index 000000000..25ec74706 --- /dev/null +++ b/pkg/cmd/commands/cliname_flag_test.go @@ -0,0 +1,32 @@ +package commands + +import ( + "testing" + + "github.com/alecthomas/kong" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/block/schemabot/pkg/cmd/cliname" +) + +// TestCLINameFlagSpelling pins the two independent spellings of the +// --cli-name flag to each other: the hidden kong declaration on Globals, +// which makes kong accept the flag at any position, and the raw-args scan in +// cliname.FromArgs, which consumes the value before kong parses so it can +// feed kong's usage name. A rename of either spelling without the other +// fails one of these assertions. +func TestCLINameFlagSpelling(t *testing.T) { + var cli struct { + Globals + Status struct{} `cmd:""` + } + parser, err := kong.New(&cli, kong.Name("schemabot")) + require.NoError(t, err) + + args := []string{"status", "--cli-name", "acme schemabot"} + _, err = parser.Parse(args) + require.NoError(t, err, "kong must accept the hidden flag after a subcommand") + assert.Equal(t, "acme schemabot", cli.CLIName, "kong parses the value into Globals") + assert.Equal(t, "acme schemabot", cliname.FromArgs(args), "the raw-args scan finds the same value") +} diff --git a/pkg/cmd/commands/preview.go b/pkg/cmd/commands/preview.go index 656c910aa..6d0915793 100644 --- a/pkg/cmd/commands/preview.go +++ b/pkg/cmd/commands/preview.go @@ -5,13 +5,14 @@ import ( "strings" "time" + "github.com/block/schemabot/pkg/cmd/cliname" "github.com/block/schemabot/pkg/cmd/internal/templates" webhooktemplates "github.com/block/schemabot/pkg/webhook/templates" ) // PreviewCmd previews CLI output templates without running schema changes. type PreviewCmd struct { - Type string `arg:"" optional:"" help:"Preview type (run 'schemabot preview' for valid types)"` + Type string `arg:"" optional:"" help:"Preview type (run '${cli_name} preview' for valid types)"` Live bool `help:"Run interactive preview for TUI preview types"` } @@ -152,17 +153,17 @@ func (cmd *PreviewCmd) Run(g *Globals) error { fmt.Println("=" + strings.Repeat("=", 70)) previewExitAll() default: - return fmt.Errorf("unknown preview type: %s (run 'schemabot preview' for valid types)", cmd.Type) + return fmt.Errorf("unknown preview type: %s (run '%s preview' for valid types)", cmd.Type, cliname.Name()) } return nil } func printPreviewUsage() { - fmt.Println(`Preview CLI output templates without running schema changes. + fmt.Printf(`Preview CLI output templates without running schema changes. Usage: - schemabot preview + %[1]s preview Basic Types: plan Show sample plan output @@ -331,8 +332,9 @@ Meta: all Show all preview types Examples: - schemabot preview plan - schemabot preview sequential_all - schemabot preview comment_plan_all - schemabot preview all`) + %[1]s preview plan + %[1]s preview sequential_all + %[1]s preview comment_plan_all + %[1]s preview all +`, cliname.Name()) } diff --git a/pkg/cmd/internal/templates/preview_defer.go b/pkg/cmd/internal/templates/preview_defer.go index 8083048d6..3966976e6 100644 --- a/pkg/cmd/internal/templates/preview_defer.go +++ b/pkg/cmd/internal/templates/preview_defer.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/state" ) @@ -186,7 +188,7 @@ func previewDeferStoppedOutput() { } WriteProgress(data) - fmt.Println("Use 'schemabot start -e staging ' to resume.") + fmt.Printf("Use '%s start -e staging ' to resume.\n", cliname.Name()) } func previewDeferDetachedOutput() { @@ -198,18 +200,18 @@ func previewDeferDetachedOutput() { fmt.Println("Row copy is complete and the shadow table is kept in sync.") fmt.Println() fmt.Println("To check status:") - fmt.Println(" schemabot status ") + fmt.Printf(" %s status \n", cliname.Name()) fmt.Println() fmt.Println("To proceed with cutover:") - fmt.Println(" schemabot cutover -e staging ") + fmt.Printf(" %s cutover -e staging \n", cliname.Name()) fmt.Println() fmt.Println("To abort the schema change:") - fmt.Println(" schemabot stop -e staging ") + fmt.Printf(" %s stop -e staging \n", cliname.Name()) } func previewDeferCuttingOutput() { fmt.Println("Defer cutover: Cutting over in progress") - fmt.Println("(After user pressed Enter or ran `schemabot cutover`)") + fmt.Printf("(After user pressed Enter or ran `%s cutover`)\n", cliname.Name()) fmt.Println() data := ProgressData{ diff --git a/pkg/cmd/internal/templates/preview_progress.go b/pkg/cmd/internal/templates/preview_progress.go index b836dd94f..7b3a3e01c 100644 --- a/pkg/cmd/internal/templates/preview_progress.go +++ b/pkg/cmd/internal/templates/preview_progress.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/apitypes" "github.com/block/schemabot/pkg/state" "vitess.io/vitess/go/vt/key" @@ -798,7 +800,7 @@ func previewWaitingForCutoverOutput() { fmt.Println("Row copy complete. All data has been copied and new writes") fmt.Println("continue to be replicated to keep the shadow table in sync.") fmt.Println() - fmt.Println("To proceed: schemabot cutover -e staging ") + fmt.Printf("To proceed: %s cutover -e staging \n", cliname.Name()) fmt.Println("Watching for cutover... (Ctrl+C to detach)") } @@ -896,12 +898,12 @@ func previewStoppedOutput() { }, } WriteProgress(data) - fmt.Println("\nStopped. Use 'schemabot start -e staging ' to resume from checkpoint.") + fmt.Printf("\nStopped. Use '%s start -e staging ' to resume from checkpoint.\n", cliname.Name()) } func previewApplyWatchOutput() { fmt.Println("Apply watch mode: Running with footer controls") - fmt.Println("(schemabot apply -s ./schema -e staging)") + fmt.Printf("(%s apply -s ./schema -e staging)\n", cliname.Name()) fmt.Println() // In-progress state @@ -950,7 +952,7 @@ func previewApplyWatchOutput() { func previewApplyStoppedOutput() { fmt.Println("Apply watch mode: Stopped by user") - fmt.Println("(user ran schemabot stop)") + fmt.Printf("(user ran %s stop)\n", cliname.Name()) fmt.Println() data := ProgressData{ @@ -974,7 +976,7 @@ func previewApplyStoppedOutput() { WriteProgress(data) fmt.Printf("%s\n", FormatApplyStopped()) - fmt.Println("Use 'schemabot start -e staging ' to resume.") + fmt.Printf("Use '%s start -e staging ' to resume.\n", cliname.Name()) } // ============================================================================= @@ -982,7 +984,7 @@ func previewApplyStoppedOutput() { // ============================================================================= func previewStopCommandOutput() { - fmt.Println("Stop command: User runs 'schemabot stop -e staging '") + fmt.Printf("Stop command: User runs '%s stop -e staging '\n", cliname.Name()) fmt.Println() WriteStopSuccess(StopData{ @@ -1019,7 +1021,7 @@ func previewStopCommandOutput() { } func previewStartCommandOutput() { - fmt.Println("Start command: User runs 'schemabot start -e staging '") + fmt.Printf("Start command: User runs '%s start -e staging '\n", cliname.Name()) fmt.Println() WriteStartSuccess(StartData{ diff --git a/pkg/cmd/internal/templates/preview_sequential.go b/pkg/cmd/internal/templates/preview_sequential.go index 323dc1d85..7c235df71 100644 --- a/pkg/cmd/internal/templates/preview_sequential.go +++ b/pkg/cmd/internal/templates/preview_sequential.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/block/schemabot/pkg/cmd/cliname" + "github.com/block/schemabot/pkg/state" ) @@ -242,7 +244,7 @@ func previewSeqStoppedOutput() { } WriteProgress(data) - fmt.Println("Use 'schemabot start' to resume from checkpoint.") + fmt.Printf("Use '%s start' to resume from checkpoint.\n", cliname.Name()) } func previewSequentialAllOutput() { diff --git a/pkg/cmd/internal/templates/preview_vschema.go b/pkg/cmd/internal/templates/preview_vschema.go index d0a9f988b..4fe815d61 100644 --- a/pkg/cmd/internal/templates/preview_vschema.go +++ b/pkg/cmd/internal/templates/preview_vschema.go @@ -1,11 +1,15 @@ package templates -import "fmt" +import ( + "fmt" + + "github.com/block/schemabot/pkg/cmd/cliname" +) // previewVSchemaPlanOutput shows a plan with both DDL and VSchema changes in one keyspace. func previewVSchemaPlanOutput() { fmt.Println("Vitess plan: DDL + VSchema changes in a sharded keyspace") - fmt.Println("(schemabot plan -s examples/vitess/schema -e staging)") + fmt.Printf("(%s plan -s examples/vitess/schema -e staging)\n", cliname.Name()) fmt.Println() WritePlanHeader(PlanHeaderData{ @@ -68,7 +72,7 @@ func previewVSchemaPlanOutput() { // previewVSchemaOnlyOutput shows a plan with only VSchema changes (no DDL). func previewVSchemaOnlyOutput() { fmt.Println("Vitess plan: VSchema-only update (no table DDL changes)") - fmt.Println("(schemabot plan -s examples/vitess/schema -e staging)") + fmt.Printf("(%s plan -s examples/vitess/schema -e staging)\n", cliname.Name()) fmt.Println() WritePlanHeader(PlanHeaderData{ @@ -114,7 +118,7 @@ func previewVSchemaOnlyOutput() { // previewMultiKeyspacePlanOutput shows a plan spanning multiple keyspaces with VSchema. func previewMultiKeyspacePlanOutput() { fmt.Println("Vitess plan: Multi-keyspace with DDL + VSchema across keyspaces") - fmt.Println("(schemabot plan -s examples/vitess/schema -e staging)") + fmt.Printf("(%s plan -s examples/vitess/schema -e staging)\n", cliname.Name()) fmt.Println() WritePlanHeader(PlanHeaderData{ diff --git a/pkg/cmd/main.go b/pkg/cmd/main.go index eeda2ecd2..61576259d 100644 --- a/pkg/cmd/main.go +++ b/pkg/cmd/main.go @@ -71,7 +71,10 @@ func main() { kong.Name(cliname.Name()), kong.Description("Declarative schema GitOps orchestrator"), kong.UsageOnError(), - kong.Vars{"version": fmt.Sprintf("%s (commit: %s)", version, commit)}, + kong.Vars{ + "version": fmt.Sprintf("%s (commit: %s)", version, commit), + "cli_name": cliname.Name(), + }, ) cli.Version = version diff --git a/pkg/cmd/main_test.go b/pkg/cmd/main_test.go index 0b1b1ece6..a2fc77c23 100644 --- a/pkg/cmd/main_test.go +++ b/pkg/cmd/main_test.go @@ -14,6 +14,7 @@ func TestRollbackRequiresEnvironmentFlag(t *testing.T) { parser, err := kong.New(&cli, kong.Name("schemabot"), kong.Writers(io.Discard, io.Discard), + kong.Vars{"cli_name": "schemabot"}, ) require.NoError(t, err)