From ab69c15fa63ae52ed563365aec0c52e98b8ae6b1 Mon Sep 17 00:00:00 2001 From: dandeandean Date: Sat, 2 May 2026 14:22:08 -0400 Subject: [PATCH 1/2] yoke: refactor cli and add bash completions --- cmd/yoke/cmd_atc.go | 21 +++-- cmd/yoke/cmd_blackbox.go | 54 +++++------ cmd/yoke/cmd_descent.go | 72 +++++++-------- cmd/yoke/cmd_help.txt | 1 + cmd/yoke/cmd_mayday.go | 51 +++++------ cmd/yoke/cmd_schematics.go | 169 +++++++++++++++++++++------------- cmd/yoke/cmd_sign.go | 39 ++++---- cmd/yoke/cmd_stow.go | 45 +++++----- cmd/yoke/cmd_takeoff.go | 71 +++++++-------- cmd/yoke/cmd_turbulence.go | 42 +++++---- cmd/yoke/cmd_unlatch.go | 44 ++++----- cmd/yoke/cmd_verify.go | 33 ++++--- cmd/yoke/cmd_version.go | 7 ++ cmd/yoke/command.go | 77 ++++++++++++++++ cmd/yoke/command_test.go | 85 ++++++++++++++++++ cmd/yoke/complete.go | 115 ++++++++++++++++++++++++ cmd/yoke/complete_test.go | 179 +++++++++++++++++++++++++++++++++++++ cmd/yoke/main.go | 152 +++++++++---------------------- cmd/yoke/main_test.go | 11 +-- 19 files changed, 831 insertions(+), 437 deletions(-) create mode 100644 cmd/yoke/command.go create mode 100644 cmd/yoke/command_test.go create mode 100644 cmd/yoke/complete.go create mode 100644 cmd/yoke/complete_test.go diff --git a/cmd/yoke/cmd_atc.go b/cmd/yoke/cmd_atc.go index d703e128..0659c203 100644 --- a/cmd/yoke/cmd_atc.go +++ b/cmd/yoke/cmd_atc.go @@ -25,19 +25,18 @@ type ATCParams struct { Debug string } -func GetAtcParams(settings GlobalSettings, args []string) ATCParams { +var CmdATC = NewCommand("atc", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("atc", flag.ExitOnError) - - params := ATCParams{GlobalSettings: settings} - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - + params := ATCParams{} flagset.StringVar(¶ms.Debug, "debug-file", "", "debug file") - - flagset.Parse(args) - - return params -} + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + flagset.Parse(args) + ATC(ctx, params) + return nil + } +}) func ATC(ctx context.Context, params ATCParams) error { client, err := k8s.NewClientFromConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_blackbox.go b/cmd/yoke/cmd_blackbox.go index 5efb7d06..786e7efc 100644 --- a/cmd/yoke/cmd_blackbox.go +++ b/cmd/yoke/cmd_blackbox.go @@ -32,45 +32,39 @@ type BlackboxParams struct { //go:embed cmd_blackbox_help.txt var blackboxHelp string -func init() { - blackboxHelp = strings.TrimSpace(internal.Colorize(blackboxHelp)) -} - -func GetBlackBoxParams(settings GlobalSettings, args []string) (*BlackboxParams, error) { +var CmdBlackbox = NewCommand("blackbox", []string{"inspect"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + params := BlackboxParams{} flagset := flag.NewFlagSet("blackbox", flag.ExitOnError) + flagset.IntVar(¶ms.Context, "context", 4, "number of lines of context in diff (ignored if not comparing revisions)") + flagset.StringVar(¶ms.Namespace, "namespace", "", "namespace of release to inspect") flagset.Usage = func() { + blackboxHelp = strings.TrimSpace(internal.Colorize(blackboxHelp)) fmt.Fprintln(flagset.Output(), blackboxHelp) flagset.PrintDefaults() } - - params := BlackboxParams{GlobalSettings: settings} - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - flagset.IntVar(¶ms.Context, "context", 4, "number of lines of context in diff (ignored if not comparing revisions)") - flagset.StringVar(¶ms.Namespace, "namespace", "", "namespace of release to inspect") - flagset.Parse(args) - - params.Release = flagset.Arg(0) - - if revision := flagset.Arg(1); revision != "" { - revisionID, err := strconv.Atoi(flagset.Arg(1)) - if err != nil { - return nil, fmt.Errorf("revision must be an integer ID: %w", err) + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + flagset.Parse(args) + params.Release = flagset.Arg(0) + if revision := flagset.Arg(1); revision != "" { + revisionID, err := strconv.Atoi(flagset.Arg(1)) + if err != nil { + return fmt.Errorf("revision must be an integer ID: %w", err) + } + params.RevisionID = revisionID } - params.RevisionID = revisionID - } - - if revision := flagset.Arg(2); revision != "" { - revisionID, err := strconv.Atoi(flagset.Arg(2)) - if err != nil { - return nil, fmt.Errorf("revision to diff must be an integer ID: %w", err) + if revision := flagset.Arg(2); revision != "" { + revisionID, err := strconv.Atoi(flagset.Arg(2)) + if err != nil { + return fmt.Errorf("revision to diff must be an integer ID: %w", err) + } + params.DiffRevisionID = revisionID } - params.DiffRevisionID = revisionID + return Blackbox(ctx, params) } - - return ¶ms, nil -} +}) func Blackbox(ctx context.Context, params BlackboxParams) error { client, err := k8s.NewClientFromConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_descent.go b/cmd/yoke/cmd_descent.go index 1e318c4e..110fb7e5 100644 --- a/cmd/yoke/cmd_descent.go +++ b/cmd/yoke/cmd_descent.go @@ -16,64 +16,54 @@ import ( //go:embed cmd_descent_help.txt var descentHelp string -func init() { - descentHelp = strings.TrimSpace(internal.Colorize(descentHelp)) -} - type DescentParams struct { GlobalSettings yoke.DescentParams } -func GetDescentfParams(settings GlobalSettings, args []string) (*DescentParams, error) { +var CmdDescent = NewCommand("descent", []string{"down", "restore"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + params := DescentParams{} + var removeAll bool flagset := flag.NewFlagSet("descent", flag.ExitOnError) - flagset.Usage = func() { - fmt.Fprintln(flagset.Output(), descentHelp) - flagset.PrintDefaults() - } - - params := DescentParams{ - GlobalSettings: settings, - } - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - flagset.StringVar(¶ms.Namespace, "namespace", "", "release target namespace, defaults to context namespace if not provided") flagset.DurationVar(¶ms.Wait, "wait", 0, "time to wait for release to become ready") flagset.DurationVar(¶ms.Poll, "poll", 5*time.Second, "interval to poll resource state at. Used with --wait") flagset.BoolVar(¶ms.Lock, "lock", false, "if enabled does locks release before deploying revision (only prevents other locked runs from running).") - - var removeAll bool flagset.BoolVar(&removeAll, "remove-all", false, "enables pruning of crds and namespaces owned by the release if a new revision would orphan them.\nDestructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveCRDs, "remove-crds", false, "enables pruning of crds owned by the release.\nDestructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveNamespaces, "remove-namespaces", false, "enables pruning of namespaces owned by the release.\nDestructive and dangerous use with caution.") - flagset.Parse(args) - - if removeAll { - params.RemoveCRDs = true - params.RemoveNamespaces = true - } - - params.Release = flagset.Arg(0) - if params.Release == "" { - return nil, fmt.Errorf("release is required as first positional arg") - } - - if len(flagset.Args()) < 2 { - return nil, fmt.Errorf("revision is required as second positional arg") + descentHelp = strings.TrimSpace(internal.Colorize(descentHelp)) + flagset.Usage = func() { + fmt.Fprintln(flagset.Output(), descentHelp) + flagset.PrintDefaults() } - - revisionID, err := strconv.Atoi(flagset.Arg(1)) - if err != nil { - return nil, fmt.Errorf("revision must be an integer ID: %w", err) + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + flagset.Parse(args) + if removeAll { + params.RemoveCRDs = true + params.RemoveNamespaces = true + } + params.Release = flagset.Arg(0) + if params.Release == "" { + return fmt.Errorf("release is required as first positional arg") + } + + if len(flagset.Args()) < 2 { + return fmt.Errorf("revision is required as second positional arg") + } + + revisionID, err := strconv.Atoi(flagset.Arg(1)) + if err != nil { + return fmt.Errorf("revision must be an integer ID: %w", err) + } + params.RevisionID = revisionID + return Descent(ctx, params) } - - params.RevisionID = revisionID - - return ¶ms, nil -} +}) func Descent(ctx context.Context, params DescentParams) error { commander, err := yoke.FromKubeConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_help.txt b/cmd/yoke/cmd_help.txt index 63b8d679..c612455f 100644 --- a/cmd/yoke/cmd_help.txt +++ b/cmd/yoke/cmd_help.txt @@ -17,5 +17,6 @@ atc sign verify version +complete !cyan Global Flags: diff --git a/cmd/yoke/cmd_mayday.go b/cmd/yoke/cmd_mayday.go index dae28773..b912707c 100644 --- a/cmd/yoke/cmd_mayday.go +++ b/cmd/yoke/cmd_mayday.go @@ -19,43 +19,36 @@ type MaydayParams struct { //go:embed cmd_mayday_help.txt var maydayHelp string -func init() { - maydayHelp = strings.TrimSpace(internal.Colorize(maydayHelp)) -} - -func GetMaydayParams(settings GlobalSettings, args []string) (*MaydayParams, error) { +var CmdMayday = NewCommand("mayday", []string{"delete"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("mayday", flag.ExitOnError) - - flagset.Usage = func() { - fmt.Fprintln(flagset.Output(), maydayHelp) - flagset.PrintDefaults() - } - - params := MaydayParams{GlobalSettings: settings} - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - - flagset.StringVar(¶ms.Namespace, "namespace", "", "release target namespace, defaults to context namespace if not provided") - var removeAll bool + params := MaydayParams{} + flagset.StringVar(¶ms.Namespace, "namespace", "", "release target namespace, defaults to context namespace if not provided") flagset.BoolVar(&removeAll, "remove-all", false, "deletes crds and namespaces owned by the release. Destructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveCRDs, "remove-crds", false, "deletes crds owned by the release. Destructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveNamespaces, "remove-namespaces", false, "deletes namespaces owned by the release. Destructive and dangerous use with caution.") - flagset.Parse(args) - - if removeAll { - params.RemoveCRDs = true - params.RemoveNamespaces = true + flagset.Usage = func() { + maydayHelp = strings.TrimSpace(internal.Colorize(maydayHelp)) + fmt.Fprintln(flagset.Output(), maydayHelp) + flagset.PrintDefaults() } - - params.Release = flagset.Arg(0) - if params.Release == "" { - return nil, fmt.Errorf("release is required") + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + + flagset.Parse(args) + if removeAll { + params.RemoveCRDs = true + params.RemoveNamespaces = true + } + params.Release = flagset.Arg(0) + if params.Release == "" { + return fmt.Errorf("release is required") + } + return Mayday(ctx, params) } - - return ¶ms, nil -} +}) func Mayday(ctx context.Context, params MaydayParams) error { commander, err := yoke.FromKubeConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_schematics.go b/cmd/yoke/cmd_schematics.go index 2cd58824..a90bbcd4 100644 --- a/cmd/yoke/cmd_schematics.go +++ b/cmd/yoke/cmd_schematics.go @@ -6,96 +6,141 @@ import ( "flag" "fmt" "os" + "slices" "strings" "github.com/yokecd/yoke/internal" "github.com/yokecd/yoke/pkg/yoke" ) -//go:embed cmd_schematics_help.txt -var schematicsHelp string - func init() { - schematicsHelp = strings.TrimSpace(internal.Colorize(schematicsHelp)) + CmdSchematics.AddCommand(CmdSchematicsGet) + CmdSchematics.AddCommand(CmdSchematicsLs) + CmdSchematics.AddCommand(CmdSchematicsSet) } -func SchematicsCommand(ctx context.Context, args []string) error { - flagset := flag.NewFlagSet("schematics", flag.ExitOnError) +//go:embed cmd_schematics_help.txt +var schematicsHelp string +var CmdSchematics = NewCommand("schematics", []string{"meta"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + flagset := flag.NewFlagSet("schematics", flag.ExitOnError) + var wasmPath string + flagset.StringVar(&wasmPath, "wasm", "", "path to wasm file. http(s), and oci urls are supported") flagset.Usage = func() { + schematicsHelp = strings.TrimSpace(internal.Colorize(schematicsHelp)) fmt.Fprintln(flagset.Output(), schematicsHelp) flagset.PrintDefaults() } + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + flagset.Parse(args) + return fmt.Errorf("subcommand is required") + } +}) + +var CmdSchematicsLs = NewCommand("ls", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + flagset := flag.NewFlagSet("schematics ls", flag.ExitOnError) var wasmPath string flagset.StringVar(&wasmPath, "wasm", "", "path to wasm file. http(s), and oci urls are supported") - - flagset.Parse(args) - - if wasmPath == "" { + flagset.Usage = func() { flagset.Usage() - return fmt.Errorf("--wasm is required") + flagset.PrintDefaults() } + return flagset, func(ctx context.Context, settings GlobalSettings, _ []string) error { + // This is so incredibily janky, but I can't think of another way to do this + args := os.Args + idxBase := slices.Index(args, "schematics") + if idxBase >= 0 { + args = os.Args[idxBase+1:] + } else { + return fmt.Errorf("orphaned subcommand") + } - if len(flagset.Args()) == 0 { - flagset.Usage() - return fmt.Errorf("no subcommand given to schematics") + flagset.Parse(args) + schematics, err := yoke.ListSchematics(ctx, yoke.ListSchematicsParams{WasmURL: wasmPath}) + if err != nil { + return fmt.Errorf("failed to list schematics: %w", err) + } + for _, schematic := range schematics { + fmt.Fprintln(internal.Stdout(ctx), schematic) + } + return nil } +}) - subcmd, subargs := flagset.Arg(0), flagset.Args()[1:] +var CmdSchematicsGet = NewCommand("get", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + flagset := flag.NewFlagSet("get", flag.ExitOnError) - switch subcmd { - case "ls": - { - schematics, err := yoke.ListSchematics(ctx, yoke.ListSchematicsParams{WasmURL: wasmPath}) - if err != nil { - return fmt.Errorf("failed to list schematics: %w", err) - } - for _, schematic := range schematics { - fmt.Fprintln(internal.Stdout(ctx), schematic) - } - return nil + var wasmPath string + flagset.StringVar(&wasmPath, "wasm", "", "path to wasm file. http(s), and oci urls are supported") + flagset.Usage = func() { + flagset.Usage() + flagset.PrintDefaults() + } + + return flagset, func(ctx context.Context, settings GlobalSettings, _ []string) error { + args := os.Args + idxBase := slices.Index(args, "schematics") + if idxBase >= 0 { + args = os.Args[idxBase+1:] + } else { + return fmt.Errorf("orphaned subcommand") + } + flagset.Parse(args) + idxGet := slices.Index(flagset.Args(), "get") + name := "" + if idxGet >= 0 { + name = flagset.Arg(idxGet + 1) } - case "get": - { - if len(subargs) == 0 || subargs[0] == "" { - return fmt.Errorf("name of schematics property is required") - } - data, err := yoke.GetSchematic(ctx, yoke.GetSchematicParams{ - WasmURL: wasmPath, - Name: subargs[0], - }) - if err != nil { - return fmt.Errorf("failed to get schematics: %w", err) - } - _, err = internal.Stdout(ctx).Write(data) - return err + if name == "" { + return fmt.Errorf("name of schematics property is required") } - case "set": - { - setcmdFlagset := flag.NewFlagSet("schematics set", flag.ExitOnError) - cmd := setcmdFlagset.Bool("cmd", false, "marks the input as command args to be executed to generate the schematic data") + data, err := yoke.GetSchematic(ctx, yoke.GetSchematicParams{ + WasmURL: wasmPath, + Name: name, + }) + if err != nil { + return fmt.Errorf("failed to get schematics: %w", err) + } + _, err = internal.Stdout(ctx).Write(data) + return err + } +}) - setcmdFlagset.Usage = func() { - flagset.Usage() - setcmdFlagset.PrintDefaults() - } +var CmdSchematicsSet = NewCommand("set", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + flagset := flag.NewFlagSet("set", flag.ExitOnError) + cmd := flagset.Bool("cmd", false, "marks the input as command args to be executed to generate the schematic data") - _ = setcmdFlagset.Parse(subargs) + var wasmPath string + flagset.StringVar(&wasmPath, "wasm", "", "path to wasm file. http(s), and oci urls are supported") + flagset.Usage = func() { + flagset.Usage() + flagset.PrintDefaults() + } - name := setcmdFlagset.Arg(0) - if name == "" { - return fmt.Errorf("name of schematics property is required") - } + return flagset, func(ctx context.Context, settings GlobalSettings, _ []string) error { + args := os.Args + idxBase := slices.Index(args, "schematics") + if idxBase >= 0 { + args = os.Args[idxBase+1:] + } else { + return fmt.Errorf("orphaned subcommand") + } - return yoke.SetSchematic(ctx, yoke.SetSchematicParams{ - WasmPath: wasmPath, - Name: name, - Input: os.Stdin, - CMD: *cmd, - }) + _ = flagset.Parse(args) + idxGet := slices.Index(flagset.Args(), "set") + name := "" + if idxGet >= 0 { + name = flagset.Arg(idxGet + 1) } - default: - return fmt.Errorf("unknown schematics subcommand: %q", subcmd) + if name == "" { + return fmt.Errorf("name of schematics property is required") + } + return yoke.SetSchematic(ctx, yoke.SetSchematicParams{ + WasmPath: wasmPath, + Name: name, + Input: os.Stdin, + CMD: *cmd, + }) } -} +}) diff --git a/cmd/yoke/cmd_sign.go b/cmd/yoke/cmd_sign.go index 67176a64..6f49de41 100644 --- a/cmd/yoke/cmd_sign.go +++ b/cmd/yoke/cmd_sign.go @@ -1,6 +1,7 @@ package main import ( + "context" _ "embed" "flag" "fmt" @@ -13,33 +14,29 @@ import ( //go:embed cmd_sign_help.txt var signHelp string -func init() { - signHelp = strings.TrimSpace(internal.Colorize(signHelp)) -} - -func GetSignParams(args []string) (*yoke.SignParams, error) { +var CmdSign = NewCommand("sign", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("sign", flag.ExitOnError) - + params := yoke.SignParams{} + flagset.StringVar(¶ms.KeyPath, "key", "", "Path to private key pem used for signing") + flagset.StringVar(¶ms.Out, "o", "", "output file to write signed wasm module. If omitted module will be signed in place") + flagset.BoolVar(¶ms.Force, "f", false, "forcefully override existing signature on module") flagset.Usage = func() { + signHelp = strings.TrimSpace(internal.Colorize(signHelp)) fmt.Fprintln(flagset.Output(), signHelp) flagset.PrintDefaults() } + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { - var params yoke.SignParams - flagset.StringVar(¶ms.KeyPath, "key", "", "Path to private key pem used for signing") - flagset.StringVar(¶ms.Out, "o", "", "output file to write signed wasm module. If omitted module will be signed in place") - flagset.BoolVar(¶ms.Force, "f", false, "forcefully override existing signature on module") - - flagset.Parse(args) + flagset.Parse(args) - params.WasmFile = flagset.Arg(0) + params.WasmFile = flagset.Arg(0) - if params.WasmFile == "" { - return nil, fmt.Errorf("wasm file must be specified as first argument") + if params.WasmFile == "" { + return fmt.Errorf("wasm file must be specified as first argument") + } + if params.KeyPath == "" { + return fmt.Errorf("key is required") + } + return yoke.Sign(params) } - if params.KeyPath == "" { - return nil, fmt.Errorf("key is required") - } - - return ¶ms, nil -} +}) diff --git a/cmd/yoke/cmd_stow.go b/cmd/yoke/cmd_stow.go index d2f34476..4581d80b 100644 --- a/cmd/yoke/cmd_stow.go +++ b/cmd/yoke/cmd_stow.go @@ -1,6 +1,7 @@ package main import ( + "context" _ "embed" "flag" "fmt" @@ -13,36 +14,32 @@ import ( //go:embed cmd_stow_help.txt var stowHelp string -func init() { - stowHelp = strings.TrimSpace(internal.Colorize(stowHelp)) -} - -func GetStowParams(args []string) (*yoke.StowParams, error) { +var CmdStow = NewCommand("stow", []string{"push"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("stow", flag.ExitOnError) - - flagset.Usage = func() { - fmt.Fprintln(flagset.Output(), stowHelp) - flagset.PrintDefaults() - } - - var params yoke.StowParams + params := yoke.StowParams{} flagset.BoolVar(¶ms.Insecure, "insecure", false, "allows image references to be fetched without TLS") flagset.Func("tag", "comma separated list of tags", func(s string) error { params.Tags = append(params.Tags, strings.Split(s, ",")...) return nil }) - flagset.Parse(args) - - params.WasmFile = flagset.Arg(0) - params.URL = flagset.Arg(1) - - if params.WasmFile == "" { - return nil, fmt.Errorf("wasm file must be specified as first argument") - } - if params.URL == "" { - return nil, fmt.Errorf("OCI url must be specified as second argument") + flagset.Usage = func() { + stowHelp = strings.TrimSpace(internal.Colorize(stowHelp)) + fmt.Fprintln(flagset.Output(), stowHelp) + flagset.PrintDefaults() } - return ¶ms, nil -} + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + flagset.Parse(args) + params.WasmFile = flagset.Arg(0) + params.URL = flagset.Arg(1) + + if params.WasmFile == "" { + return fmt.Errorf("wasm file must be specified as first argument") + } + if params.URL == "" { + return fmt.Errorf("OCI url must be specified as second argument") + } + return yoke.Stow(ctx, params) + } +}) diff --git a/cmd/yoke/cmd_takeoff.go b/cmd/yoke/cmd_takeoff.go index 182f3ac8..cf02d6db 100644 --- a/cmd/yoke/cmd_takeoff.go +++ b/cmd/yoke/cmd_takeoff.go @@ -24,26 +24,11 @@ type TakeoffParams struct { //go:embed cmd_takeoff_help.txt var takeoffHelp string -func init() { - takeoffHelp = strings.TrimSpace(internal.Colorize(takeoffHelp)) -} +var CmdTakeoff = NewCommand("takeoff", []string{"up", "apply"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { -func GetTakeoffParams(settings GlobalSettings, source io.Reader, args []string) (*TakeoffParams, error) { flagset := flag.NewFlagSet("takeoff", flag.ExitOnError) - - flagset.Usage = func() { - fmt.Fprintln(flagset.Output(), takeoffHelp) - flagset.PrintDefaults() - } - - params := TakeoffParams{ - GlobalSettings: settings, - TakeoffParams: yoke.TakeoffParams{ - Flight: yoke.FlightParams{Input: source}, - }, - } - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + params := TakeoffParams{} + removeAll := false flagset.BoolVar(¶ms.SendToStdout, "stdout", false, "execute the underlying wasm and outputs it to stdout but does not apply any resources to the cluster") flagset.BoolVar(¶ms.DryRun, "dry", false, "only call the kubernetes api with dry-run; takes precedence over skip-dry-run.") @@ -72,7 +57,6 @@ func GetTakeoffParams(settings GlobalSettings, source io.Reader, args []string) flagset.StringVar(¶ms.Flight.CompilationCacheDir, "compilation-cache", "", "location to cache wasm compilations") flagset.StringVar(¶ms.Checksum, "checksum", "", "sha256 checksum for desired module. If module does not match checksum takeoff will fail. Checksum can be inferred from oci tag or from http basepath") flagset.StringVar(¶ms.VerifyKeyPath, "verify", "", "path to public key or directory of keys to verify module signature against.") - flagset.Func( "resource-access", "allows flights with cluster-access to read resources outside of the release that match pattern. This flag can be set many times and matchers can be comma separated.", @@ -81,33 +65,46 @@ func GetTakeoffParams(settings GlobalSettings, source io.Reader, args []string) return nil }, ) - - var removeAll bool flagset.BoolVar(&removeAll, "remove-all", false, "enables pruning of crds and namespaces owned by the release if a new revision would orphan them.\nDestructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveCRDs, "remove-crds", false, "enables pruning of crds owned by the release.\nDestructive and dangerous use with caution.") flagset.BoolVar(¶ms.RemoveNamespaces, "remove-namespaces", false, "enables pruning of namespaces owned by the release.\nDestructive and dangerous use with caution.") + flagset.Usage = func() { + takeoffHelp = strings.TrimSpace(internal.Colorize(takeoffHelp)) + fmt.Fprintln(flagset.Output(), takeoffHelp) + flagset.PrintDefaults() + } - args, params.Flight.Args = internal.CutArgs(args) + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + var source io.Reader + if !term.IsTerminal(int(os.Stdin.Fd())) { + source = os.Stdin + } + params.Flight = yoke.FlightParams{Input: source} + params.Kube = settings.Kube + RegisterGlobalFlags(flagset, &settings) - flagset.Parse(args) + args, params.Flight.Args = internal.CutArgs(args) - if removeAll { - params.RemoveCRDs = true - params.RemoveNamespaces = true - } + flagset.Parse(args) - params.Release = flagset.Arg(0) - params.Flight.Path = flagset.Arg(1) + if removeAll { + params.RemoveCRDs = true + params.RemoveNamespaces = true + } - if params.Release == "" { - return nil, fmt.Errorf("release is required as first positional arg") - } - if params.Flight.Input == nil && params.Flight.Path == "" { - return nil, fmt.Errorf("flight-path is required as second position arg") - } + params.Release = flagset.Arg(0) + params.Flight.Path = flagset.Arg(1) - return ¶ms, nil -} + if params.Release == "" { + return fmt.Errorf("release is required as first positional arg") + } + if params.Flight.Input == nil && params.Flight.Path == "" { + return fmt.Errorf("flight-path is required as second position arg") + } + + return TakeOff(ctx, params) + } +}) func TakeOff(ctx context.Context, params TakeoffParams) error { commander, err := yoke.FromKubeConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_turbulence.go b/cmd/yoke/cmd_turbulence.go index 9bb28760..ca93a71a 100644 --- a/cmd/yoke/cmd_turbulence.go +++ b/cmd/yoke/cmd_turbulence.go @@ -22,21 +22,9 @@ type TurbulenceParams struct { //go:embed cmd_turbulence_help.txt var turbulenceHelp string -func init() { - turbulenceHelp = strings.TrimSpace(internal.Colorize(turbulenceHelp)) -} - -func GetTurbulenceParams(settings GlobalSettings, args []string) (*TurbulenceParams, error) { +var CmdTurbulence = NewCommand("turbulence", []string{"drift", "diff"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("turbulence", flag.ExitOnError) - - flagset.Usage = func() { - fmt.Fprintln(flagset.Output(), turbulenceHelp) - flagset.PrintDefaults() - } - - params := TurbulenceParams{GlobalSettings: settings} - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + params := TurbulenceParams{} flagset.IntVar(¶ms.Context, "context", 4, "number of lines of context in diff") flagset.BoolVar( @@ -52,17 +40,27 @@ func GetTurbulenceParams(settings GlobalSettings, args []string) (*TurbulencePar flagset.BoolVar(¶ms.Color, "color", term.IsTerminal(int(os.Stdout.Fd())), "outputs diff with color") flagset.StringVar(¶ms.Namespace, "namespace", "", "release target namespace, defaults to context namespace if not provided") - flagset.Parse(args) - - params.Release = flagset.Arg(0) - if params.Release == "" { - return nil, fmt.Errorf("release is required") + flagset.Usage = func() { + turbulenceHelp = strings.TrimSpace(internal.Colorize(turbulenceHelp)) + fmt.Fprintln(flagset.Output(), turbulenceHelp) + flagset.PrintDefaults() } + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { - params.ConflictsOnly = params.ConflictsOnly || params.Fix + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - return ¶ms, nil -} + flagset.Parse(args) + + params.Release = flagset.Arg(0) + if params.Release == "" { + return fmt.Errorf("release is required") + } + + params.ConflictsOnly = params.ConflictsOnly || params.Fix + return Turbulence(ctx, params) + } +}) func Turbulence(ctx context.Context, params TurbulenceParams) error { commander, err := yoke.FromKubeConfigFlags(params.Kube) diff --git a/cmd/yoke/cmd_unlatch.go b/cmd/yoke/cmd_unlatch.go index 95bdc85b..27737860 100644 --- a/cmd/yoke/cmd_unlatch.go +++ b/cmd/yoke/cmd_unlatch.go @@ -19,38 +19,30 @@ type UnlatchParams struct { //go:embed cmd_unlatch_help.txt var unlatchHelp string -func init() { - maydayHelp = strings.TrimSpace(internal.Colorize(unlatchHelp)) -} - -func GetUnlatchParams(settings GlobalSettings, args []string) (*UnlatchParams, error) { +var CmdUnlatch = NewCommand("unlatch", []string{"unlock"}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("unlatch", flag.ExitOnError) + params := UnlatchParams{} flagset.Usage = func() { + maydayHelp = strings.TrimSpace(internal.Colorize(unlatchHelp)) fmt.Fprintln(flagset.Output(), maydayHelp) flagset.PrintDefaults() } - - params := UnlatchParams{GlobalSettings: settings} - - RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) - flagset.StringVar(¶ms.Namespace, "namespace", "default", "target namespace of release to remove") - flagset.Parse(args) - - params.Release = flagset.Arg(0) - if params.Release == "" { - return nil, fmt.Errorf("release is required") + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + params.GlobalSettings = settings + RegisterGlobalFlags(flagset, ¶ms.GlobalSettings) + flagset.Parse(args) + params.Release = flagset.Arg(0) + params.Release = flagset.Arg(0) + if params.Release == "" { + return fmt.Errorf("release is required") + } + commander, err := yoke.FromKubeConfigFlags(params.Kube) + if err != nil { + return err + } + return commander.UnlockRelease(ctx, params.UnlockParams) } - - return ¶ms, nil -} - -func Unlatch(ctx context.Context, params UnlatchParams) error { - commander, err := yoke.FromKubeConfigFlags(params.Kube) - if err != nil { - return err - } - return commander.UnlockRelease(ctx, params.UnlockParams) -} +}) diff --git a/cmd/yoke/cmd_verify.go b/cmd/yoke/cmd_verify.go index ec36dce1..9901d70d 100644 --- a/cmd/yoke/cmd_verify.go +++ b/cmd/yoke/cmd_verify.go @@ -1,6 +1,7 @@ package main import ( + "context" _ "embed" "flag" "fmt" @@ -13,31 +14,27 @@ import ( //go:embed cmd_verify_help.txt var verifyHelp string -func init() { - verifyHelp = strings.TrimSpace(internal.Colorize(verifyHelp)) -} - -func GetVerifyParams(args []string) (*yoke.VerifyParams, error) { +var CmdVerify = NewCommand("verify", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { flagset := flag.NewFlagSet("verify", flag.ExitOnError) - + params := yoke.VerifyParams{} flagset.Usage = func() { + verifyHelp = strings.TrimSpace(internal.Colorize(verifyHelp)) fmt.Fprintln(flagset.Output(), verifyHelp) flagset.PrintDefaults() } - - var params yoke.VerifyParams flagset.StringVar(¶ms.KeyPath, "key", "", "Path to pulbic key pem used for verifying") + return flagset, func(ctx context.Context, settings GlobalSettings, args []string) error { + flagset.Parse(args) - flagset.Parse(args) + params.WasmFile = flagset.Arg(0) - params.WasmFile = flagset.Arg(0) + if params.WasmFile == "" { + return fmt.Errorf("wasm file must be specified as first argument") + } + if params.KeyPath == "" { + return fmt.Errorf("key is required") + } - if params.WasmFile == "" { - return nil, fmt.Errorf("wasm file must be specified as first argument") + return yoke.Verify(params) } - if params.KeyPath == "" { - return nil, fmt.Errorf("key is required") - } - - return ¶ms, nil -} +}) diff --git a/cmd/yoke/cmd_version.go b/cmd/yoke/cmd_version.go index 3e454035..6f0da533 100644 --- a/cmd/yoke/cmd_version.go +++ b/cmd/yoke/cmd_version.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "runtime/debug" @@ -10,6 +11,12 @@ import ( "github.com/yokecd/yoke/internal" ) +var CmdVersion = NewCommand("version", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + return flag.NewFlagSet("version", flag.ExitOnError), func(ctx context.Context, settings GlobalSettings, args []string) error { + return Version(ctx) + } +}) + func Version(ctx context.Context) error { tbl := table.NewWriter() tbl.SetStyle(table.StyleRounded) diff --git a/cmd/yoke/command.go b/cmd/yoke/command.go new file mode 100644 index 00000000..102d52a5 --- /dev/null +++ b/cmd/yoke/command.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "flag" +) + +type YokeCommandRunner interface { + Run(ctx context.Context, settings GlobalSettings, subCommands string) error +} + +// The YokeCommand struct represents a cli commmand +// It should have a name, alias, and subcommands +type YokeCommand struct { + Name string + Aliases []string + FlagSet *flag.FlagSet + SubCommands map[string]*YokeCommand + CompletionFunc func([]string) + Parent *YokeCommand + Runner CmdRunner +} + +// AddCommand registers sub into the parents SubCommands +func (y *YokeCommand) AddCommand(sub *YokeCommand) { + sub.Parent = y + y.SubCommands[sub.Name] = sub + for _, alias := range sub.Aliases { + _, alreadyThere := y.SubCommands[alias] + if !alreadyThere { + y.SubCommands[alias] = sub + } + } +} + +// AllNames returns the name of a command and all of its aliases +func (y YokeCommand) AllNames() []string { + return append(y.Aliases, y.Name) +} + +type CmdRunner func(ctx context.Context, settings GlobalSettings, args []string) error +type CmdBuilder func(ctx context.Context) (*flag.FlagSet, CmdRunner) + +func NewCommand(name string, aliases []string, builder CmdBuilder) *YokeCommand { + flagset, runner := builder(context.Background()) + return &YokeCommand{ + Name: name, + Aliases: aliases, + FlagSet: flagset, + Runner: runner, + SubCommands: make(map[string]*YokeCommand), + } +} + +// Seek returns the YokeCommand and the commands after the found command +// for a given set of args +func Seek(args []string) (*YokeCommand, []string) { + cmdPtr := CmdRoot + var argsOut = args + for i, arg := range args { + nextCmd, ok := cmdPtr.SubCommands[arg] + if ok { + cmdPtr = nextCmd + if i+1 < len(args) { + argsOut = args[i+1:] + } else { + argsOut = []string{} + } + } + } + if cmdPtr == CmdRoot { + if len(args) > 0 { + argsOut = args[1:] + } + } + return cmdPtr, argsOut +} diff --git a/cmd/yoke/command_test.go b/cmd/yoke/command_test.go new file mode 100644 index 00000000..0b768556 --- /dev/null +++ b/cmd/yoke/command_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "reflect" + "testing" +) + +// TestCmdSubs asserts that all of the expected sub commands are in place +func TestCmdSubs(t *testing.T) { + validCommands := []string{ + "apply", "atc", "blackbox", + "delete", "descent", "diff", + "down", "drift", "inspect", + "mayday", "meta", "push", + "restore", "schematics", "sign", + "stow", "takeoff", "turbulence", + "unlatch", "unlock", "up", + "verify", "version", + } + for _, cmd := range validCommands { + if _, ok := CmdRoot.SubCommands[cmd]; !ok { + t.Fatalf("expected %s to be a subcommand of the root command", cmd) + } + } + for _, cmd := range []string{"ls", "set", "get"} { + if _, ok := CmdSchematics.SubCommands[cmd]; !ok { + t.Fatalf("expected %s to be a subcommand of the schematics command", cmd) + } + } +} + +// TestCmdSeekRunner tests that the Seek function can find the correct +// sub command runner given a slice of args +func TestCmdSeekRunner(t *testing.T) { + type tMatrix struct { + Want CmdRunner + Args []string + } + tests := []tMatrix{ + {Want: CmdRoot.Runner, Args: []string{"yoke"}}, + {Want: CmdSchematicsGet.Runner, Args: []string{"yoke", "schematics", "get"}}, + {Want: CmdSchematicsLs.Runner, Args: []string{"yoke", "schematics", "ls"}}, + {Want: CmdSchematicsSet.Runner, Args: []string{"yoke", "schematics", "set"}}, + } + for k, c := range CmdRoot.SubCommands { + tests = append(tests, tMatrix{ + Want: c.Runner, + Args: []string{"yoke", k}, + }) + } + for _, s := range tests { + cmd, _ := Seek(s.Args) + if reflect.ValueOf(cmd.Runner) != reflect.ValueOf(s.Want) { + t.Fatalf("got function mismatch for %v", s.Args) + } + } +} + +func TestCmdSeekExtraArgs(t *testing.T) { + type tMatrix struct { + Want CmdRunner + Args []string + } + tests := []tMatrix{ + {Want: CmdRoot.Runner, Args: []string{"yoke", "--debug", "foo"}}, + {Want: CmdSchematicsGet.Runner, Args: []string{"yoke", "schematics", "get", "--debug", "foo"}}, + {Want: CmdSchematicsLs.Runner, Args: []string{"yoke", "schematics", "ls", "--debug", "foo"}}, + {Want: CmdSchematicsSet.Runner, Args: []string{"yoke", "schematics", "set", "--debug", "foo"}}, + } + for k, c := range CmdRoot.SubCommands { + tests = append(tests, tMatrix{ + Want: c.Runner, + Args: []string{"yoke", k, "--debug", "foo"}, + }) + } + for _, s := range tests { + _, args := Seek(s.Args) + if !isSubset(args, []string{"--debug", "foo"}) || !(len(args) == 2) { + t.Fatalf("got args mismatch for %v, got: %v", s.Args, args) + } + } + if _, args := Seek([]string{"yoke"}); len(args) != 0 { + t.Fatalf("expected root command to have zero args after it") + } +} diff --git a/cmd/yoke/complete.go b/cmd/yoke/complete.go new file mode 100644 index 00000000..0ef7c843 --- /dev/null +++ b/cmd/yoke/complete.go @@ -0,0 +1,115 @@ +package main + +import ( + "flag" + "fmt" + "os" + "slices" + "strings" +) + +func cleanArg(argIn string) string { + return argIn +} + +func printFlagCompletion(args []string, cmd *YokeCommand) { + for _, flag := range getFlagCompletion(args, cmd) { + fmt.Println(flag) + } +} + +// get the flags associated with a yokeCommand +// it takes the args slice after the YokeCommand +func getFlagCompletion(args []string, cmd *YokeCommand) []string { + flagSetAll := make(map[string]bool) + out := make([]string, 0) + partial := strings.TrimLeft(args[len(args)-1], "-") + if cmd.FlagSet == nil { + return out + } + appendWithPrefix := func(f *flag.Flag, p string) { + if p == "" || strings.HasPrefix(f.Name, p) { + if !slices.Contains(args, "-"+f.Name) { + flagSetAll["-"+f.Name] = true + } + } + } + // Iterate through all of the places we get flags from + cur := cmd + for cur != nil && cur.FlagSet != nil { + cur.FlagSet.VisitAll(func(f *flag.Flag) { + appendWithPrefix(f, partial) + }) + cur = cur.Parent + } + + for k := range flagSetAll { + out = append(out, k) + } + return out +} + +// given the args passed, yield all of the valid next top level cocmmands +func getCommandCompletions(args []string, cmd *YokeCommand) []*YokeCommand { + out := make([]*YokeCommand, 0) + outSet := make(map[string]*YokeCommand) + partial := "" + if len(args) > 0 { + partial = args[len(args)-1] + } + if partial == "complete" || partial == "yoke" { + partial = "" + } + for _, v := range cmd.SubCommands { + if strings.HasPrefix(v.Name, partial) || partial == "" { + outSet[v.Name] = v + } + } + for _, cmd := range outSet { + out = append(out, cmd) + } + return out +} + +func printCommandCompletions(args []string, cmd *YokeCommand) { + for _, cmd := range getCommandCompletions(args, cmd) { + fmt.Println(cmd.Name) + } +} + +func Complete() { + if len(os.Args) < 2 { + return + } + if _, ok := os.LookupEnv("COMP_LINE"); !ok { + fmt.Print(` +# Please add the following to your bashrc file to enable tab completions + +function _yoke() { + COMPREPLY=($(yoke complete $COMP_LINE)); +}; + +complete -F _yoke yoke +`) + return + } + + argSet := make(map[string]bool) + for _, arg := range os.Args { + argSet[arg] = true + } + argsAfterComp := os.Args[2:] + if len(argsAfterComp) > 1 && argsAfterComp[0] == "yoke" { + argsAfterComp = argsAfterComp[1:] + } + cmd, rest := Seek(argsAfterComp) + partial := "" + if len(rest) > 0 { + partial = rest[len(rest)-1] + } + if strings.HasPrefix(partial, "-") { + printFlagCompletion(rest, cmd) + } + //if it's not a full command, get top-level completions + printCommandCompletions(rest, cmd) +} diff --git a/cmd/yoke/complete_test.go b/cmd/yoke/complete_test.go new file mode 100644 index 00000000..aabe4193 --- /dev/null +++ b/cmd/yoke/complete_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "slices" + "testing" +) + +// returns true if a is a subset of b +func isSubset(a, b []string) bool { + set := make(map[string]bool) + for _, s := range b { + set[s] = true + } + for _, s := range a { + _, ok := set[s] + if !ok { + return false + } + } + return true +} + +func TestCompFlags(t *testing.T) { + t.Setenv("COMP_LINE", "") + for _, comp := range []struct { + Command *YokeCommand + Args []string + Wanted []string + }{ + { + Command: CmdATC, + Args: []string{"-"}, + Wanted: []string{ + "-debug-file", + }, + }, + { + Command: CmdBlackbox, + Args: []string{"-"}, + Wanted: []string{ + "-context", + "-namespace", + }, + }, + { + Command: CmdDescent, + Args: []string{"-"}, + Wanted: []string{ + "-lock", + "-namespace", + "-poll", + "-remove-all", + "-remove-crds", + "-remove-namespaces", + "-wait", + }, + }, + { + Command: CmdMayday, + Args: []string{"-"}, + Wanted: []string{ + "-namespace", + "-remove-all", + "-remove-crds", + "-remove-namespaces", + }, + }, + { + Command: CmdSchematics, + Args: []string{"-"}, + Wanted: []string{"-wasm"}, + }, + { + Command: CmdSign, + Args: []string{"-"}, + Wanted: []string{ + "-key", + "-o", + "-f", + }, + }, + { + Command: CmdStow, + Args: []string{"-"}, + Wanted: []string{ + "-insecure", + "-tag", + }, + }, + { + Command: CmdTakeoff, + Args: []string{"-"}, + Wanted: []string{ + "-checksum", + "-color", + "-context", + "-cross-namespace", + "-diff-only", + "-dry", + "-insecure", + "-remove-crds", + "-cluster-access", + "-out", + "-remove-namespaces", + "-timeout", + "-verify", + "-wait", + "-create-namespace", + "-force-conflicts", + "-history-cap", + "-poll", + "-resource-access", + "-stdout", + "-compilation-cache", + "-force-ownership", + "-lock", + "-max-memory-mib", + "-namespace", + "-remove-all", + "-skip-dry-run", + }, + }, + { + Command: CmdTurbulence, + Args: []string{"-"}, + Wanted: []string{ + "-conflict-only", + "-context", + "-fix", + "-namespace", + "-color", + }, + }, + { + Command: CmdUnlatch, + Args: []string{"-"}, + Wanted: []string{"-namespace"}, + }, + { + Command: CmdVerify, + Args: []string{"-"}, + Wanted: []string{"-key"}, + }, + } { + compLine := getFlagCompletion(comp.Args, comp.Command) + // We can't do a full compare here because 'go test' introduces its own flag sets + if !isSubset(comp.Wanted, compLine) { + t.Fatalf("%s did not yield expected flags, got: %s args: %s", comp.Command.Name, compLine, comp.Args) + } + } +} + +func TestCompFlagDuplicates(t *testing.T) { + t.Setenv("COMP_LINE", "") + for _, comp := range []struct { + Comp []string + Command *YokeCommand + Args []string + NotInComp []string + }{ + { + Args: []string{"-context", "-"}, + Command: CmdBlackbox, + NotInComp: []string{"-context"}, + }, + { + Args: []string{"-context", "-namespace"}, + Command: CmdBlackbox, + NotInComp: []string{"-context", "-namespace"}, + }, + } { + compLine := getFlagCompletion(comp.Args, comp.Command) + for _, unwanted := range comp.NotInComp { + if slices.Contains(compLine, unwanted) { + t.Fatalf("got unexpected flag completion for %s with args %v: got %v", comp.Command.Name, comp.Args, compLine) + } + } + } +} diff --git a/cmd/yoke/main.go b/cmd/yoke/main.go index 47141b01..7b8b6aaf 100644 --- a/cmd/yoke/main.go +++ b/cmd/yoke/main.go @@ -6,20 +6,16 @@ import ( _ "embed" "flag" "fmt" - "io" "os" "strings" "syscall" - "golang.org/x/term" - "github.com/davidmdm/x/xcontext" "k8s.io/cli-runtime/pkg/genericclioptions" "github.com/yokecd/yoke/internal" "github.com/yokecd/yoke/internal/home" - "github.com/yokecd/yoke/pkg/yoke" ) func main() { @@ -35,8 +31,41 @@ func main() { //go:embed cmd_help.txt var rootHelp string +var CmdRoot = NewCommand("yoke", []string{}, func(ctx context.Context) (*flag.FlagSet, CmdRunner) { + flagset := flag.NewFlagSet("yoke", flag.ExitOnError) + flagset.Usage = func() { + rootHelp = strings.TrimSpace(internal.Colorize(rootHelp)) + fmt.Fprintln(flag.CommandLine.Output(), rootHelp) + flagset.PrintDefaults() + fmt.Fprintln(os.Stderr) + } + runner := func(ctx context.Context, settings GlobalSettings, args []string) error { + RegisterGlobalFlags(flagset, &settings) + flagset.Parse(args) + if len(flagset.Args()) > 0 { + return fmt.Errorf("unknown command: %s", flagset.Arg(0)) + } + if len(flagset.Args()) == 0 { + flagset.Usage() + } + return fmt.Errorf("no command provided") + } + return flagset, runner +}) + func init() { - rootHelp = strings.TrimSpace(internal.Colorize(rootHelp)) + CmdRoot.AddCommand(CmdATC) + CmdRoot.AddCommand(CmdBlackbox) + CmdRoot.AddCommand(CmdDescent) + CmdRoot.AddCommand(CmdMayday) + CmdRoot.AddCommand(CmdSchematics) + CmdRoot.AddCommand(CmdSign) + CmdRoot.AddCommand(CmdStow) + CmdRoot.AddCommand(CmdTakeoff) + CmdRoot.AddCommand(CmdTurbulence) + CmdRoot.AddCommand(CmdVersion) + CmdRoot.AddCommand(CmdUnlatch) + CmdRoot.AddCommand(CmdVerify) } func run() error { @@ -45,120 +74,25 @@ func run() error { Kube: genericclioptions.NewConfigFlags(false), } - RegisterGlobalFlags(flag.CommandLine, &settings) - - flag.Usage = func() { - fmt.Fprintln(flag.CommandLine.Output(), rootHelp) - flag.PrintDefaults() - fmt.Fprintln(os.Stderr) + if len(os.Args) > 1 && os.Args[1] == "complete" { + Complete() + return nil } - flag.Parse() + CmdRoot.FlagSet.Parse(os.Args) ctx, cancel := xcontext.WithSignalCancelation(context.Background(), syscall.SIGINT) defer cancel() ctx = internal.WithDebugFlag(ctx, settings.Debug) - if len(flag.Args()) == 0 { - flag.Usage() - return fmt.Errorf("no command provided") - } - - subcmdArgs := flag.Args()[1:] - - switch cmd := flag.Arg(0); cmd { - case "atc": - return ATC(ctx, GetAtcParams(settings, subcmdArgs)) - case "takeoff", "up", "apply": - { - var source io.Reader - if !term.IsTerminal(int(os.Stdin.Fd())) { - source = os.Stdin - } - params, err := GetTakeoffParams(settings, source, subcmdArgs) - if err != nil { - return err - } - return TakeOff(ctx, *params) - } - case "descent", "down", "restore": - { - params, err := GetDescentfParams(settings, subcmdArgs) - if err != nil { - return err - } - return Descent(ctx, *params) - } - case "mayday", "delete": - { - params, err := GetMaydayParams(settings, subcmdArgs) - if err != nil { - return err - } - return Mayday(ctx, *params) - } - case "blackbox", "inspect": - { - params, err := GetBlackBoxParams(settings, subcmdArgs) - if err != nil { - return err - } - return Blackbox(ctx, *params) - } - case "turbulence", "drift", "diff": - { - params, err := GetTurbulenceParams(settings, subcmdArgs) - if err != nil { - return err - } - return Turbulence(ctx, *params) - } - case "stow", "push": - { - params, err := GetStowParams(subcmdArgs) - if err != nil { - return err - } - return yoke.Stow(ctx, *params) - } - case "unlatch", "unlock": - { - params, err := GetUnlatchParams(settings, subcmdArgs) - if err != nil { - return err - } - return Unlatch(ctx, *params) - } - case "schematics", "meta": - { - return SchematicsCommand(ctx, subcmdArgs) - } - - case "sign": - { - params, err := GetSignParams(subcmdArgs) - if err != nil { - return err - } - return yoke.Sign(*params) - } - case "verify": - { - params, err := GetVerifyParams(subcmdArgs) - if err != nil { - return err - } - return yoke.Verify(*params) - } + cmd, subCmdArgs := Seek(CmdRoot.FlagSet.Args()) - case "version": - { - return Version(ctx) - } - default: - return fmt.Errorf("unknown command: %s", cmd) + if cmd == nil || cmd.Runner == nil { + return fmt.Errorf("unknown command") } + + return cmd.Runner(ctx, settings, subCmdArgs) } type GlobalSettings struct { diff --git a/cmd/yoke/main_test.go b/cmd/yoke/main_test.go index 2b2a7a16..4de85012 100644 --- a/cmd/yoke/main_test.go +++ b/cmd/yoke/main_test.go @@ -56,14 +56,11 @@ func TestMain(m *testing.M) { } var ( - settings = GlobalSettings{ - Kube: func() *genericclioptions.ConfigFlags { - flags := genericclioptions.NewConfigFlags(false) - flags.KubeConfig = &home.Kubeconfig - return flags - }(), - } background = internal.WithStdio(context.Background(), io.Discard, io.Discard, nil) + settings = GlobalSettings{ + Debug: new(bool), + Kube: genericclioptions.NewConfigFlags(false), + } ) func createBasicDeployment(t *testing.T, name, namespace string) io.Reader { From 6aa1dfa3d715947eca2e45155b6d46ef3f079539 Mon Sep 17 00:00:00 2001 From: dandeandean Date: Thu, 7 May 2026 07:04:55 -0400 Subject: [PATCH 2/2] off by one --- cmd/yoke/complete.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cmd/yoke/complete.go b/cmd/yoke/complete.go index 0ef7c843..3757f36d 100644 --- a/cmd/yoke/complete.go +++ b/cmd/yoke/complete.go @@ -83,9 +83,10 @@ func Complete() { } if _, ok := os.LookupEnv("COMP_LINE"); !ok { fmt.Print(` -# Please add the following to your bashrc file to enable tab completions +# Please add the following to your bash completion file to enable tab completions function _yoke() { + export COMP_LINE COMPREPLY=($(yoke complete $COMP_LINE)); }; @@ -94,15 +95,13 @@ complete -F _yoke yoke return } - argSet := make(map[string]bool) - for _, arg := range os.Args { - argSet[arg] = true - } - argsAfterComp := os.Args[2:] - if len(argsAfterComp) > 1 && argsAfterComp[0] == "yoke" { - argsAfterComp = argsAfterComp[1:] + if len(os.Args) < 2 { + return } - cmd, rest := Seek(argsAfterComp) + // We only want to pass `yoke ...` to Seek, + // not `yoke complete yoke ...` + argsAfterComplete := os.Args[2:] + cmd, rest := Seek(argsAfterComplete) partial := "" if len(rest) > 0 { partial = rest[len(rest)-1]