From 112ffd8c74e9a8146b46c85a07f904fc77bf8103 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 14:41:00 +0000 Subject: [PATCH] feat: extract CI/release and SDK commands from core/cli Port CI commands (changelog, version, publish, init) and SDK commands into a standalone module for CI/CD pipeline tooling. Co-Authored-By: Claude Opus 4.6 --- cmd_changelog.go | 57 +++++++++++++++++++ cmd_ci.go | 84 +++++++++++++++++++++++++++ cmd_commands.go | 23 ++++++++ cmd_init.go | 43 ++++++++++++++ cmd_publish.go | 81 ++++++++++++++++++++++++++ cmd_version.go | 25 +++++++++ go.mod | 43 ++++++++++++++ go.sum | 86 ++++++++++++++++++++++++++++ sdk/cmd_commands.go | 8 +++ sdk/cmd_sdk.go | 134 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 584 insertions(+) create mode 100644 cmd_changelog.go create mode 100644 cmd_ci.go create mode 100644 cmd_commands.go create mode 100644 cmd_init.go create mode 100644 cmd_publish.go create mode 100644 cmd_version.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 sdk/cmd_commands.go create mode 100644 sdk/cmd_sdk.go diff --git a/cmd_changelog.go b/cmd_changelog.go new file mode 100644 index 0000000..8f91f95 --- /dev/null +++ b/cmd_changelog.go @@ -0,0 +1,57 @@ +package ci + +import ( + "os" + "os/exec" + "strings" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/release" +) + +func runChangelog(fromRef, toRef string) error { + cwd, err := os.Getwd() + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + // Auto-detect refs if not provided + if fromRef == "" || toRef == "" { + tag, err := latestTag(cwd) + if err == nil { + if fromRef == "" { + fromRef = tag + } + if toRef == "" { + toRef = "HEAD" + } + } else { + // No tags, use initial commit? Or just HEAD? + cli.Text(i18n.T("cmd.ci.changelog.no_tags")) + return nil + } + } + + cli.Print("%s %s..%s\n\n", releaseDimStyle.Render(i18n.T("cmd.ci.changelog.generating")), fromRef, toRef) + + // Generate changelog + changelog, err := release.Generate(cwd, fromRef, toRef) + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.generate", "changelog"), err) + } + + cli.Text(changelog) + + return nil +} + +func latestTag(dir string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} diff --git a/cmd_ci.go b/cmd_ci.go new file mode 100644 index 0000000..0190416 --- /dev/null +++ b/cmd_ci.go @@ -0,0 +1,84 @@ +// Package ci provides release publishing commands. +package ci + +import ( + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" +) + +// Style aliases from shared +var ( + releaseHeaderStyle = cli.RepoStyle + releaseSuccessStyle = cli.SuccessStyle + releaseErrorStyle = cli.ErrorStyle + releaseDimStyle = cli.DimStyle + releaseValueStyle = cli.ValueStyle +) + +// Flag variables for ci command +var ( + ciGoForLaunch bool + ciVersion string + ciDraft bool + ciPrerelease bool +) + +// Flag variables for changelog subcommand +var ( + changelogFromRef string + changelogToRef string +) + +var ciCmd = &cli.Command{ + Use: "ci", + Short: i18n.T("cmd.ci.short"), + Long: i18n.T("cmd.ci.long"), + RunE: func(cmd *cli.Command, args []string) error { + dryRun := !ciGoForLaunch + return runCIPublish(dryRun, ciVersion, ciDraft, ciPrerelease) + }, +} + +var ciInitCmd = &cli.Command{ + Use: "init", + Short: i18n.T("cmd.ci.init.short"), + Long: i18n.T("cmd.ci.init.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runCIReleaseInit() + }, +} + +var ciChangelogCmd = &cli.Command{ + Use: "changelog", + Short: i18n.T("cmd.ci.changelog.short"), + Long: i18n.T("cmd.ci.changelog.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runChangelog(changelogFromRef, changelogToRef) + }, +} + +var ciVersionCmd = &cli.Command{ + Use: "version", + Short: i18n.T("cmd.ci.version.short"), + Long: i18n.T("cmd.ci.version.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runCIReleaseVersion() + }, +} + +func init() { + // Main ci command flags + ciCmd.Flags().BoolVar(&ciGoForLaunch, "we-are-go-for-launch", false, i18n.T("cmd.ci.flag.go_for_launch")) + ciCmd.Flags().StringVar(&ciVersion, "version", "", i18n.T("cmd.ci.flag.version")) + ciCmd.Flags().BoolVar(&ciDraft, "draft", false, i18n.T("cmd.ci.flag.draft")) + ciCmd.Flags().BoolVar(&ciPrerelease, "prerelease", false, i18n.T("cmd.ci.flag.prerelease")) + + // Changelog subcommand flags + ciChangelogCmd.Flags().StringVar(&changelogFromRef, "from", "", i18n.T("cmd.ci.changelog.flag.from")) + ciChangelogCmd.Flags().StringVar(&changelogToRef, "to", "", i18n.T("cmd.ci.changelog.flag.to")) + + // Add subcommands + ciCmd.AddCommand(ciInitCmd) + ciCmd.AddCommand(ciChangelogCmd) + ciCmd.AddCommand(ciVersionCmd) +} diff --git a/cmd_commands.go b/cmd_commands.go new file mode 100644 index 0000000..d1ff882 --- /dev/null +++ b/cmd_commands.go @@ -0,0 +1,23 @@ +// Package ci provides release publishing commands for CI/CD pipelines. +// +// Publishes pre-built artifacts from dist/ to configured targets: +// - GitHub Releases +// - S3-compatible storage +// - Custom endpoints +// +// Safe by default: runs in dry-run mode unless --we-are-go-for-launch is specified. +// Configuration via .core/release.yaml. +package ci + +import ( + "forge.lthn.ai/core/go/pkg/cli" +) + +func init() { + cli.RegisterCommands(AddCICommands) +} + +// AddCICommands registers the 'ci' command and all subcommands. +func AddCICommands(root *cli.Command) { + root.AddCommand(ciCmd) +} diff --git a/cmd_init.go b/cmd_init.go new file mode 100644 index 0000000..0548ad0 --- /dev/null +++ b/cmd_init.go @@ -0,0 +1,43 @@ +package ci + +import ( + "os" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/release" +) + +func runCIReleaseInit() error { + cwd, err := os.Getwd() + if err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + cli.Print("%s %s\n\n", releaseDimStyle.Render(i18n.Label("init")), i18n.T("cmd.ci.init.initializing")) + + // Check if already initialized + if release.ConfigExists(cwd) { + cli.Text(i18n.T("cmd.ci.init.already_initialized")) + return nil + } + + // Create release config + cfg := release.DefaultConfig() + if err := release.WriteConfig(cfg, cwd); err != nil { + return cli.Err("%s: %w", i18n.T("i18n.fail.create", "config"), err) + } + + cli.Blank() + cli.Print("%s %s\n", releaseSuccessStyle.Render("v"), i18n.T("cmd.ci.init.created_config")) + + // Templates init removed as functionality not exposed + + cli.Blank() + + cli.Text(i18n.T("cmd.ci.init.next_steps")) + cli.Print(" %s\n", i18n.T("cmd.ci.init.edit_config")) + cli.Print(" %s\n", i18n.T("cmd.ci.init.run_ci")) + + return nil +} diff --git a/cmd_publish.go b/cmd_publish.go new file mode 100644 index 0000000..aff35ff --- /dev/null +++ b/cmd_publish.go @@ -0,0 +1,81 @@ +package ci + +import ( + "context" + "errors" + "os" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/release" +) + +// runCIPublish publishes pre-built artifacts from dist/. +// It does NOT build - use `core build` first. +func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { + ctx := context.Background() + + // Get current directory + projectDir, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + // Load configuration + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return cli.WrapVerb(err, "load", "config") + } + + // Apply CLI overrides + if version != "" { + cfg.SetVersion(version) + } + + // Apply draft/prerelease overrides to all publishers + if draft || prerelease { + for i := range cfg.Publishers { + if draft { + cfg.Publishers[i].Draft = true + } + if prerelease { + cfg.Publishers[i].Prerelease = true + } + } + } + + // Print header + cli.Print("%s %s\n", releaseHeaderStyle.Render(i18n.T("cmd.ci.label.ci")), i18n.T("cmd.ci.publishing")) + if dryRun { + cli.Print(" %s\n", releaseDimStyle.Render(i18n.T("cmd.ci.dry_run_hint"))) + } else { + cli.Print(" %s\n", releaseSuccessStyle.Render(i18n.T("cmd.ci.go_for_launch"))) + } + cli.Blank() + + // Check for publishers + if len(cfg.Publishers) == 0 { + return errors.New(i18n.T("cmd.ci.error.no_publishers")) + } + + // Publish pre-built artifacts + rel, err := release.Publish(ctx, cfg, dryRun) + if err != nil { + cli.Print("%s %v\n", releaseErrorStyle.Render(i18n.Label("error")), err) + return err + } + + // Print summary + cli.Blank() + cli.Print("%s %s\n", releaseSuccessStyle.Render(i18n.T("i18n.done.pass")), i18n.T("cmd.ci.publish_completed")) + cli.Print(" %s %s\n", i18n.Label("version"), releaseValueStyle.Render(rel.Version)) + cli.Print(" %s %d\n", i18n.T("cmd.ci.label.artifacts"), len(rel.Artifacts)) + + if !dryRun { + for _, pub := range cfg.Publishers { + cli.Print(" %s %s\n", i18n.T("cmd.ci.label.published"), releaseValueStyle.Render(pub.Type)) + } + } + + return nil +} diff --git a/cmd_version.go b/cmd_version.go new file mode 100644 index 0000000..5afb237 --- /dev/null +++ b/cmd_version.go @@ -0,0 +1,25 @@ +package ci + +import ( + "os" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/release" +) + +// runCIReleaseVersion shows the determined version. +func runCIReleaseVersion() error { + projectDir, err := os.Getwd() + if err != nil { + return cli.WrapVerb(err, "get", "working directory") + } + + version, err := release.DetermineVersion(projectDir) + if err != nil { + return cli.WrapVerb(err, "determine", "version") + } + + cli.Print("%s %s\n", i18n.Label("version"), releaseValueStyle.Render(version)) + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a58fc48 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module forge.lthn.ai/core/ci + +go 1.25.5 + +require ( + forge.lthn.ai/core/go v0.0.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + cloud.google.com/go v0.123.0 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/Snider/Borg v0.2.0 // indirect + github.com/TwiN/go-color v1.4.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/oasdiff v1.11.10 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + github.com/wI2L/jsondiff v0.7.0 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace forge.lthn.ai/core/go => ../go diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d3573f --- /dev/null +++ b/go.sum @@ -0,0 +1,86 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ= +github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY= +github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc= +github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/oasdiff/oasdiff v1.11.10 h1:4I9VrktUoHmwydkJqVOC7Bd6BXKu9dc4UUP3PIu1VjM= +github.com/oasdiff/oasdiff v1.11.10/go.mod h1:GXARzmqBKN8lZHsTQD35ZM41ePbu6JdAZza4sRMeEKg= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= +github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sdk/cmd_commands.go b/sdk/cmd_commands.go new file mode 100644 index 0000000..7ed89ec --- /dev/null +++ b/sdk/cmd_commands.go @@ -0,0 +1,8 @@ +// SDK validation and API compatibility commands. +// +// Commands: +// - diff: Check for breaking API changes between spec versions +// - validate: Validate OpenAPI spec syntax +// +// Configuration via .core/sdk.yaml. For SDK generation, use: core build sdk +package sdkcmd diff --git a/sdk/cmd_sdk.go b/sdk/cmd_sdk.go new file mode 100644 index 0000000..b2290d3 --- /dev/null +++ b/sdk/cmd_sdk.go @@ -0,0 +1,134 @@ +package sdkcmd + +import ( + "errors" + "fmt" + "os" + + "forge.lthn.ai/core/go/pkg/cli" + "forge.lthn.ai/core/go/pkg/i18n" + "forge.lthn.ai/core/go/pkg/sdk" + "github.com/spf13/cobra" +) + +func init() { + cli.RegisterCommands(AddSDKCommands) +} + +// SDK styles (aliases to shared) +var ( + sdkHeaderStyle = cli.TitleStyle + sdkSuccessStyle = cli.SuccessStyle + sdkErrorStyle = cli.ErrorStyle + sdkDimStyle = cli.DimStyle +) + +var sdkCmd = &cobra.Command{ + Use: "sdk", + Short: i18n.T("cmd.sdk.short"), + Long: i18n.T("cmd.sdk.long"), +} + +var diffBasePath string +var diffSpecPath string + +var sdkDiffCmd = &cobra.Command{ + Use: "diff", + Short: i18n.T("cmd.sdk.diff.short"), + Long: i18n.T("cmd.sdk.diff.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runSDKDiff(diffBasePath, diffSpecPath) + }, +} + +var validateSpecPath string + +var sdkValidateCmd = &cobra.Command{ + Use: "validate", + Short: i18n.T("cmd.sdk.validate.short"), + Long: i18n.T("cmd.sdk.validate.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runSDKValidate(validateSpecPath) + }, +} + +func initSDKCommands() { + // sdk diff flags + sdkDiffCmd.Flags().StringVar(&diffBasePath, "base", "", i18n.T("cmd.sdk.diff.flag.base")) + sdkDiffCmd.Flags().StringVar(&diffSpecPath, "spec", "", i18n.T("cmd.sdk.diff.flag.spec")) + + // sdk validate flags + sdkValidateCmd.Flags().StringVar(&validateSpecPath, "spec", "", i18n.T("common.flag.spec")) + + // Add subcommands + sdkCmd.AddCommand(sdkDiffCmd) + sdkCmd.AddCommand(sdkValidateCmd) +} + +// AddSDKCommands registers the 'sdk' command and all subcommands. +func AddSDKCommands(root *cobra.Command) { + initSDKCommands() + root.AddCommand(sdkCmd) +} + +func runSDKDiff(basePath, specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + // Detect current spec if not provided + if specPath == "" { + s := sdk.New(projectDir, nil) + specPath, err = s.DetectSpec() + if err != nil { + return err + } + } + + if basePath == "" { + return errors.New(i18n.T("cmd.sdk.diff.error.base_required")) + } + + fmt.Printf("%s %s\n", sdkHeaderStyle.Render(i18n.T("cmd.sdk.diff.label")), i18n.ProgressSubject("check", "breaking changes")) + fmt.Printf(" %s %s\n", i18n.T("cmd.sdk.diff.base_label"), sdkDimStyle.Render(basePath)) + fmt.Printf(" %s %s\n", i18n.Label("current"), sdkDimStyle.Render(specPath)) + fmt.Println() + + result, err := sdk.Diff(basePath, specPath) + if err != nil { + return cli.Exit(2, cli.Wrap(err, i18n.Label("error"))) + } + + if result.Breaking { + fmt.Printf("%s %s\n", sdkErrorStyle.Render(i18n.T("cmd.sdk.diff.breaking")), result.Summary) + for _, change := range result.Changes { + fmt.Printf(" - %s\n", change) + } + return cli.Exit(1, cli.Err("%s", result.Summary)) + } + + fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), result.Summary) + return nil +} + +func runSDKValidate(specPath string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) + } + + s := sdk.New(projectDir, &sdk.Config{Spec: specPath}) + + fmt.Printf("%s %s\n", sdkHeaderStyle.Render(i18n.T("cmd.sdk.label.sdk")), i18n.T("cmd.sdk.validate.validating")) + + detectedPath, err := s.DetectSpec() + if err != nil { + fmt.Printf("%s %v\n", sdkErrorStyle.Render(i18n.Label("error")), err) + return err + } + + fmt.Printf(" %s %s\n", i18n.Label("spec"), sdkDimStyle.Render(detectedPath)) + fmt.Printf("%s %s\n", sdkSuccessStyle.Render(i18n.T("cmd.sdk.label.ok")), i18n.T("cmd.sdk.validate.valid")) + return nil +}