Skip to content

Commit 0e0fa0d

Browse files
committed
feat(cli): add non-interactive mode for configure sources and targets
Add CLI flags to enable non-interactive operation for `speakeasy configure sources` and `speakeasy configure targets` commands, allowing usage in CI/CD pipelines and automated workflows without requiring a TTY. For configure sources: - --location: OpenAPI document location (local file or URL) - --source-name: name for the source - --auth-header: authentication header name (optional) - --output: output path for compiled source (optional) For configure targets: - --target-type: language type (typescript, python, go, java, etc.) - --source: name of the source to generate from - --target-name: name for the target (defaults to target-type) - --sdk-class-name: SDK class name (optional) - --package-name: package name (optional) - --base-server-url: base server URL (optional) - --output: output directory (optional) Non-interactive mode is automatically enabled when the required flags are provided. Closes: GEN-2415
1 parent 29ea33d commit 0e0fa0d

File tree

2 files changed

+672
-6
lines changed

2 files changed

+672
-6
lines changed

cmd/configure.go

Lines changed: 312 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,18 @@ var configureGenerationCmd = &model.CommandGroup{
7777
}
7878

7979
type ConfigureSourcesFlags struct {
80-
ID string `json:"id"`
81-
New bool `json:"new"`
80+
ID string `json:"id"`
81+
New bool `json:"new"`
82+
Location string `json:"location"`
83+
SourceName string `json:"source-name"`
84+
AuthHeader string `json:"auth-header"`
85+
OutputPath string `json:"output"`
8286
}
8387

8488
var configureSourcesCmd = &model.ExecutableCommand[ConfigureSourcesFlags]{
8589
Usage: "sources",
8690
Short: "Configure new or existing sources.",
87-
Long: "Guided prompts to configure a new or existing source in your speakeasy workflow.",
91+
Long: "Guided prompts to configure a new or existing source in your speakeasy workflow. When --location and --source-name are provided, runs in non-interactive mode suitable for CI/CD.",
8892
Run: configureSources,
8993
RequiresAuth: true,
9094
Flags: []flag.Flag{
@@ -98,18 +102,44 @@ var configureSourcesCmd = &model.ExecutableCommand[ConfigureSourcesFlags]{
98102
Shorthand: "n",
99103
Description: "configure a new source",
100104
},
105+
flag.StringFlag{
106+
Name: "location",
107+
Shorthand: "l",
108+
Description: "location of the OpenAPI document (local file path or URL); enables non-interactive mode when combined with --source-name",
109+
},
110+
flag.StringFlag{
111+
Name: "source-name",
112+
Shorthand: "s",
113+
Description: "name for the source; enables non-interactive mode when combined with --location",
114+
},
115+
flag.StringFlag{
116+
Name: "auth-header",
117+
Description: "authentication header name for remote documents (value from $OPENAPI_DOC_AUTH_TOKEN)",
118+
},
119+
flag.StringFlag{
120+
Name: "output",
121+
Shorthand: "o",
122+
Description: "output path for the compiled source document",
123+
},
101124
},
102125
}
103126

104127
type ConfigureTargetFlags struct {
105-
ID string `json:"id"`
106-
New bool `json:"new"`
128+
ID string `json:"id"`
129+
New bool `json:"new"`
130+
TargetType string `json:"target-type"`
131+
SourceID string `json:"source"`
132+
TargetName string `json:"target-name"`
133+
SDKClassName string `json:"sdk-class-name"`
134+
PackageName string `json:"package-name"`
135+
BaseServerURL string `json:"base-server-url"`
136+
OutputDir string `json:"output"`
107137
}
108138

109139
var configureTargetCmd = &model.ExecutableCommand[ConfigureTargetFlags]{
110140
Usage: "targets",
111141
Short: "Configure new or existing targets.",
112-
Long: "Guided prompts to configure a new or existing target in your speakeasy workflow.",
142+
Long: "Guided prompts to configure a new or existing target in your speakeasy workflow. When --target-type and --source are provided, runs in non-interactive mode suitable for CI/CD.",
113143
Run: configureTarget,
114144
RequiresAuth: true,
115145
Flags: []flag.Flag{
@@ -123,6 +153,37 @@ var configureTargetCmd = &model.ExecutableCommand[ConfigureTargetFlags]{
123153
Shorthand: "n",
124154
Description: "configure a new target",
125155
},
156+
flag.StringFlag{
157+
Name: "target-type",
158+
Shorthand: "t",
159+
Description: "target language/type: typescript, python, go, java, csharp, php, ruby, terraform, mcp-typescript; enables non-interactive mode",
160+
},
161+
flag.StringFlag{
162+
Name: "source",
163+
Shorthand: "s",
164+
Description: "name of the source to generate from; enables non-interactive mode when combined with --target-type",
165+
},
166+
flag.StringFlag{
167+
Name: "target-name",
168+
Description: "name for the target (defaults to target-type if not specified)",
169+
},
170+
flag.StringFlag{
171+
Name: "sdk-class-name",
172+
Description: "SDK class name (PascalCase, e.g., MyCompanySDK)",
173+
},
174+
flag.StringFlag{
175+
Name: "package-name",
176+
Description: "package name for the generated SDK",
177+
},
178+
flag.StringFlag{
179+
Name: "base-server-url",
180+
Description: "base server URL for the SDK",
181+
},
182+
flag.StringFlag{
183+
Name: "output",
184+
Shorthand: "o",
185+
Description: "output directory for the generated target",
186+
},
126187
},
127188
}
128189

@@ -218,6 +279,11 @@ func configureSources(ctx context.Context, flags ConfigureSourcesFlags) error {
218279
}
219280
}
220281

282+
// Non-interactive mode: when both --location and --source-name are provided
283+
if flags.Location != "" && flags.SourceName != "" {
284+
return configureSourcesNonInteractive(ctx, workingDir, workflowFile, flags)
285+
}
286+
221287
var existingSourceName string
222288
var existingSource *workflow.Source
223289
if source, ok := workflowFile.Sources[flags.ID]; ok {
@@ -291,6 +357,81 @@ func configureSources(ctx context.Context, flags ConfigureSourcesFlags) error {
291357
return nil
292358
}
293359

360+
// configureSourcesNonInteractive handles source configuration without interactive prompts.
361+
// This is used when --location and --source-name flags are both provided.
362+
func configureSourcesNonInteractive(ctx context.Context, workingDir string, workflowFile *workflow.Workflow, flags ConfigureSourcesFlags) error {
363+
logger := log.From(ctx)
364+
365+
// Validate source name doesn't already exist
366+
if _, ok := workflowFile.Sources[flags.SourceName]; ok {
367+
return fmt.Errorf("a source with the name %q already exists", flags.SourceName)
368+
}
369+
370+
// Validate source name format
371+
if strings.Contains(flags.SourceName, " ") {
372+
return fmt.Errorf("source name must not contain spaces")
373+
}
374+
375+
// Build the source document
376+
document := workflow.Document{
377+
Location: workflow.LocationString(flags.Location),
378+
}
379+
380+
// Add authentication if provided
381+
if flags.AuthHeader != "" {
382+
document.Auth = &workflow.Auth{
383+
Header: flags.AuthHeader,
384+
Secret: "$openapi_doc_auth_token",
385+
}
386+
}
387+
388+
// Build the source
389+
source := workflow.Source{
390+
Inputs: []workflow.Document{document},
391+
}
392+
393+
// Set output path if provided
394+
if flags.OutputPath != "" {
395+
source.Output = &flags.OutputPath
396+
}
397+
398+
// Validate the source
399+
if err := source.Validate(); err != nil {
400+
return errors.Wrap(err, "failed to validate source configuration")
401+
}
402+
403+
// Add source to workflow
404+
workflowFile.Sources[flags.SourceName] = source
405+
406+
// Validate the workflow
407+
if err := workflowFile.Validate(generate.GetSupportedTargetNames()); err != nil {
408+
return errors.Wrap(err, "failed to validate workflow file")
409+
}
410+
411+
// Ensure .speakeasy directory exists
412+
if _, err := os.Stat(".speakeasy"); os.IsNotExist(err) {
413+
if err := os.MkdirAll(".speakeasy", 0o755); err != nil {
414+
return err
415+
}
416+
}
417+
418+
// Save the workflow
419+
if err := workflow.Save(workingDir, workflowFile); err != nil {
420+
return errors.Wrap(err, "failed to save workflow file")
421+
}
422+
423+
// Print success message
424+
logger.Printf("Successfully configured source %q with location %q\n", flags.SourceName, flags.Location)
425+
if flags.OutputPath != "" {
426+
logger.Printf(" Output path: %s\n", flags.OutputPath)
427+
}
428+
if flags.AuthHeader != "" {
429+
logger.Printf(" Auth header: %s (value from $OPENAPI_DOC_AUTH_TOKEN)\n", flags.AuthHeader)
430+
}
431+
432+
return nil
433+
}
434+
294435
func configureTarget(ctx context.Context, flags ConfigureTargetFlags) error {
295436
workingDir, err := os.Getwd()
296437
if err != nil {
@@ -312,6 +453,11 @@ func configureTarget(ctx context.Context, flags ConfigureTargetFlags) error {
312453
workflowFile.Targets = make(map[string]workflow.Target)
313454
}
314455

456+
// Non-interactive mode: when --target-type and --source are provided
457+
if flags.TargetType != "" && flags.SourceID != "" {
458+
return configureTargetNonInteractive(ctx, workingDir, workflowFile, flags)
459+
}
460+
315461
existingTarget := ""
316462
if _, ok := workflowFile.Targets[flags.ID]; ok {
317463
existingTarget = flags.ID
@@ -452,6 +598,166 @@ func configureTarget(ctx context.Context, flags ConfigureTargetFlags) error {
452598
return nil
453599
}
454600

601+
// configureTargetNonInteractive handles target configuration without interactive prompts.
602+
// This is used when --target-type and --source flags are both provided.
603+
func configureTargetNonInteractive(ctx context.Context, workingDir string, workflowFile *workflow.Workflow, flags ConfigureTargetFlags) error {
604+
logger := log.From(ctx)
605+
606+
// Validate target type is supported
607+
supportedTargets := generate.GetSupportedTargetNames()
608+
if !slices.Contains(supportedTargets, flags.TargetType) {
609+
return fmt.Errorf("unsupported target type %q; supported types: %s", flags.TargetType, strings.Join(supportedTargets, ", "))
610+
}
611+
612+
// Validate source exists
613+
if _, ok := workflowFile.Sources[flags.SourceID]; !ok {
614+
var sourceNames []string
615+
for name := range workflowFile.Sources {
616+
sourceNames = append(sourceNames, name)
617+
}
618+
return fmt.Errorf("source %q not found; available sources: %s", flags.SourceID, strings.Join(sourceNames, ", "))
619+
}
620+
621+
// Default target name to target type if not provided
622+
targetName := flags.TargetName
623+
if targetName == "" {
624+
targetName = flags.TargetType
625+
}
626+
627+
// Validate target name doesn't already exist
628+
if _, ok := workflowFile.Targets[targetName]; ok {
629+
return fmt.Errorf("a target with the name %q already exists", targetName)
630+
}
631+
632+
// Validate target name format
633+
if strings.Contains(targetName, " ") {
634+
return fmt.Errorf("target name must not contain spaces")
635+
}
636+
637+
// Build the target
638+
target := workflow.Target{
639+
Target: flags.TargetType,
640+
Source: flags.SourceID,
641+
}
642+
643+
// Set output directory if provided
644+
if flags.OutputDir != "" {
645+
target.Output = &flags.OutputDir
646+
}
647+
648+
// Validate the target
649+
if err := target.Validate(supportedTargets, workflowFile.Sources); err != nil {
650+
return errors.Wrap(err, "failed to validate target configuration")
651+
}
652+
653+
// Add target to workflow
654+
workflowFile.Targets[targetName] = target
655+
656+
// Build config for target
657+
targetConfig, err := config.GetDefaultConfig(true, generate.GetLanguageConfigDefaults, map[string]bool{flags.TargetType: true})
658+
if err != nil {
659+
return errors.Wrapf(err, "failed to generate config for target %s", targetName)
660+
}
661+
662+
// Set SDK class name if provided
663+
if flags.SDKClassName != "" {
664+
targetConfig.Generation.SDKClassName = flags.SDKClassName
665+
}
666+
667+
// Set base server URL if provided
668+
if flags.BaseServerURL != "" {
669+
targetConfig.Generation.BaseServerURL = flags.BaseServerURL
670+
}
671+
672+
// Set package name if provided
673+
if flags.PackageName != "" {
674+
if langConfig, ok := targetConfig.Languages[flags.TargetType]; ok {
675+
if langConfig.Cfg == nil {
676+
langConfig.Cfg = make(map[string]interface{})
677+
}
678+
// Different languages use different config keys for package name
679+
switch flags.TargetType {
680+
case "go":
681+
langConfig.Cfg["modulePath"] = flags.PackageName
682+
case "java":
683+
// For Java, split packageName into groupID and artifactID if it contains a colon
684+
if strings.Contains(flags.PackageName, ":") {
685+
parts := strings.SplitN(flags.PackageName, ":", 2)
686+
langConfig.Cfg["groupID"] = parts[0]
687+
langConfig.Cfg["artifactID"] = parts[1]
688+
} else {
689+
langConfig.Cfg["groupID"] = flags.PackageName
690+
}
691+
default:
692+
langConfig.Cfg["packageName"] = flags.PackageName
693+
}
694+
targetConfig.Languages[flags.TargetType] = langConfig
695+
}
696+
}
697+
698+
// Determine output directory
699+
outDir := workingDir
700+
if target.Output != nil {
701+
outDir = *target.Output
702+
}
703+
704+
// Ensure .speakeasy directory exists in output dir
705+
if _, err := os.Stat(filepath.Join(outDir, ".speakeasy")); os.IsNotExist(err) {
706+
if err := os.MkdirAll(filepath.Join(outDir, ".speakeasy"), 0o755); err != nil {
707+
return err
708+
}
709+
}
710+
711+
// Create empty gen.yaml if it doesn't exist
712+
genYamlPath := filepath.Join(outDir, ".speakeasy/gen.yaml")
713+
if _, err := os.Stat(genYamlPath); os.IsNotExist(err) {
714+
if err := os.WriteFile(genYamlPath, []byte{}, 0o644); err != nil {
715+
return err
716+
}
717+
}
718+
719+
// Save config
720+
if err := config.SaveConfig(outDir, targetConfig); err != nil {
721+
return errors.Wrapf(err, "failed to save config for target %s", targetName)
722+
}
723+
724+
// Validate the workflow
725+
if err := workflowFile.Validate(supportedTargets); err != nil {
726+
return errors.Wrap(err, "failed to validate workflow file")
727+
}
728+
729+
// Ensure .speakeasy directory exists in working dir
730+
if _, err := os.Stat(".speakeasy"); os.IsNotExist(err) {
731+
if err := os.MkdirAll(".speakeasy", 0o755); err != nil {
732+
return err
733+
}
734+
}
735+
736+
// Save the workflow
737+
if err := workflow.Save(workingDir, workflowFile); err != nil {
738+
return errors.Wrap(err, "failed to save workflow file")
739+
}
740+
741+
// Print success message
742+
logger.Printf("Successfully configured target %q\n", targetName)
743+
logger.Printf(" Type: %s\n", flags.TargetType)
744+
logger.Printf(" Source: %s\n", flags.SourceID)
745+
if flags.OutputDir != "" {
746+
logger.Printf(" Output: %s\n", flags.OutputDir)
747+
}
748+
if flags.SDKClassName != "" {
749+
logger.Printf(" SDK Class Name: %s\n", flags.SDKClassName)
750+
}
751+
if flags.PackageName != "" {
752+
logger.Printf(" Package Name: %s\n", flags.PackageName)
753+
}
754+
if flags.BaseServerURL != "" {
755+
logger.Printf(" Base Server URL: %s\n", flags.BaseServerURL)
756+
}
757+
758+
return nil
759+
}
760+
455761
func configurePublishing(ctx context.Context, flags ConfigureGithubFlags) error {
456762
logger := log.From(ctx)
457763

0 commit comments

Comments
 (0)