Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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


Expand Down
6 changes: 4 additions & 2 deletions pkg/cmd/client/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (

"github.com/block/spirit/pkg/utils"
"gopkg.in/yaml.v3"

"github.com/block/schemabot/pkg/cmd/cliname"
)

// Config represents the global SchemaBot CLI configuration.
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions pkg/cmd/cliname/cliname.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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. 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 (
"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.Pointer[string]

// Set records the tool name command hints render, typically a wrapper
// 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 == "" {
return
}
name.Store(&n)
}

// Name returns the tool name to render at the start of CLI command hints.
func Name() string {
if p := name.Load(); p != nil {
return *p
}
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. 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) && !strings.HasPrefix(args[i+1], "-") {
value = args[i+1]
i++
continue
}
if rest, ok := strings.CutPrefix(args[i], flagName+"="); ok {
value = rest
}
}
return value
}
48 changes: 48 additions & 0 deletions pkg/cmd/cliname/cliname_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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. 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("")
assert.Equal(t, "schemabot", Name(), "empty Set keeps the default")

Set("acme schemabot")
assert.Equal(t, "acme 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=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"}, ""},
{"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"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, FromArgs(tt.args))
})
}
}
9 changes: 5 additions & 4 deletions pkg/cmd/commands/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/block/schemabot/pkg/apitypes"
"github.com/block/schemabot/pkg/cmd/client"
"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"
Expand All @@ -25,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"`
Expand Down Expand Up @@ -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):
Expand All @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
32 changes: 32 additions & 0 deletions pkg/cmd/commands/cliname_flag_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
13 changes: 9 additions & 4 deletions pkg/cmd/commands/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/block/schemabot/pkg/apitypes"
"github.com/block/schemabot/pkg/cmd/client"
"github.com/block/schemabot/pkg/cmd/cliname"
"github.com/block/schemabot/pkg/cmd/internal/templates"
"github.com/block/schemabot/pkg/state"
)
Expand All @@ -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:"-"`
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
5 changes: 3 additions & 2 deletions pkg/cmd/commands/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/block/schemabot/pkg/cmd/client"
"github.com/block/schemabot/pkg/cmd/cliname"
)

// ConfigureCmd configures CLI settings.
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/cmd/commands/fixlint.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"github.com/block/schemabot/pkg/cmd/cliname"
"github.com/block/schemabot/pkg/lint"
)

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions pkg/cmd/commands/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/block/schemabot/pkg/cmd/client"
"github.com/block/schemabot/pkg/cmd/cliname"
)

// loginTimeout bounds the whole interactive login, including the wait for the
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion pkg/cmd/commands/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/block/schemabot/pkg/apitypes"
"github.com/block/schemabot/pkg/cmd/client"
"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"
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading