Skip to content
Open
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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ jobs:
- name: Check goreleaser config
run: make goreleaser-check

# Checks that the embedded lint registry and schemas match upstream.
# Fails when upstream publishes changes; run `make lint-assets` to refresh.
lint-assets:
runs-on: ubuntu-latest

steps:
- name: Check out repository code
uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: ./go.mod

- name: Check embedded lint assets are up to date
run: make lint-assets-check

legacy-php:
runs-on: ubuntu-latest

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ go test -v -run TestName ./path/to/package
### Hybrid CLI System

The CLI operates as a wrapper around a legacy PHP CLI:
- Go layer: Handles new commands (init, list, version, config:install, project:convert) and core infrastructure
- Go layer: Handles new commands (init, list, version, config:install, project:convert, lint) and core infrastructure
- PHP layer: Legacy commands are proxied through `internal/legacy/CLIWrapper`
- The PHP CLI (platform.phar) is embedded at build time via go:embed

Expand All @@ -66,7 +66,8 @@ The CLI operates as a wrapper around a legacy PHP CLI:

**Commands**: `commands/`
- `root.go`: Root command that sets up the Cobra CLI and delegates to legacy CLI when needed
- Native Go commands: init, list, version, config:install, project:convert, completion
- Native Go commands: init, list, version, config:install, project:convert, completion, lint
- `lint.go`: Native config linter (aliases `validate`, `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `internal/lint`, reporting all errors at once
- Unrecognized commands are passed to the legacy PHP CLI

**Configuration**: `internal/config/`
Expand Down
17 changes: 17 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@ lint-gomod:
lint-golangci:
golangci-lint run --timeout=2m

# Embedded lint assets, refreshed from upstream.
# - registry.json: transformed from https://meta.upsun.com/images by gen.go.
# - upsun-config-schema.json and the platformsh.*.json schemas: from platformify.
PLATFORMIFY_SCHEMA_URL = https://raw.githubusercontent.com/platformsh/platformify/refs/heads/main/validator/schema

.PHONY: lint-assets
lint-assets: ## Refresh the embedded lint registry and schemas from upstream
cd internal/lint/registry && GOEXPERIMENT=jsonv2 go run gen.go
curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json
Comment on lines +141 to +143
curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.application.json -o internal/lint/schema/platformsh.application.json
curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.routes.json -o internal/lint/schema/platformsh.routes.json
curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/platformsh.services.json -o internal/lint/schema/platformsh.services.json

.PHONY: lint-assets-check
lint-assets-check: lint-assets ## Fail if the embedded lint assets are stale
git diff --exit-code -- internal/lint/registry/registry.json internal/lint/schema

.goreleaser.vendor.yaml: check-vendor ## Generate the goreleaser vendor config
cat .goreleaser.vendor.yaml.tpl | envsubst > .goreleaser.vendor.yaml

Expand Down
165 changes: 165 additions & 0 deletions commands/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package commands

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"unicode"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/upsun/cli/internal/config"
"github.com/upsun/cli/internal/lint"
)

// errLintFailed signals that the configuration has errors, for a non-zero exit
// code. Its message is empty because output is printed by the command itself.
var errLintFailed = errors.New("")

func newLintCommand(cnf *config.Config) *cobra.Command {
cmd := &cobra.Command{
Use: "app:config-validate [path]",
Short: "Validate project configuration",
Aliases: []string{"lint", "validate"},
Args: cobra.MaximumNArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runLint(cmd, args, vendorFromConfig(cnf))
},
}
cmd.Flags().Bool("stdin", false, "Read merged Flex configuration from standard input")
cmd.Flags().String("format", "text", "Output format: text or json")
cmd.SetHelpFunc(func(_ *cobra.Command, _ []string) {
internalCmd := innerAppConfigValidateCommand(cnf)
fmt.Println(internalCmd.HelpPage(cnf))
})
return cmd
}

// vendorFromConfig builds the linter's vendor conventions from the CLI config.
func vendorFromConfig(cnf *config.Config) lint.Vendor {
return lint.Vendor{
Flavor: cnf.Service.ProjectConfigFlavor,
ConfigDir: cnf.Service.ProjectConfigDir,
AppFile: cnf.Service.AppConfigFile,
}
}

func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error {
result, format, err := lintInput(cmd, args, vendor)
if err != nil {
// Print operational errors ourselves, since the command silences errors.
// Go error strings are lowercase by convention; capitalize for display.
fmt.Fprintln(cmd.ErrOrStderr(), color.RedString(capitalizeFirst(err.Error())))
return errLintFailed
}
return printLintResult(cmd, result, format)
}

func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Result, string, error) {
explicitStdin, _ := cmd.Flags().GetBool("stdin")
format, _ := cmd.Flags().GetString("format")
if format != "text" && format != "json" {
return nil, "", fmt.Errorf("invalid --format %q: must be \"text\" or \"json\"", format)
}

ctx := cmd.Context()
if explicitStdin {
result, err := lintStdin(ctx, cmd)
return result, format, err
}

// With no path argument, lint piped stdin if it carries content; otherwise
// (e.g. a non-interactive shell or CI with no input) fall back to the directory.
if len(args) == 0 && stdinIsPiped() {
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, format, err
}
if strings.TrimSpace(string(content)) != "" {
result, err := lint.CheckContent(ctx, string(content))
return result, format, err
}
}

path := "."
if len(args) == 1 {
path = args[0]
}
root := lint.FindProjectRoot(path)
if format == "text" {
fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root))
}
result, _, err := lint.CheckDir(ctx, root, vendor)
return result, format, err
}

// capitalizeFirst upper-cases the first rune of s for user-facing display.
func capitalizeFirst(s string) string {
if s == "" {
return s
}
r := []rune(s)
r[0] = unicode.ToUpper(r[0])
return string(r)
}

// lintStdin reads configuration from standard input and lints it.
func lintStdin(ctx context.Context, cmd *cobra.Command) (*lint.Result, error) {
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, err
}
return lint.CheckContent(ctx, string(content))
}

// stdinIsPiped reports whether standard input is a pipe or file rather than a terminal.
func stdinIsPiped() bool {
stat, err := os.Stdin.Stat()
return err == nil && (stat.Mode()&os.ModeCharDevice) == 0
}

func printLintResult(cmd *cobra.Command, result *lint.Result, format string) error {
if format == "json" {
out := struct {
Errors []lint.Issue `json:"errors"`
Warnings []lint.Issue `json:"warnings"`
}{Errors: result.Errors, Warnings: result.Warnings}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
return err
}
if result.HasErrors() {
return errLintFailed
}
return nil
}

w := cmd.ErrOrStderr()
printIssueSection(w, color.New(color.FgRed, color.Bold), "Linter errors:", result.ErrorLines())
printIssueSection(w, color.New(color.FgYellow, color.Bold), "Linter warnings:", result.WarningLines())
if result.HasErrors() {
return errLintFailed
}
if !result.HasWarnings() {
fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.")
}
return nil
}

// printIssueSection prints a colored heading followed by the issue lines in the
// default color. It is a no-op when there are no lines.
func printIssueSection(w io.Writer, heading *color.Color, title string, lines []string) {
if len(lines) == 0 {
return
}
fmt.Fprintln(w, heading.Sprint(title))
fmt.Fprintln(w, strings.Join(lines, "\n"))
}
38 changes: 35 additions & 3 deletions commands/list_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,23 +112,55 @@ func innerAppConfigValidateCommand(cnf *config.Config) Command {
Command: "config-validate",
},
Usage: []string{
cnf.Application.Executable + " app:config-validate",
cnf.Application.Executable + " app:config-validate [<path>]",
},
Aliases: []string{
"validate",
"lint",
},
Description: "Validate the config files of a project",
Description: "Validate the configuration files of a project, reporting all errors at once",
Help: "",
Examples: []Example{
{
Commandline: "",
Description: "Validate the project configuration files in your current directory",
},
{
Commandline: "--format=json",
Description: "Validate the current directory and output the results as JSON",
},
},
Definition: Definition{
Arguments: &orderedmap.OrderedMap[string, Argument]{},
Arguments: orderedmap.New[string, Argument](orderedmap.WithInitialData[string, Argument](
orderedmap.Pair[string, Argument]{
Key: "path",
Value: Argument{
Name: "path",
IsRequired: false,
IsArray: false,
Description: "The path to a project directory to validate (default: the current directory)",
},
},
)),
Options: orderedmap.New[string, Option](orderedmap.WithInitialData[string, Option](
orderedmap.Pair[string, Option]{
Key: "--format",
Value: Option{
Name: "--format",
AcceptValue: true,
IsValueRequired: true,
Description: "The output format: \"text\" or \"json\"",
Default: Any{"text"},
},
},
orderedmap.Pair[string, Option]{
Key: "--stdin",
Value: Option{
Name: "--stdin",
Description: "Read merged Flex configuration from standard input",
Default: Any{false},
},
},
orderedmap.Pair[string, Option]{
Key: HelpOption.GetName(),
Value: HelpOption,
Expand Down
11 changes: 1 addition & 10 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"strings"

"github.com/fatih/color"
"github.com/platformsh/platformify/commands"
"github.com/platformsh/platformify/vendorization"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -133,22 +132,14 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
" This implies --no-interaction. Ignored in verbose mode.",
)

validateCmd := commands.NewValidateCommand(assets)
validateCmd.Use = "app:config-validate"
validateCmd.Aliases = []string{"validate", "lint"}
validateCmd.SetHelpFunc(func(_ *cobra.Command, _ []string) {
internalCmd := innerAppConfigValidateCommand(cnf)
fmt.Println(internalCmd.HelpPage(cnf))
})

// Add subcommands.
cmd.AddCommand(
newConfigInstallCommand(),
newCompletionCommand(cnf),
newHelpCommand(cnf),
newInitCommand(cnf, assets),
newLintCommand(cnf),
newListCommand(cnf),
validateCmd,
versionCommand,
)
if cnf.Service.ProjectConfigFlavor == "upsun" {
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.5.0
github.com/alecthomas/chroma/v2 v2.26.1
github.com/briandowns/spinner v1.23.2
github.com/dlclark/regexp2/v2 v2.1.1
github.com/fatih/color v1.19.0
github.com/go-chi/chi/v5 v5.3.0
github.com/go-playground/validator/v10 v10.30.3
Expand All @@ -20,12 +21,14 @@ require (
github.com/upsun/lib-sun v0.3.16
github.com/upsun/whatsun v0.2.0
github.com/wk8/go-ordered-map/v2 v2.1.8
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1
mvdan.cc/sh/v3 v3.13.1
)

require (
Expand Down Expand Up @@ -57,7 +60,6 @@ require (
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2/v2 v2.1.1 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/semgroup v1.3.0 // indirect
Expand Down Expand Up @@ -125,7 +127,6 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zricethezav/gitleaks/v8 v8.30.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
Expand Down
7 changes: 6 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -123,6 +124,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
Expand Down Expand Up @@ -381,3 +384,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
1 change: 1 addition & 0 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Config struct {
Name string `validate:"required"` // e.g. "Upsun"
EnvPrefix string `validate:"required" yaml:"env_prefix"` // e.g. "PLATFORM_"
ProjectConfigDir string `validate:"required" yaml:"project_config_dir"` // e.g. ".platform"
AppConfigFile string `validate:"omitempty" yaml:"app_config_file,omitempty"` // e.g. ".platform.app.yaml" (Fixed only)
ProjectConfigFlavor string `validate:"omitempty" yaml:"project_config_flavor,omitempty"` // default: "platform"
ConsoleURL string `validate:"omitempty,url" yaml:"console_url,omitempty"` // e.g. "https://console.upsun.com"
DocsURL string `validate:"omitempty,url" yaml:"docs_url,omitempty"` // e.g. "https://docs.upsun.com"
Expand Down
Loading