diff --git a/Makefile b/Makefile index a659e82..3c93ef3 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,17 @@ .PHONY: generate generate: go generate ./... + cp ./spec.schema.json ./validate/spec.schema.json + cp ./spec.schema.json ./web/src/spec.schema.json + +.PHONY: update-schema-version +update-schema-version: + go run ./internal/genschema \ + -schema ./spec.schema.json \ + -out ./spec/schema_version_gen.go \ + -update-documents \ + -document-root . + $(MAKE) generate .PHONY: test test: generate diff --git a/adapters/ocobra/ocobra.go b/adapters/ocobra/ocobra.go index f9b01eb..c05aca4 100644 --- a/adapters/ocobra/ocobra.go +++ b/adapters/ocobra/ocobra.go @@ -23,8 +23,6 @@ import ( "github.com/spf13/pflag" ) -const defaultOpenCLIVersion = "1.0.0-alpha.14" - // config holds the functional-option state. type config struct { info *spec.Info @@ -115,7 +113,7 @@ func GenerateDocument(rootCmd *cobra.Command, opts ...Option) *spec.Document { func documentFromCommand(rootCmd *cobra.Command, c *config) *spec.Document { var doc spec.Document - doc.OpenCLIVersion = defaultOpenCLIVersion + doc.OpenCLIVersion = spec.SchemaVersion doc.Info = c.getInfo() if doc.Info.Binary == "" { doc.Info.Binary = GetBinaryName(rootCmd) diff --git a/adapters/ocobra/ocobra_test.go b/adapters/ocobra/ocobra_test.go index fd50f86..acd4e15 100644 --- a/adapters/ocobra/ocobra_test.go +++ b/adapters/ocobra/ocobra_test.go @@ -27,7 +27,7 @@ func TestGenerateDocument_Basic(t *testing.T) { doc := GenerateDocument(root) - if doc.OpenCLIVersion != defaultOpenCLIVersion { + if doc.OpenCLIVersion != spec.SchemaVersion { t.Fatalf("unexpected version: %s", doc.OpenCLIVersion) } if doc.Info.Binary != "myapp" { diff --git a/adapters/ourfave/ourfave.go b/adapters/ourfave/ourfave.go index f27bcd4..0b9497b 100644 --- a/adapters/ourfave/ourfave.go +++ b/adapters/ourfave/ourfave.go @@ -23,8 +23,6 @@ import ( "github.com/urfave/cli/v3" ) -const defaultOpenCLIVersion = "1.0.0-alpha.14" - // config holds the functional-option state. type config struct { info *spec.Info @@ -115,7 +113,7 @@ func GenerateDocument(rootCmd *cli.Command, opts ...Option) *spec.Document { func documentFromCommand(rootCmd *cli.Command, c *config) *spec.Document { var doc spec.Document - doc.OpenCLIVersion = defaultOpenCLIVersion + doc.OpenCLIVersion = spec.SchemaVersion doc.Info = c.getInfo() if doc.Info.Binary == "" { doc.Info.Binary = GetBinaryName(rootCmd) diff --git a/adapters/ourfave/ourfave_test.go b/adapters/ourfave/ourfave_test.go index a98fe58..8ca5d30 100644 --- a/adapters/ourfave/ourfave_test.go +++ b/adapters/ourfave/ourfave_test.go @@ -27,7 +27,7 @@ func TestGenerateDocument_Basic(t *testing.T) { doc := GenerateDocument(root) - if doc.OpenCLIVersion != defaultOpenCLIVersion { + if doc.OpenCLIVersion != spec.SchemaVersion { t.Fatalf("unexpected version: %s", doc.OpenCLIVersion) } if doc.Info.Binary != "myapp" { diff --git a/codec/default_test.go b/codec/default_test.go index c1191da..9ecddf2 100644 --- a/codec/default_test.go +++ b/codec/default_test.go @@ -15,7 +15,7 @@ func defaultTestSpecYAML(t *testing.T, frag string) []byte { t.Helper() doc := strings.Join([]string{ - "opencliVersion: 1.0.0-alpha.14", + "opencliVersion: " + spec.SchemaVersion, "info:", " title: default test", " version: 0.1.0", @@ -34,7 +34,7 @@ func defaultTestSpecYAML(t *testing.T, frag string) []byte { func defaultTestSpecJSON(t *testing.T, flagObj string) []byte { t.Helper() - doc := `{"opencliVersion":"1.0.0-alpha.14","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"commands":{"root":{"flags":[` + flagObj + `]}}}` + doc := `{"opencliVersion":"` + spec.SchemaVersion + `","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"commands":{"root":{"flags":[` + flagObj + `]}}}` return []byte(doc) } @@ -44,7 +44,7 @@ func globalFlagSpecYAML(t *testing.T, frag string) []byte { t.Helper() doc := strings.Join([]string{ - "opencliVersion: 1.0.0-alpha.14", + "opencliVersion: " + spec.SchemaVersion, "info:", " title: default test", " version: 0.1.0", @@ -64,7 +64,7 @@ func globalFlagSpecYAML(t *testing.T, frag string) []byte { func globalFlagSpecJSON(t *testing.T, flagObj string) []byte { t.Helper() - doc := `{"opencliVersion":"1.0.0-alpha.14","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"global":{"flags":[` + flagObj + `]},"commands":{"root":{}}}` + doc := `{"opencliVersion":"` + spec.SchemaVersion + `","info":{"title":"default test","version":"0.1.0","binary":"deftest"},"global":{"flags":[` + flagObj + `]},"commands":{"root":{}}}` return []byte(doc) } diff --git a/gen/cli_group_test.go b/gen/cli_group_test.go index 40d12a0..0fbf944 100644 --- a/gen/cli_group_test.go +++ b/gen/cli_group_test.go @@ -13,7 +13,7 @@ import ( // which validation forbids but callers may skip (e.g., ocli gen cli). func groupTestDoc(root *spec.CommandItem) *spec.Document { return &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "Group Test CLI", Binary: "grouptest"}, Commands: root, } diff --git a/gen/cli_test.go b/gen/cli_test.go index 5c980f1..fb83059 100644 --- a/gen/cli_test.go +++ b/gen/cli_test.go @@ -645,7 +645,7 @@ func TestCLI_UrfaveCli_ConfigEmissionConditional(t *testing.T) { } t.Run("no_alt_sources", func(t *testing.T) { - files := generate(t, `opencliVersion: 1.0.0-alpha.14 + files := generate(t, `opencliVersion: `+spec.SchemaVersion+` info: title: minimal cli for alt-source emission test binary: minicli @@ -668,7 +668,7 @@ commands: }) t.Run("with_alt_sources", func(t *testing.T) { - files := generate(t, `opencliVersion: 1.0.0-alpha.14 + files := generate(t, `opencliVersion: `+spec.SchemaVersion+` info: title: minimal cli for alt-source emission test binary: minicli @@ -868,7 +868,7 @@ func TestCLI_Yargs_ConfigEmissionConditional(t *testing.T) { } t.Run("no_alt_sources", func(t *testing.T) { - files := generate(t, `opencliVersion: 1.0.0-alpha.14 + files := generate(t, `opencliVersion: `+spec.SchemaVersion+` info: title: minimal cli for alt-source emission test binary: minicli diff --git a/gen/cli_urfave_cli_globalchoices_test.go b/gen/cli_urfave_cli_globalchoices_test.go index bbd8000..0cd87a2 100644 --- a/gen/cli_urfave_cli_globalchoices_test.go +++ b/gen/cli_urfave_cli_globalchoices_test.go @@ -15,7 +15,7 @@ import ( // their plain Go types (no cast, no validation) to avoid golden churn for specs without choices. func TestCLI_UrfaveCliGlobalChoicesValidation(t *testing.T) { doc := &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "GChoice CLI", Binary: "gchoice"}, Global: &spec.Global{ Flags: []spec.FlagItem{ diff --git a/gen/cli_yargs_requiredaltsources_test.go b/gen/cli_yargs_requiredaltsources_test.go index c88ab30..2e81781 100644 --- a/gen/cli_yargs_requiredaltsources_test.go +++ b/gen/cli_yargs_requiredaltsources_test.go @@ -14,7 +14,7 @@ import ( // must not be validated, and required flags WITHOUT alternative sources keep plain demandOption. func TestCLI_YargsRequiredAltSourcesValidation(t *testing.T) { doc := &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "ReqAlt CLI", Binary: "reqalt"}, Commands: &spec.CommandItem{ Segment: "login", @@ -111,7 +111,7 @@ func TestCLI_YargsRequiredAltSourcesValidation(t *testing.T) { func TestCLI_YargsRequiredAltSourcesGlobalValidation(t *testing.T) { makeDoc := func(globalFlags []spec.FlagItem, cmdFlags []spec.FlagItem) *spec.Document { return &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "ReqAlt Global CLI", Binary: "reqaltg"}, Global: &spec.Global{Flags: globalFlags}, Commands: &spec.CommandItem{ diff --git a/gen/cli_yargs_variadic_test.go b/gen/cli_yargs_variadic_test.go index b88fdad..c14960b 100644 --- a/gen/cli_yargs_variadic_test.go +++ b/gen/cli_yargs_variadic_test.go @@ -14,7 +14,7 @@ import ( // typed. String variadics must remain byte-identical to prior output. func TestCLI_YargsVariadicNonString(t *testing.T) { doc := &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "VarTest CLI", Binary: "vartest"}, Commands: &spec.CommandItem{ Segment: "greet", @@ -78,7 +78,7 @@ func TestCLI_YargsVariadicNonString(t *testing.T) { // on GlobalFlags. String variadics must keep string:true (golden parity). func TestCLI_YargsGlobalVariadicNonString(t *testing.T) { doc := &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "GVarTest CLI", Binary: "gvar"}, Global: &spec.Global{ Config: spec.Configuration{}, @@ -135,7 +135,7 @@ func TestCLI_YargsGlobalVariadicNonString(t *testing.T) { // the boolean coerce function rather than string:true. func TestCLI_YargsGlobalVariadicBoolean(t *testing.T) { doc := &spec.Document{ - OpenCLIVersion: "1.0.0-alpha.14", + OpenCLIVersion: spec.SchemaVersion, Info: spec.Info{Title: "GBoolTest CLI", Binary: "gbool"}, Global: &spec.Global{ Config: spec.Configuration{}, diff --git a/internal/cli/app/actions_test.go b/internal/cli/app/actions_test.go index 556c6b3..f269522 100644 --- a/internal/cli/app/actions_test.go +++ b/internal/cli/app/actions_test.go @@ -21,7 +21,7 @@ func (m *MockFileWriter) Fd() uintptr { // validSpecJSON is a minimal valid OpenCLI specification in JSON format const validSpecJSON = `{ - "opencliVersion": "1.0.0-alpha.14", + "opencliVersion": "` + spec.SchemaVersion + `", "info": { "title": "Test CLI", "binary": "test", @@ -40,7 +40,7 @@ const validSpecJSON = `{ }` // validSpecYAML is a minimal valid OpenCLI specification in YAML format -const validSpecYAML = `opencliVersion: 1.0.0-alpha.14 +const validSpecYAML = `opencliVersion: ` + spec.SchemaVersion + ` info: title: Test CLI binary: test @@ -55,7 +55,7 @@ commands: // invalidSpec is an invalid OpenCLI specification (missing required fields) const invalidSpec = `{ - "opencliVersion": "1.0.0-alpha.14" + "opencliVersion": "` + spec.SchemaVersion + `" }` func TestOcliCheckValidJSON(t *testing.T) { diff --git a/internal/genschema/main.go b/internal/genschema/main.go new file mode 100644 index 0000000..4310ce0 --- /dev/null +++ b/internal/genschema/main.go @@ -0,0 +1,145 @@ +// Command genschema derives generated constants and synchronized schema copies +// from the repository's canonical spec.schema.json. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/format" + "io/fs" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type schema struct { + ID string `json:"$id"` + Properties struct { + OpenCLIVersion struct { + Enum []string `json:"enum"` + } `json:"opencliVersion"` + } `json:"properties"` +} + +func main() { + schemaPath := flag.String("schema", "spec.schema.json", "path to the canonical schema") + goOutput := flag.String("out", "schema_version_gen.go", "path to the generated Go file") + updateDocs := flag.Bool("update-documents", false, "update checked-in document and documentation versions") + documentRoot := flag.String("document-root", ".", "root directory to scan when updating documents") + flag.Parse() + + version, err := readVersion(*schemaPath) + if err != nil { + fail(err) + } + + contents := fmt.Appendf(nil, `// Code generated by genschema; DO NOT EDIT. + +package spec + +// SchemaVersion is the version declared by the canonical OpenCLI schema. +const SchemaVersion = %s +`, strconv.Quote(version)) + contents, err = format.Source(contents) + if err != nil { + fail(fmt.Errorf("format generated Go source: %w", err)) + } + if err := os.WriteFile(*goOutput, contents, 0644); err != nil { + fail(fmt.Errorf("write %s: %w", *goOutput, err)) + } + if *updateDocs { + if err := updateDocumentVersions(*documentRoot, version); err != nil { + fail(err) + } + } +} + +func readVersion(path string) (string, error) { + contents, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read schema: %w", err) + } + + var document schema + if err := json.Unmarshal(contents, &document); err != nil { + return "", fmt.Errorf("parse schema: %w", err) + } + + versions := document.Properties.OpenCLIVersion.Enum + + if len(versions) < 1 || versions[len(versions)-1] == "" { + return "", fmt.Errorf("schema properties.opencliVersion.enum must contain at least one non-empty string") + } + // take most recent version (versions will be in ascending order in the schema) + version := versions[len(versions)-1] + + const schemaSuffix = "/spec.schema.json" + if !strings.HasSuffix(document.ID, schemaSuffix) { + return "", fmt.Errorf("schema $id must end with %q", schemaSuffix) + } + idWithoutSuffix := strings.TrimSuffix(document.ID, schemaSuffix) + idVersion := idWithoutSuffix[strings.LastIndex(idWithoutSuffix, "/")+1:] + if idVersion != version { + return "", fmt.Errorf("schema $id version %q does not match opencliVersion enum %q", idVersion, version) + } + + return version, nil +} + +var ( +quotedVersionPattern = regexp.MustCompile(`(opencliVersion["']?\s*:\s*["'])([^"']+)(["'])`) + bareVersionPattern = regexp.MustCompile(`(opencliVersion\s*:\s*)([0-9][^[:space:]#,}]+)`) +) + +func updateDocumentVersions(root, version string) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".next", ".scratch", "build", "dist", "node_modules", "out", "scratch": + return filepath.SkipDir + } + return nil + } + if entry.Name() == "spec.schema.json" { + return nil + } + + extension := strings.ToLower(filepath.Ext(path)) + switch extension { + case ".json", ".md", ".txt", ".yaml", ".yml": + default: + return nil + } + + contents, err := os.ReadFile(path) + if err != nil { + return err + } + updated := quotedVersionPattern.ReplaceAll(contents, []byte(`${1}`+version+`${3}`)) + updated = bareVersionPattern.ReplaceAll(updated, []byte(`${1}`+version)) + if string(updated) == string(contents) { + return nil + } + + info, err := entry.Info() + if err != nil { + return err + } + if err := os.WriteFile(path, updated, info.Mode().Perm()); err != nil { + return fmt.Errorf("update %s: %w", path, err) + } + fmt.Println("updated", path) + return nil + }) +} + +func fail(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/spec/schema_version_gen.go b/spec/schema_version_gen.go new file mode 100644 index 0000000..4d44bb6 --- /dev/null +++ b/spec/schema_version_gen.go @@ -0,0 +1,6 @@ +// Code generated by genschema; DO NOT EDIT. + +package spec + +// SchemaVersion is the version declared by the canonical OpenCLI schema. +const SchemaVersion = "1.0.0-alpha.14" diff --git a/spec/schema_version_test.go b/spec/schema_version_test.go new file mode 100644 index 0000000..aeddb26 --- /dev/null +++ b/spec/schema_version_test.go @@ -0,0 +1,105 @@ +package spec_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/bcdxn/opencli/spec" + "gopkg.in/yaml.v3" +) + +type schemaVersionDocument struct { + ID string `json:"$id"` + Properties struct { + OpenCLIVersion struct { + Enum []string `json:"enum"` + } `json:"opencliVersion"` + } `json:"properties"` +} + +func TestSchemaVersionArtifacts(t *testing.T) { + rootSchema, err := os.ReadFile("../spec.schema.json") + if err != nil { + t.Fatal(err) + } + + var document schemaVersionDocument + if err := json.Unmarshal(rootSchema, &document); err != nil { + t.Fatal(err) + } + if len(document.Properties.OpenCLIVersion.Enum) != 1 { + t.Fatalf("schema opencliVersion enum has %d values, want exactly one", len(document.Properties.OpenCLIVersion.Enum)) + } + if got := document.Properties.OpenCLIVersion.Enum[0]; got != spec.SchemaVersion { + t.Fatalf("generated SchemaVersion is %q, schema declares %q", spec.SchemaVersion, got) + } + + for _, relativePath := range []string{"../validate/spec.schema.json", "../web/src/spec.schema.json"} { + copy, err := os.ReadFile(relativePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(copy, rootSchema) { + t.Errorf("%s is not synchronized with ../spec.schema.json", relativePath) + } + } +} + +func TestOpenCLIVersionDocuments(t *testing.T) { + var mismatches []string + root := filepath.Clean("..") + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".next", ".scratch", "build", "dist", "node_modules", "out", "scratch": + return filepath.SkipDir + } + return nil + } + + extension := strings.ToLower(filepath.Ext(path)) + if extension != ".json" && extension != ".yaml" && extension != ".yml" { + return nil + } + + contents, err := os.ReadFile(path) + if err != nil { + return err + } + if !bytes.Contains(contents, []byte("opencliVersion")) { + return nil + } + + var document map[string]any + if extension == ".json" { + err = json.Unmarshal(contents, &document) + } else { + err = yaml.Unmarshal(contents, &document) + } + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + + version, ok := document["opencliVersion"].(string) + if ok && version != spec.SchemaVersion { + relativePath, _ := filepath.Rel(root, path) + mismatches = append(mismatches, fmt.Sprintf("%s declares %q", relativePath, version)) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(mismatches) > 0 { + t.Fatalf("documents do not use schema version %q:\n%s", spec.SchemaVersion, strings.Join(mismatches, "\n")) + } +} diff --git a/spec/types.go b/spec/types.go index 26405b9..0d7aac7 100644 --- a/spec/types.go +++ b/spec/types.go @@ -1,5 +1,7 @@ package spec +//go:generate go run ../internal/genschema -schema ../spec.schema.json -out schema_version_gen.go + // Document represents the top-level OpenCLI document. type Document struct { OpenCLIVersion string diff --git a/validate/validate_test.go b/validate/validate_test.go index adc25f1..08f199f 100644 --- a/validate/validate_test.go +++ b/validate/validate_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/bcdxn/opencli/spec" "github.com/bcdxn/opencli/validate" ) @@ -145,7 +146,7 @@ func TestValidationError_PathFormatting(t *testing.T) { } func TestValidateYAML_DuplicateFlagNames(t *testing.T) { - yaml := `opencliVersion: 1.0.0-alpha.14 + yaml := `opencliVersion: ` + spec.SchemaVersion + ` info: title: Test CLI version: "1.0.0" @@ -259,7 +260,7 @@ func TestValidateJSON_InvalidSchema(t *testing.T) { func TestValidateYAML_SchemaValidationErrorFormatting(t *testing.T) { // Invalid schema produces a schemaValidationError which uses writeSchemaError - err := validate.ValidateYAML([]byte(`opencliVersion: 1.0.0-alpha.14`)) + err := validate.ValidateYAML([]byte("opencliVersion: " + spec.SchemaVersion)) if err == nil { t.Fatal("expected schema validation error") } diff --git a/web/src/schemaVersion.ts b/web/src/schemaVersion.ts new file mode 100644 index 0000000..00262d9 --- /dev/null +++ b/web/src/schemaVersion.ts @@ -0,0 +1,3 @@ +import schema from "./spec.schema.json"; + +export const schemaVersion = String(schema.properties.opencliVersion.enum[0]); diff --git a/web/src/views/GeneratingGoCode.tsx b/web/src/views/GeneratingGoCode.tsx index f824165..b8c2785 100644 --- a/web/src/views/GeneratingGoCode.tsx +++ b/web/src/views/GeneratingGoCode.tsx @@ -4,6 +4,7 @@ import React, { useState, useCallback } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { nord } from "react-syntax-highlighter/dist/esm/styles/prism"; import SiteHeader from "../components/SiteHeader"; +import { schemaVersion } from "../schemaVersion"; import "./Docs.css"; // ── Highlighted Code Block (with syntax highlighting) ───────────────────────── @@ -169,7 +170,7 @@ function GeneratingGoCodePage() {