Skip to content
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ 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, lint, and with Go auth (below): auth:browser-login (login), auth:api-token-login, auth:logout (logout), auth:token
- `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. The Flex JSON schema and the image registry come from meta.upsun.com (`make update-schema`, `make update-registry`): schema changes are made in the upsun/meta repository first. Fixed config is checked with the same schema, patched by `internal/lint/schema/fixed-overlay.json` (a JSON Merge Patch adding Fixed-only keys such as `disk`)
- `lint.go`: Native config linter, `lint` (also `validate`; registered under the namespaced name `app:config-validate`). Validates Flex (`.upsun`) and Fixed (`.platform`) config in `pkg/lint`, reporting all errors at once. It looks for those names only, so it is not registered in CLIs configured with other names. `pkg/lint` and `pkg/registry` are public because other Upsun projects import them; their API is not covered by semantic versioning. The Flex JSON schema and the image registry come from meta.upsun.com (`make update-schema`, `make update-registry`): schema changes are made in the upsun/meta repository first. Fixed config is checked with the same schema, patched by `internal/schema/fixed-overlay.json` (a JSON Merge Patch adding Fixed-only keys such as `disk`)
- Unrecognized commands are passed to the legacy PHP CLI

**Configuration**: `internal/config/`
Expand Down
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -236,23 +236,23 @@ lint-php: composer-dev ## Run PHP linters (php-cs-fixer, PHPStan)
# The image registry, used by lint, is transformed from https://meta.upsun.com/images by gen.go.
.PHONY: update-registry
update-registry: ## Refresh the embedded image registry from meta.upsun.com
cd internal/lint/registry && go run gen.go
cd pkg/registry && go run gen.go

# Fail if the embedded image registry is stale.
.PHONY: check-registry
check-registry: update-registry
git diff --exit-code -- internal/lint/registry/registry.json
git diff --exit-code -- pkg/registry/registry.json

# The Flex config schema, used by lint, is from https://meta.upsun.com/schema/upsun (the upsun/meta repository).
# Fixed config is checked with the same schema and an overlay, internal/lint/schema/fixed-overlay.json.
# Fixed config is checked with the same schema and an overlay, internal/schema/fixed-overlay.json.
.PHONY: update-schema
update-schema: ## Refresh the embedded Flex config schema from meta.upsun.com
cd internal/lint/schema && go run gen.go
cd internal/schema && go run gen.go

# Fail if the embedded Flex config schema is stale.
.PHONY: check-schema
check-schema: update-schema
git diff --exit-code -- internal/lint/schema/upsun-config-schema.json
git diff --exit-code -- internal/schema/upsun-config-schema.json

# Installs dev dependencies, which a phar build removes. PHPStan's binary marks that they are installed.
.PHONY: composer-dev
Expand Down
54 changes: 39 additions & 15 deletions commands/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
"github.com/spf13/cobra"

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

// errLintFailed exits with code 1 when the configuration has errors, without another message.
Expand All @@ -41,26 +41,28 @@ func newLintCommand(cnf *config.Config) *cobra.Command {
Args: cobra.MaximumNArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runLint(cmd, args, vendorFromConfig(cnf))
},
RunE: runLint,
}
cmd.Flags().Bool("stdin", false, "Read merged Flex configuration from standard input")
cmd.Flags().String("format", "text", `The output format: "text" or "json"`)
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,
// lintSupported reports whether the CLI uses the config names that lint looks
// for (.upsun, or .platform with .platform.app.yaml).
func lintSupported(cnf *config.Config) bool {
switch cnf.Service.ProjectConfigDir {
case ".upsun":
return true
case ".platform":
return cnf.Service.AppConfigFile == "" || cnf.Service.AppConfigFile == ".platform.app.yaml"
default:
return false
}
}

func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error {
result, format, err := lintInput(cmd, args, vendor)
func runLint(cmd *cobra.Command, args []string) error {
result, format, err := lintInput(cmd, args)
if err != nil {
// Report operational errors as lint errors in JSON, so there is always a document.
if format == "json" {
Expand All @@ -75,7 +77,7 @@ func runLint(cmd *cobra.Command, args []string, vendor lint.Vendor) error {
return printLintResult(cmd, result, format)
}

func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Result, string, error) {
func lintInput(cmd *cobra.Command, args []string) (*lint.Result, string, error) {
explicitStdin, _ := cmd.Flags().GetBool("stdin")
format, _ := cmd.Flags().GetString("format")
if format != "text" && format != "json" {
Expand Down Expand Up @@ -104,15 +106,37 @@ func lintInput(cmd *cobra.Command, args []string, vendor lint.Vendor) (*lint.Res
}
root = abs
} else {
root = lint.FindProjectRoot(".")
root = findProjectRoot(".")
}
if format == "text" {
fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root))
}
result, _, err := lint.CheckDir(root, vendor)
result, err := lint.CheckDir(root)
Comment thread
upsun-dispatch[bot] marked this conversation as resolved.
return result, format, err
}

// findProjectRoot resolves the project root for a path: the nearest ancestor
// directory that contains a .git entry, or the path itself if none is found.
// This lets the command be run from anywhere inside a repository. The nearest
// (not topmost) .git is used so a stray repository higher up the tree (e.g. a
// dotfiles repo in the home directory) cannot hijack the result.
func findProjectRoot(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
for dir := abs; ; {
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return abs
}
dir = parent
}
}

// capitalizeFirst upper-cases the first rune of s for user-facing display.
func capitalizeFirst(s string) string {
if s == "" {
Expand Down
61 changes: 55 additions & 6 deletions commands/lint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import (
"testing"

"github.com/fatih/color"
"github.com/platformsh/platformify/vendorization"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

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

// The JSON output documents "errors" and "warnings" as arrays, so a valid
Expand Down Expand Up @@ -46,9 +47,10 @@ func TestPrintLintResult_JSONWithIssues(t *testing.T) {
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")
result := &lint.Result{
Errors: []lint.Issue{{Path: "applications.app1.type", Message: "invalid type"}},
Warnings: []lint.Issue{{Path: "applications.app1.web.commands.start", Message: "a start command is needed"}},
}

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

Expand Down Expand Up @@ -157,11 +159,58 @@ func TestPrintLintResult_ValidWithWarnings(t *testing.T) {
var out bytes.Buffer
cmd.SetOut(&out)

result := &lint.Result{}
result.AddWarning("", "both .upsun and .platform configuration found")
result := &lint.Result{Warnings: []lint.Issue{{Message: "both .upsun and .platform configuration found"}}}
require.NoError(t, printLintResult(cmd, result, "text"))
assert.Equal(t, `Warnings:
both .upsun and .platform configuration found
✓ The configuration is valid, with 1 warning.
`, out.String())
}

func TestFindProjectRoot(t *testing.T) {
gitInit := func(t *testing.T, dir string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
}

t.Run("ascends to the enclosing git root", func(t *testing.T) {
root := t.TempDir()
gitInit(t, root)
sub := filepath.Join(root, "services", "api")
require.NoError(t, os.MkdirAll(sub, 0o755))
assert.Equal(t, root, findProjectRoot(sub))
})

t.Run("nearest git root wins over an outer one", func(t *testing.T) {
outer := t.TempDir()
gitInit(t, outer)
inner := filepath.Join(outer, "vendor", "pkg")
gitInit(t, inner)
assert.Equal(t, inner, findProjectRoot(inner))
})
}

func TestLintCommand_Registration(t *testing.T) {
cases := []struct {
name string
configDir string
appFile string
want bool
}{
{"upsun", ".upsun", "", true},
{"platform", ".platform", ".platform.app.yaml", true},
{"custom config dir", ".acme", "", false},
{"custom app file", ".platform", ".acme.app.yaml", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cnf := testConfig()
cnf.Service.ProjectConfigDir = c.configDir
cnf.Service.AppConfigFile = c.appFile
root := newRootCommand(cnf, &vendorization.VendorAssets{})
cmd, _, err := root.Find([]string{"lint"})
require.NoError(t, err)
assert.Equal(t, c.want, cmd != root)
})
}
}
4 changes: 3 additions & 1 deletion commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,12 @@ func newRootCommand(cnf *config.Config, assets *vendorization.VendorAssets) *cob
newCompletionCommand(cnf),
newHelpCommand(cnf),
useLegacyStyleHelp(cnf, newInitCommand(cnf, assets)),
useLegacyStyleHelp(cnf, newLintCommand(cnf)),
newListCommand(cnf),
versionCommand,
)
if lintSupported(cnf) {
cmd.AddCommand(useLegacyStyleHelp(cnf, newLintCommand(cnf)))
}
if cnf.Service.ProjectConfigFlavor == "upsun" {
cmd.AddCommand(useLegacyStyleHelp(cnf, newProjectConvertCommand(cnf)))
}
Expand Down
54 changes: 0 additions & 54 deletions internal/lint/linter.go

This file was deleted.

Loading
Loading