Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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, `lint` (also `validate`; registered under the namespaced name `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 && go run gen.go
curl -sfSL $(PLATFORMIFY_SCHEMA_URL)/upsun.json -o internal/lint/schema/upsun-config-schema.json
Comment thread
Copilot marked this conversation as resolved.
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
174 changes: 174 additions & 0 deletions commands/lint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
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
}

// issuesOrEmpty replaces a nil slice with an empty one, so that the JSON output
// always contains arrays rather than null.
func issuesOrEmpty(issues []lint.Issue) []lint.Issue {
if issues == nil {
return []lint.Issue{}
}
return issues
}

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: issuesOrEmpty(result.Errors), Warnings: issuesOrEmpty(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"))
}
59 changes: 59 additions & 0 deletions commands/lint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package commands

import (
"bytes"
"encoding/json"
"io"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

// The JSON output documents "errors" and "warnings" as arrays, so a valid
// configuration must still yield arrays rather than null.
func TestPrintLintResult_JSONAlwaysArrays(t *testing.T) {
cmd := &cobra.Command{}
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)

require.NoError(t, printLintResult(cmd, &lint.Result{}, "json"))

// Pointers distinguish a JSON null from an empty array.
var decoded struct {
Errors *[]lint.Issue `json:"errors"`
Warnings *[]lint.Issue `json:"warnings"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
require.NotNil(t, decoded.Errors, "errors should be an array, not null")
require.NotNil(t, decoded.Warnings, "warnings should be an array, not null")
assert.Empty(t, *decoded.Errors)
assert.Empty(t, *decoded.Warnings)
}

func TestPrintLintResult_JSONWithIssues(t *testing.T) {
cmd := &cobra.Command{}
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)

result := &lint.Result{}
result.AddError("applications.app1.type", "invalid type")
result.AddWarning("applications.app1.web.commands.start", "a start command is needed")

require.ErrorIs(t, printLintResult(cmd, result, "json"), errLintFailed)

var decoded struct {
Errors []lint.Issue `json:"errors"`
Warnings []lint.Issue `json:"warnings"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
assert.Equal(t, []lint.Issue{{Path: "applications.app1.type", Message: "invalid type"}}, decoded.Errors)
assert.Equal(t, []lint.Issue{
{Path: "applications.app1.web.commands.start", Message: "a start command is needed"},
}, decoded.Warnings)
}
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
Loading