Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8951f2d
feat(lint): port pure config linter from ai-api
pjcdawkins Jun 16, 2026
e037496
feat(lint): add native lint command for Flex config
pjcdawkins Jun 16, 2026
4cdf0d3
feat(lint): support Fixed-style (legacy Platform.sh) config
pjcdawkins Jun 16, 2026
805a54f
feat(lint): refresh embedded registry and schemas from upstream
pjcdawkins Jun 16, 2026
068c5b9
docs(lint): document path argument and output flags
pjcdawkins Jun 16, 2026
acebaf6
fix(lint): don't read stdin in non-interactive contexts; review fixes
pjcdawkins Jun 16, 2026
446db76
fix(lint): sort available route targets for deterministic output
pjcdawkins Jun 16, 2026
bc3fbc6
feat(lint): resolve project root and detect style from vendor config
pjcdawkins Jun 16, 2026
1603cf5
feat(lint): clearer command output
pjcdawkins Jun 16, 2026
196654c
Drop GOEXPERIMENT=jsonv2
bojanz Aug 7, 2026
a7dc7ae
fix(lint): build Flex glob patterns with forward slashes
pjcdawkins Aug 8, 2026
edea3fb
fix(lint): always emit JSON arrays for errors and warnings
pjcdawkins Aug 8, 2026
f3e00fb
feat(lint): check the shell syntax of worker commands
pjcdawkins Aug 8, 2026
aaccab5
chore(lint): drop unused registry helper and tidy tests and docs
pjcdawkins Aug 8, 2026
49da939
fix(lint): accept valkey-persistent and replica service types
pjcdawkins Aug 8, 2026
d76dab3
fix(lint): scope worker names to their application
pjcdawkins Aug 8, 2026
3f311a6
chore(lint): refresh embedded registry and derive replica types
pjcdawkins Aug 8, 2026
cf808f6
refactor(lint): require --stdin for piped input and drop unused param…
pjcdawkins Sep 24, 2026
8424257
chore(lint): refresh embedded registry
pjcdawkins Sep 24, 2026
7d4553c
fix(lint): address review findings
pjcdawkins Sep 24, 2026
9678f31
fix(lint): accept type alongside stack in Fixed app config
pjcdawkins Sep 24, 2026
ef8e33a
feat(lint): warn when stack is set with a non-composable type
pjcdawkins Sep 24, 2026
d5fa7ee
feat(lint): allow retired image versions with a warning
pjcdawkins Sep 24, 2026
5a3d9ef
fix(lint): omit the version list when a type has no supported versions
pjcdawkins Sep 24, 2026
645c99b
style(lint): normalize the Flex schema's JSON formatting
pjcdawkins Sep 25, 2026
acd0dfb
style(lint): normalize the Fixed application schema's JSON formatting
pjcdawkins Sep 25, 2026
9cebf68
feat(lint): support tasks and workload authorizations
pjcdawkins Sep 25, 2026
daf0268
fix(lint): count service mounts and accept PCRE named groups
pjcdawkins Sep 25, 2026
b92d97b
fix(lint): drop the missing start command warning
pjcdawkins Sep 25, 2026
6b5cdfb
fix(lint): allow Fixed operations and warn on unused services
pjcdawkins Sep 25, 2026
9d2b5f9
feat(lint): resolve YAML tags and report files and line numbers
pjcdawkins Sep 25, 2026
9ead80a
fix(lint): confine includes to the project and follow YAML aliases
pjcdawkins Sep 25, 2026
45f27e9
fix(lint): address review findings on output and Fixed rules
pjcdawkins Sep 25, 2026
875e21c
fix(lint): say the configuration is valid when there are only warnings
pjcdawkins Sep 25, 2026
8271c79
test(lint): use a Fixed-style project in the command test
pjcdawkins Sep 25, 2026
8cd6411
feat(lint): support egress, OCI images, container profiles and struct…
pjcdawkins Sep 25, 2026
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: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ jobs:
- name: Check goreleaser config
run: make goreleaser-check

# Checks that the embedded lint registry matches upstream.
# Fails when upstream publishes changes; run `make lint-assets` to refresh.
# Non-blocking, since upstream changes are unrelated to the pull request.
lint-assets:
runs-on: ubuntu-latest
continue-on-error: true

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

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

- name: Check embedded lint assets are up to date
run: make lint-assets-check
Comment thread
pjcdawkins marked this conversation as resolved.

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
- An index of legacy commands (commands.json, from `list --all --format=json`) is embedded too, so the Go layer can resolve abbreviations like `p:init` in the same way as Symfony Console
Expand All @@ -67,7 +67,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
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ lint-gomod:
lint-golangci:
golangci-lint run --timeout=2m

# The embedded lint registry is transformed from https://meta.upsun.com/images by gen.go.
# The schemas in internal/lint/schema are maintained in this repository.
.PHONY: lint-assets
lint-assets: ## Refresh the embedded lint registry from upstream
cd internal/lint/registry && go run gen.go

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

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

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

import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strconv"
"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 {
// Report operational errors as lint errors in JSON, so there is always a document.
if format == "json" {
result = &lint.Result{Errors: []lint.Issue{{Message: err.Error()}}}
return printLintResult(cmd, result, format)
}
// 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())))
Comment thread
pjcdawkins marked this conversation as resolved.
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)
}

if explicitStdin && len(args) > 0 {
return nil, format, errors.New("--stdin cannot be used with a path")
}
if explicitStdin {
result, err := lintStdin(cmd)
return result, format, err
}

// An explicit path is linted as given; by default the enclosing repository root is used.
var root string
if len(args) == 1 {
abs, err := filepath.Abs(args[0])
if err != nil {
return nil, format, err
}
if fi, err := os.Stat(abs); err != nil {
return nil, format, err
} else if !fi.IsDir() {
return nil, format, fmt.Errorf("not a directory: %s", args[0])
}
root = abs
} else {
root = lint.FindProjectRoot(".")
}
if format == "text" {
fmt.Fprintln(cmd.ErrOrStderr(), "Validating configuration in directory: "+color.CyanString(root))
}
result, _, err := lint.CheckDir(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(cmd *cobra.Command) (*lint.Result, error) {
content, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, err
}
return lint.CheckContent(string(content))
}

// 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
}

// The report is the command's output, so it is not hidden by --quiet.
w := cmd.OutOrStdout()
printIssues(w, color.New(color.FgRed, color.Bold), "Errors", result.Errors)
printIssues(w, color.New(color.FgYellow, color.Bold), "Warnings", result.Warnings)
if result.HasErrors() {
return errLintFailed
}
switch n := len(result.Warnings); n {
case 0:
fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid.")
case 1:
fmt.Fprintln(w, color.GreenString("✓")+" The configuration is valid, with 1 warning.")
default:
fmt.Fprintf(w, "%s The configuration is valid, with %d warnings.\n", color.GreenString("✓"), n)
}
return nil
}

// printIssues prints a colored heading followed by the issues grouped by file,
// each with its line number and path, and the message below. For example:
//
// Errors:
// .upsun/config.yaml
// 28 applications.app.authorizations.0.action
// authorization type 'env' only allows the action 'view'
//
// It is a no-op when there are no issues.
func printIssues(w io.Writer, heading *color.Color, title string, issues []lint.Issue) {
if len(issues) == 0 {
return
}
sorted := slices.Clone(issues)
slices.SortStableFunc(sorted, func(a, b lint.Issue) int {
return cmp.Or(cmp.Compare(a.File, b.File), cmp.Compare(a.Line, b.Line),
cmp.Compare(a.Path, b.Path), cmp.Compare(a.Message, b.Message))
})

fmt.Fprintln(w, heading.Sprint(title+":"))
for len(sorted) > 0 {
n := 1
for n < len(sorted) && sorted[n].File == sorted[0].File {
n++
}
group := sorted[:n]
sorted = sorted[n:]
indent := " "
if file := group[0].File; file != "" {
fmt.Fprintln(w, " "+color.New(color.Bold).Sprint(file))
indent = " "
}
// Right-align the line numbers within the file.
width := 0
for _, issue := range group {
if issue.Line > 0 {
width = max(width, len(strconv.Itoa(issue.Line)))
}
}
msgIndent := indent + " "
if width > 0 {
msgIndent = indent + strings.Repeat(" ", width+2)
}
for _, issue := range group {
var line string
if width > 0 {
line = strings.Repeat(" ", width+2)
if issue.Line > 0 {
line = color.New(color.Faint).Sprintf("%*d", width, issue.Line) + " "
}
}
if issue.Path == "" {
fmt.Fprintln(w, indent+line+issue.Message)
continue
}
fmt.Fprintln(w, indent+line+color.CyanString(issue.Path))
fmt.Fprintln(w, msgIndent+issue.Message)
}
}
}
Loading
Loading