Skip to content

Commit 19449ab

Browse files
authored
Commands generator binary (#875)
<!--- Note to EXTERNAL Contributors --> <!-- Thanks for opening a PR! If it is a significant code change, please **make sure there is an open issue** for this. We work best with you when we have accepted the idea first before you code. --> <!--- For ALL Contributors 👇 --> ## What was changed <!-- Describe what has changed in this PR --> Make `gen-commands` and `gen-docs` a binary that can be used any YAML input. ## Why? <!-- Tell your future self why have you made these changes --> Temporal wants to re-use the `commandsgen` tool for other _internal_ CLIs. **It is not supported for users outside of Temporal.** ## Checklist <!--- add/delete as needed ---> 1. Closes <!-- add issue number here --> 2. How was this tested: <!--- Please describe how you tested your changes/how we can test them --> 3. Any docs updates needed? <!--- update README if applicable or point out where to update docs.temporal.io -->
1 parent 959f910 commit 19449ab

File tree

20 files changed

+457
-158
lines changed

20 files changed

+457
-158
lines changed

.github/workflows/ci.yaml

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@ jobs:
1010
strategy:
1111
fail-fast: false
1212
matrix:
13-
os: [ubuntu-latest, macos-latest, macos-13, windows-latest, ubuntu-arm]
13+
os:
14+
[
15+
ubuntu-latest,
16+
macos-latest,
17+
macos-15-intel,
18+
windows-latest,
19+
ubuntu-arm,
20+
]
1421
include:
1522
- os: ubuntu-latest
16-
checkGenCodeTarget: true
23+
checkGenCommands: true
1724
cloudTestTarget: true
1825
- os: ubuntu-arm
1926
runsOn: buildjet-4vcpu-ubuntu-2204-arm
@@ -51,11 +58,16 @@ jobs:
5158
retention-days: 14
5259

5360
- name: Regen code, confirm unchanged
54-
if: ${{ matrix.checkGenCodeTarget }}
61+
if: ${{ matrix.checkGenCommands }}
5562
run: |
56-
go run ./internal/cmd/gen-commands
63+
go run ./internal/cmd/gen-commands -input internal/temporalcli/commands.yaml -pkg temporalcli -context "*CommandContext" > internal/temporalcli/commands.gen.go
5764
git diff --exit-code
5865
66+
- name: Generate docs, confirm working
67+
if: ${{ matrix.checkGenCommands }}
68+
run: |
69+
go run ./internal/cmd/gen-docs -input internal/temporalcli/commands.yaml -output dist/docs
70+
5971
- name: Test cloud mTLS
6072
if: ${{ matrix.cloudTestTarget && env.HAS_SECRETS == 'true' }}
6173
env:

CONTRIBUTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ Example to run a single test case:
2020

2121
## Adding/updating commands
2222

23-
First, update [commands.yml](internal/commandsgen/commands.yml) following the rules in that file. Then to regenerate the
24-
[commands.gen.go](internal/commands.gen.go) file from code, simply run:
23+
First, update [commands.yaml](internal/temporalcli/commands.yaml) following the rules in that file. Then to regenerate the
24+
[commands.gen.go](internal/temporalcli/commands.gen.go) file from code, run:
2525

26-
go run ./internal/cmd/gen-commands
26+
go run ./internal/cmd/gen-commands -input internal/temporalcli/commands.yaml -pkg temporalcli -context "*CommandContext" > internal/temporalcli/commands.gen.go
2727

2828
This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement
2929
`run` on the new command in a separate file before it will compile.
3030

3131
Once a command is updated, the CI will automatically generate new docs
3232
and create a PR in the Documentation repo with the corresponding updates. To generate these docs locally, run:
3333

34-
go run ./internal/cmd/gen-docs
34+
go run ./internal/cmd/gen-docs -input internal/temporalcli/commands.yaml -output dist/docs
3535

3636
This will auto-generate a new set of docs to `dist/docs/`. If a new root command is added, a new file will be automatically generated, like `temporal activity` and `activity.mdx`.
3737

Makefile

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
.PHONY: all gen build fmt-imports
1+
.PHONY: all gen gen-docs build
22

33
all: gen build
44

5-
gen: internal/commands.gen.go
5+
gen: internal/temporalcli/commands.gen.go
66

7-
internal/commands.gen.go: internal/commandsgen/commands.yml
8-
go run ./internal/cmd/gen-commands
7+
internal/temporalcli/commands.gen.go: internal/temporalcli/commands.yaml
8+
go run ./internal/cmd/gen-commands -input $< -pkg temporalcli -context "*CommandContext" > $@
9+
10+
gen-docs: internal/temporalcli/commands.yaml
11+
go run ./internal/cmd/gen-docs -input $< -output dist/docs
912

1013
build:
1114
go build ./cmd/temporal
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
This package provides a CLI command generator for Temporal CLI applications.
2+
It is designed specifically for the needs of Temporal CLIs, and not general-purpose command-line tools.
3+
4+
Backwards-compatibility is not guaranteed.

internal/cmd/gen-commands/main.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
package main
22

33
import (
4+
"flag"
45
"fmt"
56
"log"
67
"os"
7-
"path/filepath"
8-
"runtime"
98

109
"github.com/temporalio/cli/internal/commandsgen"
1110
)
@@ -17,26 +16,42 @@ func main() {
1716
}
1817

1918
func run() error {
20-
// Get commands dir
21-
_, file, _, _ := runtime.Caller(0)
22-
genCommandsDir := filepath.Dir(file)
23-
commandsDir := filepath.Join(genCommandsDir, "../../")
19+
var (
20+
pkg string
21+
contextType string
22+
inputFile string
23+
)
24+
25+
flag.StringVar(&pkg, "pkg", "main", "Package name for generated code")
26+
flag.StringVar(&contextType, "context", "*CommandContext", "Context type for generated code")
27+
flag.StringVar(&inputFile, "input", "", "Input YAML file (required)")
28+
flag.Parse()
29+
30+
// Read input from file
31+
if inputFile == "" {
32+
return fmt.Errorf("-input flag is required")
33+
}
34+
yamlBytes, err := os.ReadFile(inputFile)
35+
if err != nil {
36+
return fmt.Errorf("failed reading input: %w", err)
37+
}
2438

2539
// Parse YAML
26-
cmds, err := commandsgen.ParseCommands()
40+
cmds, err := commandsgen.ParseCommands(yamlBytes)
2741
if err != nil {
2842
return fmt.Errorf("failed parsing YAML: %w", err)
2943
}
3044

3145
// Generate code
32-
b, err := commandsgen.GenerateCommandsCode("temporalcli", cmds)
46+
b, err := commandsgen.GenerateCommandsCode(pkg, contextType, cmds)
3347
if err != nil {
3448
return fmt.Errorf("failed generating code: %w", err)
3549
}
3650

37-
// Write
38-
if err := os.WriteFile(filepath.Join(commandsDir, "commands.gen.go"), b, 0644); err != nil {
39-
return fmt.Errorf("failed writing file: %w", err)
51+
// Write output to stdout
52+
if _, err = os.Stdout.Write(b); err != nil {
53+
return fmt.Errorf("failed writing output: %w", err)
4054
}
55+
4156
return nil
4257
}

internal/cmd/gen-docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
This package provides a CLI command documentation generator for Temporal CLI applications.
2+
It is designed specifically for the needs of Temporal CLIs, and not general-purpose command-line tools.
3+
4+
Backwards-compatibility is not guaranteed.

internal/cmd/gen-docs/main.go

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package main
22

33
import (
4+
"flag"
45
"fmt"
56
"log"
67
"os"
78
"path/filepath"
8-
"runtime"
99

1010
"github.com/temporalio/cli/internal/commandsgen"
1111
)
@@ -17,33 +17,46 @@ func main() {
1717
}
1818

1919
func run() error {
20-
// Get commands dir
21-
_, file, _, _ := runtime.Caller(0)
22-
genDocsDir := filepath.Dir(file)
23-
docsDir := filepath.Join(genDocsDir, "../../../dist/docs/")
20+
var (
21+
outputDir string
22+
inputFile string
23+
)
2424

25-
err := os.MkdirAll(docsDir, os.ModePerm)
25+
flag.StringVar(&inputFile, "input", "", "Input YAML file (required)")
26+
flag.StringVar(&outputDir, "output", ".", "Output directory for docs")
27+
flag.Parse()
28+
29+
// Read input from file
30+
if inputFile == "" {
31+
return fmt.Errorf("-input flag is required")
32+
}
33+
yamlBytes, err := os.ReadFile(inputFile)
2634
if err != nil {
27-
log.Fatalf("Error creating directory: %v", err)
35+
return fmt.Errorf("failed reading input: %w", err)
36+
}
37+
38+
// Create output directory
39+
if err := os.MkdirAll(outputDir, 0755); err != nil {
40+
return fmt.Errorf("failed creating output directory: %w", err)
2841
}
2942

3043
// Parse YAML
31-
cmds, err := commandsgen.ParseCommands()
44+
cmds, err := commandsgen.ParseCommands(yamlBytes)
3245
if err != nil {
3346
return fmt.Errorf("failed parsing YAML: %w", err)
3447
}
3548

3649
// Generate docs
37-
b, err := commandsgen.GenerateDocsFiles(cmds)
50+
docs, err := commandsgen.GenerateDocsFiles(cmds)
3851
if err != nil {
39-
return err
52+
return fmt.Errorf("failed generating docs: %w", err)
4053
}
4154

42-
// Write
43-
for filename, content := range b {
44-
filePath := filepath.Join(docsDir, filename+".mdx")
55+
// Write files
56+
for filename, content := range docs {
57+
filePath := filepath.Join(outputDir, filename+".mdx")
4558
if err := os.WriteFile(filePath, content, 0644); err != nil {
46-
return fmt.Errorf("failed writing file: %w", err)
59+
return fmt.Errorf("failed writing %s: %w", filePath, err)
4760
}
4861
}
4962

internal/commandsgen/code.go

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,27 @@ package commandsgen
22

33
import (
44
"bytes"
5+
"embed"
56
"fmt"
7+
"go/ast"
68
"go/format"
9+
"go/parser"
10+
"go/token"
11+
"io/fs"
712
"path"
813
"regexp"
914
"sort"
1015
"strings"
1116

12-
"go.temporal.io/server/common/primitives/timestamp"
17+
"github.com/temporalio/cli/internal/commandsgen/types"
1318
)
1419

15-
func GenerateCommandsCode(pkg string, commands Commands) ([]byte, error) {
16-
w := &codeWriter{allCommands: commands.CommandList, OptionSets: commands.OptionSets}
20+
//go:embed types/*.go
21+
var typesFS embed.FS
22+
23+
func GenerateCommandsCode(pkg string, contextType string, commands Commands) ([]byte, error) {
24+
w := &codeWriter{allCommands: commands.CommandList, OptionSets: commands.OptionSets, contextType: contextType}
25+
1726
// Put terminal check at top
1827
w.writeLinef("var hasHighlighting = %v.IsTerminal(%v.Stdout.Fd())", w.importIsatty(), w.importPkg("os"))
1928

@@ -24,13 +33,28 @@ func GenerateCommandsCode(pkg string, commands Commands) ([]byte, error) {
2433
}
2534
}
2635

27-
// Write all commands, then come back and write package and imports
36+
// Write all commands
2837
for _, cmd := range commands.CommandList {
2938
if err := cmd.writeCode(w); err != nil {
3039
return nil, fmt.Errorf("failed writing command %v: %w", cmd.FullName, err)
3140
}
3241
}
3342

43+
// Append embedded Go files from types/ (parse imports with go/ast, write code after imports)
44+
err := fs.WalkDir(typesFS, "types", func(path string, d fs.DirEntry, err error) error {
45+
if err != nil || d.IsDir() || strings.Contains(path, "_test.go") {
46+
return err
47+
}
48+
src, err := typesFS.ReadFile(path)
49+
if err != nil {
50+
return err
51+
}
52+
return w.appendGoSource(string(src))
53+
})
54+
if err != nil {
55+
return nil, err
56+
}
57+
3458
// Write package and imports to final buf
3559
var finalBuf bytes.Buffer
3660
finalBuf.WriteString("// Code generated. DO NOT EDIT.\n\n")
@@ -59,6 +83,7 @@ type codeWriter struct {
5983
buf bytes.Buffer
6084
allCommands []Command
6185
OptionSets []OptionSets
86+
contextType string
6287
// Key is short ref, value is full
6388
imports map[string]string
6489
}
@@ -98,6 +123,36 @@ func (c *codeWriter) importPkg(pkg string) string {
98123

99124
func (c *codeWriter) importCobra() string { return c.importPkg("github.com/spf13/cobra") }
100125

126+
// appendGoSource parses a Go source file, registers its imports, and appends
127+
// everything after the import block to the output buffer.
128+
func (c *codeWriter) appendGoSource(src string) error {
129+
fset := token.NewFileSet()
130+
f, err := parser.ParseFile(fset, "", src, parser.ImportsOnly)
131+
if err != nil {
132+
return fmt.Errorf("failed to parse embedded source: %w", err)
133+
}
134+
135+
// Register imports
136+
for _, imp := range f.Imports {
137+
// imp.Path.Value includes quotes, so trim them
138+
c.importPkg(strings.Trim(imp.Path.Value, `"`))
139+
}
140+
141+
// Find end of imports and append the rest
142+
var lastImportEnd token.Pos
143+
for _, decl := range f.Decls {
144+
if genDecl, ok := decl.(*ast.GenDecl); ok && genDecl.Tok == token.IMPORT {
145+
if genDecl.End() > lastImportEnd {
146+
lastImportEnd = genDecl.End()
147+
}
148+
}
149+
}
150+
151+
// Write everything after imports
152+
c.buf.WriteString(src[fset.Position(lastImportEnd).Offset:])
153+
return nil
154+
}
155+
101156
func (c *codeWriter) importPflag() string { return c.importPkg("github.com/spf13/pflag") }
102157

103158
func (c *codeWriter) importIsatty() string { return c.importPkg("github.com/mattn/go-isatty") }
@@ -120,8 +175,8 @@ func (o *OptionSets) writeCode(w *codeWriter) error {
120175
w.writeLinef("}\n")
121176

122177
// write flags
123-
w.writeLinef("func (v *%v) buildFlags(cctx *CommandContext, f *%v.FlagSet) {",
124-
o.setStructName(), w.importPflag())
178+
w.writeLinef("func (v *%v) buildFlags(cctx %s, f *%v.FlagSet) {",
179+
o.setStructName(), w.contextType, w.importPflag())
125180
o.writeFlagBuilding("v", "f", w)
126181
w.writeLinef("}\n")
127182

@@ -164,10 +219,10 @@ func (c *Command) writeCode(w *codeWriter) error {
164219

165220
// Constructor builds the struct and sets the flags
166221
if hasParent {
167-
w.writeLinef("func New%v(cctx *CommandContext, parent *%v) *%v {",
168-
c.structName(), parent.structName(), c.structName())
222+
w.writeLinef("func New%v(cctx %s, parent *%v) *%v {",
223+
c.structName(), w.contextType, parent.structName(), c.structName())
169224
} else {
170-
w.writeLinef("func New%v(cctx *CommandContext) *%v {", c.structName(), c.structName())
225+
w.writeLinef("func New%v(cctx %s) *%v {", c.structName(), w.contextType, c.structName())
171226
}
172227
w.writeLinef("var s %v", c.structName())
173228
if hasParent {
@@ -328,7 +383,7 @@ func (o *Option) writeFlagBuilding(selfVar, flagVar string, w *codeWriter) error
328383
case "duration":
329384
flagMeth, setDefault = "Var", "0"
330385
if o.Default != "" {
331-
dur, err := timestamp.ParseDuration(o.Default)
386+
dur, err := types.ParseDuration(o.Default)
332387
if err != nil {
333388
return fmt.Errorf("invalid default: %w", err)
334389
}

0 commit comments

Comments
 (0)