diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d0b9e50 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,28 @@ +# Zeabur CLI - Development Notes + +## Build & Test +- Build: `go build ./...` +- Run: `go run ./cmd/main.go ` +- Test: `go test ./...` + +## Project Structure +- `cmd/main.go` — entry point +- `internal/cmd//` — each CLI command in its own package +- `internal/cmdutil/` — shared command utilities (Factory, auth checks, spinner config) +- `pkg/api/` — GraphQL API client +- `pkg/model/` — data models (GraphQL struct tags) +- `internal/cmd/root/root.go` — root command, registers all subcommands + +## Important: Keep `help --all` in sync +When adding or modifying CLI commands, flags, or subcommands, the output of `zeabur help --all` automatically reflects changes (it walks the Cobra command tree at runtime). No manual update is needed for the help output itself. + +However, when adding a **new subcommand**, you must: +1. Create the command package under `internal/cmd///` +2. Register it in the parent command file (e.g., `internal/cmd/template/template.go`) + +## Conventions +- Each subcommand lives in its own package: `internal/cmd///.go` +- Commands support both interactive and non-interactive modes; if a flag is provided, skip the interactive prompt +- Use `cmdutil.SpinnerCharSet`, `cmdutil.SpinnerInterval`, `cmdutil.SpinnerColor` for spinners +- Models in `pkg/model/` use `graphql:"fieldName"` struct tags — only add fields that exist in the backend GraphQL schema +- Backend GraphQL schema lives in `../backend/internal/gateway/graphql/` diff --git a/internal/cmd/help/help.go b/internal/cmd/help/help.go new file mode 100644 index 0000000..af89dbf --- /dev/null +++ b/internal/cmd/help/help.go @@ -0,0 +1,75 @@ +package help + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func NewCmdHelp(rootCmd *cobra.Command) *cobra.Command { + var all bool + + cmd := &cobra.Command{ + Use: "help [command]", + Short: "Help about any command", + RunE: func(cmd *cobra.Command, args []string) error { + if all { + printAllCommands(rootCmd, "") + return nil + } + + // default: find the target command and show its help + target, _, err := rootCmd.Find(args) + if err != nil { + return err + } + return target.Help() + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Show all commands with their flags") + + return cmd +} + +func printAllCommands(cmd *cobra.Command, prefix string) { + fullName := prefix + cmd.Name() + + if cmd.Runnable() || len(cmd.Commands()) == 0 { + fmt.Printf("%s - %s\n", fullName, cmd.Short) + printFlags(cmd, fullName) + } + + for _, child := range cmd.Commands() { + if child.Hidden || child.Name() == "help" { + continue + } + printAllCommands(child, fullName+" ") + } +} + +func printFlags(cmd *cobra.Command, fullName string) { + var flags []string + + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + entry := " --" + f.Name + if f.Shorthand != "" { + entry = " -" + f.Shorthand + ", --" + f.Name + } + if f.DefValue != "" && f.DefValue != "false" { + entry += fmt.Sprintf(" (default: %s)", f.DefValue) + } + entry += " " + f.Usage + flags = append(flags, entry) + }) + + if len(flags) > 0 { + fmt.Println(strings.Join(flags, "\n")) + fmt.Println() + } +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 197d660..55f2dc0 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -10,6 +10,7 @@ import ( authCmd "github.com/zeabur/cli/internal/cmd/auth" completionCmd "github.com/zeabur/cli/internal/cmd/completion" + helpCmd "github.com/zeabur/cli/internal/cmd/help" contextCmd "github.com/zeabur/cli/internal/cmd/context" deployCmd "github.com/zeabur/cli/internal/cmd/deploy" deploymentCmd "github.com/zeabur/cli/internal/cmd/deployment" @@ -129,5 +130,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman cmd.AddCommand(completionCmd.NewCmdCompletion(f)) cmd.AddCommand(variableCmd.NewCmdVariable(f)) + // replace default help command with our custom one that supports --all + cmd.SetHelpCommand(helpCmd.NewCmdHelp(cmd)) + return cmd, nil } diff --git a/internal/cmd/template/get/get.go b/internal/cmd/template/get/get.go index e8e35c4..f0330fb 100644 --- a/internal/cmd/template/get/get.go +++ b/internal/cmd/template/get/get.go @@ -3,6 +3,11 @@ package get import ( "context" "fmt" + "io" + "net/http" + "net/url" + "os" + "time" "github.com/briandowns/spinner" "github.com/spf13/cobra" @@ -12,6 +17,7 @@ import ( type Options struct { code string + raw bool } func NewCmdGet(f *cmdutil.Factory) *cobra.Command { @@ -26,6 +32,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVarP(&opts.code, "code", "c", "", "Template code") + cmd.Flags().BoolVar(&opts.raw, "raw", false, "Output raw YAML spec") return cmd } @@ -38,19 +45,18 @@ func runGet(f *cmdutil.Factory, opts Options) error { } func runGetInteractive(f *cmdutil.Factory, opts Options) error { - code, err := f.Prompter.Input("Template Code: ", "") - if err != nil { - return err + if opts.code == "" { + code, err := f.Prompter.Input("Template Code: ", "") + if err != nil { + return err + } + opts.code = code } - opts.code = code - - err = getTemplate(f, opts) - if err != nil { + if err := paramCheck(opts); err != nil { return err } - - return nil + return getTemplate(f, opts) } func runGetNonInteractive(f *cmdutil.Factory, opts Options) error { @@ -68,6 +74,10 @@ func runGetNonInteractive(f *cmdutil.Factory, opts Options) error { } func getTemplate(f *cmdutil.Factory, opts Options) error { + if opts.raw { + return getTemplateRaw(opts.code) + } + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, spinner.WithColor(cmdutil.SpinnerColor), spinner.WithSuffix(" Fetching template..."), @@ -88,6 +98,28 @@ func getTemplate(f *cmdutil.Factory, opts Options) error { return nil } +func getTemplateRaw(code string) error { + u := "https://zeabur.com/templates/" + url.PathEscape(code) + ".yaml" + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return fmt.Errorf("failed to build request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch template YAML: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("template not found (HTTP %d)", resp.StatusCode) + } + + _, err = io.Copy(os.Stdout, resp.Body) + return err +} + func paramCheck(opts Options) error { if opts.code == "" { return fmt.Errorf("template code is required") diff --git a/internal/cmd/template/search/search.go b/internal/cmd/template/search/search.go new file mode 100644 index 0000000..beab292 --- /dev/null +++ b/internal/cmd/template/search/search.go @@ -0,0 +1,91 @@ +package search + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/briandowns/spinner" + "github.com/spf13/cobra" + + "github.com/zeabur/cli/internal/cmdutil" + "github.com/zeabur/cli/pkg/model" +) + +type Options struct { + keyword string +} + +func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { + opts := Options{} + + cmd := &cobra.Command{ + Use: "search [keyword]", + Short: "Search templates by keyword", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.keyword = args[0] + } + return runSearch(f, opts) + }, + } + + return cmd +} + +func runSearch(f *cmdutil.Factory, opts Options) error { + if opts.keyword == "" { + if f.Interactive { + keyword, err := f.Prompter.Input("Search keyword: ", "") + if err != nil { + return err + } + opts.keyword = keyword + } else { + return fmt.Errorf("keyword is required") + } + } + + s := spinner.New(cmdutil.SpinnerCharSet, cmdutil.SpinnerInterval, + spinner.WithColor(cmdutil.SpinnerColor), + spinner.WithSuffix(" Searching templates..."), + ) + s.Start() + allTemplates, err := f.ApiClient.ListAllTemplates(context.Background()) + if err != nil { + s.Stop() + return err + } + s.Stop() + + keyword := strings.ToLower(opts.keyword) + var matched model.Templates + for _, t := range allTemplates { + name := strings.ToLower(t.Name) + desc := strings.ToLower(t.Description) + if strings.Contains(name, keyword) || strings.Contains(desc, keyword) { + matched = append(matched, t) + } + } + + sort.Slice(matched, func(i, j int) bool { + return matched[i].DeploymentCnt > matched[j].DeploymentCnt + }) + + if len(matched) == 0 { + fmt.Println("No templates found") + return nil + } + + header := []string{"Code", "Name", "Description", "Deployments"} + rows := make([][]string, 0, len(matched)) + for _, t := range matched { + rows = append(rows, []string{t.Code, t.Name, t.Description, strconv.Itoa(t.DeploymentCnt)}) + } + f.Printer.Table(header, rows) + + return nil +} diff --git a/internal/cmd/template/template.go b/internal/cmd/template/template.go index 4a68bdc..f19557b 100644 --- a/internal/cmd/template/template.go +++ b/internal/cmd/template/template.go @@ -10,6 +10,7 @@ import ( templateDeployCmd "github.com/zeabur/cli/internal/cmd/template/deploy" templateGetCmd "github.com/zeabur/cli/internal/cmd/template/get" templateListCmd "github.com/zeabur/cli/internal/cmd/template/list" + templateSearchCmd "github.com/zeabur/cli/internal/cmd/template/search" templateUpdateCmd "github.com/zeabur/cli/internal/cmd/template/update" ) @@ -22,6 +23,7 @@ func NewCmdTemplate(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(templateListCmd.NewCmdList(f)) cmd.AddCommand(templateDeployCmd.NewCmdDeploy(f)) cmd.AddCommand(templateGetCmd.NewCmdGet(f)) + cmd.AddCommand(templateSearchCmd.NewCmdSearch(f)) cmd.AddCommand(templateDeleteCmd.NewCmdDelete(f)) cmd.AddCommand(templateCreateCmd.NewCmdCreate(f)) cmd.AddCommand(templateUpdateCmd.NewCmdUpdate(f))