Skip to content
Closed
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
16 changes: 12 additions & 4 deletions cmd/gen-docs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
161 changes: 155 additions & 6 deletions internal/commandsgen/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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{}
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
}
6 changes: 3 additions & 3 deletions internal/commandsgen/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 27 additions & 17 deletions internal/temporalcli/commands.gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.")
}
Expand Down Expand Up @@ -1821,6 +1829,7 @@ type TemporalScheduleCreateCommand struct {
Parent *TemporalScheduleCommand
Command cobra.Command
ScheduleConfigurationOptions
ScheduleCreateOnlyOptions
ScheduleIdOptions
OverlapPolicyOptions
SharedWorkflowStartOptions
Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
14 changes: 14 additions & 0 deletions internal/temporalcli/commands.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
Loading