Skip to content
This repository was archived by the owner on Mar 17, 2026. It is now read-only.
Merged
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
19 changes: 7 additions & 12 deletions cli/internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"time"

"github.com/nitrictech/suga/cli/internal/api"
Expand All @@ -32,18 +31,14 @@ func sanitizeForFilename(input string) string {
}


func (b *BuilderService) BuildProjectForTarget(appSpec *schema.Application, target string, currentTeam string) (string, error) {
func (b *BuilderService) BuildProject(appSpec *schema.Application, currentTeam string) (string, error) {
platformRepository := platforms.NewPlatformRepository(b.apiClient, currentTeam)

if len(appSpec.Targets) == 0 {
return "", fmt.Errorf("no targets specified in project %s", appSpec.Name)
if appSpec.Target == "" {
return "", fmt.Errorf("no target specified in project %s", appSpec.Name)
}

if !slices.Contains(appSpec.Targets, target) {
return "", fmt.Errorf("target %s not found in project %s", target, appSpec.Name)
}

platform, err := terraform.PlatformFromId(b.fs, target, platformRepository)
platform, err := terraform.PlatformFromId(b.fs, appSpec.Target, platformRepository)
if err != nil {
return "", err
}
Expand All @@ -53,18 +48,18 @@ func (b *BuilderService) BuildProjectForTarget(appSpec *schema.Application, targ

stackPath, err := engine.Apply(appSpec)
if err != nil {
return "", b.processBuildError(err, target)
return "", b.processBuildError(err, appSpec.Target)
}
return stackPath, nil
}

func (b *BuilderService) BuildProjectFromFileForTarget(projectFile, target, currentTeam string) (string, error) {
func (b *BuilderService) BuildProjectFromFile(projectFile, currentTeam string) (string, error) {
appSpec, err := schema.LoadFromFile(b.fs, projectFile, true)
if err != nil {
return "", fmt.Errorf("failed to load project file: %w", err)
}

return b.BuildProjectForTarget(appSpec, target, currentTeam)
return b.BuildProject(appSpec, currentTeam)
}

func NewBuilderService(injector do.Injector) (*BuilderService, error) {
Expand Down
4 changes: 2 additions & 2 deletions cli/internal/devserver/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type SugaProjectBuild struct {
}

type ProjectBuild struct {
Target string `json:"target"`
// Empty struct - target is now read from the project file
}

type ProjectBuildSuccess struct {
Expand Down Expand Up @@ -46,7 +46,7 @@ func (n *SugaProjectBuild) OnMessage(message json.RawMessage) {
return
}

stackPath, err := n.builder.BuildProjectFromFileForTarget(version.ConfigFileName, buildMessage.Payload.Target, n.currentTeam)
stackPath, err := n.builder.BuildProjectFromFile(version.ConfigFileName, n.currentTeam)
if err != nil {
fmt.Println(err.Error())

Expand Down
42 changes: 7 additions & 35 deletions cli/pkg/app/suga.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ func (c *SugaApp) getCurrentTeam() *api.Team {
return currentTeam
}


// Templates handles the templates command logic
func (c *SugaApp) Templates() error {
team := c.getCurrentTeam()
Expand Down Expand Up @@ -149,7 +148,7 @@ func (c *SugaApp) Init() error {
}

fmt.Printf("Welcome to %s, this command will walk you through creating a %s file.\n", c.styles.Emphasize.Render(version.ProductName), version.ConfigFileName)
fmt.Printf("This file is used to define your app's infrastructure, resources and deployment targets.\n")
fmt.Printf("This file is used to define your app's infrastructure, resources and deployment target.\n")
fmt.Println()
fmt.Printf("Here we'll only cover the basics, use %s to continue editing the project.\n", c.styles.Emphasize.Render(version.GetCommand("edit")))
fmt.Println()
Expand Down Expand Up @@ -221,7 +220,7 @@ func (c *SugaApp) Init() error {
fmt.Println()
fmt.Println("Next steps:")
fmt.Println("1. Run", c.styles.Emphasize.Render(version.GetCommand("edit")), "to start the", version.ProductName, "editor")
fmt.Println("2. Design your app's resources and deployment targets")
fmt.Println("2. Design your app's resources and deployment target")
fmt.Println("3. Optionally, use", c.styles.Emphasize.Render(version.GetCommand("generate")), "to generate the client libraries for your app")
fmt.Println("4. Run", c.styles.Emphasize.Render(version.GetCommand("dev")), "to start the development server")
fmt.Println("5. Run", c.styles.Emphasize.Render(version.GetCommand("build")), "to build the project for a specific platform")
Expand Down Expand Up @@ -429,7 +428,7 @@ func (c *SugaApp) New(projectName string, force bool) error {
b.WriteString("Next steps:\n")
b.WriteString("1. Run " + c.styles.Emphasize.Render("cd ./"+projectDir) + " to move to the project directory\n")
b.WriteString("2. Run " + c.styles.Emphasize.Render(version.GetCommand("edit")) + " to start the " + version.ProductName + " editor\n")
b.WriteString("3. Design your app's resources and deployment targets\n")
b.WriteString("3. Design your app's resources and deployment target\n")
b.WriteString("4. Run " + c.styles.Emphasize.Render(version.GetCommand("dev")) + " to start the development server\n")
b.WriteString("5. Run " + c.styles.Emphasize.Render(version.GetCommand("build")) + " to build the project for a specific platform\n")
b.WriteString("\n")
Expand All @@ -451,45 +450,18 @@ func (c *SugaApp) Build() error {
return err
}

if len(appSpec.Targets) == 0 {
if appSpec.Target == "" {
editCommand := c.styles.Emphasize.Render(version.GetCommand("edit"))
fmt.Printf("No targets specified in %s, run %s to add a target\n", version.ConfigFileName, editCommand)
fmt.Printf("No target specified in %s, run %s to add a target\n", version.ConfigFileName, editCommand)
return nil
}

var targetPlatform string

if len(appSpec.Targets) == 1 {
targetPlatform = appSpec.Targets[0]
} else {
err := ask.NewSelect[string]().
Title("Select a build target").
Options(huh.NewOptions(appSpec.Targets...)...).
Value(&targetPlatform).
Validate(func(targetPlatform string) error {
if targetPlatform == "" {
return errors.New("target platform is required")
}

return nil
}).
Run()

if errors.Is(err, huh.ErrUserAborted) {
return nil
}

if err != nil {
return err
}
}

stackPath, err := c.builder.BuildProjectForTarget(appSpec, targetPlatform, team.Slug)
stackPath, err := c.builder.BuildProject(appSpec, team.Slug)
if err != nil {
return err
}

fmt.Println(c.styles.Success.Render(" " + icons.Check + " Terraform generated successfully"))
fmt.Println(c.styles.Success.Render("\n " + icons.Check + " Terraform generated successfully"))
fmt.Println(c.styles.Faint.Render(" output written to " + stackPath))

fmt.Println()
Expand Down
9 changes: 4 additions & 5 deletions cli/pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@ import (
)

type Application struct {
// Targets sets platforms this application should be expected to work on
// This gives us room to move away from LCD expectations around how platforms are built
Targets []string `json:"targets" yaml:"targets" jsonschema:"required,pattern=^(([a-z][a-z0-9-]*)/([a-z][a-z0-9-]*)@(\\d+)|file:([^\\s]+))$"`
Name string `json:"name" yaml:"name" jsonschema:"required"`
Description string `json:"description" yaml:"description"`
// Target sets the platform this application should be deployed to
Target string `json:"target" yaml:"target" jsonschema:"required,pattern=^(([a-z][a-z0-9-]*)/([a-z][a-z0-9-]*)@(\\d+)|file:([^\\s]+))$"`
Comment thread
jyecusch marked this conversation as resolved.
Name string `json:"name" yaml:"name" jsonschema:"required"`
Description string `json:"description" yaml:"description"`

ServiceIntents map[string]*ServiceIntent `json:"services,omitempty" yaml:"services,omitempty"`
BucketIntents map[string]*BucketIntent `json:"buckets,omitempty" yaml:"buckets,omitempty"`
Expand Down
75 changes: 28 additions & 47 deletions cli/pkg/schema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ func TestApplicationFromYaml_ValidBasic(t *testing.T) {
yaml := `
name: test-app
description: A test application
targets:
- team/platform@1
- file:./local.yaml
target: team/platform@1
services:
api:
container:
Expand All @@ -31,7 +29,7 @@ buckets:
assert.NoError(t, err)
assert.True(t, result.Valid(), "Expected valid result, got validation errors: %v", result.Errors())
assert.Equal(t, "test-app", app.Name)
assert.Len(t, app.Targets, 2)
assert.Equal(t, "team/platform@1", app.Target)
assert.Len(t, app.ServiceIntents, 1)
assert.Len(t, app.BucketIntents, 1)
}
Expand All @@ -55,15 +53,14 @@ services:

errString := FormatValidationErrors(validationErrs)
assert.Contains(t, errString, "name: # <-- The name property is required")
assert.Contains(t, errString, "targets: # <-- The targets property is required")
assert.Contains(t, errString, "target: # <-- The target property is required")
}

func TestApplicationFromYaml_InvalidTargetFormat(t *testing.T) {
yaml := `
name: test-app
description: A test application
targets:
- invalid-target-format
target: invalid-target-format
services:
api:
container:
Expand All @@ -85,11 +82,8 @@ services:
func TestApplicationFromYaml_ValidHyphenatedTargets(t *testing.T) {
yaml := `
name: test-app
description: A test application with hyphenated targets
targets:
- suga/aws-fargate@1
- my-team/custom-platform@2
- nitric/gcp-service-account@1
description: A test application with hyphenated target
target: suga/aws-fargate@1
services:
api:
container:
Expand All @@ -101,20 +95,14 @@ services:
assert.NoError(t, err)
assert.True(t, result.Valid(), "Expected valid result, got validation errors: %v", result.Errors())
assert.Equal(t, "test-app", app.Name)
assert.Len(t, app.Targets, 3)
assert.Contains(t, app.Targets, "suga/aws-fargate@1")
assert.Contains(t, app.Targets, "my-team/custom-platform@2")
assert.Contains(t, app.Targets, "nitric/gcp-service-account@1")
assert.Equal(t, "suga/aws-fargate@1", app.Target)
}

func TestApplicationFromYaml_InvalidHyphenatedTargets(t *testing.T) {
yaml := `
name: test-app
description: A test application with invalid hyphenated targets
targets:
- -team/platform@1
- team/-platform@1
- 1team/platform@1
description: A test application with invalid hyphenated target
target: -team/platform@1
services:
api:
container:
Expand All @@ -124,10 +112,10 @@ services:

_, result, err := ApplicationFromYaml(yaml)
assert.NoError(t, err)
assert.False(t, result.Valid(), "Expected invalid result due to invalid hyphenated target formats")
assert.False(t, result.Valid(), "Expected invalid result due to invalid hyphenated target format")

validationErrs := GetSchemaValidationErrors(result.Errors())
assert.Len(t, validationErrs, 3)
assert.Len(t, validationErrs, 1)

errString := FormatValidationErrors(validationErrs)
assert.Contains(t, errString, "Must be in the format: `<team>/<platform>@<revision>` or `file:<path>`")
Expand All @@ -137,8 +125,7 @@ func TestApplicationFromYaml_ServiceWithImage(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
services:
api:
container:
Expand All @@ -161,8 +148,7 @@ func TestApplicationFromYaml_ServiceWithTriggers(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
services:
worker:
container:
Expand Down Expand Up @@ -194,8 +180,7 @@ func TestApplicationFromYaml_ServiceMissingContainerType(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
services:
api:
container: {}
Expand All @@ -217,8 +202,7 @@ func TestApplicationFromYaml_EntrypointMissingTrailingSlash(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
entrypoints:
api:
routes:
Expand All @@ -241,8 +225,7 @@ func TestApplicationFromYaml_EntrypointValidTrailingSlash(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
entrypoints:
api:
routes:
Expand All @@ -267,8 +250,7 @@ func TestApplicationFromYaml_InvalidYaml(t *testing.T) {
yaml := `
name: test-app
description: test
targets:
- team/platform@1
target: team/platform@1
services:
api:
container:
Expand All @@ -285,8 +267,8 @@ services:

func TestApplication_IsValid_NoNameConflicts(t *testing.T) {
app := &Application{
Name: "test-app",
Targets: []string{"team/platform@1"},
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"api": {
Container: Container{
Expand All @@ -305,8 +287,8 @@ func TestApplication_IsValid_NoNameConflicts(t *testing.T) {

func TestApplication_IsValid_NameConflicts(t *testing.T) {
app := &Application{
Name: "test-app",
Targets: []string{"team/platform@1"},
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"api": {
Container: Container{
Expand Down Expand Up @@ -336,8 +318,8 @@ func TestApplication_IsValid_NameConflicts(t *testing.T) {

func TestApplication_IsValid_ReservedNames(t *testing.T) {
app := &Application{
Name: "test-app",
Targets: []string{"team/platform@1"},
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"backend": { // Reserved name
Container: Container{
Expand Down Expand Up @@ -368,8 +350,8 @@ func TestApplication_IsValid_ReservedNames(t *testing.T) {

func TestApplication_IsValid_ValidSnakeCaseNames(t *testing.T) {
app := &Application{
Name: "test-app",
Targets: []string{"team/platform@1"},
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"user_api": {
Container: Container{
Expand Down Expand Up @@ -417,8 +399,8 @@ func TestApplication_IsValid_ValidSnakeCaseNames(t *testing.T) {

func TestApplication_IsValid_InvalidSnakeCaseNames(t *testing.T) {
app := &Application{
Name: "test-app",
Targets: []string{"team/platform@1"},
Name: "test-app",
Target: "team/platform@1",
ServiceIntents: map[string]*ServiceIntent{
"user-api": { // kebab-case
Container: Container{
Expand Down Expand Up @@ -489,8 +471,7 @@ func TestApplicationFromYaml_InvalidResourceNames(t *testing.T) {
yaml := `
name: test-app
description: A test application with invalid resource names
targets:
- team/platform@1
target: team/platform@1
services:
user-api:
container:
Expand Down
2 changes: 1 addition & 1 deletion docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ suga dev
</Card>
<Card title="Visual Editor" icon="pencil-ruler" href="/cli/edit">
Use `suga edit` to design your application architecture and configure
deployment targets
a deployment target
</Card>
<Card title="Local Development" icon="play" href="/cli/dev">
Use `suga dev` to run your application locally with hot reload and instant
Expand Down
Loading