diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 11ea430e0..f98c24591 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -10,7 +10,7 @@ import ( "github.com/temporalio/cli/internal/commandsgen" ) -// stringSlice implements flag.Value to support multiple -input flags +// stringSlice implements flag.Value to support multiple flags of the same name type stringSlice []string func (s *stringSlice) String() string { @@ -30,12 +30,14 @@ func main() { func run() error { var ( - outputDir string - inputFiles stringSlice + outputDir string + inputFiles stringSlice + splitNames stringSlice ) flag.Var(&inputFiles, "input", "Input YAML file (can be specified multiple times)") flag.StringVar(&outputDir, "output", ".", "Output directory for docs") + flag.Var(&splitNames, "split", "Command name whose subcommands get separate files in a subdirectory (can be specified multiple times)") flag.Parse() if len(inputFiles) == 0 { @@ -60,13 +62,19 @@ func run() error { return fmt.Errorf("failed parsing YAML: %w", err) } - docs, err := commandsgen.GenerateDocsFiles(cmds) + docs, err := commandsgen.GenerateDocsFiles(cmds, splitNames) if err != nil { return fmt.Errorf("failed generating docs: %w", err) } for filename, content := range docs { filePath := filepath.Join(outputDir, filename+".mdx") + // Create subdirectories if the filename contains a path separator (e.g., "cloud/namespace") + if dir := filepath.Dir(filePath); dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed creating directory %s: %w", dir, err) + } + } if err := os.WriteFile(filePath, content, 0644); err != nil { return fmt.Errorf("failed writing %s: %w", filePath, err) } diff --git a/internal/commandsgen/docs.go b/internal/commandsgen/docs.go index b3857503e..fb1a4e0b8 100644 --- a/internal/commandsgen/docs.go +++ b/internal/commandsgen/docs.go @@ -8,17 +8,27 @@ import ( "strings" ) -func GenerateDocsFiles(commands Commands) (map[string][]byte, error) { +// GenerateDocsFiles generates documentation files from parsed commands. +// splitNames specifies command names whose subcommands should each get their +// own file in a subdirectory (e.g., passing "cloud" produces cloud/namespace.mdx, +// cloud/user.mdx, etc. instead of a single cloud.mdx). +func GenerateDocsFiles(commands Commands, splitNames []string) (map[string][]byte, error) { optionSetMap := make(map[string]OptionSets) for i, optionSet := range commands.OptionSets { optionSetMap[optionSet.Name] = commands.OptionSets[i] } + splitParents := make(map[string]bool) + for _, name := range splitNames { + splitParents[name] = true + } + w := &docWriter{ fileMap: make(map[string]*bytes.Buffer), optionSetMap: optionSetMap, allCommands: commands.CommandList, globalFlagsMap: make(map[string]map[string]Option), + splitParents: splitParents, } // sorted ascending by full name of command (activity complete, batch list, etc) @@ -45,13 +55,34 @@ type docWriter struct { optionSetMap map[string]OptionSets optionsStack [][]Option globalFlagsMap map[string]map[string]Option // fileName -> optionName -> Option + splitParents map[string]bool // depth-1 command names that use subdirectory splitting } func (c *Command) writeDoc(w *docWriter) error { w.processOptions(c) - // If this is a root command, write a new file depth := c.depth() + + // Check if this command itself is the grouped root, or belongs to one. + // A grouped root command (e.g., "cloud") is skipped - it has no standalone file. + // Its direct children (e.g., "cloud namespace") each get their own file in a subdirectory. + if w.splitParents[c.FullName] { + // This is the grouped root - skip it + return nil + } + if depth >= 1 { + // Walk up the tree to find if any ancestor is a grouped parent + parts := strings.Split(c.FullName, " ") + for i := len(parts) - 1; i >= 1; i-- { + ancestor := strings.Join(parts[:i], " ") + if w.splitParents[ancestor] { + c.writeGroupedDoc(w, ancestor) + return nil + } + } + } + + // Standard (non-grouped) handling if depth == 1 { w.writeCommand(c) } else if depth > 1 { @@ -60,6 +91,22 @@ func (c *Command) writeDoc(w *docWriter) error { return nil } +func (c *Command) writeGroupedDoc(w *docWriter, groupRoot string) { + // How many parts does the group root have? + groupDepth := len(strings.Split(groupRoot, " ")) + parts := strings.Split(c.FullName, " ") + relativeDepth := len(parts) - groupDepth + + switch { + case relativeDepth == 1: + // Direct child of grouped root (e.g., "cloud namespace") - new file + w.writeGroupedCommand(c, groupRoot) + case relativeDepth > 1: + // Deeper subcommand (e.g., "cloud namespace get") - append to parent file + w.writeGroupedSubcommand(c, groupRoot) + } +} + func (w *docWriter) writeCommand(c *Command) { fileName := c.fileName() w.fileMap[fileName] = &bytes.Buffer{} @@ -88,6 +135,107 @@ func (w *docWriter) writeCommand(c *Command) { w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") } +// groupedFileName returns the file path for a command within a grouped parent. +// For example, with groupRoot "cloud" and command "cloud namespace", returns "cloud/namespace". +func groupedFileName(c *Command, groupRoot string) string { + groupParts := strings.Split(groupRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + // The file is named after the first part after the group root + if len(cmdParts) <= len(groupParts) { + return "" + } + return strings.Join(groupParts, "-") + "/" + cmdParts[len(groupParts)] +} + +func (w *docWriter) writeGroupedCommand(c *Command, groupRoot string) { + fileName := groupedFileName(c, groupRoot) + groupParts := strings.Split(groupRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + leafName := cmdParts[len(groupParts)] + fullCmdName := strings.Join(cmdParts, " ") + + w.fileMap[fileName] = &bytes.Buffer{} + w.fileMap[fileName].WriteString("---\n") + w.fileMap[fileName].WriteString("id: " + leafName + "\n") + w.fileMap[fileName].WriteString("title: Temporal CLI " + fullCmdName + " command reference\n") + w.fileMap[fileName].WriteString("sidebar_label: " + leafName + "\n") + w.fileMap[fileName].WriteString("description: " + c.Docs.DescriptionHeader + "\n") + w.fileMap[fileName].WriteString("toc_max_heading_level: 4\n") + + w.fileMap[fileName].WriteString("keywords:\n") + for _, keyword := range c.Docs.Keywords { + w.fileMap[fileName].WriteString(" - " + keyword + "\n") + } + w.fileMap[fileName].WriteString("tags:\n") + for _, tag := range c.Docs.Tags { + w.fileMap[fileName].WriteString(" - " + tag + "\n") + } + w.fileMap[fileName].WriteString("---") + w.fileMap[fileName].WriteString("\n\n") + w.fileMap[fileName].WriteString("{/* NOTE: This is an auto-generated file. Any edit to this file will be overwritten.\n") + w.fileMap[fileName].WriteString("This file is generated from https://github.com/temporalio/cli via cmd/gen-docs */}\n\n") + w.fileMap[fileName].WriteString(fmt.Sprintf("This page provides a reference for the `temporal %s` commands. ", fullCmdName)) + w.fileMap[fileName].WriteString("The flags applicable to each subcommand are presented in a table within the heading for the subcommand. ") + w.fileMap[fileName].WriteString("Refer to [Global Flags](#global-flags) for flags that you can use with every subcommand.\n\n") +} + +func (w *docWriter) writeGroupedSubcommand(c *Command, groupRoot string) { + fileName := groupedFileName(c, groupRoot) + groupParts := strings.Split(groupRoot, " ") + cmdParts := strings.Split(c.FullName, " ") + // Heading depth is relative to the grouped file root + // e.g., "cloud namespace get" with groupRoot "cloud" → relativeDepth 2 → ## heading + relativeDepth := len(cmdParts) - len(groupParts) + prefix := strings.Repeat("#", relativeDepth) + leafParts := cmdParts[len(groupParts)+1:] + leafName := strings.Join(leafParts, "") + + w.fileMap[fileName].WriteString(prefix + " " + leafName + "\n\n") + w.fileMap[fileName].WriteString(c.Description + "\n\n") + + if w.isLeafCommand(c) { + var options = make([]Option, 0) + var globalOptions = make([]Option, 0) + for i, o := range w.optionsStack { + if i == len(w.optionsStack)-1 { + options = append(options, o...) + } else { + globalOptions = append(globalOptions, o...) + } + } + + sort.Slice(options, func(i, j int) bool { + return options[i].Name < options[j].Name + }) + + if len(options) > 0 { + w.fileMap[fileName].WriteString("Use the following options to change the behavior of this command. ") + w.fileMap[fileName].WriteString("You can also use any of the [global flags](#global-flags) that apply to all subcommands.\n\n") + w.writeGroupedOptionsTable(options, fileName) + } else { + w.fileMap[fileName].WriteString("Use [global flags](#global-flags) to customize the connection to the Temporal Service for this command.\n\n") + } + + w.collectGlobalFlags(fileName, globalOptions) + } +} + +func (w *docWriter) writeGroupedOptionsTable(options []Option, fileName string) { + if len(options) == 0 { + return + } + + buf := w.fileMap[fileName] + + buf.WriteString("| Flag | Required | Description |\n") + buf.WriteString("|------|----------|-------------|\n") + + for _, o := range options { + w.writeOptionRow(buf, o, false) + } + buf.WriteString("\n") +} + func (w *docWriter) writeSubcommand(c *Command) { fileName := c.fileName() prefix := strings.Repeat("#", c.depth()) @@ -255,10 +403,11 @@ func (w *docWriter) isLeafCommand(c *Command) bool { } func encodeJSONExample(v string) string { - // example: 'YourKey={"your": "value"}' - // results in an mdx acorn rendering error - // and wrapping in backticks lets it render - re := regexp.MustCompile(`('[a-zA-Z0-9]*={.*}')`) + // JSON objects in single quotes cause MDX acorn rendering errors + // because curly braces are interpreted as JSX expressions. + // Wrapping in backticks makes them render as inline code. + // Matches both 'Key={"value"}' and '{"key": "value"}' patterns. + re := regexp.MustCompile(`('\{.*?\}')`) v = re.ReplaceAllString(v, "`$1`") return v } diff --git a/internal/commandsgen/parse.go b/internal/commandsgen/parse.go index 2083a033d..58d62568f 100644 --- a/internal/commandsgen/parse.go +++ b/internal/commandsgen/parse.go @@ -45,9 +45,9 @@ type ( MaximumArgs int `yaml:"maximum-args"` IgnoreMissingEnv bool `yaml:"ignores-missing-env"` SubcommandsOptional bool `yaml:"subcommands-optional"` - Options []Option `yaml:"options"` - OptionSets []string `yaml:"option-sets"` - Docs Docs `yaml:"docs"` + Options []Option `yaml:"options"` + OptionSets []string `yaml:"option-sets"` + Docs Docs `yaml:"docs"` } // Docs represents docs-only information that is not used in CLI generation. diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index 8682b415b..1b1f31295 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -39,21 +39,19 @@ func (v *ScheduleIdOptions) BuildFlags(f *pflag.FlagSet) { } type ScheduleConfigurationOptions struct { - Calendar []string - CatchupWindow cliext.FlagDuration - Cron []string - EndTime cliext.FlagTimestamp - Interval []string - Jitter cliext.FlagDuration - Notes string - Paused bool - PauseOnFailure bool - RemainingActions int - StartTime cliext.FlagTimestamp - TimeZone string - ScheduleSearchAttribute []string - ScheduleMemo []string - FlagSet *pflag.FlagSet + Calendar []string + CatchupWindow cliext.FlagDuration + Cron []string + EndTime cliext.FlagTimestamp + Interval []string + Jitter cliext.FlagDuration + Notes string + Paused bool + PauseOnFailure bool + RemainingActions int + StartTime cliext.FlagTimestamp + TimeZone string + FlagSet *pflag.FlagSet } func (v *ScheduleConfigurationOptions) BuildFlags(f *pflag.FlagSet) { @@ -72,6 +70,16 @@ func (v *ScheduleConfigurationOptions) BuildFlags(f *pflag.FlagSet) { f.IntVar(&v.RemainingActions, "remaining-actions", 0, "Total allowed actions. Default is zero (unlimited).") f.Var(&v.StartTime, "start-time", "Schedule start time.") f.StringVar(&v.TimeZone, "time-zone", "", "Interpret calendar specs with the `TZ` time zone. For a list of time zones, see: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones.") +} + +type ScheduleCreateOnlyOptions struct { + ScheduleSearchAttribute []string + ScheduleMemo []string + FlagSet *pflag.FlagSet +} + +func (v *ScheduleCreateOnlyOptions) BuildFlags(f *pflag.FlagSet) { + v.FlagSet = f f.StringArrayVar(&v.ScheduleSearchAttribute, "schedule-search-attribute", nil, "Set schedule Search Attributes using `KEY=\"VALUE` pairs. Keys must be identifiers, and values must be JSON values. For example: 'YourKey={\"your\": \"value\"}'. Can be passed multiple times.") f.StringArrayVar(&v.ScheduleMemo, "schedule-memo", nil, "Set schedule memo using `KEY=\"VALUE` pairs. Keys must be identifiers, and values must be JSON values. For example: 'YourKey={\"your\": \"value\"}'. Can be passed multiple times.") } @@ -1821,6 +1829,7 @@ type TemporalScheduleCreateCommand struct { Parent *TemporalScheduleCommand Command cobra.Command ScheduleConfigurationOptions + ScheduleCreateOnlyOptions ScheduleIdOptions OverlapPolicyOptions SharedWorkflowStartOptions @@ -1840,6 +1849,7 @@ func NewTemporalScheduleCreateCommand(cctx *CommandContext, parent *TemporalSche } s.Command.Args = cobra.NoArgs s.ScheduleConfigurationOptions.BuildFlags(s.Command.Flags()) + s.ScheduleCreateOnlyOptions.BuildFlags(s.Command.Flags()) s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) s.SharedWorkflowStartOptions.BuildFlags(s.Command.Flags()) @@ -2019,9 +2029,9 @@ func NewTemporalScheduleUpdateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Use = "update [flags]" s.Command.Short = "Update Schedule details" if hasHighlighting { - s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n\x1b[1mtemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-type \"NewWorkflowType\"\x1b[0m" + s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n\x1b[1mtemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-type \"NewWorkflowType\"\x1b[0m\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n\x1b[1mtemporal schedule describe\x1b[0m before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates." } else { - s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n```\ntemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-type \"NewWorkflowType\"\n```" + s.Command.Long = "Update an existing Schedule with new configuration details, including time\nspecifications, action, and policies:\n\n```\ntemporal schedule update \\\n --schedule-id \"YourScheduleId\" \\\n --workflow-type \"NewWorkflowType\"\n```\n\nThis command performs a full replacement of the Schedule\nconfiguration. Any options not provided will be reset to their default\nvalues. You must re-specify all options, not just the ones you want to\nchange. To view the current configuration of a Schedule, use\n`temporal schedule describe` before updating.\n\nSchedule memo and search attributes cannot be updated with this\ncommand. They are set only during Schedule creation and are not affected\nby updates." } s.Command.Args = cobra.NoArgs s.ScheduleConfigurationOptions.BuildFlags(s.Command.Flags()) diff --git a/internal/temporalcli/commands.yaml b/internal/temporalcli/commands.yaml index ce311b230..d293b75f8 100644 --- a/internal/temporalcli/commands.yaml +++ b/internal/temporalcli/commands.yaml @@ -2122,6 +2122,7 @@ commands: For example, every Friday at 12:30 PM: `30 12 * * Fri`. option-sets: - schedule-configuration + - schedule-create-only - schedule-id - overlap-policy - shared-workflow-start @@ -2240,6 +2241,16 @@ commands: --schedule-id "YourScheduleId" \ --workflow-type "NewWorkflowType" ``` + + This command performs a full replacement of the Schedule + configuration. Any options not provided will be reset to their default + values. You must re-specify all options, not just the ones you want to + change. To view the current configuration of a Schedule, use + `temporal schedule describe` before updating. + + Schedule memo and search attributes cannot be updated with this + command. They are set only during Schedule creation and are not affected + by updates. option-sets: - schedule-configuration - schedule-id @@ -4351,6 +4362,9 @@ option-sets: Interpret calendar specs with the `TZ` time zone. For a list of time zones, see: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. + + - name: schedule-create-only + options: - name: schedule-search-attribute type: string[] description: |