Compare commits
153 Commits
v0.1.0
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| a320c76574 | |||
| 841e60fed3 | |||
| 654d1b4a6d | |||
| a6278a6d9f | |||
| 114c7da7be | |||
| 94c352dbd6 | |||
| 1d0085450a | |||
| 0fe77baa29 | |||
| 730f930b47 | |||
| e653b7d6ec | |||
| 8d1f33a40e | |||
| 35e958c210 | |||
| 079576361b | |||
| 80f31d2ec0 | |||
| 2e14d973ee | |||
| 20ef7bb8c4 | |||
| 1650680723 | |||
| 3e9c871cdc | |||
| 7a91f722fd | |||
| 504ea03574 | |||
| dc4261a1ca | |||
| c1fa18629d | |||
| 66b2994e48 | |||
| b56cae70b9 | |||
| 3124d572cf | |||
| b2caf8371a | |||
| 29582dd6b7 | |||
| 38b49f29fb | |||
| f173fdbc1b | |||
| 7eb859d036 | |||
| 502ea5eca8 | |||
| 7b9b945baa | |||
| 5f633e3fee | |||
| 775c32479d | |||
| 99e2089e75 | |||
| 420483b2d4 | |||
| 88d4059e8d | |||
| be8bd9c1ea | |||
| 9e6431be63 | |||
| 346bcaeddf | |||
| 686a7ba0ab | |||
| f5c1c69ccb | |||
| 7fc5423688 | |||
| 1c542505b7 | |||
| 3218222e53 | |||
| 1119bda6b7 | |||
| 87391903f4 | |||
| 700f10d78e | |||
| 40499a4360 | |||
| e5faa1855d | |||
| 92c1d49ade | |||
| 5dc83d9460 | |||
| cfc2d1dfd0 | |||
| d3cbfc4e9a | |||
| 3710492d2d | |||
| b27cf02c0e | |||
| 7dce7b816b | |||
| 0276930b12 | |||
| ebd29598db | |||
| 3935dcdbda | |||
| 49b7e686b3 | |||
| fa0cd9a73b | |||
| 12b52b3174 | |||
| 8a82620dfc | |||
| 61f0d8806d | |||
| b9d5ea4b1b | |||
| f8f8d27e71 | |||
| def9b90b7c | |||
| ee038df530 | |||
| 4bc0689c44 | |||
| 526a033da5 | |||
| e4cdedd6db | |||
| c75d371ecd | |||
| 330017fd75 | |||
| f7b1d2c583 | |||
| ddd14b70a8 | |||
| c6b4a42287 | |||
| ec4eb9d3a6 | |||
| d825538921 | |||
| 20e19b9569 | |||
| 603b6b3758 | |||
| ab270e2c82 | |||
| c7fb5a27bb | |||
| 251d3adeee | |||
| 98c9c3f00d | |||
| 7bf89e9b56 | |||
| 28aa3b04ad | |||
| c69a75d1c9 | |||
| 976b55309c | |||
| 8b46e12da7 | |||
| f0853d1000 | |||
| 3cbb1f35b0 | |||
| 18ee987a88 | |||
| 783f17f274 | |||
| 30787b2806 | |||
| 3542422b2f | |||
| d2434b9580 | |||
| d1591463ee | |||
| 018f90a23c | |||
| c883c64be9 | |||
| e8b44ca765 | |||
| 7fc425ede3 | |||
| f8bb78924e | |||
| 92727ecf2b | |||
| 21c2fde8d5 | |||
| fbee871731 | |||
| f67ee235d7 | |||
| ecd713ef2f | |||
| bb2528532b | |||
| 5aac2a0ee1 | |||
| 960a7c963e | |||
| 4ba5a56e21 | |||
| 7ce2fc0bf2 | |||
| cec4da69d3 | |||
| fba917f5bd | |||
| 6d660585d1 | |||
| dc252d9fb0 | |||
| cc574915ee | |||
| d2b5b0f3a8 | |||
| 18e81a5579 | |||
| e89cc2220f | |||
| f7174a078e | |||
| eeeaca6c5e | |||
| c0f3a21fc7 | |||
| 809a36bc46 | |||
| 1e1eca4a88 | |||
| 7e5f84bf16 | |||
| 24e8cd8629 | |||
| 74f6bdeade | |||
| 4e0611bc13 | |||
| f63b096525 | |||
| de9bfdcc4c | |||
| 476e98a463 | |||
| 0d23676144 | |||
| bb849ff3e1 | |||
| 336d489727 | |||
| 3b69cf85e7 | |||
| 14c304b6f9 | |||
| b8aada3b6e | |||
| 160604095c | |||
| 8f86852456 | |||
| 892186d4ac | |||
| 0b5c1422ed | |||
| 48469ea509 | |||
| 58ee418249 | |||
| d9f60d2d5a | |||
| eeb9fdd66c | |||
| 4ca0e9fdae | |||
| ecc0ad4e70 | |||
| 76f923e619 | |||
| 9456bef1c1 | |||
| 085302d78a | |||
| 06859b1b09 |
42
README.md
42
README.md
@@ -1,19 +1,33 @@
|
||||
# CommandKit
|
||||
|
||||
CommandKit is a Go library project for consistent CLI inspection, command syntax,
|
||||
and shared command infrastructure across related applications.
|
||||
CommandKit is a Go library for typed command declarations, shared CLI behavior,
|
||||
and application-owned execution. The core `commandkit` module is usable by
|
||||
itself. ConfigKit and PromptKit support lives in separately versioned optional
|
||||
modules, so applications choose which upstream libraries they add.
|
||||
|
||||
The current Go library provides an instance-owned command registry, typed flag
|
||||
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
|
||||
application or CommandKit version reporting, configuration scope discovery,
|
||||
ConfigKit-backed validation, native effective YAML display, source inventory,
|
||||
and path explanation reports. Applications can register a PromptKit engine
|
||||
configuration scope independently of PromptKit definition commands, and can
|
||||
install optional prompt and profile inspection commands.
|
||||
The core captures reusable command declarations, validates installation batches
|
||||
before changing an application's registry, converts typed inputs before
|
||||
constructing them, and returns outcomes without exiting the process. Its
|
||||
[consumer guide](docs/consumers/guide.md) covers declaration and invocation.
|
||||
|
||||
The [consumer guide](docs/consumers/guide.md) links to two maintained examples:
|
||||
[simple](examples/simple/README.md) and
|
||||
[composed](examples/composed/README.md).
|
||||
## Modules
|
||||
|
||||
| Module | Package | Provides |
|
||||
| --- | --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | `commandkit` | Typed commands, bindings, execution, outcomes, help, and version. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | `ckconfig` | ConfigKit-backed `config` scopes, including configuration source evidence. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | `ckprompt` | PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, concrete request validation, and native smoke tests. |
|
||||
|
||||
Importing an integration does not install commands. Pass its declaration to
|
||||
`commandkit.New` or `App.Register` to install it. The integrations depend on the
|
||||
core and their selected upstream libraries; the core does not depend on either
|
||||
integration.
|
||||
|
||||
## Examples
|
||||
|
||||
- [Core-only example](examples/core/main.go)
|
||||
- [ConfigKit example](integrations/ckconfig/examples/simple/README.md)
|
||||
- [Composed ConfigKit and PromptKit example](integrations/ckprompt/examples/composed/README.md)
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -24,11 +38,7 @@ The [consumer guide](docs/consumers/guide.md) links to two maintained examples:
|
||||
- [Maintainer release procedure](docs/release.md)
|
||||
- [Architecture policy](docs/policy/architecture.md)
|
||||
- [Policy provenance](docs/policy/meta.md)
|
||||
|
||||
## Future work and history
|
||||
|
||||
- [Deferred capabilities and proposals](docs/roadmap/deferred.md)
|
||||
- [Completed planning and implementation records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap)
|
||||
|
||||
CommandKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||
|
||||
|
||||
44
app.go
44
app.go
@@ -3,47 +3,39 @@ package commandkit
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// App owns one command registry and its application identity.
|
||||
// App owns one command registry and its application identity. Each App has an
|
||||
// independent registry; command declarations may be reused across Apps.
|
||||
type App struct {
|
||||
identity Identity
|
||||
commands map[string]*commandNode
|
||||
commandTree *internalcobra.Command
|
||||
configRegistered bool
|
||||
identity Identity
|
||||
commands map[string]*commandNode
|
||||
snapshot *routingSnapshot
|
||||
}
|
||||
|
||||
type routingSnapshot struct {
|
||||
metadata *commandmeta.Command
|
||||
runners map[commandmeta.Key]commandPayload
|
||||
}
|
||||
|
||||
type commandNode struct {
|
||||
command Command
|
||||
reserved bool
|
||||
command Command
|
||||
}
|
||||
|
||||
// New creates an application with the supplied identity and initial commands.
|
||||
// The identity name must not be empty. The root help and version names are
|
||||
// The identity name must not be empty. New installs its generated help and
|
||||
// version commands and caller declarations as one batch; an invalid declaration
|
||||
// returns an error without exposing an App. The root help and version names are
|
||||
// reserved for generated commands.
|
||||
func New(identity Identity, commands ...Command) (*App, error) {
|
||||
if identity.Name == "" {
|
||||
return nil, fmt.Errorf("application identity name must not be empty")
|
||||
}
|
||||
|
||||
app := &App{
|
||||
identity: identity,
|
||||
commands: map[string]*commandNode{
|
||||
"help": {
|
||||
command: Command{name: "help", summary: "Show help for commands."},
|
||||
reserved: true,
|
||||
},
|
||||
"version": {
|
||||
command: Command{name: "version", summary: "Show application version."},
|
||||
reserved: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := app.installBuiltins(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := app.Register(commands...); err != nil {
|
||||
app := &App{identity: identity, commands: make(map[string]*commandNode)}
|
||||
all := append(app.builtinCommands(), commands...)
|
||||
if err := app.installBatch(all...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
|
||||
106
binding.go
106
binding.go
@@ -6,71 +6,72 @@ import (
|
||||
)
|
||||
|
||||
// ValueFlag declares a singleton flag with a typed parser, optional default,
|
||||
// and setter. Every supplied occurrence is parsed; the last parsed value is
|
||||
// applied with Set=true.
|
||||
// and setter. The default value is captured at construction. Every supplied
|
||||
// occurrence is parsed before input construction; the last parsed value is
|
||||
// applied with Set=true. A malformed earlier occurrence remains an error.
|
||||
func ValueFlag[I, V any](meta FlagMeta, defaultValue *V, parse func(string) (V, error), set func(*I, Supplied[V])) Flag[I] {
|
||||
var fallback V
|
||||
if defaultValue != nil {
|
||||
fallback = *defaultValue
|
||||
}
|
||||
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback)}
|
||||
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback)}
|
||||
if parse == nil || set == nil {
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
binding.valid = true
|
||||
binding.clear = func(input *I) { set(input, Supplied[V]{}) }
|
||||
binding.prepare = func(values []string) (func(*I), error) {
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
value := fallback
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedValue[I]{}, err
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
result := Supplied[V]{Value: value, Set: supplied}
|
||||
return func(input *I) { set(input, result) }, nil
|
||||
return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
|
||||
}
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
|
||||
// BoolFlag declares a boolean flag. A bare flag is supplied by the parser as
|
||||
// "true"; explicit values are converted with strconv.ParseBool.
|
||||
// "true"; explicit values are converted with strconv.ParseBool. Set distinguishes
|
||||
// an omitted flag from an explicit false value.
|
||||
func BoolFlag[I any](meta FlagMeta, defaultValue bool, set func(*I, Supplied[bool])) Flag[I] {
|
||||
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
||||
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(defaultValue), boolean: true}
|
||||
if set == nil {
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
binding.valid = true
|
||||
binding.clear = func(input *I) { set(input, Supplied[bool]{}) }
|
||||
binding.prepare = func(values []string) (func(*I), error) {
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
value := defaultValue
|
||||
supplied := len(values) > 0
|
||||
for _, raw := range values {
|
||||
parsed, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedValue[I]{}, err
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
result := Supplied[bool]{Value: value, Set: supplied}
|
||||
return func(input *I) { set(input, result) }, nil
|
||||
return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
|
||||
}
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
|
||||
// RepeatFlag declares a repeatable flag. Supplied occurrences replace defaults
|
||||
// and are parsed in argv order, with each occurrence treated as one value.
|
||||
// RepeatFlag declares a repeatable flag. It captures a copy of defaults. Supplied
|
||||
// occurrences replace defaults and are parsed in argv order, with each
|
||||
// occurrence treated as one value. The core-owned result slice is copied when
|
||||
// applied to input.
|
||||
func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, error), set func(*I, Supplied[[]V])) Flag[I] {
|
||||
fallback := append([]V(nil), defaults...)
|
||||
binding := flagBinding[I]{meta: meta, defaultText: fmt.Sprint(fallback), repeated: true}
|
||||
binding := flagBinding[I]{identity: newBindingIdentity(), meta: meta, defaultText: fmt.Sprint(fallback), repeated: true}
|
||||
if parse == nil || set == nil {
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
binding.valid = true
|
||||
binding.clear = func(input *I) { set(input, Supplied[[]V]{}) }
|
||||
binding.prepare = func(values []string) (func(*I), error) {
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
parsedValues := append([]V(nil), fallback...)
|
||||
supplied := len(values) > 0
|
||||
if supplied {
|
||||
@@ -78,81 +79,83 @@ func RepeatFlag[I, V any](meta FlagMeta, defaults []V, parse func(string) (V, er
|
||||
for _, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedValue[I]{}, err
|
||||
}
|
||||
parsedValues = append(parsedValues, parsed)
|
||||
}
|
||||
}
|
||||
return func(input *I) {
|
||||
return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) {
|
||||
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: supplied}
|
||||
set(input, result)
|
||||
}, nil
|
||||
}}, nil
|
||||
}
|
||||
return Flag[I]{binding: binding}
|
||||
}
|
||||
|
||||
// ValueArgument declares an optional or required singleton positional
|
||||
// argument.
|
||||
// ValueArgument declares an optional or required singleton positional argument.
|
||||
// Required cardinality is enforced for execution, while help can be rendered
|
||||
// without a supplied operand. Parsing completes before input construction.
|
||||
func ValueArgument[I, V any](meta ArgumentMeta, required bool, parse func(string) (V, error), set func(*I, Supplied[V])) Argument[I] {
|
||||
min := 0
|
||||
if required {
|
||||
min = 1
|
||||
}
|
||||
binding := argumentBinding[I]{meta: meta, min: min, max: 1}
|
||||
binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: 1}
|
||||
if parse == nil || set == nil {
|
||||
return Argument[I]{binding: binding}
|
||||
}
|
||||
binding.valid = true
|
||||
binding.prepare = func(values []string) (func(*I), error) {
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
if len(values) > 1 {
|
||||
return nil, fmt.Errorf("received %d values; maximum is 1", len(values))
|
||||
return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is 1", len(values))
|
||||
}
|
||||
var value V
|
||||
supplied := len(values) == 1
|
||||
if supplied {
|
||||
parsed, err := parse(values[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedValue[I]{}, err
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
result := Supplied[V]{Value: value, Set: supplied}
|
||||
return func(input *I) { set(input, result) }, nil
|
||||
return preparedValue[I]{identity: binding.identity, supplied: supplied, apply: func(input *I) { set(input, result) }}, nil
|
||||
}
|
||||
return Argument[I]{binding: binding}
|
||||
}
|
||||
|
||||
// RepeatArgument declares a positional argument that accepts a bounded number
|
||||
// of values. A maximum of -1 means unbounded.
|
||||
// RepeatArgument declares a positional argument that accepts at least min and
|
||||
// at most max values. A maximum of -1 means unbounded. Parsing completes before
|
||||
// input construction, and the core-owned result slice is copied when applied.
|
||||
func RepeatArgument[I, V any](meta ArgumentMeta, min, max int, parse func(string) (V, error), set func(*I, Supplied[[]V])) Argument[I] {
|
||||
binding := argumentBinding[I]{meta: meta, min: min, max: max}
|
||||
binding := argumentBinding[I]{identity: newBindingIdentity(), meta: meta, min: min, max: max}
|
||||
if parse == nil || set == nil {
|
||||
return Argument[I]{binding: binding}
|
||||
}
|
||||
binding.valid = true
|
||||
binding.prepare = func(values []string) (func(*I), error) {
|
||||
binding.prepare = func(values []string) (preparedValue[I], error) {
|
||||
if max >= 0 && len(values) > max {
|
||||
return nil, fmt.Errorf("received %d values; maximum is %d", len(values), max)
|
||||
return preparedValue[I]{}, fmt.Errorf("received %d values; maximum is %d", len(values), max)
|
||||
}
|
||||
parsedValues := make([]V, 0, len(values))
|
||||
for _, raw := range values {
|
||||
parsed, err := parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedValue[I]{}, err
|
||||
}
|
||||
parsedValues = append(parsedValues, parsed)
|
||||
}
|
||||
return func(input *I) {
|
||||
return preparedValue[I]{identity: binding.identity, supplied: len(values) > 0, apply: func(input *I) {
|
||||
result := Supplied[[]V]{Value: append([]V(nil), parsedValues...), Set: len(values) > 0}
|
||||
set(input, result)
|
||||
}, nil
|
||||
}}, nil
|
||||
}
|
||||
return Argument[I]{binding: binding}
|
||||
}
|
||||
|
||||
type preparedInput[I any] struct {
|
||||
newInput func() I
|
||||
setters []func(*I)
|
||||
values []preparedValue[I]
|
||||
}
|
||||
|
||||
func (p preparedInput[I]) build() I {
|
||||
@@ -160,8 +163,8 @@ func (p preparedInput[I]) build() I {
|
||||
if p.newInput != nil {
|
||||
input = p.newInput()
|
||||
}
|
||||
for _, setter := range p.setters {
|
||||
setter(&input)
|
||||
for _, value := range p.values {
|
||||
value.apply(&input)
|
||||
}
|
||||
return input
|
||||
}
|
||||
@@ -173,33 +176,40 @@ func prepareInput[I any](spec CommandSpec[I], flagValues [][]string, operands []
|
||||
if len(flagValues) != len(spec.Flags) {
|
||||
return preparedInput[I]{}, fmt.Errorf("received %d flag value sets for %d bindings", len(flagValues), len(spec.Flags))
|
||||
}
|
||||
setters := make([]func(*I), 0, len(spec.Flags)+len(spec.Arguments))
|
||||
preparedFlags := make([]preparedValue[I], len(spec.Flags))
|
||||
missingRequired := ""
|
||||
for i, flag := range spec.Flags {
|
||||
binding := flag.binding
|
||||
if enforceRequired && binding.meta.Required && len(flagValues[i]) == 0 {
|
||||
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", binding.meta.Name)
|
||||
}
|
||||
setter, err := binding.prepare(flagValues[i])
|
||||
value, err := binding.prepare(flagValues[i])
|
||||
if err != nil {
|
||||
return preparedInput[I]{}, fmt.Errorf("flag --%s: %w", binding.meta.Name, err)
|
||||
}
|
||||
setters = append(setters, setter)
|
||||
preparedFlags[i] = value
|
||||
if enforceRequired && binding.meta.Required && !binding.deferredRequired && !value.supplied && missingRequired == "" {
|
||||
missingRequired = binding.meta.Name
|
||||
}
|
||||
}
|
||||
if missingRequired != "" {
|
||||
return preparedInput[I]{}, fmt.Errorf("required flag --%s was not supplied", missingRequired)
|
||||
}
|
||||
argumentValues, err := allocateOperands(operands, spec.Arguments, enforceRequired)
|
||||
if err != nil {
|
||||
return preparedInput[I]{}, err
|
||||
}
|
||||
preparedValues := append([]preparedValue[I](nil), preparedFlags...)
|
||||
for i, argument := range spec.Arguments {
|
||||
binding := argument.binding
|
||||
setter, err := binding.prepare(argumentValues[i])
|
||||
value, err := binding.prepare(argumentValues[i])
|
||||
if err != nil {
|
||||
return preparedInput[I]{}, fmt.Errorf("argument %q: %w", binding.meta.Name, err)
|
||||
}
|
||||
setters = append(setters, setter)
|
||||
preparedValues = append(preparedValues, value)
|
||||
}
|
||||
return preparedInput[I]{newInput: spec.NewInput, setters: setters}, nil
|
||||
return preparedInput[I]{newInput: spec.NewInput, values: preparedValues}, nil
|
||||
}
|
||||
|
||||
func newBindingIdentity() *bindingIdentity { return &bindingIdentity{marker: 1} }
|
||||
|
||||
func allocateOperands[I any](operands []string, definitions []Argument[I], enforceRequired bool) ([][]string, error) {
|
||||
for _, argument := range definitions {
|
||||
binding := argument.binding
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestScalarBindingsKeepDefaultsSeparateFromPresence(t *testing.T) {
|
||||
BoolFlag(FlagMeta{Name: "enabled"}, true, func(input *bindingInput, value Supplied[bool]) { input.enabled = value }),
|
||||
},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaultMessage = "changed after declaration"
|
||||
@@ -104,8 +104,7 @@ func TestBoolFlagRejectsExplicitEmptyValue(t *testing.T) {
|
||||
|
||||
func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
|
||||
defaults := []string{"base"}
|
||||
var captured Supplied[[]string]
|
||||
flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(_ *bindingInput, value Supplied[[]string]) { captured = value })
|
||||
flag := RepeatFlag(FlagMeta{Name: "tag"}, defaults, func(value string) (string, error) { return value, nil }, func(input *bindingInput, value Supplied[[]string]) { input.tags = value })
|
||||
defaults[0] = "changed after declaration"
|
||||
|
||||
applyDefault, err := flag.binding.prepare(nil)
|
||||
@@ -113,25 +112,25 @@ func TestRepeatFlagUsesFreshDefaultsAndWholeOccurrenceValues(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := &bindingInput{}
|
||||
applyDefault(first)
|
||||
applyDefault.apply(first)
|
||||
second := &bindingInput{}
|
||||
applyDefault(second)
|
||||
if !reflect.DeepEqual(captured.Value, []string{"base"}) || captured.Set {
|
||||
t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", captured)
|
||||
applyDefault.apply(second)
|
||||
if !reflect.DeepEqual(first.tags.Value, []string{"base"}) || first.tags.Set {
|
||||
t.Fatalf("omitted repeat value = %#v, want copied default with Set=false", first.tags)
|
||||
}
|
||||
captured.Value[0] = "changed one input"
|
||||
applyDefault(second)
|
||||
if !reflect.DeepEqual(captured.Value, []string{"base"}) {
|
||||
t.Fatalf("repeat default collection was reused: %#v", captured.Value)
|
||||
first.tags.Value[0] = "changed one input"
|
||||
if !reflect.DeepEqual(second.tags.Value, []string{"base"}) {
|
||||
t.Fatalf("repeat default collection was reused: %#v", second.tags.Value)
|
||||
}
|
||||
|
||||
applyValues, err := flag.binding.prepare([]string{"a,b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyValues(&bindingInput{})
|
||||
if !reflect.DeepEqual(captured.Value, []string{"a,b", "c"}) || !captured.Set {
|
||||
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", captured)
|
||||
third := &bindingInput{}
|
||||
applyValues.apply(third)
|
||||
if !reflect.DeepEqual(third.tags.Value, []string{"a,b", "c"}) || !third.tags.Set {
|
||||
t.Fatalf("supplied repeat values = %#v, want two ordered occurrences", third.tags)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +148,46 @@ func TestSingletonParsesEveryOccurrenceAndPreservesCallerCause(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedScalarConvertsEveryOccurrenceOnceBeforeInputCreation(t *testing.T) {
|
||||
parseCalls := 0
|
||||
var received Supplied[string]
|
||||
flag := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) {
|
||||
parseCalls++
|
||||
return value, nil
|
||||
}, func(input *bindingInput, value Supplied[string]) { input.message = value })
|
||||
prepared, err := flag.binding.prepare([]string{"earlier", "last"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parseCalls != 2 || prepared.identity != flag.binding.identity || !prepared.supplied {
|
||||
t.Fatalf("prepared scalar = %#v, parse calls = %d", prepared, parseCalls)
|
||||
}
|
||||
copy := flag
|
||||
lookalike := ValueFlag(FlagMeta{Name: "value"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*bindingInput, Supplied[string]) {})
|
||||
if copy.binding.identity != flag.binding.identity || lookalike.binding.identity == flag.binding.identity {
|
||||
t.Fatal("binding identity was not preserved by copying or distinct across constructors")
|
||||
}
|
||||
input := &bindingInput{}
|
||||
prepared.apply(input)
|
||||
received = input.message
|
||||
if parseCalls != 2 || received.Value != "last" || !received.Set {
|
||||
t.Fatalf("applied value = %#v, parse calls = %d", received, parseCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBindingConstructionIsReportedAtInstallation(t *testing.T) {
|
||||
badFlag := ValueFlag[struct{}, string](FlagMeta{Name: "broken"}, nil, nil, func(*struct{}, Supplied[string]) {})
|
||||
badArgument := RepeatArgument[struct{}, string](ArgumentMeta{Name: "items"}, 2, 1, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[[]string]) {})
|
||||
command := NewCommand(CommandSpec[struct{}]{Name: "run", Flags: []Flag[struct{}]{badFlag}, Arguments: []Argument[struct{}]{badArgument}})
|
||||
app, err := New(Identity{Name: "example"}, command)
|
||||
if err == nil {
|
||||
t.Fatal("installation accepted nil parser wiring and invalid argument cardinality")
|
||||
}
|
||||
if app != nil {
|
||||
t.Fatal("failed installation returned a usable app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
||||
type operandsInput struct {
|
||||
prefix Supplied[[]string]
|
||||
@@ -163,7 +202,7 @@ func TestRepeatArgumentAllocationReservesLaterMinimums(t *testing.T) {
|
||||
RepeatArgument(ArgumentMeta{Name: "suffix"}, 1, -1, func(value string) (string, error) { return value, nil }, func(input *operandsInput, value Supplied[[]string]) { input.suffix = value }),
|
||||
},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared, err := prepareInput(spec, nil, []string{"a", "b", "id-1", "x", "y"}, true)
|
||||
@@ -193,7 +232,7 @@ func TestIncompletePreparationSkipsMissingRequirementsAndInputFactory(t *testing
|
||||
Flags: []Flag[struct{}]{ValueFlag(FlagMeta{Name: "required", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
|
||||
Arguments: []Argument[struct{}]{ValueArgument(ArgumentMeta{Name: "id"}, true, func(value string) (string, error) { return value, nil }, func(*struct{}, Supplied[string]) {})},
|
||||
}
|
||||
if _, err := NewCommand(spec); err != nil {
|
||||
if err := validateCommandDeclaration(NewCommand(spec)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareInput(spec, [][]string{nil}, nil, true); err == nil {
|
||||
|
||||
51
builtins.go
51
builtins.go
@@ -6,9 +6,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -29,24 +29,20 @@ type versionDocument struct {
|
||||
Revision string `yaml:"revision"`
|
||||
}
|
||||
|
||||
func (a *App) installBuiltins() error {
|
||||
help, err := NewCommand(CommandSpec[helpInput]{
|
||||
func (a *App) builtinCommands() []Command {
|
||||
help := captureCommand(CommandSpec[helpInput]{
|
||||
Name: "help",
|
||||
Summary: "Show help for commands.",
|
||||
Description: "Describe registered commands and their inputs.",
|
||||
Arguments: []Argument[helpInput]{RepeatArgument(ArgumentMeta{Name: "COMMAND", Usage: "registered command path"}, 0, -1,
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(input *helpInput, value Supplied[[]string]) { input.Targets = value })},
|
||||
Run: func(invocation Invocation, input helpInput) Outcome {
|
||||
return a.renderHelpTargets(input.Targets.Value, invocation.Streams)
|
||||
},
|
||||
}, func(current commandExecution, input helpInput) Outcome {
|
||||
return current.renderHelpTargets(input.Targets.Value)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in help command: %w", err)
|
||||
}
|
||||
scopeDefault := "app"
|
||||
formatDefault := "text"
|
||||
version, err := NewCommand(CommandSpec[versionInput]{
|
||||
version := NewCommand(CommandSpec[versionInput]{
|
||||
Name: "version",
|
||||
Summary: "Show application or CommandKit version.",
|
||||
Description: "Report identity and build metadata without loading application configuration.",
|
||||
@@ -65,12 +61,7 @@ func (a *App) installBuiltins() error {
|
||||
return renderVersionOutcome(identity, input.Format, invocation.Streams)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create built-in version command: %w", err)
|
||||
}
|
||||
a.commands["help"] = &commandNode{command: help, reserved: true}
|
||||
a.commands["version"] = &commandNode{command: version, reserved: true}
|
||||
return nil
|
||||
return []Command{help, version}
|
||||
}
|
||||
|
||||
func parseVersionScope(value string) (string, error) {
|
||||
@@ -88,31 +79,25 @@ func parseVersionFormat(value string) (string, error) {
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcome(path []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{})
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpOutcomeWithSession(path, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargets(targets []string, streams Streams) Outcome {
|
||||
session := internalcobra.Prepare(a.bridgeTree(), internalcobra.ParseOptions{})
|
||||
session := internalcobra.Prepare(a.routeSnapshot().metadata, commandmeta.ParseOptions{})
|
||||
return a.renderHelpTargetsWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpTargetsWithSession(targets []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
target, err := a.helpCommandTarget(targets)
|
||||
if err != nil {
|
||||
return Usage(err)
|
||||
}
|
||||
return a.renderHelpOutcomeWithSession(target, streams, session)
|
||||
return a.renderHelpOutcomeWithSession(targets, streams, session)
|
||||
}
|
||||
|
||||
func (a *App) renderHelpOutcomeWithSession(path []string, streams Streams, session *internalcobra.Session) Outcome {
|
||||
if len(path) > 0 {
|
||||
if _, ok := a.commandAt(path); !ok {
|
||||
return Usage(fmt.Errorf("unknown help path %q", strings.Join(path, " ")))
|
||||
}
|
||||
}
|
||||
content, failure := session.RenderHelp(path)
|
||||
if failure != nil {
|
||||
if failure.Usage {
|
||||
return Usage(errors.New(failure.Message))
|
||||
}
|
||||
return Failure(errors.New(failure.Message))
|
||||
}
|
||||
if err := writeReport(streams.Out, []byte(content)); err != nil {
|
||||
@@ -210,13 +195,3 @@ func commandKitBuildIdentity(info *debug.BuildInfo) Identity {
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func (a *App) helpCommandTarget(targets []string) ([]string, error) {
|
||||
if len(targets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if _, ok := a.commandAt(targets); !ok {
|
||||
return nil, fmt.Errorf("unknown help path %q", strings.Join(targets, " "))
|
||||
}
|
||||
return append([]string(nil), targets...), nil
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package commandkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStandardConfigurationCancellationBoundaries(t *testing.T) {
|
||||
for _, operation := range []string{"validate", "show", "sources", "explain"} {
|
||||
for _, before := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%s/before=%t", operation, before), func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if before {
|
||||
cancel()
|
||||
}
|
||||
loads, contexts := 0, 0
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
|
||||
loads++
|
||||
cancel()
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[struct{}]{})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}, NewContext: func() struct{} { contexts++; return struct{}{} }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"config", operation}
|
||||
if operation == "explain" {
|
||||
args = append(args, "--path", "")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
result := app.Execute(ctx, args, commandkit.Streams{Out: &out})
|
||||
want := 1
|
||||
if before {
|
||||
want = 0
|
||||
}
|
||||
if result.Kind != commandkit.OutcomeOperational || result.Status != 1 || !errors.Is(result.Cause, context.Canceled) || loads != want || contexts != want || out.Len() != 0 {
|
||||
t.Fatalf("result=%#v loads=%d contexts=%d out=%q", result, loads, contexts, out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateCancellationRetainsCompletedValidationAndDiagnostics(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
calls := 0
|
||||
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
cancel()
|
||||
}
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}}, nil
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
|
||||
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
|
||||
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, diagnostics bytes.Buffer
|
||||
result := app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &out, Err: &diagnostics})
|
||||
if !errors.Is(result.Cause, context.Canceled) || !result.ReportIncomplete || calls != 2 || out.String() != "app: valid\n" || strings.Count(diagnostics.String(), "acquired warning") != 2 {
|
||||
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, calls, out.String(), diagnostics.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionInspectionCancellationBoundaries(t *testing.T) {
|
||||
for _, resource := range []string{"prompt", "profile"} {
|
||||
for _, before := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("%s/before=%t", resource, before), func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if before {
|
||||
cancel()
|
||||
}
|
||||
calls, contexts := 0, 0
|
||||
diagnostics := []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "acquired warning")}
|
||||
suite := commandkit.PromptKitSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} },
|
||||
InspectPrompt: func(context.Context, commandkit.Selection, struct{}, string, string) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
calls++
|
||||
cancel()
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{}, Diagnostics: diagnostics}, nil
|
||||
},
|
||||
InspectProfile: func(context.Context, commandkit.Selection, struct{}, string) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
calls++
|
||||
cancel()
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{}, Diagnostics: diagnostics}, nil
|
||||
},
|
||||
}
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterPromptKit(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errs bytes.Buffer
|
||||
result := app.Execute(ctx, []string{"promptkit", "show", resource, "example"}, commandkit.Streams{Out: &out, Err: &errs})
|
||||
want := 1
|
||||
if before {
|
||||
want = 0
|
||||
}
|
||||
if result.Kind != commandkit.OutcomeOperational || !errors.Is(result.Cause, context.Canceled) || calls != want || contexts != want || out.Len() != 0 || (!before && !strings.Contains(errs.String(), "acquired warning")) {
|
||||
t.Fatalf("result=%#v calls=%d contexts=%d out=%q diagnostics=%q", result, calls, contexts, out.String(), errs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
297
command.go
297
command.go
@@ -1,24 +1,34 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// Command is an opaque command declaration. Use NewCommand or Group to create
|
||||
// declarations and WithChildren to compose them.
|
||||
type Command struct {
|
||||
// Command is an opaque, reusable declaration captured by NewCommand, Group,
|
||||
// RootGroup, or an integration. Capture copies declaration slices but retains
|
||||
// callback and caller-owned object references. Installation validates the
|
||||
// declaration before changing an App registry.
|
||||
type Command struct{ record *commandRecord }
|
||||
|
||||
type commandRecord struct {
|
||||
name string
|
||||
summary string
|
||||
description string
|
||||
children []Command
|
||||
payload commandPayload
|
||||
issues []error
|
||||
rootOnly bool
|
||||
absent bool
|
||||
invalid bool
|
||||
}
|
||||
|
||||
// Supplied carries a value and whether it was explicitly provided.
|
||||
// Supplied carries a value and whether it was explicitly provided. Defaults can
|
||||
// have Set=false; an explicitly supplied empty, false, or zero value has Set=true.
|
||||
type Supplied[T any] struct {
|
||||
// Value is the associated value.
|
||||
Value T
|
||||
@@ -26,7 +36,8 @@ type Supplied[T any] struct {
|
||||
Set bool
|
||||
}
|
||||
|
||||
// FlagMeta describes a command flag.
|
||||
// FlagMeta describes a command flag. Required applies when execution prepares
|
||||
// the selected command; help reports the metadata without requiring the value.
|
||||
type FlagMeta struct {
|
||||
// Name is the flag name without leading dashes.
|
||||
Name string
|
||||
@@ -38,7 +49,8 @@ type FlagMeta struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
// ArgumentMeta describes a positional command argument.
|
||||
// ArgumentMeta describes a positional command argument. Its name and usage are
|
||||
// used in generated help.
|
||||
type ArgumentMeta struct {
|
||||
// Name is the argument name shown in command help.
|
||||
Name string
|
||||
@@ -46,19 +58,30 @@ type ArgumentMeta struct {
|
||||
Usage string
|
||||
}
|
||||
|
||||
// Flag represents a typed binding for a command flag.
|
||||
// Flag represents a typed binding for a command flag. Copies retain binding
|
||||
// identity; separately constructed flags are distinct even when their metadata
|
||||
// and callbacks are equivalent.
|
||||
type Flag[I any] struct {
|
||||
binding flagBinding[I]
|
||||
}
|
||||
|
||||
type bindingIdentity struct{ marker byte }
|
||||
|
||||
type preparedValue[I any] struct {
|
||||
identity *bindingIdentity
|
||||
supplied bool
|
||||
apply func(*I)
|
||||
}
|
||||
|
||||
type flagBinding[I any] struct {
|
||||
meta FlagMeta
|
||||
defaultText string
|
||||
repeated bool
|
||||
boolean bool
|
||||
prepare func([]string) (func(*I), error)
|
||||
clear func(*I)
|
||||
valid bool
|
||||
identity *bindingIdentity
|
||||
meta FlagMeta
|
||||
deferredRequired bool
|
||||
defaultText string
|
||||
repeated bool
|
||||
boolean bool
|
||||
prepare func([]string) (preparedValue[I], error)
|
||||
valid bool
|
||||
}
|
||||
|
||||
// Argument represents a typed binding for a positional command argument.
|
||||
@@ -67,14 +90,19 @@ type Argument[I any] struct {
|
||||
}
|
||||
|
||||
type argumentBinding[I any] struct {
|
||||
meta ArgumentMeta
|
||||
min int
|
||||
max int
|
||||
prepare func([]string) (func(*I), error)
|
||||
valid bool
|
||||
identity *bindingIdentity
|
||||
meta ArgumentMeta
|
||||
min int
|
||||
max int
|
||||
prepare func([]string) (preparedValue[I], error)
|
||||
valid bool
|
||||
}
|
||||
|
||||
// CommandSpec declares one command using typed input and handler callbacks.
|
||||
// Capture copies its Flags, Arguments, and Children slices. NewInput runs only
|
||||
// when execution reaches input construction; a nil factory yields a fresh zero
|
||||
// value. CheckInput runs after supplied values are converted and applied, before
|
||||
// Run. Run may be nil for a parent that only groups child commands.
|
||||
type CommandSpec[I any] struct {
|
||||
// Name is one command path component.
|
||||
Name string
|
||||
@@ -82,6 +110,8 @@ type CommandSpec[I any] struct {
|
||||
Summary string
|
||||
// Description provides additional command detail.
|
||||
Description string
|
||||
// Children are relative child declarations captured with this command.
|
||||
Children []Command
|
||||
// NewInput creates an input value for execution. A nil factory uses a fresh
|
||||
// zero value.
|
||||
NewInput func() I
|
||||
@@ -98,15 +128,14 @@ type CommandSpec[I any] struct {
|
||||
type commandPayload interface {
|
||||
validate() error
|
||||
hasRun() bool
|
||||
bridgeFlags() []internalcobra.Flag
|
||||
bridgeArguments() []internalcobra.Argument
|
||||
prepare([][]string, []string, bool) (func() any, error)
|
||||
checkInput(any) error
|
||||
run(Invocation, any) Outcome
|
||||
bridgeFlags() []commandmeta.Flag
|
||||
bridgeArguments() []commandmeta.Argument
|
||||
prepare([][]string, []string, bool) (func(*execution) Outcome, error)
|
||||
}
|
||||
|
||||
type typedCommand[I any] struct {
|
||||
spec CommandSpec[I]
|
||||
spec CommandSpec[I]
|
||||
privateRun func(commandExecution, I) Outcome
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) validate() error {
|
||||
@@ -114,14 +143,14 @@ func (c typedCommand[I]) validate() error {
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) hasRun() bool {
|
||||
return c.spec.Run != nil
|
||||
return c.spec.Run != nil || c.privateRun != nil
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
|
||||
flags := make([]internalcobra.Flag, len(c.spec.Flags))
|
||||
func (c typedCommand[I]) bridgeFlags() []commandmeta.Flag {
|
||||
flags := make([]commandmeta.Flag, len(c.spec.Flags))
|
||||
for index, flag := range c.spec.Flags {
|
||||
binding := flag.binding
|
||||
flags[index] = internalcobra.Flag{
|
||||
flags[index] = commandmeta.Flag{
|
||||
Name: binding.meta.Name,
|
||||
Short: binding.meta.Short,
|
||||
Usage: binding.meta.Usage,
|
||||
@@ -134,11 +163,11 @@ func (c typedCommand[I]) bridgeFlags() []internalcobra.Flag {
|
||||
return flags
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument {
|
||||
arguments := make([]internalcobra.Argument, len(c.spec.Arguments))
|
||||
func (c typedCommand[I]) bridgeArguments() []commandmeta.Argument {
|
||||
arguments := make([]commandmeta.Argument, len(c.spec.Arguments))
|
||||
for index, argument := range c.spec.Arguments {
|
||||
binding := argument.binding
|
||||
arguments[index] = internalcobra.Argument{
|
||||
arguments[index] = commandmeta.Argument{
|
||||
Name: binding.meta.Name,
|
||||
Usage: binding.meta.Usage,
|
||||
Min: binding.min,
|
||||
@@ -148,116 +177,146 @@ func (c typedCommand[I]) bridgeArguments() []internalcobra.Argument {
|
||||
return arguments
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func() any, error) {
|
||||
func (c typedCommand[I]) prepare(flagValues [][]string, operands []string, enforceRequired bool) (func(*execution) Outcome, error) {
|
||||
prepared, err := prepareInput(c.spec, flagValues, operands, enforceRequired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() any {
|
||||
return func(current *execution) Outcome {
|
||||
input := prepared.build()
|
||||
return &input
|
||||
if c.spec.CheckInput != nil {
|
||||
if err := c.spec.CheckInput(input); err != nil {
|
||||
return classifyInputError(err)
|
||||
}
|
||||
}
|
||||
if c.privateRun != nil {
|
||||
return c.privateRun(current.commandContext(), input)
|
||||
}
|
||||
return c.spec.Run(Invocation{Context: current.ctx, Streams: current.streams}, input)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) run(invocation Invocation, input any) Outcome {
|
||||
return c.spec.Run(invocation, *input.(*I))
|
||||
// NewCommand captures a typed command declaration without constructing its
|
||||
// input or running callbacks. Construction issues are retained and reported
|
||||
// when the declaration is installed. The returned declaration can be reused.
|
||||
func NewCommand[I any](spec CommandSpec[I]) Command {
|
||||
return captureCommand(spec, nil)
|
||||
}
|
||||
|
||||
func (c typedCommand[I]) checkInput(input any) error {
|
||||
if c.spec.CheckInput == nil {
|
||||
return nil
|
||||
}
|
||||
return c.spec.CheckInput(*input.(*I))
|
||||
}
|
||||
|
||||
// NewCommand validates and creates a typed command declaration. It retains
|
||||
// callbacks without invoking them and copies the declarative flag and argument
|
||||
// slices.
|
||||
func NewCommand[I any](spec CommandSpec[I]) (Command, error) {
|
||||
if err := validateCommandName(spec.Name); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
func captureCommand[I any](spec CommandSpec[I], privateRun func(commandExecution, I) Outcome) Command {
|
||||
copySpec := spec
|
||||
copySpec.Flags = append([]Flag[I](nil), spec.Flags...)
|
||||
copySpec.Arguments = append([]Argument[I](nil), spec.Arguments...)
|
||||
payload := typedCommand[I]{spec: copySpec}
|
||||
copySpec.Children = append([]Command(nil), spec.Children...)
|
||||
payload := typedCommand[I]{spec: copySpec, privateRun: privateRun}
|
||||
record := &commandRecord{
|
||||
name: spec.Name, summary: spec.Summary, description: spec.Description,
|
||||
children: copySpec.Children, payload: payload,
|
||||
}
|
||||
if err := payload.validate(); err != nil {
|
||||
return Command{}, fmt.Errorf("command %q: %w", spec.Name, err)
|
||||
record.issues = append(record.issues, fmt.Errorf("command %q: %w", spec.Name, err))
|
||||
}
|
||||
return Command{
|
||||
name: spec.Name,
|
||||
summary: spec.Summary,
|
||||
description: spec.Description,
|
||||
payload: payload,
|
||||
}, nil
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// Group creates a non-runnable command group with the supplied children.
|
||||
func Group(name, summary string, children ...Command) (Command, error) {
|
||||
if err := validateCommandName(name); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
group := Command{name: name, summary: summary, children: cloneCommands(children)}
|
||||
if err := validateCommandDeclaration(group); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return group, nil
|
||||
// Group captures a non-runnable command group and a copy of its relative child
|
||||
// slice. Child declarations remain reusable and are not recursively copied.
|
||||
func Group(name, summary string, children ...Command) Command {
|
||||
record := &commandRecord{name: name, summary: summary, children: append([]Command(nil), children...)}
|
||||
captureComposition(record)
|
||||
return Command{record: record}
|
||||
}
|
||||
|
||||
// WithChildren returns a copy of parent with the supplied relative child
|
||||
// declarations. The parent's optional handler and input bindings are retained.
|
||||
func WithChildren(parent Command, children ...Command) (Command, error) {
|
||||
if err := validateCommandDeclaration(parent); err != nil {
|
||||
return Command{}, fmt.Errorf("parent command: %w", err)
|
||||
}
|
||||
combined := make([]Command, 0, len(parent.children)+len(children))
|
||||
combined = append(combined, parent.children...)
|
||||
combined = append(combined, children...)
|
||||
result := parent
|
||||
result.children = cloneCommands(combined)
|
||||
if err := validateCommandDeclaration(result); err != nil {
|
||||
return Command{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func cloneCommands(commands []Command) []Command {
|
||||
if len(commands) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Command, len(commands))
|
||||
for i, command := range commands {
|
||||
cloned[i] = cloneCommand(command)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneCommand(command Command) Command {
|
||||
command.children = cloneCommands(command.children)
|
||||
// RootGroup captures a group that may only be installed at the application root.
|
||||
// Installation rejects it below any other command, including through wrappers.
|
||||
func RootGroup(name, summary string, children ...Command) Command {
|
||||
command := Group(name, summary, children...)
|
||||
command.record.rootOnly = true
|
||||
return command
|
||||
}
|
||||
|
||||
func validateCommandDeclaration(command Command) error {
|
||||
if err := validateCommandName(command.name); err != nil {
|
||||
return err
|
||||
// WithError returns a new declaration that also retains a non-nil err for
|
||||
// installation. Earlier declaration and child errors remain available, and
|
||||
// wrapped error identity is preserved. A nil err returns command unchanged.
|
||||
func WithError(command Command, err error) Command {
|
||||
if err == nil {
|
||||
return command
|
||||
}
|
||||
if command.payload != nil {
|
||||
if err := command.payload.validate(); err != nil {
|
||||
return fmt.Errorf("command %q: %w", command.name, err)
|
||||
}
|
||||
var record commandRecord
|
||||
if command.record == nil {
|
||||
record.invalid = true
|
||||
} else {
|
||||
record = *command.record
|
||||
record.issues = append([]error(nil), record.issues...)
|
||||
}
|
||||
record.issues = append(record.issues, err)
|
||||
return Command{record: &record}
|
||||
}
|
||||
|
||||
children := make(map[string]struct{}, len(command.children))
|
||||
for _, child := range command.children {
|
||||
if err := validateCommandDeclaration(child); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := children[child.name]; exists {
|
||||
return fmt.Errorf("duplicate child command %q under %q", child.name, command.name)
|
||||
}
|
||||
children[child.name] = struct{}{}
|
||||
// NoCommand explicitly represents an absent optional declaration. Installation
|
||||
// skips it, but reports errors attached to it; a zero Command is invalid and is
|
||||
// not an absence marker.
|
||||
func NoCommand() Command { return Command{record: &commandRecord{absent: true}} }
|
||||
|
||||
func captureComposition(record *commandRecord) {
|
||||
if err := validateCommandName(record.name); err != nil {
|
||||
record.issues = append(record.issues, err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(record.children))
|
||||
for _, child := range record.children {
|
||||
if child.record == nil {
|
||||
record.issues = append(record.issues, fmt.Errorf("command %q has an invalid child declaration", record.name))
|
||||
continue
|
||||
}
|
||||
if child.record.absent {
|
||||
continue
|
||||
}
|
||||
name := child.record.name
|
||||
if _, exists := seen[name]; exists {
|
||||
record.issues = append(record.issues, fmt.Errorf("duplicate child command %q under %q", name, record.name))
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func validateCommandDeclaration(command Command) error {
|
||||
return validateCommandPlacement(command)
|
||||
}
|
||||
|
||||
func validateCommandPlacement(command Command) error {
|
||||
var issues []error
|
||||
collectDeclarationIssues(command, nil, true, &issues)
|
||||
return errors.Join(issues...)
|
||||
}
|
||||
|
||||
func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
|
||||
if command.record == nil {
|
||||
*issues = append(*issues, errors.New("invalid zero command declaration"))
|
||||
return
|
||||
}
|
||||
record := command.record
|
||||
path := append(append([]string(nil), parents...), record.name)
|
||||
label := strings.Join(path, " ")
|
||||
for _, issue := range record.issues {
|
||||
if label != "" {
|
||||
*issues = append(*issues, fmt.Errorf("command %q: %w", label, issue))
|
||||
} else {
|
||||
*issues = append(*issues, issue)
|
||||
}
|
||||
}
|
||||
if record.rootOnly && !root {
|
||||
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
|
||||
}
|
||||
if record.invalid || record.absent {
|
||||
return
|
||||
}
|
||||
for _, child := range record.children {
|
||||
if child.record == nil {
|
||||
continue
|
||||
}
|
||||
collectDeclarationIssues(child, path, false, issues)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCommandName(name string) error {
|
||||
@@ -295,7 +354,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
}
|
||||
shortNames[binding.meta.Short] = struct{}{}
|
||||
}
|
||||
if !binding.valid || binding.prepare == nil {
|
||||
if binding.identity == nil || !binding.valid || binding.prepare == nil {
|
||||
return fmt.Errorf("flag %q is missing parser or setter wiring", binding.meta.Name)
|
||||
}
|
||||
}
|
||||
@@ -308,7 +367,7 @@ func validateBindings[I any](flags []Flag[I], arguments []Argument[I]) error {
|
||||
if binding.min < 0 || binding.max < -1 || (binding.max >= 0 && binding.min > binding.max) {
|
||||
return fmt.Errorf("argument %q has invalid bounds %d..%d", binding.meta.Name, binding.min, binding.max)
|
||||
}
|
||||
if !binding.valid || binding.prepare == nil {
|
||||
if binding.identity == nil || !binding.valid || binding.prepare == nil {
|
||||
return fmt.Errorf("argument %q is missing parser or setter wiring", binding.meta.Name)
|
||||
}
|
||||
}
|
||||
|
||||
461
config.go
461
config.go
@@ -1,461 +0,0 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// Selection identifies the application configuration inputs supplied to a
|
||||
// configuration scope.
|
||||
type Selection struct {
|
||||
Config Supplied[string]
|
||||
Overlay Supplied[string]
|
||||
}
|
||||
|
||||
// ConfigOperation identifies a supported configuration inspection operation.
|
||||
type ConfigOperation string
|
||||
|
||||
const (
|
||||
ConfigValidate ConfigOperation = "validate"
|
||||
ConfigShow ConfigOperation = "show"
|
||||
ConfigSources ConfigOperation = "sources"
|
||||
ConfigExplain ConfigOperation = "explain"
|
||||
)
|
||||
|
||||
// ConfigRequest describes one configuration operation and its selected inputs.
|
||||
type ConfigRequest struct {
|
||||
Operation ConfigOperation
|
||||
Selection Selection
|
||||
}
|
||||
|
||||
// EffectiveWriter writes an owning library's native effective configuration.
|
||||
type EffectiveWriter interface {
|
||||
WriteEffective(io.Writer) error
|
||||
}
|
||||
|
||||
// SourceReader reports the sources used to construct a configuration.
|
||||
type SourceReader interface {
|
||||
Sources() []configkit.SourceInfo
|
||||
}
|
||||
|
||||
// ExplanationReader writes an owning library's path explanation.
|
||||
type ExplanationReader interface {
|
||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||
WriteExplanation(io.Writer, configkit.Path) error
|
||||
}
|
||||
|
||||
// ConfigView contains optional views relevant to supported operations.
|
||||
type ConfigView struct {
|
||||
Effective EffectiveWriter
|
||||
Sources SourceReader
|
||||
Explanation ExplanationReader
|
||||
}
|
||||
|
||||
// Acquired contains a configuration view and diagnostics from its acquisition.
|
||||
type Acquired[T any] struct {
|
||||
Value T
|
||||
Diagnostics []Diagnostic
|
||||
}
|
||||
|
||||
// ConfigScope declares one named configuration and its supported operations.
|
||||
type ConfigScope[C any] struct {
|
||||
Name string
|
||||
Summary string
|
||||
Operations []ConfigOperation
|
||||
Acquire func(context.Context, ConfigRequest, C) (Acquired[ConfigView], error)
|
||||
}
|
||||
|
||||
// Selector binds one typed command flag to selected configuration scopes.
|
||||
// A required binding is required only when a selected scope uses it.
|
||||
type Selector[C any] struct {
|
||||
Binding Flag[C]
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// ConfigSuite declares selectors and configuration scopes for an App.
|
||||
type ConfigSuite[C any] struct {
|
||||
NewContext func() C
|
||||
Selectors []Selector[C]
|
||||
CheckInput func(Selection, C) error
|
||||
Scopes []ConfigScope[C]
|
||||
}
|
||||
|
||||
type configScopeInfo struct {
|
||||
Name string `yaml:"name"`
|
||||
Operations []ConfigOperation `yaml:"operations"`
|
||||
}
|
||||
|
||||
type scopesInventory struct {
|
||||
Scopes []configScopeInfo `yaml:"scopes"`
|
||||
}
|
||||
|
||||
type preparedConfigSuite[C any] struct {
|
||||
suite ConfigSuite[C]
|
||||
ordered []ConfigScope[C]
|
||||
byName map[string]ConfigScope[C]
|
||||
installed []ConfigOperation
|
||||
inventory scopesInventory
|
||||
}
|
||||
|
||||
type configScopesInput struct {
|
||||
Format string
|
||||
}
|
||||
|
||||
type selectorState[C any] struct {
|
||||
name string
|
||||
required bool
|
||||
scopes []string
|
||||
supplied bool
|
||||
apply func(*C)
|
||||
clear func(*C)
|
||||
}
|
||||
|
||||
type configInput[C any] struct {
|
||||
Selection Selection
|
||||
Scope string
|
||||
Format string
|
||||
Path Supplied[string]
|
||||
SelectorContext C
|
||||
selectors []selectorState[C]
|
||||
}
|
||||
|
||||
// RegisterConfig installs the available config handlers and the scope discovery
|
||||
// command. Its registration and discovery paths do not invoke scope loaders.
|
||||
func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("cannot register configuration on a nil app")
|
||||
}
|
||||
if app.configRegistered {
|
||||
return fmt.Errorf("configuration suite is already registered")
|
||||
}
|
||||
normalized, err := normalizeConfigSuite(suite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := app.commands["config"]; exists {
|
||||
return fmt.Errorf("command path %q is already registered", "config")
|
||||
}
|
||||
prepared := prepareConfigSuite(normalized)
|
||||
|
||||
commands := make([]Command, 0, 5)
|
||||
for _, operation := range prepared.installed {
|
||||
var command Command
|
||||
switch operation {
|
||||
case ConfigValidate:
|
||||
command, err = newConfigValidateCommand(prepared)
|
||||
case ConfigShow:
|
||||
command, err = newConfigShowCommand(prepared)
|
||||
case ConfigSources:
|
||||
command, err = newConfigSourcesCommand(prepared)
|
||||
case ConfigExplain:
|
||||
command, err = newConfigExplainCommand(prepared)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commands = append(commands, command)
|
||||
}
|
||||
command, err := newConfigScopesCommandWithPrepared(prepared)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commands = append(commands, command)
|
||||
root, err := Group("config", "Inspect configuration.", commands...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Register(root); err != nil {
|
||||
return err
|
||||
}
|
||||
app.configRegistered = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewConfigKitScope adapts a ConfigKit loader into a scope with validation,
|
||||
// effective display, source listing, and path explanation capabilities. The
|
||||
// loader runs once per selected operation. Successful loads expose the result's
|
||||
// public facets directly; ConfigKit LoadError records become CommandKit
|
||||
// diagnostics while the original error is returned.
|
||||
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
|
||||
if load == nil {
|
||||
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")
|
||||
}
|
||||
return ConfigScope[C]{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
|
||||
Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) {
|
||||
result, err := load(ctx, request.Selection, contextValue)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||
}
|
||||
return Acquired[ConfigView]{}, err
|
||||
}
|
||||
if result == nil {
|
||||
return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
return Acquired[ConfigView]{
|
||||
Value: ConfigView{Effective: result, Sources: result, Explanation: result},
|
||||
Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()),
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend.
|
||||
func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
severity := configkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = configkit.SeverityError
|
||||
break
|
||||
}
|
||||
}
|
||||
return []Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||
if err := writeDiagnosticText(writer, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||
written, err := io.WriteString(writer, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
|
||||
normalized := suite
|
||||
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
|
||||
normalized.Scopes = make([]ConfigScope[C], 0, len(suite.Scopes))
|
||||
scopeNames := make(map[string]struct{}, len(suite.Scopes))
|
||||
for _, scope := range suite.Scopes {
|
||||
if scope.Name == "" {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name must not be empty")
|
||||
}
|
||||
if scope.Name == "all" {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration scope name %q is reserved", scope.Name)
|
||||
}
|
||||
if _, exists := scopeNames[scope.Name]; exists {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration scope %q", scope.Name)
|
||||
}
|
||||
scopeNames[scope.Name] = struct{}{}
|
||||
operations, err := normalizeConfigOperations(scope.Operations)
|
||||
if err != nil {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q: %w", scope.Name, err)
|
||||
}
|
||||
if len(operations) > 0 && scope.Acquire == nil {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)
|
||||
}
|
||||
scope.Operations = operations
|
||||
normalized.Scopes = append(normalized.Scopes, scope)
|
||||
}
|
||||
seenNames := make(map[string]struct{}, len(normalized.Selectors))
|
||||
seenShorts := make(map[string]struct{}, len(normalized.Selectors))
|
||||
for index := range normalized.Selectors {
|
||||
selector := &normalized.Selectors[index]
|
||||
binding := selector.Binding.binding
|
||||
if !binding.valid || binding.clear == nil || binding.meta.Name == "" {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration selector %d has an invalid flag binding", index)
|
||||
}
|
||||
if !validFlagName(binding.meta.Name) {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag name %q is invalid", binding.meta.Name)
|
||||
}
|
||||
if binding.meta.Name == "config" || binding.meta.Name == "overlay" || binding.meta.Name == "scope" || binding.meta.Name == "format" || binding.meta.Name == "help" {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration selector flag --%s conflicts with a standard flag", binding.meta.Name)
|
||||
}
|
||||
if binding.meta.Short == "h" {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short flag -%s conflicts with help", binding.meta.Short)
|
||||
}
|
||||
if binding.meta.Short != "" && (len(binding.meta.Short) != 1 || binding.meta.Short == "-") {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("configuration selector short spelling %q must be one byte", binding.meta.Short)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(selector.Scopes))
|
||||
unique := make([]string, 0, len(selector.Scopes))
|
||||
for _, scopeName := range selector.Scopes {
|
||||
if _, exists := scopeNames[scopeName]; !exists {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("selector --%s references unknown configuration scope %q", binding.meta.Name, scopeName)
|
||||
}
|
||||
if _, exists := seen[scopeName]; exists {
|
||||
continue
|
||||
}
|
||||
seen[scopeName] = struct{}{}
|
||||
unique = append(unique, scopeName)
|
||||
}
|
||||
selector.Scopes = append([]string(nil), unique...)
|
||||
if _, exists := seenNames[binding.meta.Name]; exists {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
|
||||
}
|
||||
if binding.meta.Short != "" {
|
||||
if _, exists := seenShorts[binding.meta.Short]; exists {
|
||||
return ConfigSuite[C]{}, fmt.Errorf("duplicate configuration selector flag --%s", binding.meta.Name)
|
||||
}
|
||||
seenShorts[binding.meta.Short] = struct{}{}
|
||||
}
|
||||
seenNames[binding.meta.Name] = struct{}{}
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func prepareConfigSuite[C any](suite ConfigSuite[C]) *preparedConfigSuite[C] {
|
||||
prepared := &preparedConfigSuite[C]{
|
||||
suite: suite,
|
||||
ordered: orderedConfigScopes(suite.Scopes),
|
||||
byName: make(map[string]ConfigScope[C], len(suite.Scopes)),
|
||||
installed: make([]ConfigOperation, 0, 4),
|
||||
inventory: scopesInventory{Scopes: make([]configScopeInfo, 0, len(suite.Scopes))},
|
||||
}
|
||||
for _, scope := range suite.Scopes {
|
||||
prepared.byName[scope.Name] = scope
|
||||
}
|
||||
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
|
||||
if configOperationDeclared(suite.Scopes, operation) {
|
||||
prepared.installed = append(prepared.installed, operation)
|
||||
}
|
||||
}
|
||||
for _, scope := range prepared.ordered {
|
||||
operations := make([]ConfigOperation, 0, len(scope.Operations))
|
||||
for _, operation := range scope.Operations {
|
||||
if containsOperation(prepared.installed, operation) {
|
||||
operations = append(operations, operation)
|
||||
}
|
||||
}
|
||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, configScopeInfo{Name: scope.Name, Operations: operations})
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func normalizeConfigOperations(operations []ConfigOperation) ([]ConfigOperation, error) {
|
||||
seen := make(map[ConfigOperation]struct{}, len(operations))
|
||||
for _, operation := range operations {
|
||||
switch operation {
|
||||
case ConfigValidate, ConfigShow, ConfigSources, ConfigExplain:
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown operation %q", operation)
|
||||
}
|
||||
seen[operation] = struct{}{}
|
||||
}
|
||||
ordered := make([]ConfigOperation, 0, len(seen))
|
||||
for _, operation := range []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain} {
|
||||
if _, exists := seen[operation]; exists {
|
||||
ordered = append(ordered, operation)
|
||||
}
|
||||
}
|
||||
return ordered, nil
|
||||
}
|
||||
|
||||
func newConfigScopesCommandWithPrepared[C any](prepared *preparedConfigSuite[C]) (Command, error) {
|
||||
formatDefault := "text"
|
||||
spec := CommandSpec[configScopesInput]{
|
||||
Name: "scopes",
|
||||
Summary: "List configuration scopes and supported operations.",
|
||||
Flags: []Flag[configScopesInput]{ValueFlag[configScopesInput](FlagMeta{
|
||||
Name: "format", Usage: "Output format: text or yaml.",
|
||||
}, &formatDefault, parseConfigFormat, func(input *configScopesInput, value Supplied[string]) {
|
||||
input.Format = value.Value
|
||||
})},
|
||||
CheckInput: func(input configScopesInput) error { return checkConfigFormat(input.Format) },
|
||||
Run: func(invocation Invocation, input configScopesInput) Outcome {
|
||||
var output []byte
|
||||
var err error
|
||||
if input.Format == "yaml" {
|
||||
output, err = yaml.Marshal(prepared.inventory)
|
||||
} else {
|
||||
output = []byte(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
if err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
if err := writeReport(invocation.Streams.Out, output); err != nil {
|
||||
return incompleteReport(err)
|
||||
}
|
||||
return Success()
|
||||
},
|
||||
}
|
||||
return NewCommand(spec)
|
||||
}
|
||||
|
||||
func orderedConfigScopes[C any](scopes []ConfigScope[C]) []ConfigScope[C] {
|
||||
ordered := append([]ConfigScope[C](nil), scopes...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return configScopeOrder(ordered[i].Name) < configScopeOrder(ordered[j].Name)
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func configScopeOrder(name string) string {
|
||||
switch name {
|
||||
case "app":
|
||||
return "\x00"
|
||||
case "promptkit":
|
||||
return "\x01"
|
||||
default:
|
||||
return "\x02" + name
|
||||
}
|
||||
}
|
||||
|
||||
func formatScopeInventory(inventory scopesInventory) string {
|
||||
var output strings.Builder
|
||||
for _, scope := range inventory.Scopes {
|
||||
output.WriteString(scope.Name)
|
||||
output.WriteString(":")
|
||||
if len(scope.Operations) > 0 {
|
||||
output.WriteString(" ")
|
||||
for index, operation := range scope.Operations {
|
||||
if index > 0 {
|
||||
output.WriteString(", ")
|
||||
}
|
||||
output.WriteString(string(operation))
|
||||
}
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func parseConfigFormat(value string) (string, error) {
|
||||
if err := checkConfigFormat(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func checkConfigFormat(value string) error {
|
||||
if value != "text" && value != "yaml" {
|
||||
return fmt.Errorf("format must be text or yaml")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
359
config_test.go
359
config_test.go
@@ -1,359 +0,0 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
|
||||
app, err := New(Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newContextCalls := 0
|
||||
acquireCalls := 0
|
||||
suite := ConfigSuite[struct{}]{
|
||||
NewContext: func() struct{} { newContextCalls++; return struct{}{} },
|
||||
Scopes: []ConfigScope[struct{}]{
|
||||
{Name: "zeta", Operations: []ConfigOperation{ConfigExplain, ConfigShow, ConfigExplain}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
|
||||
acquireCalls++
|
||||
return Acquired[ConfigView]{}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []ConfigOperation{ConfigSources}, Acquire: noopConfigAcquire[struct{}]},
|
||||
{Name: "app", Operations: []ConfigOperation{ConfigShow, ConfigValidate, ConfigShow}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
|
||||
acquireCalls++
|
||||
return Acquired[ConfigView]{}, nil
|
||||
}},
|
||||
{Name: "metadata", Summary: "No provider is required."},
|
||||
},
|
||||
}
|
||||
normalized, err := normalizeConfigSuite(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(normalized.Scopes[0].Operations, []ConfigOperation{ConfigShow, ConfigExplain}) || !reflect.DeepEqual(normalized.Scopes[2].Operations, []ConfigOperation{ConfigValidate, ConfigShow}) {
|
||||
t.Fatalf("normalized capabilities = %#v", normalized.Scopes)
|
||||
}
|
||||
if err := RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var textOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &textOutput})
|
||||
if outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("text inventory outcome = %#v", outcome)
|
||||
}
|
||||
wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show, explain\n"
|
||||
if textOutput.String() != wantText {
|
||||
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
|
||||
}
|
||||
|
||||
var yamlOutput bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "yaml"}, Streams{Out: &yamlOutput})
|
||||
if outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("YAML inventory outcome = %#v", outcome)
|
||||
}
|
||||
if got := yamlOutput.String(); !strings.Contains(got, "scopes:") || strings.Index(got, "name: app") > strings.Index(got, "name: promptkit") || !strings.Contains(got, "operations: []") {
|
||||
t.Fatalf("YAML inventory = %q", got)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--help"}, Streams{})
|
||||
if outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("scope help outcome = %#v", outcome)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "scopes", "--format", "json"}, Streams{})
|
||||
if outcome.Kind != OutcomeUsage {
|
||||
t.Fatalf("invalid format outcome = %#v", outcome)
|
||||
}
|
||||
if newContextCalls != 0 || acquireCalls != 0 {
|
||||
t.Fatalf("discovery called runtime callbacks: NewContext=%d Acquire=%d", newContextCalls, acquireCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInputDefaultsToAppAndRetainsAbsentSelections(t *testing.T) {
|
||||
suite := ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{{Name: "app"}}}
|
||||
prepared, err := prepareInput(CommandSpec[configInput[struct{}]]{Flags: configInputFlags(suite, "yaml")}, make([][]string, 4), nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := prepared.build()
|
||||
if input.Scope != "app" || input.Format != "yaml" || input.Selection.Config.Set || input.Selection.Overlay.Set {
|
||||
t.Fatalf("configuration defaults = %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterConfigOwnsPreparedScopeAndSelectorMetadata(t *testing.T) {
|
||||
type selectorInput struct{ Region Supplied[string] }
|
||||
var acquired selectorInput
|
||||
suite := ConfigSuite[selectorInput]{
|
||||
Selectors: []Selector[selectorInput]{{
|
||||
Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }),
|
||||
Scopes: []string{"app"},
|
||||
}},
|
||||
Scopes: []ConfigScope[selectorInput]{{
|
||||
Name: "app",
|
||||
Operations: []ConfigOperation{ConfigValidate},
|
||||
Acquire: func(_ context.Context, _ ConfigRequest, input selectorInput) (Acquired[ConfigView], error) {
|
||||
acquired = input
|
||||
return Acquired[ConfigView]{}, nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
app, err := New(Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
suite.Scopes[0].Name = "renamed"
|
||||
suite.Scopes[0].Operations[0] = ConfigShow
|
||||
suite.Selectors[0].Scopes[0] = "renamed"
|
||||
|
||||
var inventory bytes.Buffer
|
||||
if result := app.Execute(context.Background(), []string{"config", "scopes"}, Streams{Out: &inventory}); result.Kind != OutcomeSuccess {
|
||||
t.Fatalf("scope inventory outcome = %#v", result)
|
||||
}
|
||||
if got := inventory.String(); got != "app: validate\n" {
|
||||
t.Fatalf("scope inventory after caller mutation = %q", got)
|
||||
}
|
||||
if result := app.Execute(context.Background(), []string{"config", "validate", "--scope=app", "--region=west"}, Streams{}); result.Kind != OutcomeSuccess {
|
||||
t.Fatalf("registered scope execution after caller mutation = %#v", result)
|
||||
}
|
||||
if acquired.Region != (Supplied[string]{Value: "west", Set: true}) {
|
||||
t.Fatalf("acquired selector after caller mutation = %#v", acquired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterConfigRejectsDuplicateSelectorNamesAndShorts(t *testing.T) {
|
||||
type selectorInput struct{ First, Second string }
|
||||
tests := []struct {
|
||||
name string
|
||||
firstMeta FlagMeta
|
||||
secondMeta FlagMeta
|
||||
}{
|
||||
{name: "name", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "region", Short: "g"}},
|
||||
{name: "short", firstMeta: FlagMeta{Name: "region", Short: "r"}, secondMeta: FlagMeta{Name: "group", Short: "r"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
app, err := New(Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
suite := ConfigSuite[selectorInput]{Selectors: []Selector[selectorInput]{
|
||||
{Binding: ValueFlag[selectorInput](test.firstMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.First = value.Value })},
|
||||
{Binding: ValueFlag[selectorInput](test.secondMeta, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Second = value.Value })},
|
||||
}}
|
||||
if err := RegisterConfig(app, suite); err == nil || !strings.Contains(err.Error(), "duplicate configuration selector flag") {
|
||||
t.Fatalf("RegisterConfig error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterConfigRejectsInvalidSuitesAndCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scope ConfigScope[struct{}]
|
||||
want string
|
||||
}{
|
||||
{name: "empty name", scope: ConfigScope[struct{}]{}, want: "name must not be empty"},
|
||||
{name: "all name", scope: ConfigScope[struct{}]{Name: "all"}, want: "reserved"},
|
||||
{name: "unknown operation", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{"diff"}}, want: "unknown operation"},
|
||||
{name: "missing acquisition", scope: ConfigScope[struct{}]{Name: "app", Operations: []ConfigOperation{ConfigValidate}}, want: "without an acquisition"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
app, _ := New(Identity{Name: "example"})
|
||||
err := RegisterConfig(app, ConfigSuite[struct{}]{Scopes: []ConfigScope[struct{}]{test.scope}})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("RegisterConfig error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app, _ := New(Identity{Name: "example"})
|
||||
custom, err := NewCommand(CommandSpec[struct{}]{Name: "config", Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Register(custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RegisterConfig(app, ConfigSuite[struct{}]{}); err == nil {
|
||||
t.Fatal("replaced a pre-existing config command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSelectorPreflightAndScopedContextProjection(t *testing.T) {
|
||||
type selectorInput struct {
|
||||
Count Supplied[int]
|
||||
Enabled Supplied[bool]
|
||||
Other Supplied[int]
|
||||
Runtime string
|
||||
}
|
||||
baseContextCalls := 0
|
||||
checkCalls := 0
|
||||
var checked selectorInput
|
||||
countDefault := 9
|
||||
suite := ConfigSuite[selectorInput]{
|
||||
NewContext: func() selectorInput { baseContextCalls++; return selectorInput{Runtime: "invocation cache"} },
|
||||
Selectors: []Selector[selectorInput]{
|
||||
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "count"}, &countDefault, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Count = value }), Scopes: []string{"app"}},
|
||||
{Binding: BoolFlag[selectorInput](FlagMeta{Name: "enabled"}, true, func(input *selectorInput, value Supplied[bool]) { input.Enabled = value }), Scopes: []string{"promptkit"}},
|
||||
{Binding: ValueFlag[selectorInput](FlagMeta{Name: "other"}, nil, strconv.Atoi, func(input *selectorInput, value Supplied[int]) { input.Other = value })},
|
||||
},
|
||||
CheckInput: func(_ Selection, input selectorInput) error { checkCalls++; checked = input; return nil },
|
||||
Scopes: []ConfigScope[selectorInput]{
|
||||
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||
{Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||
},
|
||||
}
|
||||
normalized, err := normalizeConfigSuite(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preparedSuite := prepareConfigSuite(normalized)
|
||||
spec := CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}
|
||||
prepared, err := prepareInput(spec, [][]string{{"all"}, {"text"}, {""}, nil, {"0"}, {"false"}, nil}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := prepared.build()
|
||||
scopeNames, err := preflightConfig(preparedSuite, input, ConfigValidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(scopeNames, []string{"app", "promptkit"}) || checkCalls != 1 || checked.Count != (Supplied[int]{Value: 0, Set: true}) || checked.Enabled != (Supplied[bool]{Value: false, Set: true}) {
|
||||
t.Fatalf("preflight result=%v checked=%#v calls=%d", scopeNames, checked, checkCalls)
|
||||
}
|
||||
if !input.Selection.Config.Set || input.Selection.Config.Value != "" || input.Selection.Overlay.Set {
|
||||
t.Fatalf("configuration selection presence = %#v", input.Selection)
|
||||
}
|
||||
if baseContextCalls != 0 {
|
||||
t.Fatalf("NewContext called during preflight %d times", baseContextCalls)
|
||||
}
|
||||
|
||||
contexts := contextsForScopes(preparedSuite, input, scopeNames)
|
||||
if baseContextCalls != 1 {
|
||||
t.Fatalf("NewContext calls = %d, want one", baseContextCalls)
|
||||
}
|
||||
if contexts["app"].Count != (Supplied[int]{Value: 0, Set: true}) || contexts["app"].Enabled != (Supplied[bool]{}) || contexts["app"].Other != (Supplied[int]{}) {
|
||||
t.Fatalf("app selectors = %#v", contexts["app"])
|
||||
}
|
||||
if contexts["promptkit"].Count != (Supplied[int]{}) || contexts["promptkit"].Enabled != (Supplied[bool]{Value: false, Set: true}) || contexts["promptkit"].Runtime != "invocation cache" {
|
||||
t.Fatalf("promptkit selectors = %#v", contexts["promptkit"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPreflightRejectsUnsupportedAggregateAndInapplicableSelector(t *testing.T) {
|
||||
type selectorInput struct{ Region Supplied[string] }
|
||||
newContextCalls := 0
|
||||
suite := ConfigSuite[selectorInput]{
|
||||
NewContext: func() selectorInput { newContextCalls++; return selectorInput{} },
|
||||
Selectors: []Selector[selectorInput]{{Binding: ValueFlag[selectorInput](FlagMeta{Name: "region"}, nil, parseString, func(input *selectorInput, value Supplied[string]) { input.Region = value }), Scopes: []string{"promptkit"}}},
|
||||
Scopes: []ConfigScope[selectorInput]{
|
||||
{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: noopConfigAcquire[selectorInput]},
|
||||
{Name: "promptkit", Operations: []ConfigOperation{ConfigShow}, Acquire: noopConfigAcquire[selectorInput]},
|
||||
},
|
||||
}
|
||||
normalized, err := normalizeConfigSuite(suite)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preparedSuite := prepareConfigSuite(normalized)
|
||||
prepared, err := prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"all"}, {"text"}, nil, nil, nil}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "does not support validate") {
|
||||
t.Fatalf("unsupported aggregate error = %v", err)
|
||||
}
|
||||
if newContextCalls != 0 {
|
||||
t.Fatalf("unsupported aggregate created context %d times", newContextCalls)
|
||||
}
|
||||
|
||||
prepared, err = prepareInput(CommandSpec[configInput[selectorInput]]{Flags: configInputFlags(normalized, "text")}, [][]string{{"app"}, {"text"}, nil, nil, {"west"}}, nil, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := preflightConfig(preparedSuite, prepared.build(), ConfigValidate); err == nil || !strings.Contains(err.Error(), "not applicable") {
|
||||
t.Fatalf("inapplicable selector error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func noopConfigAcquire[C any](context.Context, ConfigRequest, C) (Acquired[ConfigView], error) {
|
||||
return Acquired[ConfigView]{}, nil
|
||||
}
|
||||
|
||||
func TestRequiredConfigSelectorOnlyAppliesToSelectedScopes(t *testing.T) {
|
||||
app, _ := New(Identity{Name: "example"})
|
||||
calls := 0
|
||||
acquire := func(context.Context, ConfigRequest, string) (Acquired[ConfigView], error) {
|
||||
calls++
|
||||
return Acquired[ConfigView]{}, nil
|
||||
}
|
||||
err := RegisterConfig(app, ConfigSuite[string]{
|
||||
Selectors: []Selector[string]{{Scopes: []string{"app"}, Binding: ValueFlag[string](FlagMeta{Name: "campaign", Required: true}, nil, parseString, func(c *string, v Supplied[string]) { *c = v.Value })}},
|
||||
Scopes: []ConfigScope[string]{{Name: "app", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}, {Name: "promptkit", Operations: []ConfigOperation{ConfigValidate}, Acquire: acquire}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
kind OutcomeKind
|
||||
calls int
|
||||
}{
|
||||
{[]string{"--scope", "promptkit"}, OutcomeSuccess, 1},
|
||||
{nil, OutcomeUsage, 0},
|
||||
{[]string{"--scope", "all"}, OutcomeUsage, 0},
|
||||
{[]string{"--scope", "all", "--campaign", "spring"}, OutcomeSuccess, 2},
|
||||
{[]string{"--scope", "promptkit", "--campaign", "spring"}, OutcomeUsage, 0},
|
||||
{[]string{"--help"}, OutcomeSuccess, 0},
|
||||
} {
|
||||
calls = 0
|
||||
var out bytes.Buffer
|
||||
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.args...), Streams{Out: &out})
|
||||
if result.Kind != tc.kind || calls != tc.calls {
|
||||
t.Fatalf("%v: result=%#v calls=%d", tc.args, result, calls)
|
||||
}
|
||||
if len(tc.args) > 0 && tc.args[0] == "--help" && !strings.Contains(out.String(), "required where applicable") {
|
||||
t.Fatalf("help=%q", out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownConfigScopeReportsChoicesWithoutRuntimeWork(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
names []string
|
||||
scope []string
|
||||
choices string
|
||||
}{
|
||||
{[]string{"promptkit"}, nil, "available: promptkit"},
|
||||
{[]string{"zeta", "alpha", "promptkit", "app"}, []string{"--scope", "unknown"}, "available: app, promptkit, alpha, zeta"},
|
||||
} {
|
||||
runtimeCalls := 0
|
||||
suite := ConfigSuite[struct{}]{NewContext: func() struct{} { runtimeCalls++; return struct{}{} }}
|
||||
for _, name := range tc.names {
|
||||
suite.Scopes = append(suite.Scopes, ConfigScope[struct{}]{Name: name, Operations: []ConfigOperation{ConfigValidate}, Acquire: func(context.Context, ConfigRequest, struct{}) (Acquired[ConfigView], error) {
|
||||
runtimeCalls++
|
||||
return Acquired[ConfigView]{}, nil
|
||||
}})
|
||||
}
|
||||
app, _ := New(Identity{Name: "example"})
|
||||
if err := RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, diagnostics bytes.Buffer
|
||||
result := app.Execute(context.Background(), append([]string{"config", "validate"}, tc.scope...), Streams{Out: &out, Err: &diagnostics})
|
||||
if result.Kind != OutcomeUsage || runtimeCalls != 0 || out.Len() != 0 || !strings.Contains(diagnostics.String(), tc.choices) {
|
||||
t.Fatalf("result=%#v calls=%d out=%q diagnostics=%q", result, runtimeCalls, out.String(), diagnostics.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,720 +0,0 @@
|
||||
package commandkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type effectiveYAML string
|
||||
|
||||
func (document effectiveYAML) WriteEffective(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, string(document))
|
||||
return err
|
||||
}
|
||||
|
||||
type capturedSourceReader struct {
|
||||
entries []configkit.SourceInfo
|
||||
calls int
|
||||
}
|
||||
|
||||
func TestConfigExplainPreservesConfigKitProvenanceAndRedaction(t *testing.T) {
|
||||
type entry struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
Odd map[string]string `yaml:"odd"`
|
||||
Value string `yaml:"value"`
|
||||
Dependency string `yaml:"dependency"`
|
||||
Secret configkit.Secret `yaml:"secret"`
|
||||
}
|
||||
const secretValue = "EXPLAIN_TEST_SECRET_VALUE"
|
||||
t.Setenv("EXPLAIN_TEST_SECRET", secretValue)
|
||||
input := []byte("entries:\n parent:\n enabled: false\n credential: EXPLAIN_TEST_SECRET\n child:\n extends: parent\nodd:\n \"key/name~\": escaped\nvalue: original\ndependency: source\nsecret: EXPLAIN_TEST_SECRET\n")
|
||||
result, err := configkit.Load[configuration](configkit.Bytes("explanation.yml", input), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
||||
resolution.Record(configkit.Keys("value"), configkit.ChangeNormalization, "upper case", configkit.Keys("dependency"))
|
||||
value.Value = strings.ToUpper(value.Value)
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loads, contexts := 0, 0
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
loads++
|
||||
return result, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{NewContext: func() struct{} { contexts++; return struct{}{} }, Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runYAML := func(pointer string) (map[string]any, commandkit.Outcome, string) {
|
||||
var output bytes.Buffer
|
||||
argv := []string{"config", "explain", "--path", pointer, "--format", "yaml"}
|
||||
if pointer == "" {
|
||||
argv = []string{"config", "explain", "--path=", "--format", "yaml"}
|
||||
}
|
||||
outcome := app.Execute(context.Background(), argv, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
var report map[string]any
|
||||
if outcome.Kind == commandkit.OutcomeSuccess {
|
||||
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
|
||||
t.Fatalf("decode explanation YAML: %v; output=%q", err, output.String())
|
||||
}
|
||||
}
|
||||
return report, outcome, output.String()
|
||||
}
|
||||
|
||||
path := "/entries/child/enabled"
|
||||
var native bytes.Buffer
|
||||
if err := result.WriteExplanation(&native, configkit.Keys("entries", "child", "enabled")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var textOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || textOutput.String() != native.String() {
|
||||
t.Fatalf("native text outcome=%#v output=%q want=%q", outcome, textOutput.String(), native.String())
|
||||
}
|
||||
var inherited map[string]any
|
||||
inherited, outcome, _ = runYAML(path)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || inherited["path"] != path {
|
||||
t.Fatalf("inherited explanation outcome=%#v report=%#v", outcome, inherited)
|
||||
}
|
||||
effective := inherited["effective"].(map[string]any)
|
||||
if effective["present"] != true || effective["yaml"] != "false\n" {
|
||||
t.Fatalf("inherited effective snapshot = %#v", effective)
|
||||
}
|
||||
origin := inherited["origin"].(map[string]any)
|
||||
steps := origin["inheritance"].([]any)
|
||||
step := steps[0].(map[string]any)
|
||||
if step["from"] != "/entries/parent/enabled" || step["to"] != path || step["reference"] != "/entries/child/extends" {
|
||||
t.Fatalf("inheritance paths = %#v", step)
|
||||
}
|
||||
|
||||
escaped, outcome, _ := runYAML("/odd/key~1name~0")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || escaped["path"] != "/odd/key~1name~0" || escaped["effective"].(map[string]any)["yaml"] != "escaped\n" {
|
||||
t.Fatalf("escaped path outcome=%#v report=%#v", outcome, escaped)
|
||||
}
|
||||
historical, outcome, _ := runYAML("/entries/child/extends")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || historical["effective"].(map[string]any)["present"] != false || len(historical["history"].([]any)) == 0 {
|
||||
t.Fatalf("historical path outcome=%#v report=%#v", outcome, historical)
|
||||
}
|
||||
resolved, outcome, _ := runYAML("/value")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || resolved["effective"].(map[string]any)["yaml"] != "ORIGINAL\n" {
|
||||
t.Fatalf("resolved snapshot outcome=%#v report=%#v", outcome, resolved)
|
||||
}
|
||||
changes := resolved["history"].([]any)
|
||||
last := changes[len(changes)-1].(map[string]any)
|
||||
if last["reasons"].([]any)[0] != "upper case" || last["dependencies"].([]any)[0] != "/dependency" {
|
||||
t.Fatalf("resolution change = %#v", last)
|
||||
}
|
||||
secretReport, outcome, secretText := runYAML("/entries/child/credential")
|
||||
secretEffective := secretReport["effective"].(map[string]any)
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || secretEffective["redacted"] != true || strings.Contains(secretText, secretValue) {
|
||||
t.Fatalf("secret report outcome=%#v report=%#v", outcome, secretReport)
|
||||
}
|
||||
for _, change := range secretReport["history"].([]any) {
|
||||
entry := change.(map[string]any)
|
||||
for _, key := range []string{"before", "after"} {
|
||||
snapshot := entry[key].(map[string]any)
|
||||
if snapshot["present"] == true && snapshot["redacted"] != true {
|
||||
t.Fatalf("secret history was not redacted: %#v", entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
directSecret, outcome, directSecretText := runYAML("/secret")
|
||||
secretRef, refPresent := directSecret["secret_reference"].(map[string]any)
|
||||
if !refPresent {
|
||||
t.Fatalf("secret reference was absent: %#v", directSecret)
|
||||
}
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || secretRef["name"] != "EXPLAIN_TEST_SECRET" || strings.Contains(directSecretText, secretValue) {
|
||||
t.Fatalf("direct secret reference outcome=%#v report=%#v", outcome, directSecret)
|
||||
}
|
||||
root, outcome, _ := runYAML("")
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || root["path"] != "" {
|
||||
t.Fatalf("root path outcome=%#v report=%#v", outcome, root)
|
||||
}
|
||||
|
||||
loadsBeforeInvalid, contextsBeforeInvalid := loads, contexts
|
||||
outcome = app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
|
||||
t.Fatalf("missing path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2path"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || outcome.Status != 2 || loads != loadsBeforeInvalid || contexts != contextsBeforeInvalid {
|
||||
t.Fatalf("malformed path outcome=%#v loads=%d contexts=%d", outcome, loads, contexts)
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/does-not-exist"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Status != 1 {
|
||||
t.Fatalf("unknown path outcome = %#v", outcome)
|
||||
}
|
||||
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", path}, commandkit.Streams{Out: partial, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
|
||||
t.Fatalf("explanation writer failure = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *capturedSourceReader) Sources() []configkit.SourceInfo {
|
||||
reader.calls++
|
||||
return reader.entries
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsCapturedConfigKitSourceInventory(t *testing.T) {
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
Shared string `yaml:"shared"`
|
||||
Synthetic string `yaml:"synthetic"`
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
|
||||
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nvalue: base\n")},
|
||||
"root/shared.yml": &fstest.MapFile{Data: []byte("shared: imported\n")},
|
||||
"root/overlays/dev.yml": &fstest.MapFile{Data: []byte("value: overlay\n")},
|
||||
})
|
||||
filesystemContext := &configkit.FilesystemContext{Root: "root", FS: &filesystem}
|
||||
overlayFilesystemContext := &configkit.FilesystemContext{Root: "root", BaseDir: "root/config", FS: &filesystem}
|
||||
loaderCalls := 0
|
||||
reader := &capturedSourceReader{}
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
loaderCalls++
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: filesystem.File("root/config/main.yml"),
|
||||
Imports: true,
|
||||
Filesystem: filesystemContext,
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: configkit.Definition[configuration]{},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("synthetic source", map[string]any{"synthetic": "underlay"})}}}},
|
||||
Overlay: "dev",
|
||||
OverlayFilesystem: overlayFilesystemContext,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader.entries = result.Sources()
|
||||
return result, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Replace the result's reader after the owning loader has captured its source inventory.
|
||||
originalAcquire := scope.Acquire
|
||||
scope.Acquire = func(ctx context.Context, request commandkit.ConfigRequest, input struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
acquired, err := originalAcquire(ctx, request, input)
|
||||
if err == nil {
|
||||
acquired.Value.Sources = reader
|
||||
}
|
||||
return acquired, err
|
||||
}
|
||||
// Register the wrapped scope so the command reads the captured public facet once.
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || loaderCalls != 1 || reader.calls != 1 {
|
||||
var details bytes.Buffer
|
||||
for _, diagnostic := range outcome.Diagnostics {
|
||||
_ = diagnostic.Write(&details)
|
||||
}
|
||||
t.Fatalf("sources outcome=%#v cause=%v details=%q loader calls=%d reader calls=%d", outcome, outcome.Cause, details.String(), loaderCalls, reader.calls)
|
||||
}
|
||||
var report struct {
|
||||
Scope string `yaml:"scope"`
|
||||
Sources []struct {
|
||||
ID string `yaml:"id"`
|
||||
Label string `yaml:"label"`
|
||||
Path string `yaml:"path"`
|
||||
Layer string `yaml:"layer"`
|
||||
Kind string `yaml:"kind"`
|
||||
UnderlayIndex int `yaml:"underlay_index"`
|
||||
ImportedFrom *struct {
|
||||
SourceID string `yaml:"source_id"`
|
||||
Line int `yaml:"line"`
|
||||
Column int `yaml:"column"`
|
||||
} `yaml:"imported_from"`
|
||||
} `yaml:"sources"`
|
||||
}
|
||||
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Scope != "app" || len(report.Sources) != 4 {
|
||||
t.Fatalf("source report = %#v", report)
|
||||
}
|
||||
if report.Sources[0].Path != "root/config/main.yml" || report.Sources[0].ID != "source-1" {
|
||||
t.Fatalf("main source = %#v", report.Sources[0])
|
||||
}
|
||||
if report.Sources[0].ImportedFrom != nil {
|
||||
t.Fatalf("root source imported_from = %#v, want null", report.Sources[0].ImportedFrom)
|
||||
}
|
||||
imported := report.Sources[1]
|
||||
if imported.Path != "root/shared.yml" || imported.ImportedFrom == nil || imported.ImportedFrom.SourceID != report.Sources[0].ID || imported.ImportedFrom.Line == 0 {
|
||||
t.Fatalf("import source correlation = %#v", imported)
|
||||
}
|
||||
synthetic := report.Sources[2]
|
||||
if synthetic.Kind != string(configkit.SourceKindMapping) || synthetic.Path != "" || synthetic.Layer != string(configkit.LayerUnderlay) || synthetic.UnderlayIndex != 1 {
|
||||
t.Fatalf("synthetic underlay source = %#v", synthetic)
|
||||
}
|
||||
if report.Sources[3].Layer != string(configkit.LayerOverlay) || report.Sources[3].Path != "root/overlays/dev.yml" {
|
||||
t.Fatalf("overlay source = %#v", report.Sources[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesHandlesEmptyInventoryAllPreflightAndWriterFailure(t *testing.T) {
|
||||
reader := &capturedSourceReader{}
|
||||
newContextCalls, acquireCalls := 0, 0
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
suite := commandkit.ConfigSuite[string]{
|
||||
NewContext: func() string { newContextCalls++; return "context" },
|
||||
Scopes: []commandkit.ConfigScope[string]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigSources}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Sources: reader}}, nil
|
||||
}}},
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || newContextCalls != 0 || acquireCalls != 0 || output.Len() != 0 {
|
||||
t.Fatalf("all preflight outcome=%#v contexts=%d acquires=%d output=%q", outcome, newContextCalls, acquireCalls, output.String())
|
||||
}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "scope: app\nsources: []\n" || reader.calls != 1 {
|
||||
t.Fatalf("empty source report outcome=%#v output=%q reader calls=%d", outcome, output.String(), reader.calls)
|
||||
}
|
||||
reader.entries = []configkit.SourceInfo{{ID: "source-1", Label: "label with \"quotes\"", Path: "path with spaces.yml", Layer: configkit.LayerBase, Kind: configkit.SourceKindFile}}
|
||||
var textOutput bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(textOutput.String(), `label: "label with \"quotes\""`) || !strings.Contains(textOutput.String(), `path: "path with spaces.yml"`) {
|
||||
t.Fatalf("quoted text source output outcome=%#v output=%q", outcome, textOutput.String())
|
||||
}
|
||||
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: partial, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) {
|
||||
t.Fatalf("source writer failure = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) {
|
||||
const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n"
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []string
|
||||
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
|
||||
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "zeta")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("%YAML 1.1\n# zeta comment\nvalue: zeta\n")}}, nil
|
||||
}},
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "app")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML(native)}}, nil
|
||||
}},
|
||||
}}
|
||||
if err := commandkit.RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var single bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &single, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || single.String() != native {
|
||||
t.Fatalf("single show outcome=%#v output=%q", outcome, single.String())
|
||||
}
|
||||
calls = nil
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--format", "text"}, commandkit.Streams{Out: &single, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 {
|
||||
t.Fatalf("text format outcome=%#v acquisitions=%v", outcome, calls)
|
||||
}
|
||||
|
||||
calls = nil
|
||||
var aggregate bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &aggregate, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !reflect.DeepEqual(calls, []string{"app", "zeta"}) {
|
||||
t.Fatalf("aggregate outcome=%#v acquisitions=%v", outcome, calls)
|
||||
}
|
||||
if !strings.Contains(aggregate.String(), "%YAML 1.1\n# authored comment\n---\n# scope: app\n") || !strings.Contains(aggregate.String(), "...\n%YAML 1.1\n# zeta comment\n---\n# scope: zeta\nvalue: zeta\n") {
|
||||
t.Fatalf("aggregate framing = %q", aggregate.String())
|
||||
}
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(aggregate.Bytes()))
|
||||
var first, second map[string]any
|
||||
if err := decoder.Decode(&first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := decoder.Decode(&second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
t.Fatalf("third YAML document decode error = %v, value=%#v", err, extra)
|
||||
}
|
||||
if first["value"] != nil || first["items"] == nil || first["missing"] == nil || second["value"] != "zeta" {
|
||||
t.Fatalf("decoded documents = %#v and %#v", first, second)
|
||||
}
|
||||
if _, exists := first["absent"]; exists {
|
||||
t.Fatalf("absent field was added to native document: %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowAcquiresAllBeforeOutputAndTracksWriterFailures(t *testing.T) {
|
||||
failure := errors.New("second scope failed")
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
suite := commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(configkit.SeverityWarning, "provider warning")}}, failure
|
||||
}},
|
||||
}}
|
||||
if err := commandkit.RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, failure) || outcome.ReportIncomplete || output.Len() != 0 || calls != 2 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != configkit.SeverityWarning {
|
||||
t.Fatalf("acquisition failure outcome=%#v output=%q calls=%d", outcome, output.String(), calls)
|
||||
}
|
||||
|
||||
app, _ = commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigShow}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Effective: effectiveYAML("value: app\n")}}, nil
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe}
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: partial, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrClosedPipe) || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) || len(partial.written) == 0 {
|
||||
t.Fatalf("writer failure outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
type configKitShowPartialWriter struct {
|
||||
written []byte
|
||||
failure error
|
||||
}
|
||||
|
||||
func (writer *configKitShowPartialWriter) Write(data []byte) (int, error) {
|
||||
n := len(data)
|
||||
if n > 4 {
|
||||
n = 4
|
||||
}
|
||||
writer.written = append(writer.written, data[:n]...)
|
||||
return n, writer.failure
|
||||
}
|
||||
|
||||
func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) {
|
||||
type configuration struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
|
||||
"config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")},
|
||||
"config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")},
|
||||
})
|
||||
definition := configkit.Definition[configuration]{
|
||||
Defaults: func() configuration { return configuration{Defaulted: "from default"} },
|
||||
Resolve: func(input *configuration, resolution *configkit.Resolution) error {
|
||||
resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use")
|
||||
input.Mode = strings.ToUpper(input.Mode)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var received commandkit.Selection
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
|
||||
received = selection
|
||||
return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{
|
||||
Definition: definition,
|
||||
Overlay: selection.Overlay.Value,
|
||||
OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selection := commandkit.Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "dev", Set: true},
|
||||
}
|
||||
acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(received, selection) {
|
||||
t.Fatalf("loader selection = %#v, want %#v", received, selection)
|
||||
}
|
||||
if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil {
|
||||
t.Fatalf("ConfigKit result facets = %#v", acquired.Value)
|
||||
}
|
||||
if got := acquired.Value.Sources.Sources(); len(got) < 2 {
|
||||
t.Fatalf("sources = %#v, want base and overlay", got)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") {
|
||||
t.Fatalf("effective configuration = %q", effective.String())
|
||||
}
|
||||
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 {
|
||||
t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) {
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
var seen []commandkit.Selection
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
|
||||
seen = append(seen, selection)
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}}
|
||||
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []commandkit.Selection{{}, empty}
|
||||
if !reflect.DeepEqual(seen, want) {
|
||||
t.Fatalf("loader selections = %#v, want %#v", seen, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) {
|
||||
if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil {
|
||||
t.Fatal("accepted a nil ConfigKit loader")
|
||||
}
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") {
|
||||
t.Fatalf("nil result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) {
|
||||
type configuration struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
var loadError error
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
_, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}})
|
||||
return nil, loadError
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
|
||||
var publicLoadError *configkit.LoadError
|
||||
if acquireError != loadError || !errors.As(acquireError, &publicLoadError) {
|
||||
t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError {
|
||||
t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||
t.Fatalf("grouped diagnostics = %q", rendered.String())
|
||||
}
|
||||
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 {
|
||||
t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) {
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []string
|
||||
newContextCalls := 0
|
||||
failure := errors.New("promptkit could not load")
|
||||
suite := commandkit.ConfigSuite[string]{
|
||||
NewContext: func() string { newContextCalls++; return "runtime" },
|
||||
Scopes: []commandkit.ConfigScope[string]{
|
||||
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "zeta")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "promptkit")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, failure
|
||||
}},
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "app")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete {
|
||||
t.Fatalf("validation outcome = %#v", outcome)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 {
|
||||
t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls)
|
||||
}
|
||||
if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) {
|
||||
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) {
|
||||
type configuration struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Validate: func(_ *configuration, validation *configkit.Validation) {
|
||||
validation.Warning(configkit.Keys("name"), "uses a demonstration name")
|
||||
},
|
||||
}})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning validation outcome = %#v", outcome)
|
||||
}
|
||||
if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") {
|
||||
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
|
||||
}
|
||||
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
|
||||
t.Fatalf("report short write outcome = %#v", outcome)
|
||||
}
|
||||
var delivered bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}})
|
||||
if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
|
||||
t.Fatalf("diagnostic short write outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
type configKitPartialWriter struct{}
|
||||
|
||||
func (configKitPartialWriter) Write(data []byte) (int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(data) - 1, nil
|
||||
}
|
||||
|
||||
func TestValidationReportDeliveryErrorsAtEachWriteBoundary(t *testing.T) {
|
||||
reportErr := errors.New("report failed")
|
||||
diagnosticErr := errors.New("diagnostic failed")
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
acquire := func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
|
||||
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: acquire},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, format := range []string{"text", "yaml"} {
|
||||
writes := 2
|
||||
if format == "yaml" {
|
||||
writes = 3
|
||||
}
|
||||
for at := 1; at <= writes; at++ {
|
||||
for _, brokenErr := range []bool{false, true} {
|
||||
out := &validationBoundaryWriter{failAt: at, err: reportErr}
|
||||
errStream := io.Writer(io.Discard)
|
||||
if brokenErr {
|
||||
errStream = failingWriter{diagnosticErr}
|
||||
}
|
||||
result := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--format", format}, commandkit.Streams{Out: out, Err: errStream})
|
||||
if result.Kind != commandkit.OutcomeOperational || !result.ReportIncomplete || !errors.Is(result.Cause, reportErr) || !errors.Is(result.DeliveryError, reportErr) || (brokenErr && !errors.Is(result.DeliveryError, diagnosticErr)) {
|
||||
t.Fatalf("format=%s at=%d brokenErr=%t result=%#v", format, at, brokenErr, result)
|
||||
}
|
||||
if at > 1 && out.written.Len() == 0 {
|
||||
t.Fatal("lost completed prefix")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failedApp, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(failedApp, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, reportErr
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := failedApp.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{})
|
||||
if !errors.Is(result.Cause, reportErr) || result.DeliveryError != nil {
|
||||
t.Fatalf("acquisition failure=%#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
type validationBoundaryWriter struct {
|
||||
failAt, calls int
|
||||
err error
|
||||
written bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *validationBoundaryWriter) Write(p []byte) (int, error) {
|
||||
w.calls++
|
||||
if w.calls == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.written.Write(p)
|
||||
}
|
||||
6
doc.go
6
doc.go
@@ -1,3 +1,5 @@
|
||||
// Package commandkit provides an instance-owned CLI dispatcher with typed
|
||||
// command declarations, configuration scopes, and PromptKit inspection.
|
||||
// Package commandkit provides the core instance-owned CLI dispatcher, typed
|
||||
// command declarations, input bindings, and invocation outcomes. Optional
|
||||
// integration modules provide ConfigKit and PromptKit-specific declarations;
|
||||
// importing those modules does not install their commands.
|
||||
package commandkit
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
Accepted.
|
||||
|
||||
[ADR 0011](0011-captured-declarations-and-installation.md) refines
|
||||
declaration construction, installation, optional-family namespace reservation,
|
||||
and integration input/state lifetimes.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-06
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
Partially superseded by [ADR 0010](0010-optional-integration-modules.md).
|
||||
|
||||
ADR 0010 narrows the root facade and public-type ownership decision to the core
|
||||
module and places optional library dependencies in integration modules. The
|
||||
remaining principles continue to apply; this record's decision text is retained.
|
||||
|
||||
## Date
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ schemas for implemented operations.
|
||||
|
||||
CommandKit supplies shared handlers with predictable meanings and discovers their
|
||||
availability from registration. Each enabled operation needs its own data
|
||||
contract: comparison needs safe snapshots, catalogs need enumeration, and profile
|
||||
display needs safe execution-target inspection.
|
||||
contract: comparison needs supported native comparison results, catalogs need
|
||||
enumeration, and profile display needs safe execution-target inspection.
|
||||
|
||||
The [scope model](0009-configuration-scopes.md) and
|
||||
[comparison policy](0008-semantic-configuration-diff.md) describe related
|
||||
|
||||
@@ -55,7 +55,7 @@ A writer failure can leave a partial report and returns operational failure. The
|
||||
outcome identifies incomplete delivery. Custom handler output remains
|
||||
application-owned. [ADR 0007](0007-native-yaml-reports.md) covers `config show` report
|
||||
preparation; [ADR 0008](0008-semantic-configuration-diff.md) defines successful
|
||||
comparison, including differences and coverage gaps.
|
||||
comparison outcomes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -68,6 +68,6 @@ allows a failure in one scope to prevent combined display. Explicit single-scope
|
||||
display remains available for a narrower view.
|
||||
|
||||
Additional structured schemas are compatibility contracts owned by CommandKit.
|
||||
Native concatenation can use existing writers; semantic comparison separately
|
||||
requires the safe structured data described in
|
||||
Native concatenation can use existing writers; semantic comparison uses the owning
|
||||
library's supported comparison API under
|
||||
[ADR 0008](0008-semantic-configuration-diff.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR 0008: Compare semantic snapshots with explicit sensitivity gaps
|
||||
# ADR 0008: Delegate effective configuration comparison to owning libraries
|
||||
|
||||
## Status
|
||||
|
||||
@@ -6,72 +6,79 @@ Accepted.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-06
|
||||
2026-10-06; revised 2026-10-11.
|
||||
|
||||
## Context
|
||||
|
||||
Configuration comparison helps operators understand changes in final effective
|
||||
values across roots and overlays. Formatting, authored file layout, and provenance
|
||||
identifiers are different properties from effective-value equality. Sensitivity
|
||||
classification also limits the values available for inspection.
|
||||
|
||||
Meaningful comparison requires structured effective snapshots with per-path
|
||||
sensitivity data from the owning libraries. The comparison policy must preserve
|
||||
value semantics while making limited coverage visible to operators.
|
||||
ConfigKit provides a native configuration comparison capability. Exposing it
|
||||
through CommandKit gives applications a consistent operator interface and reduces
|
||||
repeated command, selection, and reporting infrastructure. ConfigKit remains the
|
||||
authority for the meaning and disclosure of its comparison results.
|
||||
|
||||
## Decision
|
||||
|
||||
For a scope declaring safe comparison support, `config diff` compares two
|
||||
independently resolved effective configurations. CommandKit checks that capability
|
||||
before acquisition. Providers supply captured results through the owning
|
||||
libraries' supported public inspection APIs.
|
||||
### Comparison ownership
|
||||
|
||||
Compare values using the owning library's safe structured inspection semantics,
|
||||
including defaults and recorded domain resolution. Preserve the distinctions
|
||||
among absent values, null, empty mappings, and empty sequences. Mappings are
|
||||
compared recursively; ordered sequences are compared as whole values.
|
||||
For ConfigKit-backed scopes declaring comparison support, `config diff` exposes
|
||||
ConfigKit's native comparison API. CommandKit checks capability availability before
|
||||
acquisition and coordinates selection, acquisition, adaptation, presentation,
|
||||
and invocation outcomes.
|
||||
|
||||
For each path, use the union of the two sides' sensitivity classifications to
|
||||
protect both before and after presentation. A value classified on either side
|
||||
receives the corresponding safe treatment in both snapshots. Hidden values
|
||||
produce explicit comparison gaps with their paths and supported reasons.
|
||||
Integrations delegate comparison to their owning libraries' supported public
|
||||
APIs. ConfigKit owns its comparison semantics and disclosure rules. CommandKit
|
||||
follows the public contract of the ConfigKit version it adopts. When an adopted
|
||||
upstream revision changes those semantics, the changes flow through to
|
||||
CommandKit's integration. Upstream remains authoritative as the capability evolves.
|
||||
|
||||
Equality conclusions cover the comparable data. A comparison with gaps reports
|
||||
its limited coverage alongside observed differences. A complete comparison with
|
||||
an empty difference set supports an equality conclusion for the inspected
|
||||
selection. Redaction masks are presentation data; classification metadata
|
||||
determines comparison coverage.
|
||||
Application-supplied providers retain ownership of their implementation and
|
||||
loading effects. [ADR 0012](0012-typed-configuration-comparison-pairs.md) governs
|
||||
paired selections, provider contracts, and typed input/shared-state lifetimes.
|
||||
|
||||
Text and YAML reports include differences and coverage gaps. The exact report
|
||||
schema belongs in the [CLI reference](../cli.md) when implemented. Selection
|
||||
syntax, inheritance rules, and detailed change records are outside the scope
|
||||
of this decision.
|
||||
### Safe disclosure and presentation
|
||||
|
||||
A completed comparison returns success whether it finds differences or gaps;
|
||||
load, comparison, and writer failures use the operational failure classification in
|
||||
[ADR 0005](0005-invocation-outcomes.md).
|
||||
Adapt and present native comparison results in accordance with the adopted
|
||||
upstream API's disclosure contract. The integration carries the upstream results
|
||||
and relevant metadata through its public provider boundary.
|
||||
|
||||
CommandKit owns report presentation and may summarize or transform the returned
|
||||
safe comparison data for different operator needs. Reports may vary in format,
|
||||
organization, and detail while respecting safe disclosure and the evidence
|
||||
provided by the comparison. Each report's documented meaning defines the
|
||||
conclusions an operator can draw from it.
|
||||
|
||||
Report formats, schemas, and rendering choices belong in the
|
||||
[CLI reference](../cli.md) when implemented. They can evolve independently of
|
||||
this ownership decision under the [release policy](../policy/release.md).
|
||||
|
||||
### Invocation outcomes
|
||||
|
||||
A completed comparison returns success regardless of whether differences are
|
||||
reported. Load, comparison, and writer
|
||||
failures use the operational failure classification in
|
||||
[ADR 0005](0005-invocation-outcomes.md). Applications may use comparison results
|
||||
to make their own domain decisions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Rendered YAML line comparison | Reuses native display output. Formatting and masking become part of change interpretation. |
|
||||
| Compare masks as literal strings | Uses visible snapshot bytes directly. Equal masks express equal presentation while hidden values remain uncertain. |
|
||||
| Use one side's sensitivity policy | Simplifies classification selection. A value's disclosure treatment depends on which snapshot supplies that policy. |
|
||||
| Fetch secret values for comparison | Gives access to additional equality evidence. Comparison acquires sensitive material and additional confidentiality responsibilities. |
|
||||
| Element-by-element sequence diff | Gives detailed collection changes. Stable element identity and alignment need another comparison contract. |
|
||||
| A distinct status for observed differences | Supports assertion-oriented scripts. Inspection completion and change detection share an outcome signal. |
|
||||
| Compare safe snapshots in CommandKit | Gives CommandKit direct control over comparison, but requires it to maintain equality and sensitivity logic and account for values already withheld from snapshots. Native comparison keeps those responsibilities with the owning library. |
|
||||
| Compare rendered configuration text | Reuses existing output, but formatting and masking affect the apparent differences. Native comparison supplies evidence grounded in the library's effective-value model. |
|
||||
| Leave comparison commands entirely to applications | Preserves application flexibility, but repeats selection, acquisition coordination, and presentation infrastructure across consumers. Integration providers retain application control within the common command system. |
|
||||
| Return a distinct status when differences are found | Supports assertion-oriented scripts, but couples successful inspection with a policy judgment about the result. The selected outcome model leaves that judgment to consumers. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Operators receive meaningful effective-value changes with explicit uncertainty
|
||||
where sensitivity limits comparison. Whole-sequence comparison keeps
|
||||
implementation scope focused at the cost of less detailed collection changes.
|
||||
CommandKit supplies consistent comparison commands while relying on owning
|
||||
libraries for comparison semantics and safe disclosure. Dependency adoption
|
||||
includes adapting the integration and its documentation to upstream changes.
|
||||
New library integrations
|
||||
are assessed through their supported comparison contracts; each scope can expose
|
||||
its available capabilities independently under
|
||||
[ADR 0009](0009-configuration-scopes.md).
|
||||
|
||||
Diff support depends on safe snapshots preserving value semantics and sensitivity.
|
||||
CommandKit applies this comparison policy to scopes declaring that capability.
|
||||
Other operations and families can be integrated independently under
|
||||
[ADR 0004](0004-standard-inspection-surface.md).
|
||||
|
||||
Applications can use the successful report as input to domain decisions or future
|
||||
assertion workflows.
|
||||
Reports can serve different operator needs using the same native comparison
|
||||
data. Their level of detail may differ, and sensitivity rules may limit what
|
||||
can be shown. Maintaining the boundary between comparison evidence and report
|
||||
presentation allows those reports to evolve while preserving library ownership
|
||||
and consumer control.
|
||||
|
||||
100
docs/adr/0010-optional-integration-modules.md
Normal file
100
docs/adr/0010-optional-integration-modules.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ADR 0010: Separate the core facade from optional integration modules
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Partially supersedes
|
||||
[ADR 0003](0003-public-facade.md) as described below.
|
||||
Partially superseded by [ADR 0011](0011-captured-declarations-and-installation.md)
|
||||
for the consumer-migration consequence during the unreleased prototype
|
||||
transition. It also supplies the registration and invocation-lifetime
|
||||
decisions left open here. The module
|
||||
boundary and remaining principles continue to apply; this record's decision
|
||||
text is retained.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-09
|
||||
|
||||
## Context
|
||||
|
||||
CommandKit serves consumers that need its command infrastructure with different
|
||||
combinations of library integrations, including none. A consumer using only the
|
||||
core should not acquire dependencies belonging to optional integrations.
|
||||
|
||||
ADR 0003 establishes deliberate public contracts and an internal CLI backend
|
||||
boundary. Those principles remain appropriate. Its initial single root facade,
|
||||
however, combines core and integration responsibilities. Separate packages within
|
||||
one module would separate imports while retaining shared module requirements.
|
||||
|
||||
## Decision
|
||||
|
||||
Retain the root `commandkit` package as the core module's public facade. It owns
|
||||
core command declarations, registration, typed inputs, dispatch, help/version,
|
||||
shared syntax, diagnostics, and invocation outcomes.
|
||||
|
||||
Provide optional library integrations as separate Go modules with their own
|
||||
public package contracts. Each integration owns the standard command handlers,
|
||||
provider contracts, reports, and adapters appropriate to its responsibility.
|
||||
Applications select integrations and explicitly register their commands; importing
|
||||
an integration does not install a command family.
|
||||
|
||||
Integrations provide convenient registration of their standard command families,
|
||||
with standard declarations and handlers supplied by the integration. Applications
|
||||
provide acquisition and domain-specific context without assembling individual
|
||||
standard commands or repeating adapter plumbing. More specialized composition
|
||||
remains available where concrete consumer needs justify it.
|
||||
|
||||
Dependencies point from integrations toward the core. The core's public API,
|
||||
implementation, and module requirements remain independent of optional
|
||||
integration libraries. An integration may depend on another integration when
|
||||
its responsibility requires that relationship. Document such dependencies so
|
||||
consumers can understand the modules they select.
|
||||
|
||||
Integrations compose through supported public core contracts and preserve
|
||||
CommandKit's standard grammar, command ownership, help, and outcome conventions.
|
||||
They do not access private core state or backend structures. Add public contracts
|
||||
where concrete integration needs justify them, without requiring a general plugin
|
||||
or backend-selection framework.
|
||||
|
||||
### Relationship to ADR 0003
|
||||
|
||||
The root-facade and public-type ownership provisions of ADR 0003 now apply to
|
||||
the core module. Integration-owned public types and operations belong in their
|
||||
respective public packages. The permission to expose deliberately selected
|
||||
upstream library types applies within the integrations that depend on them;
|
||||
it does not justify optional integration dependencies in the core.
|
||||
|
||||
ADR 0003's remaining principles continue to apply across the core and
|
||||
integrations: deliberate exports, private implementation state, proportionate
|
||||
internal layering, no exposure of internal or backend types, and preservation of
|
||||
documented ownership, presence, callback lifetime, and error semantics. Application
|
||||
input types and caller-supplied values and errors retain their existing handling.
|
||||
Cobra/pflag remain behind the core's internal adapter.
|
||||
|
||||
This decision changes the scope of the single-facade decision rather than
|
||||
replacing its contract discipline. ADR 0003 is partially superseded and its
|
||||
original decision text remains intact.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Keep integrations in the root package | Preserves existing imports and simplifies releases, but core consumers acquire optional library dependencies. |
|
||||
| Use separate packages within one module | Separates compiled imports and retains one release unit, but keeps integration requirements in the shared module graph. |
|
||||
| Have applications implement integration handlers | Keeps the core independent, but recreates duplicated inspection commands and weakens consistent operator behavior. |
|
||||
| Introduce universal provider contracts in the core | Could support several libraries through a common model, but adds abstractions and conversion obligations before concrete needs justify them. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can adopt the core independently and add supported integrations as
|
||||
needed. Integrations remain maintained parts of CommandKit, with shared CLI
|
||||
conventions and owning-library semantics. Dependency independence does not mean
|
||||
the core has no external implementation dependencies.
|
||||
|
||||
Moving existing integration APIs requires a documented consumer migration.
|
||||
Integration modules add module-specific dependency, validation, and release work.
|
||||
Core tests, examples, and tools must not reintroduce the optional dependencies
|
||||
that this boundary removes.
|
||||
|
||||
Exact registration mechanics and release cadence are not selected here.
|
||||
Current GoDoc and CLI contracts continue to own implemented behavior.
|
||||
172
docs/adr/0011-captured-declarations-and-installation.md
Normal file
172
docs/adr/0011-captured-declarations-and-installation.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# ADR 0011: Capture command declarations and install optional families explicitly
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Refines [ADR 0001](0001-command-dispatch-and-inputs.md) for declaration
|
||||
construction, installation, optional-family namespace reservation, and integration
|
||||
input/state lifetimes. Partially supersedes
|
||||
[ADR 0010](0010-optional-integration-modules.md) only for its consumer-migration
|
||||
consequence during the unreleased prototype transition, and refines its
|
||||
convenient composition principle.
|
||||
|
||||
Refined by [ADR 0012](0012-typed-configuration-comparison-pairs.md) for
|
||||
two-selection configuration comparison and its input/state lifetime. The
|
||||
single-selection lifecycle and other decisions remain in force.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-10
|
||||
|
||||
## Context
|
||||
|
||||
Applications declare domain commands, while optional integrations assemble
|
||||
standard command families. A common construction and installation model reduces
|
||||
repeated error checks and registration plumbing. Integrations need public typed
|
||||
composition contracts to assemble those families while preserving core and
|
||||
backend boundaries.
|
||||
|
||||
Declaration and installation have different responsibilities. An optional family
|
||||
claims a namespace when installed; selecting a dependency alone should leave the
|
||||
application's command surface unchanged. During execution, scope-specific CLI
|
||||
inputs and application-owned shared state also have distinct lifetimes. Making
|
||||
those lifetimes explicit avoids accidental aliasing between selected scopes.
|
||||
|
||||
At the time of this decision, the implementation was an unreleased prototype
|
||||
with no downstream consumers. Its API shapes carried no compatibility obligations
|
||||
that would constrain the selected design.
|
||||
|
||||
## Decision
|
||||
|
||||
### Declaration capture and reuse
|
||||
|
||||
Applications and integrations construct the same opaque core command declarations
|
||||
and install them through core application construction or registration. Initial
|
||||
integrations provide complete-family factories and focused conveniences through
|
||||
that common installation API. Suite and provider types remain integration-owned.
|
||||
|
||||
Factories eagerly capture metadata, bindings, capability selections, selector
|
||||
applicability, and callback references. They copy the declaration collections
|
||||
needed to isolate returned commands from later edits to source metadata.
|
||||
Construction errors remain attached for installation to report. This model uses
|
||||
eager declarations with public typed composition; a deferred preparation lifecycle
|
||||
and untyped integration payload are outside the selected API.
|
||||
|
||||
Declarations may be reused across applications with independent registry and
|
||||
invocation state. Callback references and captured application objects retain
|
||||
caller ownership. Reuse provides no deep-copy or concurrent-dispatch guarantee.
|
||||
Application helpers performing other fallible work may still return errors.
|
||||
|
||||
### Installation and retained errors
|
||||
|
||||
Core validates each installation batch before registry mutation, checking
|
||||
retained errors, bindings, placement, and collisions. Failed application
|
||||
construction returns no usable application; failed later registration preserves
|
||||
the existing registry. Atomicity covers one installation call. Separate calls
|
||||
and arbitrary callback effects retain their own lifetimes.
|
||||
|
||||
Report retained errors with useful command context and preserve supported wrapped
|
||||
error identity. The aggregate-error representation remains an implementation
|
||||
choice.
|
||||
|
||||
An explicit absence declaration contributes no command, help entry, or namespace.
|
||||
Installation checks attached errors before skipping it. A zero command declaration
|
||||
remains invalid. Integrations define their own empty-suite semantics: the initial
|
||||
configuration suite needs at least one named scope, and metadata-only scopes
|
||||
remain discoverable. A definition suite with no capabilities contributes nothing.
|
||||
|
||||
Binding validation applies to generated command declarations. Unused selector
|
||||
bindings in an absent family are ignored; explicitly retained adapter-construction
|
||||
errors still reach installation. A nil loader supplied to an adapter remains
|
||||
invalid wiring even if its capabilities are subsequently cleared.
|
||||
|
||||
### Namespace ownership and placement
|
||||
|
||||
Core construction reserves help and version. An optional standard family owns
|
||||
its complete root namespace upon installation. Imports and absent declarations
|
||||
claim nothing. An application may occupy an uninstalled family name; installing
|
||||
a conflicting declaration then fails in either registration order. Installation
|
||||
preserves existing handlers and group ownership. Private application operations
|
||||
remain outside installed standard families.
|
||||
|
||||
Standard families carry root-only placement metadata, which core enforces when
|
||||
they are nested beneath another group. Placement is a public declaration property;
|
||||
its enforcement is independent of package identity or a list of integration names.
|
||||
A real empty root-only group claims its namespace and obeys placement checks.
|
||||
Explicit absence contributes no node to place.
|
||||
|
||||
### Input and shared-state lifetimes
|
||||
|
||||
Integration suites distinguish typed CLI input from typed shared acquisition
|
||||
state. Ordinary configuration inspection initializes one input per selected scope,
|
||||
applies its applicable selector bindings, and initializes shared state once for
|
||||
the inspection invocation. Input validation receives the scope identity and its
|
||||
typed input. Named definition inspection uses one input and one shared state.
|
||||
Providers receive both explicitly. ADR 0012 refines this lifecycle for comparison
|
||||
pairs with two selections of one scope.
|
||||
|
||||
Availability preflight and applicable input checks precede shared-state
|
||||
initialization and acquisition. Failures may stop later input factories.
|
||||
Construction, installation, help, version, and scope discovery leave suite
|
||||
input/state factories and acquisition providers uninvoked.
|
||||
|
||||
An omitted factory supplies its type's zero value, including nil pointers.
|
||||
Applications provide initialization needed by their accessors and providers;
|
||||
providers may intentionally accept nil. Allocation of application objects,
|
||||
cloning, and aliases returned by factories remain caller responsibilities.
|
||||
Core-owned parsed collections stay independent when applied to separate inputs.
|
||||
Caller-owned objects and callback effects retain their own lifetimes.
|
||||
|
||||
### Typed binding composition
|
||||
|
||||
Core provides a narrow typed contract to capture converted values and apply a
|
||||
selected binding subset to initialized input. It preserves binding identity,
|
||||
defaults, and presence while keeping private setters, parser state, and backend
|
||||
objects behind their existing boundaries. A copied binding retains its identity;
|
||||
a newly constructed lookalike has its own identity.
|
||||
|
||||
Execution-required checks apply to the selected subset. Missing required input
|
||||
is a usage failure; invalid binding selection or destination is a wiring failure
|
||||
with operational classification at invocation. These categories remain identifiable
|
||||
through supported wrapping and stop execution before shared-state initialization
|
||||
and acquisition. Metadata defects detectable during assembly of generated
|
||||
commands remain installation errors under the validation boundary above.
|
||||
|
||||
### Relationship to earlier decisions
|
||||
|
||||
For the unreleased prototype transition covered by this decision, compatibility
|
||||
wrappers and a downstream migration guide were unnecessary. This exception to
|
||||
ADR 0010's migration consequence is limited to that transition. Changes to
|
||||
published contracts follow the [release policy](../policy/release.md).
|
||||
|
||||
ADR 0001 retains dispatcher, registry, typed-input, and application-ownership
|
||||
principles. ADR 0010 governs module boundaries and dependency direction, with
|
||||
[ADR 0003](0003-public-facade.md) governing deliberate exports and backend isolation
|
||||
within that scope. ADRs 0004 through 0009 retain their operation, grammar, report,
|
||||
and outcome decisions. Go declarations and GoDoc own exact implemented signatures.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Fallible constructors plus integration-specific registration helpers | Makes each construction failure immediate, but creates several assembly conventions and repeated consumer checks. Captured errors keep one installation boundary. |
|
||||
| Deferred preparation callbacks | Allows installation to construct a family, but adds lifecycle and error timing beyond eager capture. No demonstrated assembly need requires it. |
|
||||
| Reserve all known optional family names at core construction | Prevents later name conflicts, but consumes namespaces for integrations the application has not selected. Installed-family ownership keeps conflicts explicit without that restriction. |
|
||||
| Separate family/plugin registration framework | Could encode ownership and composition, but a captured command with root-only placement and batch validation expresses the required behavior without another installation model. |
|
||||
| One shared input/state object | Uses fewer type parameters and can share a cache through references in separate input values, but leaves the distinct lifetimes implicit and scope-input isolation to application conventions. Explicit input and state types make those lifetimes visible. |
|
||||
| Clone hooks or automatic pointer allocation | Can hide input construction, but adds object-graph and initialization responsibilities that belong to the application. Explicit factories keep those choices visible. |
|
||||
| Public binding getters/setters or an untyped value bag | Enables integration assembly, but exposes private mechanics or returns typed-input assembly to string lookup. Narrow typed composition addresses the actual need. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers assemble domain commands and optional families through one installation
|
||||
boundary. Eager assembly errors reach that boundary before provider acquisition.
|
||||
Namespace collisions remain explicit choices for the application to resolve.
|
||||
|
||||
Separate input and state types make initialization and sharing visible. Focused
|
||||
conveniences support simple applications, while typed composition gives
|
||||
integrations the public contracts needed to assemble their families.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||
roadmaps own implementation status and sequencing. Application loading, cache
|
||||
design, startup, domain validation, callback effects, and process exit remain
|
||||
consumer responsibilities.
|
||||
108
docs/adr/0012-typed-configuration-comparison-pairs.md
Normal file
108
docs/adr/0012-typed-configuration-comparison-pairs.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# ADR 0012: Prepare typed selection pairs for configuration comparison
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Refines [ADR 0011](0011-captured-declarations-and-installation.md) for
|
||||
two-selection configuration comparison; its other decisions remain in force.
|
||||
[ADR 0008](0008-semantic-configuration-diff.md) governs native comparison and
|
||||
report presentation.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-11
|
||||
|
||||
## Context
|
||||
|
||||
Native effective-configuration comparison needs two completed loads. ConfigKit
|
||||
requires results of the same configuration type, which an ordinary inspection
|
||||
view does not expose for pairwise comparison. A generic adapter can retain that
|
||||
type without introducing result assertions or a universal core provider model.
|
||||
|
||||
ADR 0011 separates a selected scope's typed input from shared acquisition state.
|
||||
A comparison selects the same scope twice, potentially with different roots or
|
||||
overlays. Reusing one input obscures which selection a provider receives; creating
|
||||
separate shared state loses the invocation-wide lifetime chosen for inspection.
|
||||
|
||||
## Decision
|
||||
|
||||
### Selections and provider ownership
|
||||
|
||||
Comparison selects one scope and prepares an earlier/left selection and a
|
||||
later/right selection. Omitted right selection fields inherit the corresponding
|
||||
left field and supplied state. An explicit right override, including an empty
|
||||
value, replaces that field. Providers retain interpretation of omitted defaults
|
||||
and explicit empty values. Applicable domain selector values are shared between
|
||||
the two sides initially; independent per-side domain selectors may be considered
|
||||
for a concrete consumer need.
|
||||
|
||||
Equal selections are allowed. CommandKit does not skip acquisition merely because
|
||||
their selection values are equal: caller-owned loading may produce different
|
||||
effective results. Exact flag spellings belong in the CLI reference when
|
||||
implemented.
|
||||
|
||||
Use a narrow optional comparison provider owned by the integration. Its request
|
||||
groups each side's selection with its typed input and supplies one shared state
|
||||
value separately. The provider returns acquired native comparison results
|
||||
with diagnostics and an error through the existing acquisition convention.
|
||||
Reuse useful upstream public record types rather than duplicating them solely
|
||||
to rename fields. Exact exported signatures belong in Go declarations and GoDoc.
|
||||
|
||||
The typed ConfigKit adapter invokes the supplied loader for each reached side
|
||||
and passes the successful typed results to `configkit.Diff`. It retains the
|
||||
configuration type inside that adapter, without reflection, result type
|
||||
assertions, private core access, or a comparison-facet framework. Applications
|
||||
may supply their own pair provider through the same integration contract.
|
||||
|
||||
Declared capabilities determine required wiring. A comparison-only scope needs
|
||||
its comparison provider, without an unrelated ordinary acquisition callback.
|
||||
Ordinary operations continue to use their ordinary acquisition provider.
|
||||
Retained adapter construction errors still apply when capabilities are narrowed.
|
||||
Other scopes and families need not support comparison under
|
||||
[ADR 0009](0009-configuration-scopes.md).
|
||||
|
||||
### Typed input and shared state lifetime
|
||||
|
||||
Capture comparison declarations and applicable selector bindings under ADR 0011.
|
||||
Convert supplied flag values through core once, including on help paths; applying
|
||||
the values to both inputs does not parse them again.
|
||||
|
||||
After availability preflight, initialize each reached side's typed input
|
||||
separately, apply the same applicable captured selector values, and check that
|
||||
input with the selected scope identity and that side's selection. Finish both
|
||||
input checks before initializing shared state once and invoking the pair
|
||||
provider. Failures may stop later factories or loads. Construction, installation,
|
||||
help, version, and scope discovery invoke neither input/state factories nor
|
||||
acquisition/comparison providers.
|
||||
|
||||
Existing factory ownership rules remain unchanged: an omitted factory supplies
|
||||
its type's zero value, including nil pointers. Separate factory calls do not
|
||||
guarantee distinct caller-owned objects. CommandKit does not allocate arbitrary
|
||||
pointer types, clone application graphs, repair caller aliases, or supply or
|
||||
repair application caches. Core-owned parsed collections remain independent
|
||||
when applied to the two inputs; callback effects remain caller-owned.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Alternative | Analysis |
|
||||
| --- | --- |
|
||||
| Symmetric independently resolved left/right selectors | Makes each side explicit and avoids inheritance. It adds vocabulary and repetition for common base-versus-overlay comparisons. |
|
||||
| One typed input for both sides | Requires fewer factory calls, but obscures side-specific selection checks and leaves mutation isolation implicit. |
|
||||
| Acquire a comparison facet with each ordinary view | Shares single-side acquisition across operations, but requires a public compatibility contract between heterogeneous facets and additional handle or assertion machinery. |
|
||||
| Separate shared state for each side | Isolates acquisition state, but loses the invocation-wide sharing already selected for inspection. Applications can choose isolation within their own pair provider if needed. |
|
||||
|
||||
## Consequences
|
||||
|
||||
Pair preparation has explicit input and state lifetimes while ordinary inspection
|
||||
keeps its existing lifecycle. Generic result handling stays inside the integration
|
||||
adapter; core gains no owning-library dependency or universal comparison model.
|
||||
|
||||
Shared state permits application-owned caches. A cache used by a pair provider
|
||||
should distinguish selections and any input values affecting loading to avoid
|
||||
reusing the left result for a different right selection. This is integration
|
||||
guidance, not a CommandKit cache policy or guarantee about caller callbacks.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns normative boundaries.
|
||||
ADR 0008 governs delegation to upstream comparison semantics, safe disclosure,
|
||||
and CommandKit's presentation responsibility. This decision does not add a new
|
||||
equality engine or strengthen application
|
||||
loading, object ownership, or concurrency contracts.
|
||||
506
docs/cli.md
506
docs/cli.md
@@ -85,9 +85,12 @@ constructing application command inputs.
|
||||
|
||||
## Configuration scopes
|
||||
|
||||
An application installs one configuration suite with `RegisterConfig`. Its
|
||||
An application installs the declaration returned by
|
||||
`ckconfig.Command(ckconfig.Suite[Input, State]{...})` through
|
||||
`commandkit.New` or `App.Register`. For the common single application scope,
|
||||
`ckconfig.AppCommand` returns a convenience declaration. The installed
|
||||
`config scopes` command lists scope names and the configuration operations
|
||||
currently installed for those scopes:
|
||||
currently available for those scopes:
|
||||
|
||||
```text
|
||||
example config scopes
|
||||
@@ -102,10 +105,10 @@ runtime context or acquire configuration. It reports the intersection between
|
||||
declared scope capabilities and installed handlers, so an operation is listed
|
||||
only when the corresponding command is available.
|
||||
|
||||
Applications can register the `promptkit` scope with `NewPromptKitScope` to
|
||||
expose PromptKit engine configuration through these handlers. This configuration
|
||||
scope can be installed without registering PromptKit prompt or profile
|
||||
definition commands.
|
||||
Applications can add `ckprompt.ConfigScope(loadEngine)` to the ConfigKit suite
|
||||
to expose PromptKit engine configuration through these handlers. The scope
|
||||
declares validate, show, sources, explain, and native diff. It does not install
|
||||
PromptKit prompt or profile definition commands.
|
||||
|
||||
### Validate configuration
|
||||
|
||||
@@ -172,14 +175,36 @@ sources:
|
||||
kind: file
|
||||
underlay_index: 0
|
||||
imported_from: null
|
||||
destination: /server
|
||||
placement: mapping
|
||||
destination_unresolved: false
|
||||
version_check:
|
||||
path: /version
|
||||
version:
|
||||
present: true
|
||||
null: false
|
||||
text: "1"
|
||||
location:
|
||||
source_id: source-1
|
||||
line: 2
|
||||
column: 1
|
||||
redacted: false
|
||||
```
|
||||
|
||||
Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`,
|
||||
and `imported_from`. The import location is null when absent; otherwise it has
|
||||
`source_id`, `line`, and `column`. Owning values and source order are preserved,
|
||||
including empty fields, zero coordinates, and synthetic sources without paths.
|
||||
An empty inventory is `sources: []`. Text output begins with the scope heading
|
||||
and then prints the same source fields, quoting labels and paths.
|
||||
and `imported_from`, plus the native destination pointer, placement, unresolved
|
||||
destination flag, and nullable `version_check`. Placement is `mapping` or
|
||||
`sequence_item`. The import location is null when absent; otherwise it has
|
||||
`source_id`, `line`, and `column`. Version evidence preserves `path`, `present`,
|
||||
`null`, `text`, optional source `location`, and `redacted` as returned by
|
||||
ConfigKit. Owning values and source order are preserved, including empty fields,
|
||||
zero coordinates, false and null observations, and synthetic sources without
|
||||
paths. An absent version check is null. An empty inventory is `sources: []`.
|
||||
Text output begins with the scope heading and prints the same source fields,
|
||||
quoting labels and paths. These fields project the selected ConfigKit source
|
||||
records; CommandKit does not reload files or rerun version checks. See the
|
||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go)
|
||||
for native evidence definitions.
|
||||
|
||||
### Explain a configuration path
|
||||
|
||||
@@ -228,41 +253,110 @@ environment-variable name; effective and historical secret fragments remain
|
||||
redacted. Empty location, inheritance, history, reason, and dependency lists are
|
||||
serialized as `[]`.
|
||||
|
||||
### Compare configuration selections
|
||||
|
||||
`config diff` prepares two selections for one scope that declares comparison
|
||||
and invokes its comparison provider after input checks pass. The default scope
|
||||
is `app`; `--scope all` is unsupported.
|
||||
When an application installs `ckprompt.ConfigScope(loadEngine)`, the PromptKit
|
||||
scope declares comparison and delegates it to PromptKit's captured
|
||||
configuration inspection API. PromptKit owns the meaning of that native
|
||||
comparison; CommandKit uses the existing `config diff` preparation and report
|
||||
path.
|
||||
`--config` and `--overlay` select the left side. `--against-config` and
|
||||
`--against-overlay` independently override the right side. When a right-side
|
||||
field is omitted, it inherits the corresponding left value and supplied state;
|
||||
an explicit empty string overrides it and remains supplied. Equal selections are
|
||||
allowed; their equality alone does not cause CommandKit to skip the provider.
|
||||
|
||||
YAML is the only format and the default. Explicit `--format yaml` is accepted;
|
||||
`text` and other formats are usage errors. For example:
|
||||
|
||||
```text
|
||||
example config diff --config before.yml --against-config after.yml
|
||||
example config diff --config base.yml --against-overlay candidate
|
||||
example config diff --config base.yml --overlay current --against-overlay ""
|
||||
example config diff --scope promptkit --config before.yml --against-config after.yml
|
||||
```
|
||||
|
||||
For ConfigKit-backed scopes, ConfigKit compares frozen effective typed values
|
||||
after defaults and resolution; source or provenance differences alone do not
|
||||
create a change. PromptKit delegates its native comparison to ConfigKit. The
|
||||
report is one YAML document with `additions`, `changes`, and `deletions` in that
|
||||
order. Additions and changes preserve semantic paths and contain right-side
|
||||
values provided by the native comparison. Deletions contain canonical JSON
|
||||
Pointer paths. Explicit null is a value; it is not a deletion. Changed sequences
|
||||
and native structural replacement regions stay atomic.
|
||||
|
||||
ConfigKit pairs sensitivity across both loads. A concealed change can therefore
|
||||
appear even when its two safe snapshots are equal. For those ConfigKit-backed
|
||||
comparisons, the report omits separate redaction and hidden-change metadata: a
|
||||
reported masked region changed, but that does not mean every member inside it
|
||||
changed. Marker-looking public strings stay ordinary YAML values. An empty
|
||||
comparison succeeds with this shape:
|
||||
|
||||
```yaml
|
||||
additions: {}
|
||||
changes: {}
|
||||
deletions: []
|
||||
```
|
||||
|
||||
The reduced report is a partial safe view, not a reloadable configuration or an
|
||||
automatically applicable patch. Exceptional whole-root empty/null transitions
|
||||
can be ambiguous because the report has no root envelope; ordinary field-level
|
||||
null values and deletions retain their distinction.
|
||||
|
||||
Successful comparisons return status `0`, including empty results.
|
||||
Unsupported scopes and format errors are usage errors; input checks retain the
|
||||
existing usage/operational classification. Load and native comparison failures
|
||||
are operational and preserve supported error identity.
|
||||
|
||||
The bundled `FromConfigKit` and `AppCommand` adapters invoke the supplied loader
|
||||
for each reached side, left before right, then call native comparison. They label
|
||||
native diagnostics with their left/right side and source legend, retain left
|
||||
diagnostics if right loading fails, and gather both loads' diagnostics before
|
||||
native comparison. Custom pair providers own their loading and diagnostics;
|
||||
application loaders and caches determine whether a loader call reads sources
|
||||
again.
|
||||
|
||||
The complete report is prepared before writing. Report preparation failures and
|
||||
cancellation observed before report delivery starts leave CommandKit's report
|
||||
output empty. Once writing starts, CommandKit does not interrupt the supplied
|
||||
writer or roll back delivered bytes. A write error or short write uses the
|
||||
existing incomplete-report delivery outcome and can leave partial output.
|
||||
|
||||
## PromptKit definition inspection
|
||||
|
||||
Applications can install prompt and profile inspection independently with
|
||||
`RegisterPromptKit`. When acquisition is reached, the handler invokes its
|
||||
registered inspection callback once with the caller's selection and context.
|
||||
The bundled `NewPromptKitSuite` helper acquires an engine through the application
|
||||
loader and asks PromptKit to inspect its captured named definition. The loader
|
||||
can construct or reuse an engine; direct suite callbacks retain their own
|
||||
`ckprompt.Command(suite)`. The `ckprompt.FromEngine(loadEngine)` adapter
|
||||
supplies these callbacks alongside native requirements discovery and the five
|
||||
catalogs. Its loader receives invocation context, configuration selection,
|
||||
typed input, and shared state. Direct suite callbacks retain their own
|
||||
implementation and lifecycle.
|
||||
|
||||
CommandKit's inspection path does not execute prompts, contact model providers,
|
||||
or resolve provider credential values. Application acquisition and direct
|
||||
callback effects remain application-owned. Prompt definitions and profile
|
||||
resolution remain owned by
|
||||
[PromptKit's inspection API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go).
|
||||
Prompt inspection uses an ID only. PromptKit returns any descriptive version
|
||||
metadata as an optional report field; there is no `--version` flag for selecting
|
||||
a prompt version. The prompt format and native selection behavior remain owned by
|
||||
[PromptKit v0.16.0](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
||||
|
||||
```text
|
||||
example promptkit show prompt customer/summary --version 2 --format yaml
|
||||
example promptkit show prompt customer/summary --format yaml
|
||||
example promptkit show prompt customer/summary
|
||||
example promptkit show profile customer-support --format yaml
|
||||
```
|
||||
|
||||
Both commands require one ID operand and accept `--config`, `--overlay`, and
|
||||
`--format text|yaml` (default `text`). Prompt inspection also accepts singleton
|
||||
`--version`, defaulting to the empty string. PromptKit owns version selection:
|
||||
an omitted version works for a single-version ID, while an ambiguous ID is an
|
||||
operational error. These commands have no built-in `--scope` selection; their
|
||||
namespace is `promptkit`.
|
||||
`--format text|yaml` (default `text`). These commands have no built-in
|
||||
`--scope` selection; their namespace is `promptkit`.
|
||||
|
||||
Prompt YAML has these fields. Input order is preserved and an empty input list is
|
||||
`[]`. The prompt hash is an opaque owning-library equality value.
|
||||
Prompt YAML preserves native input order and uses an optional `version` field.
|
||||
The key is omitted when PromptKit returns no version and is retained when the
|
||||
version is present, including an empty string. The prompt hash is an opaque
|
||||
owning-library equality value.
|
||||
|
||||
```yaml
|
||||
prompt_id: customer/summary
|
||||
prompt_version: "2"
|
||||
version: "2"
|
||||
prompt_hash: opaque-value
|
||||
default_profile_id: customer-support
|
||||
inputs:
|
||||
@@ -301,11 +395,353 @@ api_key_required: true
|
||||
```
|
||||
|
||||
Text uses labeled fields and includes nested `extra_params` values. A missing ID
|
||||
is a usage error. Blank, unknown, and ambiguous definition selections reach the
|
||||
owning inspection API and produce operational results with supported public
|
||||
error identity. Cancellation is also operational: standard handlers check the
|
||||
caller's context at inspection boundaries, and PromptKit can return cancellation
|
||||
from its own inspection methods. Supported cancellation identity is preserved.
|
||||
is a usage error. Definition selection reaches the owning inspection API and
|
||||
preserves supported error identity and cancellation.
|
||||
|
||||
## PromptKit requirements
|
||||
|
||||
When `Suite.InspectRequirements` is present, `ckprompt.Command(suite)` installs
|
||||
`promptkit requirements show`. It accepts one prompt ID and uses PromptKit's
|
||||
native selection and discovery rules:
|
||||
|
||||
```text
|
||||
example promptkit requirements show briefing --campaign spring
|
||||
example promptkit requirements show briefing --profile editorial --var mode=full
|
||||
example promptkit requirements show briefing --session-id session-42 --var note= --format yaml
|
||||
```
|
||||
|
||||
The command accepts:
|
||||
|
||||
| Flag | Values | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
||||
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
||||
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
||||
| `--profile` | string | empty | Pass the profile ID to PromptKit; empty uses its native selection rule. |
|
||||
| `--session-id` | string | empty | Pass session context to PromptKit. |
|
||||
| `--var` | repeatable `KEY=VALUE` | none | Supply caller-variable context for native discovery. |
|
||||
| `--format` | `yaml` | `yaml` | Select the requirements report format. Other formats are usage errors for this operation. |
|
||||
|
||||
The `promptkit` namespace identifies the operation, so there is no built-in
|
||||
`--scope` flag. An explicitly empty profile or session value retains PromptKit's
|
||||
native empty-string meaning. Requirements inspection does not require a profile
|
||||
to be execution-ready. See the
|
||||
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
|
||||
for native configuration, selection, and requirements behavior. The built-in
|
||||
flags other than `--var` are singleton bindings; when repeated, the last value
|
||||
is used. Suite selectors retain their declared binding behavior.
|
||||
|
||||
Each `--var` occurrence must contain `=`. CommandKit splits at the first
|
||||
separator and preserves the exact key and remaining value. Empty keys and
|
||||
values, whitespace, commas, and additional `=` characters are retained. All
|
||||
occurrences are parsed; duplicate keys use the last value, while any malformed
|
||||
occurrence is a usage error even if a later assignment is valid.
|
||||
|
||||
The YAML report contains selected identities, native completeness, and input,
|
||||
variable, and issue inventories. False values and empty inventories are
|
||||
retained. Input metadata preserves null versus an empty string. Variable entries
|
||||
contain `name`, `kind`, `supplied`, and `explanation`. `supplied` reports native
|
||||
exact-key presence, including an explicitly empty value, independently of
|
||||
`kind` and `complete`. Explanations and issue descriptions are upstream-owned
|
||||
human-readable summaries, not machine-readable details. The optional `version`
|
||||
key is omitted when absent and retained when supplied by PromptKit.
|
||||
|
||||
For a prompt with the template `{{if .mode}}{{input "notes"}}{{end}}` and no
|
||||
input declarations, omitting `mode` can produce a partial report:
|
||||
|
||||
```yaml
|
||||
prompt_id: briefing
|
||||
version: "2"
|
||||
profile_id: editorial
|
||||
complete: false
|
||||
inputs:
|
||||
- name: notes
|
||||
kind: conditional
|
||||
declared: false
|
||||
content_type: null
|
||||
description: null
|
||||
explanation: This reference is not known to be reached on every execution path.
|
||||
variables:
|
||||
- name: mode
|
||||
kind: required
|
||||
supplied: false
|
||||
explanation: ""
|
||||
issues:
|
||||
- code: missing_variable
|
||||
input_name: ""
|
||||
variable_name: mode
|
||||
description: A missing variable prevents selecting a dependency-bearing branch.
|
||||
```
|
||||
|
||||
PromptKit owns the inventory, classifications, explanations, completeness, and
|
||||
structured issues. Successfully acquired and delivered partial reports return
|
||||
status `0`; structured issues do not independently fail the command. Provider
|
||||
errors, native selection errors, cancellation, and report delivery failures use
|
||||
the operational path (status `1`) with supported cause identity. Syntax,
|
||||
operand, variable-assignment, and format errors use usage status `2`.
|
||||
|
||||
### Concrete request validation
|
||||
|
||||
When `Suite.ValidateRequest` is present, `ckprompt.Command(suite)` also installs
|
||||
`promptkit requirements validate`. It can be installed without discovery, and
|
||||
discovery can remain installed without validation. The command accepts one
|
||||
prompt ID and passes a concrete request to PromptKit's native
|
||||
`Engine.ValidateRequest` preparation path:
|
||||
|
||||
```text
|
||||
example promptkit requirements validate briefing --campaign spring --input topic=topic.txt
|
||||
example promptkit requirements validate briefing --profile editorial --session-id session-42 --var mode=full --input notes=notes.txt
|
||||
```
|
||||
|
||||
The command accepts:
|
||||
|
||||
| Flag | Values | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `--config` | path string | omitted | Select the configuration input; explicit presence is retained. |
|
||||
| `--overlay` | path string | omitted | Select one overlay; explicit presence is retained. Repeated occurrences use the last value. |
|
||||
| Suite selectors | application-defined | per selector | The flags captured from `Suite.Selectors`. |
|
||||
| `--profile` | string | empty | Pass the profile ID to PromptKit. |
|
||||
| `--session-id` | string | empty | Pass session context to PromptKit. |
|
||||
| `--var` | repeatable `KEY=VALUE` | none | Supply caller variables for the concrete request. |
|
||||
| `--input` | repeatable `NAME=PATH` | none | Bind a file-backed PromptKit artifact. |
|
||||
| `--format` | `yaml` | `yaml` | Select the success confirmation format. Other formats are usage errors. |
|
||||
|
||||
The built-in flags other than repeatable `--var` and `--input` are singleton
|
||||
bindings; when repeated, the last value is used. Explicit empty `--config` or
|
||||
`--overlay` values keep their supplied-presence information. The profile and
|
||||
session fields use native empty-string semantics. PromptKit owns their
|
||||
interpretation and selection errors.
|
||||
|
||||
Each `--var` and `--input` occurrence must contain `=`. CommandKit splits at the
|
||||
first separator and preserves the exact key/name and remaining value/path.
|
||||
Empty variable keys and values and empty input names and paths are passed to
|
||||
native validation. File paths become `promptkit.File(PATH)` references without
|
||||
being read, normalized, expanded, or rebased during argument binding. Duplicate
|
||||
keys or names use the last value, but every occurrence is parsed.
|
||||
|
||||
On success, the YAML confirmation contains only the requested prompt ID and
|
||||
`valid: true`:
|
||||
|
||||
```yaml
|
||||
prompt_id: briefing
|
||||
valid: true
|
||||
```
|
||||
|
||||
Validation delegates concrete request preparation to PromptKit. Native
|
||||
preparation may resolve prompt/profile resources, load artifact and schema
|
||||
resources, render messages and session context, and perform applicable
|
||||
configuration and credential checks. It does not make a model call or retain a
|
||||
prepared execution snapshot. A successful report describes this preparation
|
||||
invocation; it does not certify provider acceptance or guarantee later
|
||||
execution. See PromptKit's
|
||||
[`Engine.ValidateRequest` API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
|
||||
|
||||
Builder, engine-loading, and native validation errors use the operational path
|
||||
(status `1`) with supported error identity and acquired diagnostics retained.
|
||||
Malformed syntax, missing operands, and unsupported formats use usage status
|
||||
`2`. Successful diagnostics and report-delivery failures follow the common
|
||||
delivery contract.
|
||||
|
||||
### PromptKit catalogs
|
||||
|
||||
An installed `ckprompt.Command(suite)` exposes each catalog whose callback is
|
||||
present:
|
||||
|
||||
```text
|
||||
example promptkit list prompts
|
||||
example promptkit list profiles --config settings.yml --campaign spring
|
||||
example promptkit list backends --namespace team
|
||||
example promptkit list namespaces --kind prompt --kind profile
|
||||
example promptkit list collections
|
||||
```
|
||||
|
||||
All catalogs accept `--config`, `--overlay`, applicable flags from
|
||||
`Suite.Selectors`, and `--format yaml` (the default). Other formats are usage
|
||||
errors. Prompt, profile, and backend listings accept namespace selection:
|
||||
|
||||
| Binding | Effect |
|
||||
| --- | --- |
|
||||
| omitted `--namespace` and `--all-namespaces` | Forward PromptKit's native default namespace filter. |
|
||||
| `--namespace team` | Forward the named namespace filter. |
|
||||
| `--namespace ""` | Select the unqualified namespace. |
|
||||
| `--all-namespaces` | Forward the all-namespaces filter. |
|
||||
| `--all-namespaces=false` | Do not select an override; a supplied namespace still applies, otherwise native default is used. |
|
||||
|
||||
The namespace binding is a singleton and its last occurrence is used.
|
||||
Supplying a namespace while `--all-namespaces=true` is active is a usage error
|
||||
before provider work. Namespace inventory accepts repeatable `--kind KIND`;
|
||||
omitting `--kind` forwards PromptKit's native default kind selection. Repeated
|
||||
values are forwarded in order. Collections have no namespace or kind filter.
|
||||
|
||||
Each operation writes one bare YAML sequence, retaining provider order and
|
||||
native identities:
|
||||
|
||||
- Prompt entries are `{id, version?}`. The optional `version` key is omitted
|
||||
when absent and retained for a present empty or nonempty value.
|
||||
- Profiles, backends, and namespaces are strings.
|
||||
- Collection entries are `{namespace, version?}`, where `version` projects
|
||||
PromptKit's optional release version.
|
||||
- Nil and allocated-empty inventories both render as `[]`.
|
||||
|
||||
```yaml
|
||||
- id: team/briefing
|
||||
version: "2"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- autumn
|
||||
- spring
|
||||
```
|
||||
|
||||
A catalog result is an inventory, not a readiness check or execution result.
|
||||
The owning provider controls membership and order. Namespace filters are passed
|
||||
to PromptKit without local membership interpretation. See the
|
||||
[PromptKit v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md)
|
||||
for native resource and selection definitions.
|
||||
|
||||
### PromptKit smoke testing
|
||||
|
||||
When `Suite.SmokeTest` is present, `ckprompt.Command(suite)` installs
|
||||
`promptkit test KIND`. The special kind `all` runs the four concrete operations
|
||||
sequentially in this order: `reachability`, `authentication`, `text_inference`,
|
||||
and `structured_output`. Both forms use the same single-operation callback;
|
||||
each aggregate call changes the seed kind before invoking it. The command
|
||||
accepts:
|
||||
|
||||
```text
|
||||
example promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
||||
example promptkit test all --campaign spring --prompt briefing --profile spring
|
||||
example promptkit test authentication --backend local
|
||||
```
|
||||
|
||||
| Flag | Values | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `--config`, `--overlay` | path strings | omitted | Select PromptKit configuration; supplied presence is retained. |
|
||||
| Suite selectors | application-defined | per selector | Captured flags from `Suite.Selectors`. |
|
||||
| `--backend` | string | empty | Native backend ID. |
|
||||
| `--prompt` | string | empty | Native prompt ID. |
|
||||
| `--profile` | string | empty | Native profile ID. |
|
||||
| `--endpoint` | string | omitted | Override the execution endpoint. |
|
||||
| `--model` | string | omitted | Override the execution model. |
|
||||
| `--temperature` | float | omitted | Override execution temperature. |
|
||||
| `--max-tokens` | integer | omitted | Override execution maximum tokens. |
|
||||
| `--top-p` | float | omitted | Override execution top-p. |
|
||||
| `--timeout-seconds` | integer | omitted | Override the execution timeout. |
|
||||
| `--service-tier` | string | omitted | Override the execution service tier. |
|
||||
| `--reasoning-effort` | string | omitted | Override execution reasoning effort. |
|
||||
| `--api-key-env` | string | omitted | Supply an API-key environment-variable name to native configuration. |
|
||||
| `--transient-retries` | integer | omitted | Set the native inference transient retry count. |
|
||||
| `--format` | `yaml` | `yaml` | Select the report format. Other formats are usage errors. |
|
||||
|
||||
The execution flags populate PromptKit's native execution override. The
|
||||
`--api-key-env` value is a variable name, not a credential value. Application
|
||||
selectors, loader policy, credentials, and client wiring remain application
|
||||
owned. The `WithSmokeTesting` helper accepts an optional typed builder. A nil
|
||||
builder forwards the seed; a supplied builder's complete native request is
|
||||
authoritative. The helper retains a native report when PromptKit returns both a
|
||||
report and an error.
|
||||
|
||||
A single report projects the native smoke report into YAML. It preserves native
|
||||
false, unknown/null, and incomplete values and does not add a readiness score.
|
||||
The report includes `kind`, `supported`, `applicable`, `completed`, `reason`,
|
||||
`target`, `facts`, `timing`, `attempts`, `usage`, and `failure`. Its target
|
||||
contains selected IDs, optional `version`, selection source, endpoint, effective
|
||||
settings, and settings-presence flags. Attempt entries retain their own facts,
|
||||
timing, failure, and token-usage evidence. Durations use Go duration strings;
|
||||
token counts preserve `value`, `known`, and `complete`.
|
||||
|
||||
Here is a native projection as rendered by the command:
|
||||
|
||||
```yaml
|
||||
kind: reachability
|
||||
supported: false
|
||||
applicable: null
|
||||
completed: true
|
||||
reason: http_diagnostics_unavailable
|
||||
target:
|
||||
backend_id: ""
|
||||
profile_id: spring
|
||||
prompt_id: briefing
|
||||
version: "1"
|
||||
default_source_backend_id: ""
|
||||
selection_source: profile
|
||||
endpoint: https://example.invalid/v1
|
||||
effective_settings:
|
||||
endpoint: https://example.invalid/v1
|
||||
model: spring-model
|
||||
temperature: 0
|
||||
max_tokens: 0
|
||||
top_p: 0
|
||||
timeout_seconds: 600
|
||||
service_tier: ""
|
||||
reasoning_effort: ""
|
||||
api_key_env: ""
|
||||
extra_params:
|
||||
campaign: spring
|
||||
settings_presence:
|
||||
temperature: false
|
||||
max_tokens: false
|
||||
top_p: false
|
||||
timeout_seconds: false
|
||||
facts:
|
||||
credentials_supplied: null
|
||||
endpoint_reachable: null
|
||||
credentials_accepted: null
|
||||
response_received: null
|
||||
generation_completed: null
|
||||
text_returned: null
|
||||
expected_text_matched: null
|
||||
structured_output_requested: null
|
||||
valid_json: null
|
||||
schema_matched: null
|
||||
timing:
|
||||
started_at: 2026-10-17T19:05:36Z
|
||||
duration: 35.685µs
|
||||
capacity_wait: null
|
||||
request_duration: null
|
||||
retry_delay: null
|
||||
evaluation_duration: null
|
||||
attempts: []
|
||||
usage:
|
||||
prompt_tokens: {value: 0, known: true, complete: true}
|
||||
completion_tokens: {value: 0, known: true, complete: true}
|
||||
total_tokens: {value: 0, known: true, complete: true}
|
||||
cached_tokens: {value: 0, known: true, complete: true}
|
||||
cache_write_tokens: {value: 0, known: true, complete: true}
|
||||
failure: ""
|
||||
```
|
||||
|
||||
Aggregate output is one YAML mapping with ordered `tests` entries. Each entry
|
||||
has selected `kind`, nullable `report`, and optional `error` text. The inner
|
||||
report keeps its native `kind`. This cancellation example shows two attempted
|
||||
operations with no reports; later kinds are absent because cancellation stops
|
||||
the sequence:
|
||||
|
||||
```yaml
|
||||
tests:
|
||||
- kind: reachability
|
||||
report: null
|
||||
error: "reachability operation failed"
|
||||
- kind: authentication
|
||||
report: null
|
||||
error: "context canceled"
|
||||
```
|
||||
|
||||
A successful delivered single operation returns status `0`. For `all`, all
|
||||
four callbacks must return without error for status `0`. Native false, unknown,
|
||||
unsupported, or incomplete observations do not independently change the
|
||||
outcome. Operation errors use operational status `1`; aggregate runs continue
|
||||
after individual errors, retain their reports and ordered error text, and join
|
||||
causes for supported `errors.Is`/`errors.As` access. Cancellation stops later
|
||||
operations and preserves results already acquired. Report encoding or delivery
|
||||
failures use the common incomplete-report delivery outcome and retain operation
|
||||
causes where applicable. Syntax, operand, unsupported format, and conflicting
|
||||
namespace selection are usage status `2`.
|
||||
|
||||
Native configuration, selection, and diagnostic meanings remain owned by
|
||||
PromptKit. See the [v0.16.0 consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md),
|
||||
[smoke API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
||||
and [engine API](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go).
|
||||
|
||||
## Outcome status
|
||||
|
||||
|
||||
@@ -1,88 +1,231 @@
|
||||
# Consumer guide
|
||||
|
||||
CommandKit's supported Go API is the root `commandkit` package. Start with an
|
||||
`App`, register CommandKit's standard inspection families, register application
|
||||
commands, and call `App.Execute` with the caller's context and streams. The
|
||||
caller retains process startup, signal handling, and exit mapping.
|
||||
CommandKit is distributed as three Go modules. Start with the core and add an
|
||||
integration module only when the application uses that library. Each module
|
||||
returns core `commandkit.Command` declarations, which the application installs
|
||||
through `commandkit.New` or `App.Register`.
|
||||
|
||||
## Module selection
|
||||
|
||||
| Import | Use when |
|
||||
| --- | --- |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit` | The application needs typed command declarations and common CLI behavior. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig` | The application wants ConfigKit-backed configuration scopes and reports. |
|
||||
| `gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt` | The application wants PromptKit engine configuration/comparison, five catalogs, named inspection, requirements discovery, request validation, or native smoke testing. |
|
||||
|
||||
The two integrations are independent optional imports. `ckprompt` uses the
|
||||
public scope and error contracts from `ckconfig`; importing it does not add the
|
||||
`config` family. An application that wants both families passes both returned
|
||||
declarations to core. The [maintained examples](../../README.md#examples) show
|
||||
core-only, ConfigKit-only, and composed applications.
|
||||
|
||||
## Go compatibility
|
||||
|
||||
The consumer minimum is declared in [go.mod](../../go.mod): Go 1.26.0.
|
||||
ReleaseKit v0.3.0 requires that version and is selected as a tool dependency in
|
||||
the same module graph. The minimum therefore applies to library consumers even
|
||||
though production packages do not import ReleaseKit. Consumers using Go 1.25
|
||||
need a Go 1.26 or newer toolchain to adopt this revision.
|
||||
All three modules declare a consumer minimum of Go 1.26.0. The root module also
|
||||
selects ReleaseKit v0.4.2 as a Go tool, which makes that tool's module graph part
|
||||
of the root consumer module requirements even though production packages do not
|
||||
import ReleaseKit. The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
defines the support assumption. Maintainers use Go 1.26.8 for validation and
|
||||
publication; see the [release procedure](../release.md#release-toolchain).
|
||||
|
||||
The [release policy](../policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
defines support assumptions and versioning for changes to that minimum.
|
||||
Maintainers select their validation toolchain separately in the
|
||||
[release procedure](../release.md#release-toolchain). Updating that toolchain's
|
||||
patch version alone leaves the consumer minimum unchanged.
|
||||
## Core commands
|
||||
|
||||
## A configuration scope
|
||||
|
||||
Use [`NewConfigKitScope`](../../config.go) to adapt one ConfigKit loader. The
|
||||
loader receives the invocation's explicit `Selection`; the application decides
|
||||
how those supplied config and overlay values map to its inputs.
|
||||
Declare typed flags and operands with `CommandSpec[I]`, then capture them with
|
||||
`NewCommand`. Bindings deliver `Supplied[T]`, which distinguishes an omitted
|
||||
default from an explicitly supplied empty, false, or zero value.
|
||||
|
||||
```go
|
||||
scope, err := commandkit.NewConfigKitScope[appContext, appConfig](
|
||||
"app",
|
||||
"Application settings.",
|
||||
func(ctx context.Context, selection commandkit.Selection, app appContext) (*configkit.Result[appConfig], error) {
|
||||
return loadAppConfig(ctx, selection, app)
|
||||
},
|
||||
package example
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
type GreetInput struct {
|
||||
Count commandkit.Supplied[int]
|
||||
}
|
||||
|
||||
func Greeting() commandkit.Command {
|
||||
countDefault := 1
|
||||
return commandkit.NewCommand(commandkit.CommandSpec[GreetInput]{
|
||||
Name: "greet", Summary: "Write a greeting.",
|
||||
Flags: []commandkit.Flag[GreetInput]{
|
||||
commandkit.ValueFlag(
|
||||
commandkit.FlagMeta{Name: "count", Usage: "Number of greetings."},
|
||||
&countDefault, strconv.Atoi,
|
||||
func(input *GreetInput, value commandkit.Supplied[int]) {
|
||||
input.Count = value
|
||||
},
|
||||
),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input GreetInput) commandkit.Outcome {
|
||||
_, _ = fmt.Fprintln(invocation.Streams.Out, input.Count.Value)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
}
|
||||
err = commandkit.RegisterConfig(app, commandkit.ConfigSuite[appContext]{
|
||||
Scopes: []commandkit.ConfigScope[appContext]{scope},
|
||||
})
|
||||
```
|
||||
|
||||
`config show`, `config sources`, and `config explain` use the configuration
|
||||
captured by that load. A scope can support only the operations it provides.
|
||||
When several scopes share invocation-local application state, create it in
|
||||
`ConfigSuite.NewContext`; use typed `Selector` bindings to copy only the values
|
||||
that apply to each scope. See the [composed example](../../examples/composed/README.md)
|
||||
for a cache shared by the `app` and `promptkit` scopes.
|
||||
The application supplies `context.Context`, argv without the executable name,
|
||||
and output streams to `App.Execute`; it decides how the returned status maps to
|
||||
process exit. `New` installs generated help/version and caller declarations as
|
||||
one batch. `Register` validates each call as a batch before changing that App.
|
||||
Invalid declaration errors retain their wrapped identity for `errors.Is` and
|
||||
`errors.As`.
|
||||
|
||||
## PromptKit configuration and definitions
|
||||
Declarations copy their metadata and slice inputs when captured, retain callback
|
||||
references, and can be reused across applications. Copies of a typed binding
|
||||
preserve its identity; a separately constructed lookalike does not. Supplied
|
||||
values convert before input construction. A command's `NewInput` creates input
|
||||
only when execution reaches it; help does not construct caller input or run
|
||||
provider callbacks.
|
||||
|
||||
Register `NewPromptKitScope` with the config suite when you want the effective
|
||||
PromptKit configuration and its ConfigKit provenance available under `config`.
|
||||
Register `NewPromptKitSuite` and `RegisterPromptKit` separately to enable
|
||||
`promptkit show prompt` and/or `promptkit show profile`. Each adapter calls the
|
||||
owning public inspection method and preserves acquisition diagnostics with a
|
||||
failed named lookup. Definition inspection does not invoke execution APIs or
|
||||
resolve provider credentials.
|
||||
## ConfigKit scopes
|
||||
|
||||
The [composed example](../../examples/composed/README.md) shares its ordinary app and
|
||||
engine-loading functions across the configuration and definition suites. It
|
||||
also keeps `session restore` as an application-owned typed command, including
|
||||
its session data and domain outcome mapping.
|
||||
For an application with one ordinary app configuration loader,
|
||||
`ckconfig.AppCommand` supplies the state-free convenience declaration:
|
||||
|
||||
## Typed application commands
|
||||
```go
|
||||
app, err := commandkit.New(
|
||||
commandkit.Identity{Name: "Example", Version: "dev"},
|
||||
ckconfig.AppCommand(loadAppConfig),
|
||||
)
|
||||
```
|
||||
|
||||
Declare flags, operands, and a typed input struct with `NewCommand`. Bindings
|
||||
preserve whether a value was explicitly supplied, so an explicit zero can differ
|
||||
from an omitted default. `RepeatFlag` retains occurrence order. The
|
||||
[simple example](../../examples/simple/README.md) combines both features with a typed
|
||||
ConfigKit scope.
|
||||
`loadAppConfig` has the signature
|
||||
`func(context.Context, ckconfig.Selection) (*configkit.Result[T], error)`.
|
||||
Construction, installation, and help do not call it. `Selection` preserves
|
||||
whether config and overlay paths were explicitly supplied, including empty
|
||||
values.
|
||||
|
||||
## Outcomes and process exit
|
||||
Use `ckconfig.Command(ckconfig.Suite[I, S]{...})` when the application needs
|
||||
multiple named scopes, selector flags, scope-aware input checks, or shared
|
||||
acquisition state or a custom comparison provider. A declared `Diff` capability
|
||||
uses `Scope.Compare`; `ckconfig.FromConfigKit` and `AppCommand` provide the
|
||||
ConfigKit-backed implementation. Each comparison side gets its own initialized
|
||||
and checked input, while state is shared by the invocation. Factory results
|
||||
remain caller-owned, so separate initialization calls do not guarantee distinct
|
||||
objects. See
|
||||
[ckconfig.Suite's GoDoc](../../integrations/ckconfig/contracts.go) for factory
|
||||
timing, omitted-factory zero values (including nil pointers), and ownership.
|
||||
The application owns the state type and what it caches. `ckconfig.FromConfigKit`
|
||||
adapts a typed ConfigKit loader without calling it during construction.
|
||||
|
||||
Handlers return `Success`, `Failure`, or an application-owned `Outcome`. The
|
||||
caller chooses how a nonzero status maps to process exit; `Execute` never exits
|
||||
the process. It writes CommandKit diagnostics to the supplied error stream and
|
||||
returns the resulting classification and delivery information. Applications
|
||||
keep their domain meanings and output in their own handlers.
|
||||
The `config` family provides scope discovery, validation, native effective YAML,
|
||||
source inventory, path explanation, and ConfigKit-backed comparison for the
|
||||
capabilities declared by its scopes. The [CLI reference](../cli.md#configuration-scopes)
|
||||
owns operator syntax and report details.
|
||||
|
||||
Exact CommandKit contracts are documented on the exported declarations and
|
||||
their GoDoc comments. ConfigKit owns configuration decoding and provenance;
|
||||
consult its [inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/inspection.go).
|
||||
PromptKit owns prompt formats and profile resolution; consult its [public
|
||||
engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/engine.go),
|
||||
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/configuration_inspection.go),
|
||||
and [inspection types](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/types.go).
|
||||
## PromptKit configuration, definitions, and requirements
|
||||
|
||||
`ckprompt.ConfigScope(loadEngine)` returns a `ckconfig.Scope` for the PromptKit
|
||||
engine's captured configuration. Add that scope to a `ckconfig.Suite` to expose
|
||||
it under `config` with validate/show/sources/explain and native diff operations;
|
||||
it does not install PromptKit catalog, definition, or requirements commands.
|
||||
|
||||
`ckprompt.FromEngine(loadEngine)` supplies eight callbacks: prompt, profile,
|
||||
backend, namespace, and collection catalogs; named prompt and profile
|
||||
inspection; and native requirements discovery. Concrete request validation and
|
||||
smoke testing are separate capabilities, added explicitly with
|
||||
`ckprompt.WithRequestValidation` and `ckprompt.WithSmokeTesting`. The suite has
|
||||
ten independently optional callbacks in total. `ckprompt.Command(suite)` creates
|
||||
only operations whose callbacks are present; discovery, validation, and smoke
|
||||
testing can be installed independently. A composed suite can enable both
|
||||
additional operations:
|
||||
|
||||
```go
|
||||
suite := ckprompt.FromEngine(loadEngine)
|
||||
suite = ckprompt.WithRequestValidation(suite, loadEngine, buildRequest)
|
||||
suite = ckprompt.WithSmokeTesting(suite, loadEngine, nil)
|
||||
suite.NewInput = newInput
|
||||
suite.NewState = newState
|
||||
suite.Selectors = selectors
|
||||
definitionCommand := ckprompt.Command(suite)
|
||||
```
|
||||
|
||||
A validation-only suite can start empty. With application types named `Input`
|
||||
and `State`, a nil builder forwards the request fields populated by standard
|
||||
flags directly to PromptKit:
|
||||
|
||||
```go
|
||||
suite := ckprompt.WithRequestValidation(
|
||||
ckprompt.Suite[Input, State]{}, loadEngine, nil,
|
||||
)
|
||||
validationCommand := ckprompt.Command(suite)
|
||||
```
|
||||
|
||||
If the loader is nil, the returned suite retains a wiring issue for installation
|
||||
to report, even if capabilities are narrowed later. A builder runs before the
|
||||
engine loader and receives the seed request, selection, typed input, shared
|
||||
state, and invocation context. It returns the
|
||||
concrete request; the adapter does not merge seed fields back afterward. The
|
||||
loader receives the original selection. Builders should start from the seed,
|
||||
preserve operator values and bindings, and apply defaults only when a map key is
|
||||
absent, so explicitly supplied empty values stay meaningful. Applications that
|
||||
validate and then execute a request are encouraged to share their request
|
||||
construction logic between those paths.
|
||||
|
||||
The eight catalog and inspection callbacks and the two explicit additions are
|
||||
independently optional. For catalogs without named or requirements inspection,
|
||||
narrow the suite before command capture:
|
||||
|
||||
```go
|
||||
definitions := ckprompt.FromEngine(loadEngine)
|
||||
definitions.InspectPrompt = nil
|
||||
definitions.InspectProfile = nil
|
||||
definitions.InspectRequirements = nil
|
||||
definitionCommand := ckprompt.Command(definitions)
|
||||
```
|
||||
|
||||
Each selected inspection, requirements, validation, smoke, or catalog operation
|
||||
prepares one input, applies its captured selectors, checks that input, and
|
||||
initializes one state value when those steps are reached. The bundled validation
|
||||
adapter delegates concrete request preparation to PromptKit and carries its
|
||||
native errors plus engine-loading diagnostics through the acquisition. It does
|
||||
not call the model or retain a prepared execution snapshot. See
|
||||
[ckprompt.Suite's GoDoc](../../integrations/ckprompt/adapters.go) for factory
|
||||
timing, omitted-factory zero values, and caller-owned objects and effects.
|
||||
The loader receives invocation context, explicitly supplied selection, input,
|
||||
and state. `FromEngine` uses PromptKit's public listing and inspection methods,
|
||||
including `Engine.InspectRequirements`; validation must be opted into separately
|
||||
and uses `Engine.ValidateRequest`.
|
||||
|
||||
Catalog, named definition inspection, and requirements discovery do not render
|
||||
messages or load request artifacts. Concrete request validation follows native
|
||||
PromptKit preparation, which can resolve prompt/profile resources, load
|
||||
artifacts and schemas, render messages, and perform applicable configuration
|
||||
and credential checks. It does not make a model call or certify that a later
|
||||
execution will succeed. Application loaders and direct callbacks retain their
|
||||
own effects. Smoke testing explicitly invokes one native PromptKit diagnostic
|
||||
or all four in order. Native inference checks use the configured public
|
||||
`promptkit.LLMClient`; applications retain ownership of client and probe
|
||||
configuration. A builder may customize the seeded native request, and its
|
||||
complete return value is authoritative. The [CLI reference](../cli.md#promptkit-smoke-testing),
|
||||
[definition inspection](../cli.md#promptkit-definition-inspection), and
|
||||
[requirements contract](../cli.md#promptkit-requirements) own operator syntax
|
||||
and reports.
|
||||
|
||||
The [composed example](../../integrations/ckprompt/examples/composed/README.md)
|
||||
combines app and PromptKit config scopes, comparison and catalogs, named and
|
||||
requirements inspection, request validation, smoke testing, and an
|
||||
application-owned `session restore` command.
|
||||
|
||||
## Outcomes and ownership
|
||||
|
||||
Core classifies usage and operational failures while preserving application
|
||||
domain outcomes and supported error identity. Report and diagnostic delivery
|
||||
errors remain inspectable separately from the operation cause. The caller owns
|
||||
process startup, signal handling, and exit mapping; `Execute` never exits the
|
||||
process.
|
||||
|
||||
Go declarations and GoDoc own exact exported contracts. ConfigKit owns
|
||||
configuration decoding, validation, and provenance. PromptKit owns prompt
|
||||
formats, profile resolution, and inspection details. See the
|
||||
[ConfigKit v0.8.0 inspection API](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/inspection.go),
|
||||
[PromptKit engine methods](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/engine.go),
|
||||
[configuration inspection](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/configuration_inspection.go),
|
||||
[smoke diagnostics](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/smoke_diagnostics.go),
|
||||
and [PromptKit v0.16.0 consumer guidance](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/docs/consumers/pkg-promptkit.md).
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Developing CommandKit
|
||||
|
||||
CommandKit currently contains adopted repository policies, accepted architectural
|
||||
decisions, identity and outcome primitives, typed command
|
||||
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
||||
adapter, public `App.Execute` coordination, and generated help/version commands.
|
||||
Configuration suite registration, scope discovery, ConfigKit validation and
|
||||
reports, PromptKit engine configuration inspection, and optional PromptKit
|
||||
prompt/profile inspection commands are implemented. The maintained
|
||||
[consumer guide](consumers/guide.md) links to maintained examples. The
|
||||
[README](../README.md) provides project orientation; the
|
||||
CommandKit is distributed as a core Go module and two optional integration
|
||||
modules. Core owns captured declarations, typed input preparation, installation,
|
||||
dispatch, outcomes, help, and version. `integrations/ckconfig` owns ConfigKit
|
||||
scope adaptation and reports; `integrations/ckprompt` owns PromptKit engine,
|
||||
definition inspection, requirements discovery, and concrete request validation.
|
||||
The maintained [consumer guide](consumers/guide.md) links to the three maintained
|
||||
consumers.
|
||||
The [README](../README.md) provides project orientation; the
|
||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||
canonical documentation owners.
|
||||
|
||||
@@ -48,12 +47,13 @@ canonical documentation owners.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Implemented CLI behavior | [CLI reference](cli.md) |
|
||||
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [configuration declarations](../config.go), [PromptKit declarations](../promptkit.go), and applicable ADRs |
|
||||
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md) |
|
||||
| Public API usage and standard-suite wiring | [Consumer guide](consumers/guide.md), [ckconfig package](../integrations/ckconfig/doc.go), [ckprompt package](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||
| Dispatcher, registration, or typed inputs | [Command declarations](../command.go), [bindings](../binding.go), [invocation](../execute.go), and [ADR 0001](adr/0001-command-dispatch-and-inputs.md), refined by [ADR 0011](adr/0011-captured-declarations-and-installation.md) |
|
||||
| Backend integration | [Internal overview](internal/overview.md), [adapter implementation](../internal/cobra/parse.go), and [ADR 0002](adr/0002-cobra-backend.md) |
|
||||
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md), and the architecture policy |
|
||||
| Public facade or adapter boundaries | [Internal overview](internal/overview.md), [ADR 0003](adr/0003-public-facade.md) as scoped by [ADR 0010](adr/0010-optional-integration-modules.md), and the architecture policy |
|
||||
| Module ownership and public contracts | [Consumer guide](consumers/guide.md), [internal overview](internal/overview.md), [core GoDoc](../doc.go), [ckconfig GoDoc](../integrations/ckconfig/doc.go), [ckprompt GoDoc](../integrations/ckprompt/doc.go), and applicable ADRs |
|
||||
| Deferred capabilities and proposals | [Deferred work](roadmap/deferred.md) |
|
||||
| Completed planning and implementation history | [Retired records in Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap) |
|
||||
| Completed planning and implementation history | [Completed dependency upgrades, PromptKit validation, discovery, smoke testing, and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/a6278a6d9f940948ec7745955d745523aaff0734/docs/roadmap), [earlier ConfigKit and PromptKit adoption and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap), [earlier retired records](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap), and [completed module implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/bb2528532b78d30e52f5082e491a5e1fd9b0fb99/docs/roadmap) in Git history |
|
||||
| Release tooling | [Maintainer release procedure](release.md) |
|
||||
| Performance maintenance | [Internal overview](internal/overview.md), existing benchmarks, and [deferred efficiency work](roadmap/deferred.md#possible-further-efficiency-work) |
|
||||
| Licensing or incorporated material | [Project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution](oss/attribution.md) |
|
||||
@@ -62,8 +62,9 @@ canonical documentation owners.
|
||||
Read all applicable rows for cross-cutting work. Go declarations and GoDoc own
|
||||
current exported contracts; the CLI reference owns operator behavior. The
|
||||
[maintainer release procedure](release.md) owns current release operations.
|
||||
Completed roadmaps and implementation evidence remain available in Git history;
|
||||
they are not assignments to restart completed work. The
|
||||
Completed roadmaps and implementation evidence remain available in Git history,
|
||||
including [PromptKit requirements implementation and remediation](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/40499a43608bf60d1104cd8ee24caf18ac0e9e66/docs/roadmap).
|
||||
These records are not assignments to restart completed work. The
|
||||
[deferred-work record](roadmap/deferred.md) preserves future capability targets
|
||||
and unselected proposals separately from current contracts.
|
||||
|
||||
@@ -88,8 +89,26 @@ Sibling-checkout references in retired planning records are historical review
|
||||
evidence, not build dependencies. Implemented contracts belong in CommandKit
|
||||
under their canonical owners.
|
||||
|
||||
Run `go test ./...` from the repository root to compile the module and run its
|
||||
deterministic tests. Format changed Go files with `gofmt`. The
|
||||
The root module does not traverse nested modules. During development, use a
|
||||
local workspace for all three modules; it is ignored and must not be committed.
|
||||
If a local `go.work` already exists, preserve it and add these module directories:
|
||||
|
||||
```sh
|
||||
if [ -f go.work ]; then
|
||||
go work use . ./integrations/ckconfig ./integrations/ckprompt
|
||||
else
|
||||
go work init . ./integrations/ckconfig ./integrations/ckprompt
|
||||
fi
|
||||
go test ./...
|
||||
(cd integrations/ckconfig && go test ./...)
|
||||
(cd integrations/ckprompt && go test ./...)
|
||||
```
|
||||
|
||||
The workspace checks package behavior. Workspace-off checks with each module's
|
||||
own dependency graph are separate evidence. For unpublished CommandKit dependency
|
||||
versions, the [completed integration validation record](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/8a82620dfcd34c0ae56bbecb0d014eb49786bc92/docs/roadmap/implementation.md#stage-12-validate-the-complete-end-state-and-finish)
|
||||
describes the disposable module-file procedure used during implementation.
|
||||
Format changed Go files with `gofmt`. The
|
||||
[maintainer release procedure](release.md) owns candidate preparation and
|
||||
publication commands.
|
||||
|
||||
@@ -111,15 +130,30 @@ or publishing.
|
||||
|
||||
## Current public examples
|
||||
|
||||
The maintained consumers under `examples/` compile with the module suite and
|
||||
run without external services:
|
||||
The maintained consumers compile with their owning module suites and run without
|
||||
external services. The PromptKit smoke examples use local fixtures and the
|
||||
example's configured client:
|
||||
|
||||
```sh
|
||||
go run ./examples/simple config show
|
||||
go run ./examples/simple greet --count=0 --tag=first --tag second
|
||||
go run ./examples/composed config validate --scope all --campaign spring
|
||||
go run ./examples/composed promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run ./examples/composed session restore session-42 --campaign spring
|
||||
go run ./examples/core workspace greet --count=0 --tag=first --tag second
|
||||
(cd integrations/ckconfig/examples/simple && go run . config show)
|
||||
(cd integrations/ckconfig/examples/simple && go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . config validate --scope all --campaign spring --limit=0)
|
||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list prompts --config fixtures/before.yml --campaign spring)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list backends --namespace team)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list namespaces --kind prompt --kind profile)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit list collections)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit show prompt briefing --campaign spring --format yaml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test reachability --campaign spring --prompt briefing --profile spring)
|
||||
(cd integrations/ckprompt/examples/composed && go run . promptkit test all --campaign spring --prompt briefing --profile spring)
|
||||
(cd integrations/ckprompt/examples/composed && go run . session restore session-42 --campaign spring)
|
||||
```
|
||||
|
||||
The [consumer guide](consumers/guide.md) explains how their public API usage
|
||||
|
||||
@@ -1,48 +1,81 @@
|
||||
# Internal package overview
|
||||
|
||||
CommandKit ships one public package, `commandkit`, and one internal package,
|
||||
`internal/cobra`. This inventory describes the current implementation; the
|
||||
[architecture policy](../policy/architecture.md) owns normative boundaries and
|
||||
[ADR 0003](../adr/0003-public-facade.md) records the public package boundary.
|
||||
CommandKit's core module contains the public `commandkit` facade, the private
|
||||
neutral metadata package, and the internal Cobra adapter. Two nested Go modules
|
||||
own the optional ConfigKit and PromptKit integrations. The
|
||||
[architecture policy](../policy/architecture.md) owns normative boundaries;
|
||||
this page inventories the implemented owners.
|
||||
|
||||
## `commandkit`
|
||||
## Root `commandkit`
|
||||
|
||||
The root package declares the public API and owns the instance registry, typed
|
||||
command input preparation, invocation coordination, standard handlers, and
|
||||
provider adapters. Configuration reports adapt ConfigKit's public records and
|
||||
writers. PromptKit scope and definition handlers use PromptKit's public
|
||||
inspection methods and map their results to CommandKit-owned reports.
|
||||
The root facade owns captured command declarations, binding conversion,
|
||||
invocation coordination, the per-App registry, standard help/version commands,
|
||||
and outcome/diagnostic delivery. Its dependency graph has no ConfigKit or
|
||||
PromptKit integration dependency. Applications provide typed callbacks and
|
||||
choose when to install declarations.
|
||||
|
||||
The root lazily caches one immutable neutral bridge tree per `App`, invalidating
|
||||
it after successful nonempty registration. For each `Execute`, it prepares one
|
||||
adapter session and reuses that session for parsing, help, and usage. If a
|
||||
callback registers commands during execution, metadata-dependent presentation
|
||||
refreshes without repeating input callbacks. The root still prepares typed
|
||||
input, invokes application/standard handlers, and coordinates outcomes and
|
||||
diagnostic delivery. Application callbacks and their supported error identity
|
||||
are handled in the root through its typed contracts.
|
||||
The shared command compiler prepares supplied values before constructing input.
|
||||
The batch installer validates retained issues, placement, and collisions before
|
||||
mutating the registry. Each invocation uses one routing snapshot and backend
|
||||
session; a successful registration invalidates the snapshot for a later
|
||||
invocation. The root package owns app input and callback execution, not provider
|
||||
configuration semantics.
|
||||
|
||||
Configuration registration owns and normalizes its declarations, then prepares
|
||||
ordered scopes, name lookup, installed operations, and a discovery DTO. Command
|
||||
handlers reuse that private metadata; each invocation still creates its context
|
||||
and invokes acquisition only for selected scopes.
|
||||
## `internal/commandmeta`
|
||||
|
||||
This package owns the private neutral command, flag, argument, parsed-value, and
|
||||
failure records shared between the public facade and backend adapter. It does
|
||||
not import Cobra or optional integration modules. The facade and adapter
|
||||
exchange these records without exporting backend-specific types.
|
||||
|
||||
## `internal/cobra`
|
||||
|
||||
This package owns the Cobra-specific command tree, argument/flag parsing,
|
||||
and help rendering. A session owns one mutable backend tree for an invocation;
|
||||
parsing and presentation reuse it. Backend commands map directly to neutral
|
||||
declarations, and Cobra parent links reconstruct only the selected command path.
|
||||
The adapter returns raw parsed values and neutral syntax-failure data consumed
|
||||
by the root package. It does not import `commandkit` and does not own registry
|
||||
policy, public command declarations, provider loading, or standard inspection
|
||||
report rendering.
|
||||
This package owns the Cobra command tree, argument/flag parsing, and help
|
||||
rendering. A session owns the mutable backend tree for one invocation; parsing
|
||||
and presentation reuse that session. The adapter returns raw values and neutral
|
||||
syntax failures to core. It does not own the registry, typed input preparation,
|
||||
integration declarations, provider acquisition, or native report rendering.
|
||||
Cobra and pflag imports remain inside this package.
|
||||
|
||||
## External implementation boundaries
|
||||
## Optional modules
|
||||
|
||||
ConfigKit owns typed configuration loading, source history, validation,
|
||||
resolution, and path explanations. PromptKit owns captured engine
|
||||
configuration, prompt definitions, profile inheritance, and named inspections.
|
||||
CommandKit depends on their public module APIs and does not maintain parallel
|
||||
schemas. Cobra and pflag are confined behind the internal adapter; YAML report
|
||||
encoding is performed by the root package.
|
||||
`integrations/ckconfig` owns ConfigKit adaptation, scope capture, the standard
|
||||
`config` command family, and native validate/show/sources/explain/diff behavior.
|
||||
It depends on the public core facade and ConfigKit; it does not depend on
|
||||
PromptKit. Each selected scope or comparison side receives an independently
|
||||
initialized input, while one suite-owned state value is shared by an invocation's
|
||||
acquisitions or comparison callback.
|
||||
|
||||
`integrations/ckprompt` owns PromptKit engine loading, configuration-scope
|
||||
adaptation and native comparison, plus optional prompt/profile/backend/namespace/
|
||||
collection catalogs, named inspection, native requirements discovery,
|
||||
concrete request validation, and native smoke testing. Validation and smoke
|
||||
testing delegate to PromptKit and are independently opted into a suite. Smoke
|
||||
operations use the application's native engine and its configured client or
|
||||
probes. The integration uses public core and ckconfig contracts plus PromptKit's
|
||||
public APIs. It does not add a configuration family when its declaration is
|
||||
installed; callers compose `ckprompt.ConfigScope` with `ckconfig.Command`
|
||||
separately when they want configuration operations alongside PromptKit
|
||||
commands.
|
||||
|
||||
The three maintained consumers live at `examples/core`,
|
||||
`integrations/ckconfig/examples/simple`, and
|
||||
`integrations/ckprompt/examples/composed`. The
|
||||
[consumer guide](../consumers/guide.md) links to those examples and explains the
|
||||
public wiring. Run tests from each module; root `./...` does not traverse the
|
||||
nested module directories. The [development guide](../development.md) gives the
|
||||
workspace test commands. Workspace builds do not establish independent module
|
||||
graph isolation.
|
||||
|
||||
## Upstream implementation boundaries
|
||||
|
||||
ConfigKit owns configuration decoding, validation, source history, and path
|
||||
explanations. PromptKit owns its captured engine configuration, prompt
|
||||
definitions, profile inheritance, named inspection, requirements discovery,
|
||||
and concrete request validation, and native smoke-test semantics. CommandKit
|
||||
adapts those public contracts without maintaining parallel schemas. Explicit
|
||||
native smoke operations may use the configured client or probes; other
|
||||
inspection, catalog, and request-validation paths do not make model calls.
|
||||
Native request validation may load resources and artifacts, render messages,
|
||||
and perform applicable configuration or credential checks through PromptKit.
|
||||
Application loaders and domain behavior remain application owned.
|
||||
|
||||
@@ -1,57 +1,5 @@
|
||||
# Open Source Attribution Notices
|
||||
|
||||
## ConfigKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.5.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/dc09959cca4e105f49ec316c6d4ba585bd2340bd).
|
||||
|
||||
## PromptKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/promptkit` at `v0.12.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/promptkit/src/commit/4b8169da286917fde7875425bb32b799b12c6af3).
|
||||
|
||||
## PromptKit OpenRouter Catalog
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3` at `v3.20260930.0` (transitive dependency).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/f17d208cb7ad1fb043cda2266cd3011d11b40aa0).
|
||||
|
||||
## PromptKit Rakestrawhome Catalog
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3` at `v3.20260930.0` (transitive dependency).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/993443d88c451901f4c917fa00c204fd3b54d28d).
|
||||
|
||||
## Cobra
|
||||
|
||||
Selected module: `github.com/spf13/cobra` at `v1.10.1`.
|
||||
@@ -149,31 +97,6 @@ License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
|
||||
|
||||
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
|
||||
|
||||
## JSON Schema
|
||||
|
||||
Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2` (transitive dependency).
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/santhosh-tekuri/jsonschema/blob/29cbed948d24a04700eb94436416b18a07953b71/LICENSE).
|
||||
|
||||
[Source](https://github.com/santhosh-tekuri/jsonschema/tree/29cbed948d24a04700eb94436416b18a07953b71).
|
||||
|
||||
## Go text
|
||||
|
||||
Selected module: `golang.org/x/text` at `v0.14.0` (transitive dependency).
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/LICENSE) |
|
||||
[PATENTS](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/PATENTS).
|
||||
|
||||
[Source](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353).
|
||||
|
||||
## gocheck
|
||||
|
||||
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
|
||||
@@ -213,3 +136,55 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
## ReleaseKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (selected Go tool; not a runtime import).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
||||
|
||||
## Goldmark
|
||||
|
||||
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright (c) 2019 Yusuke Inuzuka
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
|
||||
|
||||
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
|
||||
|
||||
## Go modules
|
||||
|
||||
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
|
||||
|
||||
## Go tools
|
||||
|
||||
Selected module: `golang.org/x/tools` at `v0.49.0` (ReleaseKit tool graph dependency).
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/tools/+/18332fec72972efbb8ab9881984fec2d8cfc2b58).
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-10-08
|
||||
**Last Updated**: 2026-10-11
|
||||
|
||||
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
## Purpose And Ownership
|
||||
|
||||
CommandKit is an opinionated Go library for standard inspection commands,
|
||||
consistent CLI syntax, and reduced application command infrastructure. This
|
||||
policy owns normative responsibility boundaries, dependency direction, and
|
||||
architectural constraints. ADRs own decision rationale; the
|
||||
consistent CLI syntax, and reduced application command infrastructure. It provides
|
||||
useful operator access to capabilities owned by its integrated libraries through
|
||||
shared command vocabulary, registration, selection, coordination, presentation,
|
||||
and invocation outcomes. This policy owns normative responsibility boundaries,
|
||||
dependency direction, and architectural constraints. ADRs own decision rationale; the
|
||||
[implementation roadmaps](../development.md#task-specific-reading-guide) own
|
||||
proposals, sequencing, and implementation status.
|
||||
|
||||
@@ -44,14 +46,23 @@ flexibility to express genuine application requirements within one command model
|
||||
|
||||
[ADR 0001](../adr/0001-command-dispatch-and-inputs.md) records the selected
|
||||
CommandKit-owned dispatcher, per-instance registry, and typed-input model.
|
||||
[ADR 0011](../adr/0011-captured-declarations-and-installation.md) refines declaration
|
||||
capture, installation, optional-family ownership, and invocation input lifetimes.
|
||||
|
||||
CommandKit owns dispatch, common parsing, presence tracking, help, shared syntax
|
||||
diagnostics, and the handlers and observable contracts of its standard families.
|
||||
diagnostics, and the handlers and CLI contracts of its standard families.
|
||||
The integrated libraries own the domain semantics exposed through those handlers.
|
||||
Applications register domain commands outside those families and own their
|
||||
meaning, effects, domain validation, artifacts, and operational outcomes.
|
||||
Namespaces provide the ownership boundary: standard `config` and an installed
|
||||
`promptkit` family remain CommandKit-owned. CommandKit reports registration errors
|
||||
for attempts to replace their handlers or add private operations beneath them.
|
||||
Namespaces provide the ownership boundary. Core construction reserves help and
|
||||
version. Optional standard families, including config and promptkit, own their
|
||||
root namespace when installed; importing an integration or declaring an absent
|
||||
family claims no namespace. An application may occupy an uninstalled family name.
|
||||
Installing the standard family then reports a collision, as does application
|
||||
registration after the standard family is installed. Neither order merges groups
|
||||
or replaces handlers. Installed standard families cannot acquire private
|
||||
application operations beneath them. Standard-family declarations are root-only;
|
||||
nesting a real family beneath another group reports an assembly error.
|
||||
|
||||
Applications own their executable entry points, context creation, signals,
|
||||
stream selection, runtime construction, and final process exit. CommandKit
|
||||
@@ -71,9 +82,24 @@ retain control of their own output. [ADR 0005](../adr/0005-invocation-outcomes.m
|
||||
describes the outcome and delivery model.
|
||||
|
||||
Use explicit instance construction and composable command registration, not
|
||||
package-global or import-time registries. One definition supplies routing,
|
||||
parsing, and help metadata. Duplicate paths and attempts to replace reserved
|
||||
commands report errors rather than letting registration order select a winner.
|
||||
package-global or import-time registries. Applications and integrations supply
|
||||
the same captured command declarations through the core installation API.
|
||||
Declaration factories capture metadata, bindings, and callback references eagerly;
|
||||
construction errors remain attached for installation to report. Capture the
|
||||
metadata collections needed to keep later edits to a source declaration from
|
||||
changing a returned command. Application callback objects retain their ownership.
|
||||
One definition supplies routing, parsing, and help metadata.
|
||||
|
||||
Validate each installation batch before mutating the instance registry. A failed
|
||||
construction yields no usable application; a failed later registration leaves the
|
||||
existing registry intact. Separate successful registration calls are not one
|
||||
transaction. Duplicate paths and attempts to replace reserved commands report
|
||||
errors rather than letting registration order select a winner.
|
||||
|
||||
Explicit absent-command declarations contribute no commands, help entries, or
|
||||
namespace claims; attached errors are reported before skipping absence. An invalid
|
||||
zero declaration is not absence. Integrations define their own empty-suite
|
||||
semantics and retain invalid wiring errors even when capabilities are narrowed.
|
||||
|
||||
Standard families are assembled from declared provider capabilities. Each family
|
||||
specifies its own resources and supported operations; shared verbs retain
|
||||
@@ -100,7 +126,40 @@ application-owned.
|
||||
Handlers receive typed input structs with explicit flag and operand bindings.
|
||||
Use presence-aware fields where omission matters; preserve explicitly supplied
|
||||
empty, false, and zero values independently of parser defaults. Context, streams,
|
||||
and execution dependencies are separate from CLI inputs.
|
||||
and execution dependencies are separate from CLI inputs. Integration suites keep
|
||||
their typed CLI input separate from shared acquisition state. For a configuration
|
||||
operation, initialize and populate each selected scope's input independently,
|
||||
applying only its applicable selector bindings; initialize shared state once for
|
||||
the selected inspection invocation. Configuration input validation receives the
|
||||
selected scope identity together with its applicable typed input. Named definition
|
||||
inspection uses one input and one shared state. Complete applicable preflight
|
||||
and input checks before shared state initialization and provider acquisition.
|
||||
Factories need not run after a failure and remain uninvoked during construction,
|
||||
installation, help, version, and scope discovery.
|
||||
|
||||
For two-selection configuration comparison, prepare and check each side's input
|
||||
separately with its scope identity and selection, applying the same applicable
|
||||
converted selector values to both. Finish both checks before initializing shared
|
||||
state once and invoking the integration-owned pair provider under
|
||||
[ADR 0012](../adr/0012-typed-configuration-comparison-pairs.md). Required provider
|
||||
wiring follows declared capabilities; a comparison-only scope does not require
|
||||
an unrelated ordinary acquisition callback.
|
||||
|
||||
Omitted input and state factories supply the respective type's zero value,
|
||||
including nil for pointers. Caller factories and callbacks retain responsibility
|
||||
for objects they return or reference; do not promise arbitrary deep copies,
|
||||
repair caller-created aliases, or silently allocate arbitrary pointer types.
|
||||
Core-owned parsed collections stay independent where values are applied to
|
||||
separate inputs.
|
||||
|
||||
Provide typed binding composition through supported public core contracts when
|
||||
integrations need to apply captured converted values to selected inputs. Preserve
|
||||
binding identity and presence without exposing private setters or backend state.
|
||||
Required-value checks apply to the selected binding subset. Missing required
|
||||
operator input is a usage failure; invalid binding selection or destination is a
|
||||
wiring failure with operational classification when detected during invocation.
|
||||
Metadata defects detectable during assembly remain installation errors. Input
|
||||
composition does not create a second parser or a raw-argument handler model.
|
||||
|
||||
CommandKit keeps parsed values, collections, and presence from one invocation out
|
||||
of subsequent runs. Storage allocation and safe reuse are implementation choices;
|
||||
@@ -114,10 +173,20 @@ Keep input declarations available without application initialization.
|
||||
|
||||
## Public Facade And Dependency Direction
|
||||
|
||||
[ADR 0003](../adr/0003-public-facade.md) records the root `commandkit` facade and
|
||||
internal adapter boundary. The root is the sole initial supported public package
|
||||
and defines CommandKit's public named types. Additional public packages require a
|
||||
deliberate decision based on a durable consumer responsibility.
|
||||
[ADR 0010](../adr/0010-optional-integration-modules.md) scopes the root
|
||||
`commandkit` facade to the core module and selects separate optional integration
|
||||
modules with their own public package contracts. It partially supersedes
|
||||
[ADR 0003](../adr/0003-public-facade.md), whose remaining facade and internal
|
||||
adapter principles apply to the core and integrations alike.
|
||||
|
||||
The core owns command declarations, registration, bindings, dispatch, help/version,
|
||||
shared syntax, diagnostics, and outcomes. Each integration owns its standard
|
||||
handlers, provider contracts, reports, adapters, and integration-specific public
|
||||
types. Dependencies point from integrations toward the core; an integration may
|
||||
depend on another integration when its responsibility requires that relationship.
|
||||
Document those dependencies. Core public signatures, implementation imports, and
|
||||
module requirements remain independent of optional integration libraries,
|
||||
including dependencies introduced through tests, examples, or tools.
|
||||
|
||||
Public handles retain private implementation state and expose explicitly selected
|
||||
methods. Any forwarding and conversions must preserve type identity, value
|
||||
@@ -125,9 +194,9 @@ ownership, callback lifetime, presence, and supported public error semantics. Do
|
||||
not alias public types to internal or backend types, anonymously embed those types
|
||||
to promote methods, or expose implementation bridge methods.
|
||||
|
||||
Straightforward implementation may live in the root package without expanding
|
||||
its exports. Introduce internal packages, interfaces, and conversions where they
|
||||
simplify implementation or protect the backend boundary. Package organization and
|
||||
Straightforward implementation may live in an owning public package without
|
||||
expanding its exports. Introduce internal packages, interfaces, and conversions
|
||||
where they simplify implementation or protect the backend boundary. Package organization and
|
||||
the amount of internal layering follow implementation needs.
|
||||
|
||||
Public signatures, fields, callbacks, constraints, and method sets must not expose
|
||||
@@ -138,9 +207,13 @@ retain their documented handling; this boundary does not require inspecting or
|
||||
transforming arbitrary application objects.
|
||||
|
||||
Standard contracts such as `context.Context` and `io.Writer` remain appropriate.
|
||||
Public types from integration libraries, such as ConfigKit and PromptKit, may be
|
||||
selected as deliberate dependencies. Application-owned input types remain
|
||||
application-owned. Do not expand exports merely to simplify internal assembly.
|
||||
Upstream public types may be selected as deliberate dependencies in the
|
||||
integrations that own them; that permission does not justify optional library
|
||||
dependencies in the core. Core diagnostics use a CommandKit-owned severity type,
|
||||
and integrations convert upstream diagnostics while preserving selected outcome
|
||||
and error semantics. Application-owned input types remain application-owned.
|
||||
Expose narrow core composition contracts for demonstrated integration needs,
|
||||
without exporting private assembly or backend structures.
|
||||
|
||||
## Backend And Library Integration
|
||||
|
||||
@@ -158,9 +231,22 @@ them; a public backend selection or plugin framework is not part of the design.
|
||||
ConfigKit and PromptKit own configuration composition, provenance, inspection
|
||||
semantics, secret classification, and native rendering. Applications provide
|
||||
schemas, acquisition, domain resolution and validation, and construction of
|
||||
inspection providers. CommandKit exposes those capabilities without implementing
|
||||
a parallel configuration or prompt engine. Use independently released public
|
||||
contracts; do not import sibling internal packages or depend on adjacent checkouts.
|
||||
inspection providers. CommandKit exposes those capabilities through supported
|
||||
upstream APIs. The public contract of the adopted library version governs their
|
||||
meaning and disclosure rules. When CommandKit adopts an upstream revision,
|
||||
changes to those semantics flow through its integration. Adaptation and
|
||||
documentation follow the adopted contract; descriptions of upstream behavior
|
||||
do not establish a separate, fixed CommandKit specification of that behavior.
|
||||
|
||||
CommandKit owns presentation and may summarize or transform returned safe data
|
||||
for different operator needs. Reports can evolve in format, organization, and
|
||||
detail while respecting upstream disclosure rules and the evidence supplied by
|
||||
the capability. Exact report contracts belong in the CLI reference when
|
||||
implemented; changes to CommandKit's published contracts follow the release
|
||||
policy.
|
||||
|
||||
Use independently released public contracts; do not import sibling internal
|
||||
packages or depend on adjacent checkouts.
|
||||
|
||||
Use narrow integration contracts, reusing upstream public types where useful and
|
||||
introducing CommandKit-owned abstractions where they simplify integration.
|
||||
@@ -170,8 +256,10 @@ Concrete integration needs guide provider representations under
|
||||
Native report writers and safe inspection data preserve owning-library semantics.
|
||||
Loading all selected views before report output is a `config show` decision under
|
||||
[ADR 0007](../adr/0007-native-yaml-reports.md); other inspection operations define
|
||||
their own output timing. Semantic comparison uses supported safe snapshots and
|
||||
reports sensitivity gaps under [ADR 0008](../adr/0008-semantic-configuration-diff.md).
|
||||
their own output timing. Semantic comparison exposes the adopted upstream
|
||||
comparison API, carrying its results and relevant metadata through the provider
|
||||
boundary. CommandKit owns report presentation under
|
||||
[ADR 0008](../adr/0008-semantic-configuration-diff.md).
|
||||
|
||||
## Scope And Non-goals
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.11
|
||||
|
||||
**Last Updated**: 2026-10-06
|
||||
**Last Updated**: 2026-10-10
|
||||
|
||||
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||
|
||||
@@ -147,6 +147,15 @@ mechanisms only.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
ADRs record durable architectural decisions: responsibility boundaries,
|
||||
ownership, significant constraints, and the reasons for choosing them. A settled
|
||||
implementation or presentation choice does not automatically belong in an ADR.
|
||||
Ask whether changing the detail would alter the architecture or represent
|
||||
reasonable evolution within it. Details that can evolve within the chosen
|
||||
architecture belong in API or CLI contracts, implementation documentation, or
|
||||
roadmaps. Describe upstream behavior as context without turning it into an
|
||||
independent project guarantee.
|
||||
|
||||
Use sequentially numbered ADR filenames such as
|
||||
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.6
|
||||
|
||||
**Last Updated**: 2026-10-08
|
||||
**Last Updated**: 2026-10-11
|
||||
|
||||
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||
|
||||
@@ -16,17 +16,20 @@ Adopt stronger verification or reproducibility requirements through an explicit
|
||||
project-specific decision justified by the intended audience and support needs.
|
||||
|
||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||
policy and link to it rather than introduce additional release requirements.
|
||||
summaries belong in each released module's `docs/releases/` directory. The
|
||||
procedure must agree with the adopted policy and link to it rather than introduce
|
||||
additional release requirements.
|
||||
|
||||
## CommandKit Release Scope
|
||||
|
||||
CommandKit releases source-only Go modules. Standard CLI behavior is exposed by
|
||||
consuming applications; CommandKit does not declare binary deliverables or a
|
||||
cross-platform build matrix. ReleaseKit v0.3.0 is selected as the maintainer
|
||||
publication tool. The [release procedure](../release.md) owns its source-only
|
||||
workflow and repository-specific commands; the integration adds no application
|
||||
command family or runtime ReleaseKit import.
|
||||
CommandKit independently versions three source-only Go modules: the root core,
|
||||
`integrations/ckconfig`, and `integrations/ckprompt`. Standard CLI behavior is
|
||||
exposed by consuming applications; CommandKit does not declare binary
|
||||
deliverables or a cross-platform build matrix. ReleaseKit v0.4.2 is selected as
|
||||
the maintainer publication tool. The [release procedure](../release.md) owns
|
||||
the selected-module workflow, release order, and repository-specific commands;
|
||||
the integration adds no application command family or runtime ReleaseKit
|
||||
import.
|
||||
|
||||
## Release Model And Completion
|
||||
|
||||
@@ -123,9 +126,18 @@ rules above.
|
||||
|
||||
## Release Documentation And Preparation
|
||||
|
||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||
source tree. Use the complete version tag as the filename and begin with a
|
||||
title containing the product name and that exact version.
|
||||
Every new module release must include `docs/releases/<version>.md` relative to
|
||||
the selected module directory in the tagged source tree. Here `<version>` is
|
||||
that module's stable semantic version, `vMAJOR.MINOR.PATCH`; use it as the
|
||||
filename and begin with a title containing the selected release profile's
|
||||
product name and that exact semantic version.
|
||||
|
||||
The semantic version and full Git tag are distinct. The root module's tag is
|
||||
its semantic version; an integration's full tag includes its module prefix,
|
||||
as recorded in the [release procedure](../release.md#modules-versions-notes-and-shared-files).
|
||||
Use the full tag for Git refs and immutable source URLs, with repository-relative
|
||||
paths identifying the selected module's note, grant, license, and attribution.
|
||||
The module prefix is not part of the note filename or title's version.
|
||||
|
||||
Include a short summary and any relevant compatibility impact or upgrade
|
||||
instructions. Choose sections suited to the release and keep the note
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-10-07
|
||||
**Last Updated**: 2026-10-17
|
||||
|
||||
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
|
||||
|
||||
@@ -70,7 +70,7 @@ implemented:
|
||||
|
||||
Use external-package consumer tests for facade semantics and real backend
|
||||
integration where correctness depends on that interaction. Internal package
|
||||
tests own focused mechanisms. Do not retest the dependencies' complete suites.
|
||||
tests own focused mechanisms.
|
||||
Boundary checks protect selected architectural constraints; behavioral evidence
|
||||
protects invocation semantics and ownership through conversions.
|
||||
|
||||
@@ -79,7 +79,7 @@ concurrency guarantees only if deliberately selected. Coverage targets, race
|
||||
testing, static analysis, example gates, and platform matrices require their own
|
||||
risk justification; another library's workflow does not establish them here.
|
||||
|
||||
To the extent the following features are present in the application, prioritize tests for:
|
||||
For behavior owned by CommandKit, prioritize tests for:
|
||||
|
||||
1. Public and package-level contracts.
|
||||
2. Domain rules and important invariants.
|
||||
@@ -106,6 +106,41 @@ application's guarantees. Account for protection already provided at other layer
|
||||
|
||||
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
|
||||
|
||||
## Upstream integration testing ownership
|
||||
|
||||
CommandKit tests the interfaces and adapters through which it exposes upstream
|
||||
APIs. ConfigKit, PromptKit, and other integrated libraries own tests establishing
|
||||
the correctness of their domain functionality. Trust their public contracts;
|
||||
CommandKit tests must not independently establish whether those libraries
|
||||
implement their claimed domain functionality. This applies to partial feature
|
||||
checks as well as complete suites.
|
||||
|
||||
Integration assertions should establish CommandKit-owned behavior:
|
||||
|
||||
- CLI choices, typed inputs, configuration selection, and context reach the
|
||||
appropriate upstream API with the intended values.
|
||||
- CommandKit performs its selected coordination and preserves returned data,
|
||||
diagnostics, and supported error identity across adaptation.
|
||||
- CommandKit's reports and invocation outcomes correctly represent the returned
|
||||
evidence, including presence, null, empty, and redacted values where applicable.
|
||||
- Command availability, input/state lifetimes, cancellation handling, and output
|
||||
delivery follow CommandKit's contracts.
|
||||
|
||||
For example, test that a namespace choice becomes the intended native listing
|
||||
filter, rather than testing which resources PromptKit considers members of a
|
||||
namespace. Test faithful presentation of ConfigKit's returned source evidence,
|
||||
rather than its destination resolution, version checking, or sensitivity
|
||||
classification. Test request forwarding and returned-error handling, rather than
|
||||
whether PromptKit correctly detects missing artifacts or invalid templates.
|
||||
|
||||
A real upstream engine or result can be a useful fixture for a focused
|
||||
integration test. Its use does not change assertion ownership: the test should
|
||||
detect incorrect CommandKit adaptation, not independently establish upstream
|
||||
domain correctness. Use the simplest suitable fixture or test double; this
|
||||
boundary does not require new mocking interfaces or prohibit real dependencies
|
||||
in tests. CommandKit's own parsing, registration, coordination, presentation,
|
||||
and outcome behavior remain within its testing scope.
|
||||
|
||||
## Choose the right test boundary
|
||||
|
||||
Choose the narrowest stable boundary that expresses the behavior clearly and
|
||||
|
||||
234
docs/release.md
234
docs/release.md
@@ -1,47 +1,46 @@
|
||||
# CommandKit release procedure
|
||||
|
||||
## Scope and prerequisites
|
||||
## Scope and release order
|
||||
|
||||
CommandKit publishes source-only Go module releases: an immutable annotated
|
||||
version tag for one committed candidate and its matching checked-in release
|
||||
note. This procedure does not publish a hosted release page, binaries,
|
||||
checksums, or other delivery assets, and it requires no delivery credentials.
|
||||
CommandKit publishes source-only Go module releases. Each module has an
|
||||
independent version and immutable annotated tag. This procedure publishes no
|
||||
hosted release page, binaries, checksums, or other delivery assets. A successful
|
||||
source-tag push is release completion; CI, module proxy availability, and
|
||||
consumer installation are not completion gates.
|
||||
|
||||
Use branch `main`, remote `origin`, and the expected effective push endpoint
|
||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The profile enforces that
|
||||
endpoint during publication. The maintainer needs the selected
|
||||
[release toolchain](#release-toolchain), Git with push access to `origin`, and a
|
||||
POSIX shell.
|
||||
Publish in dependency order: core, then `ckconfig`, then `ckprompt`. The
|
||||
integration modules require the preceding CommandKit releases, so prepare and
|
||||
publish each module only after its CommandKit dependencies are available. A root
|
||||
release runs only the root module's default suite; it need not run suites for
|
||||
independently released integrations.
|
||||
|
||||
This procedure follows the [release policy](policy/release.md); the release-note
|
||||
footer follows the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||
Use branch `main`, remote `origin`, and expected effective push endpoint
|
||||
`https://gitea.maximumdirect.net/eric/CommandKit.git`. The selected module profile
|
||||
enforces the endpoint. The maintainer needs the selected
|
||||
[release toolchain](#release-toolchain), Git push access, and a POSIX shell.
|
||||
This procedure follows the [release policy](policy/release.md); release-note
|
||||
footers follow the [OSS discoverability policy](policy/oss.md#discoverability).
|
||||
|
||||
ReleaseKit v0.3.0 owns candidate validation, publication guards, annotated tag
|
||||
creation, tag publication, and outcome reporting. The repository profile selects
|
||||
only its default candidate suite. The launcher bootstraps the module-selected
|
||||
native executable into ignored `.releasekit/` storage and invokes its `publish`
|
||||
command. The upstream [CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md),
|
||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/config.md),
|
||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/consumers/publish.md)
|
||||
own detailed command, profile, and status contracts.
|
||||
ReleaseKit v0.4.2 owns candidate validation, publication guards, tag creation,
|
||||
tag publication, and outcome reporting. Its pinned public contracts are the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/cli.md),
|
||||
[profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/config.md),
|
||||
and [publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/docs/consumers/publish.md).
|
||||
The repository selects one default module suite per invocation. The launcher
|
||||
bootstraps the native tool into ignored `.releasekit/` storage and delegates to
|
||||
its `publish` command.
|
||||
|
||||
## Release toolchain
|
||||
|
||||
The selected release toolchain is **Go 1.26.8**. The consumer minimum is
|
||||
**Go 1.26.0**; validation uses a later patch within that supported Go series.
|
||||
The [consumer guide](consumers/guide.md#go-compatibility) explains the minimum and
|
||||
its shared tool-module requirement; the
|
||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain) governs
|
||||
changes to either selection.
|
||||
The selected release toolchain is **Go 1.26.8**. Each module declares a consumer
|
||||
minimum of **Go 1.26.0**. Maintainers use Go 1.26.8 for dependency preparation,
|
||||
candidate validation, and the ReleaseKit bootstrap. The
|
||||
[consumer guide](consumers/guide.md#go-compatibility) and
|
||||
[release policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
describe these selections.
|
||||
|
||||
Use this toolchain for candidate validation and the native ReleaseKit bootstrap,
|
||||
including CI jobs that perform those tasks. The launcher and ReleaseKit invoke
|
||||
`go` from `PATH` with `GOTOOLCHAIN=local`, so select that installation in the
|
||||
shell used for preparation and publication.
|
||||
|
||||
If it is not installed, use the [standard Go installation method](https://go.dev/doc/manage-install).
|
||||
Ensure Go's executable installation directory (`GOBIN`, or `GOPATH/bin` by
|
||||
default) is on `PATH` so the downloaded-version wrapper can be invoked:
|
||||
Select Go 1.26.8 on `PATH`; the launcher uses `GOTOOLCHAIN=local`. If needed,
|
||||
install it with the standard Go tools:
|
||||
|
||||
```sh
|
||||
GOWORK=off GOTOOLCHAIN=local go install golang.org/dl/go1.26.8@latest
|
||||
@@ -51,115 +50,88 @@ hash -r
|
||||
GOWORK=off GOTOOLCHAIN=local go version
|
||||
```
|
||||
|
||||
The final command should identify Go 1.26.8. An existing Go 1.26.8 installation
|
||||
may be selected directly on `PATH` instead. Toolchain installation and dependency
|
||||
preparation precede the offline publication checks; they add no validation pass.
|
||||
## Modules, versions, notes, and shared files
|
||||
|
||||
## Prepare and publish a candidate
|
||||
Select exactly one module with the launcher:
|
||||
|
||||
Choose a fresh, unused stable Semantic Version tag in the form
|
||||
`vMAJOR.MINOR.PATCH`, with no leading zeroes. Before `v1.0.0`, a minor release
|
||||
may change a documented contract if its note explains the impact and consumer
|
||||
action; a patch release must not intentionally break contracts within its
|
||||
minor line. Never reuse a version previously published through any release
|
||||
channel. The first version is selected during release preparation, not embedded
|
||||
in the tool or profile.
|
||||
| Selector | Physical module directory | Release note | Full tag |
|
||||
| --- | --- | --- | --- |
|
||||
| `core` | repository root | `docs/releases/<version>.md` | `<version>` |
|
||||
| `ckconfig` | `integrations/ckconfig` | `integrations/ckconfig/docs/releases/<version>.md` | `integrations/ckconfig/<version>` |
|
||||
| `ckprompt` | `integrations/ckprompt` | `integrations/ckprompt/docs/releases/<version>.md` | `integrations/ckprompt/<version>` |
|
||||
|
||||
Start from `main` and prepare a note at `docs/releases/<version>.md`. Begin with
|
||||
a heading containing `CommandKit` and the exact version. Include a useful
|
||||
summary and any compatibility impact or upgrade instructions. End it with the
|
||||
license and attribution links at that version's immutable source tag, replacing
|
||||
`VERSION` with the selected tag:
|
||||
Use a fresh stable semantic version (`vMAJOR.MINOR.PATCH`) for the selected
|
||||
module. Before `v1.0.0`, a minor release may change a documented contract when
|
||||
its note explains the impact and consumer action. Never reuse a published
|
||||
version. Each release note starts with a product title and exact version, gives
|
||||
a proportionate summary and compatibility guidance, and ends with immutable
|
||||
links to that module's local grant, license, and attribution paths. The full tag
|
||||
in each URL includes the integration directory where applicable. Future tag
|
||||
URLs need not resolve before publication.
|
||||
|
||||
```markdown
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||
```
|
||||
All three release profiles explicitly select the repository-root `COPYRIGHT`,
|
||||
`LICENSE`, and `docs/oss/attribution.md` through `validation.shared_files`.
|
||||
ReleaseKit requires these committed files to exist in the candidate even when
|
||||
no optional file checks are enabled. The list does not enable content checks
|
||||
or expand Go validation scope; see the pinned [profile reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/b96262eb0ce9c8f2eb3e948a5841db2b799bc91b/docs/config.md).
|
||||
|
||||
Review these paths in the candidate; the future tag URLs do not need to resolve
|
||||
before publication.
|
||||
Each integration also distributes its own `COPYRIGHT`, `LICENSE`, and
|
||||
`docs/oss/attribution.md`. These module-local materials are separate from the
|
||||
explicit root shared-file list and remain subject to the [OSS policy](policy/oss.md).
|
||||
Automatic module file inputs include applicable module-local files and stop at
|
||||
independent nested-module boundaries. Their inclusion does not itself enable
|
||||
license or content validation.
|
||||
|
||||
Prepare dependencies separately from the offline candidate suite:
|
||||
The current profiles enable no optional file checks or additional product
|
||||
checks. Publication runs the selected module's standard offline suite and
|
||||
ReleaseKit's candidate/note and publication guards. The profiles select their
|
||||
physical module directory while using the same repository and endpoint.
|
||||
|
||||
Run these commands in the same shell session so the version variable remains
|
||||
set:
|
||||
|
||||
```sh
|
||||
commandkit_version=vX.Y.Z
|
||||
GOWORK=off GOTOOLCHAIN=local go mod download
|
||||
```
|
||||
|
||||
If dependency preparation changes `go.mod` or `go.sum`, review and commit those
|
||||
changes with the candidate. Commit the note and all candidate changes on `main`,
|
||||
then publish that commit to `origin`:
|
||||
## Prepare and publish one candidate
|
||||
|
||||
Prepare dependencies in the selected module before the candidate is finalized.
|
||||
Do this after the preceding CommandKit module has been published and before the
|
||||
selected candidate suite is run. For `core`, run the command at the repository
|
||||
root; for integrations, run it from that module directory. Commit any resulting
|
||||
`go.mod` or `go.sum` changes with the release candidate. The sum files must
|
||||
reflect dependencies actually downloaded for that module; do not fabricate
|
||||
future sums.
|
||||
|
||||
Then commit the selected module's release note and all intended candidate
|
||||
changes on `main`, and publish the candidate commit to `origin`. Set one
|
||||
selector, module directory, and version from the table above, then run:
|
||||
|
||||
```sh
|
||||
module_dir=.
|
||||
module_selector=core
|
||||
module_version=v0.2.0
|
||||
(cd "$module_dir" && GOWORK=off GOTOOLCHAIN=local go mod download)
|
||||
# Commit reviewed dependency changes and the selected module's note on main.
|
||||
git push origin HEAD:refs/heads/main
|
||||
./scripts/release "$commandkit_version"
|
||||
./scripts/release "$module_selector" "$module_version"
|
||||
```
|
||||
|
||||
The launcher builds the ReleaseKit version selected by the module in readonly
|
||||
mode. After preflight, `publish` runs the offline default Go suite once, checks
|
||||
the candidate and remote guards, creates an annotated tag from the committed
|
||||
note, and pushes only that tag object. The launcher does not push `main`; push
|
||||
the candidate first. `check` is available for optional diagnosis and is not a
|
||||
required step before `publish`. The automated publication workflow runs the
|
||||
final-candidate suite once; configured additional checks should avoid duplicating
|
||||
that suite. Maintainers may rerun diagnostics when useful. Successful source
|
||||
publication does not wait for CI, module availability, or consumer installation.
|
||||
Run only the command for the selected module. Its profile runs the complete
|
||||
offline deterministic default suite once for that module, checks candidate and
|
||||
remote guards, creates the annotated tag from the matching note, and pushes
|
||||
only that full tag. `check` is available for diagnosis and is not a required
|
||||
pre-publication command. The launcher does not push `main`; publish the
|
||||
candidate commit first. Successful source publication does not wait for CI,
|
||||
proxy availability, or downstream installation.
|
||||
|
||||
## Completion and statuses
|
||||
## Completion and ambiguous publication
|
||||
|
||||
Publication is complete when the intended annotated tag is successfully pushed
|
||||
to the expected endpoint, or when an exact tag-object query confirms that push
|
||||
after an error. Status 0 reports confirmed publication. Status 1 reports a
|
||||
known failure or conflict; status 2 reports invalid input or profile; status 3
|
||||
means the push outcome remains unresolved. See the upstream [CLI status
|
||||
contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/e43df4416e0ed987349b2ea04a2c68c25e552772/docs/cli.md).
|
||||
A successful push to the expected endpoint completes the source release. On an
|
||||
ambiguous result, use the exact endpoint, full tag, and annotated tag object
|
||||
reported by that invocation. Confirm endpoint resolution has not changed, then
|
||||
query only that exact tag ref. Compare the remote annotated tag-object ID with
|
||||
the reported object: an exact match confirms publication, a different object is
|
||||
a conflict, successful output without the ref proves absence at that endpoint,
|
||||
and a failed query leaves the result unresolved. Do not infer the captured
|
||||
endpoint's state from another remote or a rewritten URL.
|
||||
|
||||
For a failed or ambiguous push, use the exact endpoint, version, and annotated
|
||||
tag object reported by the invocation. Confirm that Git still resolves the
|
||||
captured endpoint to itself before querying only that tag ref:
|
||||
|
||||
```sh
|
||||
releasekit_endpoint='COPY THE CAPTURED ENDPOINT EXACTLY'
|
||||
releasekit_version='vX.Y.Z'
|
||||
releasekit_tag_object='COPY THE REPORTED TAG OBJECT ID'
|
||||
releasekit_ref="refs/tags/$releasekit_version"
|
||||
|
||||
releasekit_resolved_endpoint=$(git ls-remote --get-url -- "$releasekit_endpoint") || {
|
||||
printf '%s\n' 'publication state is unresolved: endpoint resolution failed' >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$releasekit_resolved_endpoint" != "$releasekit_endpoint" ]; then
|
||||
printf '%s\n' 'publication state is unresolved: endpoint resolution changed' >&2
|
||||
exit 1
|
||||
fi
|
||||
releasekit_remote_tag=$(git ls-remote --refs -- "$releasekit_endpoint" "$releasekit_ref") || {
|
||||
printf '%s\n' 'publication state is unresolved: remote query failed' >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$releasekit_remote_tag"
|
||||
```
|
||||
|
||||
`--refs` returns the annotated tag object's ID, not its peeled commit. Compare
|
||||
that ID with `releasekit_tag_object`: an exact match confirms publication; a
|
||||
different object is a conflict; successful output with no matching ref proves
|
||||
absence at the captured endpoint. A failed query or changed URL resolution is
|
||||
unresolved, never evidence of absence. Do not query a different fetch URL or
|
||||
follow a rewritten endpoint to infer the captured destination's state.
|
||||
|
||||
ReleaseKit retains a local tag after a failed push, so the tag blocks an ordinary
|
||||
retry. After proving the tag absent at the captured endpoint,
|
||||
confirm that it was never published or used by another release channel before
|
||||
deliberately removing the unpublished local tag:
|
||||
|
||||
```sh
|
||||
git tag -d "$releasekit_version"
|
||||
```
|
||||
|
||||
Do not move, overwrite, recreate, or delete a published tag. If the exact tag
|
||||
object is present remotely, keep it. Resolve conflicts with a fresh version;
|
||||
corrections to published source require a new version. A transport failure
|
||||
alone does not require a new version.
|
||||
ReleaseKit may retain a local tag after a failed push. Remove an unpublished
|
||||
local tag only after proving absence at the captured endpoint and confirming
|
||||
that the tag was never published or used by another release channel. Never
|
||||
move, overwrite, recreate, or delete a published tag. Resolve conflicts with a
|
||||
fresh version; corrections to published source require a new version.
|
||||
|
||||
52
docs/releases/v0.2.0.md
Normal file
52
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# CommandKit v0.2.0
|
||||
|
||||
## Summary
|
||||
|
||||
CommandKit v0.2.0 replaces the core registration API with reusable captured
|
||||
command declarations and moves ConfigKit and PromptKit support into independently
|
||||
versioned optional modules. This is a source-only release with incompatible API
|
||||
changes from v0.1.0.
|
||||
|
||||
## Changes
|
||||
|
||||
- Capture typed command specifications with `NewCommand`, then install the
|
||||
resulting `Command` declarations through `commandkit.New` or `App.Register`.
|
||||
Installation validates a batch before changing the application's registry.
|
||||
- Reuse captured declarations across applications. Typed bindings preserve
|
||||
supplied values separately from defaults, and input construction occurs only
|
||||
when execution reaches the command.
|
||||
- Keep ConfigKit and PromptKit dependencies outside the core module. Applications
|
||||
explicitly import integrations and install their returned declarations.
|
||||
- Adopt ReleaseKit v0.4.2 for independently versioned module publication. It
|
||||
remains a maintainer tool dependency; production packages do not import it.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
The consumer minimum remains **Go 1.26.0**. The selected maintainer release
|
||||
toolchain remains Go 1.26.8.
|
||||
|
||||
Consumers upgrading from v0.1.0 must rewrite command declarations, typed
|
||||
bindings, and installation wiring for the captured declaration API. There is no
|
||||
compatibility layer for the earlier registration API. Move configuration
|
||||
integration code to
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`, and PromptKit
|
||||
integration code to
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`.
|
||||
|
||||
The integrations' initial releases are each v0.1.0; their version numbers are
|
||||
independent of core and upstream dependency versions. They add native
|
||||
configuration comparison, richer source evidence, PromptKit requirements
|
||||
discovery and validation, five catalogs, and opt-in smoke testing. Their own
|
||||
release notes describe the features and operator changes:
|
||||
|
||||
- [ckconfig v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/docs/releases/v0.1.0.md)
|
||||
- [ckprompt v0.1.0](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/docs/releases/v0.1.0.md)
|
||||
|
||||
See the [consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/consumers/guide.md)
|
||||
for migration examples and the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/cli.md)
|
||||
for standard command and report contracts.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/v0.2.0/docs/oss/attribution.md)
|
||||
@@ -5,8 +5,8 @@ Status: retained future work and unselected proposals; no implementation schedul
|
||||
## Purpose and authority
|
||||
|
||||
Completed initial delivery, performance, ReleaseKit upgrade, and remediation
|
||||
records have been retired. This document preserves their unfinished capability
|
||||
targets and deferred ideas under the
|
||||
records have been retired. This document preserves remaining deferred
|
||||
capabilities and unselected ideas under the
|
||||
[release policy](../policy/release.md#release-documentation-and-preparation).
|
||||
The original records remain available in
|
||||
[Git history](https://gitea.maximumdirect.net/eric/CommandKit/src/commit/6bc37e7ba64ca0920b4d0cc77039a18d4006b24e/docs/roadmap).
|
||||
@@ -18,36 +18,12 @@ an idea here does not select its syntax, API, priority, or delivery date.
|
||||
|
||||
## Deferred capability targets
|
||||
|
||||
### DIFF-01: semantic configuration comparison
|
||||
### Comparison extensions
|
||||
|
||||
Expose comparison of two independently resolved selections of one scope using
|
||||
owning-library safe snapshots. [ADR 0008](../adr/0008-semantic-configuration-diff.md)
|
||||
owns the accepted comparison semantics, sensitivity union, and coverage-gap
|
||||
handling. The target includes text/YAML reports of differences and gaps;
|
||||
completed comparisons can succeed even when they find either.
|
||||
|
||||
The initial review of ConfigKit v0.5.0 and PromptKit v0.12.0 did not find the
|
||||
complete public snapshot/sensitivity contract needed for that target. Reassess
|
||||
released upstream APIs when planning it; do not treat that historical review as
|
||||
a claim about newer versions. Hidden-path metadata and supported gap reasons
|
||||
must be available without fetching secrets. Rendered YAML and sibling internals
|
||||
are not substitutes for an agreed public comparison representation.
|
||||
|
||||
Comparison selection syntax, context inheritance, and report layout remain
|
||||
future design work. Earlier possibilities included right-side root/overlay
|
||||
selection, shared versus independent domain context, and an optional assertion
|
||||
mode. Those choices are unselected. Add public types and handlers when the
|
||||
capability is supported, rather than exposing placeholders.
|
||||
|
||||
### CAT-01: PromptKit catalogs
|
||||
|
||||
List captured prompt/version entries and named profiles through owning public
|
||||
catalog APIs, with deterministic reports of loaded definitions. Resource names,
|
||||
enumeration details, and report shapes remain future design work.
|
||||
|
||||
The initial PromptKit v0.12.0 review did not find public enumeration APIs.
|
||||
Reassess current released capabilities before planning delivery. Do not infer
|
||||
catalogs by reparsing configuration YAML or inspecting sibling internals.
|
||||
Independent per-side domain selectors and an optional assertion mode remain
|
||||
unselected extensions to the delivered ConfigKit comparison. Consider them only
|
||||
for a concrete consumer need and a deliberate API/behavior decision under the
|
||||
relevant ADRs.
|
||||
|
||||
## Other deferred proposals
|
||||
|
||||
@@ -57,7 +33,8 @@ operator or consumer need and an appropriate ownership/behavior decision:
|
||||
- JSON reports, shell completion, and output-file management or reloadable export.
|
||||
- Configuration mutation, generic value assignment, and exhaustive field-origin
|
||||
inventories.
|
||||
- Credential diagnostics and live provider probes.
|
||||
- Credential diagnostics beyond PromptKit's native smoke checks and additional
|
||||
live provider probes.
|
||||
- Additional component identity registration and domain artifact catalogs.
|
||||
- Additional library families, including possible FeedKit integration; families
|
||||
can define suitable verbs without requiring comparison or configuration scopes.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Composed consumer
|
||||
|
||||
This example composes application configuration, PromptKit configuration,
|
||||
definition inspection, and one application-owned command:
|
||||
|
||||
```sh
|
||||
go run . config scopes
|
||||
go run . config validate --scope all --campaign spring
|
||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run . promptkit show profile spring --campaign spring
|
||||
go run . session restore session-42 --campaign spring
|
||||
```
|
||||
|
||||
The sources and session store are small in-memory fixtures. `--campaign` is a
|
||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
||||
scope contexts share a per-invocation cache created by `ConfigSuite.NewContext`.
|
||||
Prompt/profile inspection has its own invocation context and composes the same
|
||||
application and engine loader functions.
|
||||
@@ -1,223 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
type campaign struct {
|
||||
PromptID string `yaml:"prompt_id"`
|
||||
PromptVersion string `yaml:"prompt_version"`
|
||||
ProfileID string `yaml:"profile_id"`
|
||||
Model string `yaml:"model"`
|
||||
}
|
||||
|
||||
type appConfiguration struct {
|
||||
DefaultCampaign string `yaml:"default_campaign"`
|
||||
Campaigns map[string]campaign `yaml:"campaigns"`
|
||||
}
|
||||
|
||||
type invocationCache struct {
|
||||
application *configkit.Result[appConfiguration]
|
||||
}
|
||||
|
||||
type configContext struct {
|
||||
Cache *invocationCache
|
||||
Campaign commandkit.Supplied[string]
|
||||
}
|
||||
|
||||
type definitionContext struct {
|
||||
Cache *invocationCache
|
||||
Campaign commandkit.Supplied[string]
|
||||
}
|
||||
|
||||
type restoreInput struct {
|
||||
Campaign commandkit.Supplied[string]
|
||||
Session string
|
||||
}
|
||||
|
||||
type session struct {
|
||||
ID string
|
||||
Campaign string
|
||||
Summary string
|
||||
}
|
||||
|
||||
type sessionStore struct {
|
||||
active string
|
||||
records map[string]session
|
||||
}
|
||||
|
||||
func loadApplicationConfig(_ context.Context, _ commandkit.Selection, cache *invocationCache) (*configkit.Result[appConfiguration], error) {
|
||||
if cache != nil && cache.application != nil {
|
||||
return cache.application, nil
|
||||
}
|
||||
result, err := configkit.Load[appConfiguration](configkit.Mapping("composed app config", map[string]any{
|
||||
"default_campaign": "spring",
|
||||
"campaigns": map[string]any{
|
||||
"spring": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "spring", "model": "spring-model"},
|
||||
"autumn": map[string]any{"prompt_id": "briefing", "prompt_version": "1", "profile_id": "autumn", "model": "autumn-model"},
|
||||
},
|
||||
}))
|
||||
if err == nil && cache != nil {
|
||||
cache.application = result
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func selectedCampaign(configuration appConfiguration, supplied commandkit.Supplied[string]) (string, campaign, error) {
|
||||
name := configuration.DefaultCampaign
|
||||
if supplied.Set {
|
||||
name = supplied.Value
|
||||
}
|
||||
value, ok := configuration.Campaigns[name]
|
||||
if !ok {
|
||||
return "", campaign{}, fmt.Errorf("unknown campaign %q", name)
|
||||
}
|
||||
return name, value, nil
|
||||
}
|
||||
|
||||
func loadPromptEngine(ctx context.Context, selection commandkit.Selection, cache *invocationCache, selected commandkit.Supplied[string]) (*promptkit.Engine, error) {
|
||||
application, err := loadApplicationConfig(ctx, selection, cache)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, chosen, err := selectedCampaign(application.Config, selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
engineConfig := configkit.Mapping("campaign "+name, map[string]any{
|
||||
"profiles": map[string]any{chosen.ProfileID: map[string]any{
|
||||
"endpoint": "https://example.invalid/v1",
|
||||
"model": chosen.Model,
|
||||
"extra_params": map[string]any{
|
||||
"campaign": name,
|
||||
},
|
||||
}},
|
||||
"prompts": map[string]any{chosen.PromptID: map[string]any{
|
||||
chosen.PromptVersion: map[string]any{
|
||||
"default_profile": chosen.ProfileID,
|
||||
"inputs": []any{map[string]any{"name": "topic", "required": true, "content_type": "text/plain", "description": "Topic to brief"}},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Write a concise briefing about {{.topic}}."}},
|
||||
"output": map[string]any{"format": "text", "validation_mode": "none"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
return promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: engineConfig}}})
|
||||
}
|
||||
|
||||
func campaignSelector[C any](set func(*C, commandkit.Supplied[string])) commandkit.Selector[C] {
|
||||
return commandkit.Selector[C]{
|
||||
Binding: commandkit.ValueFlag[C](commandkit.FlagMeta{Name: "campaign", Usage: "Select a configured campaign."}, nil, func(value string) (string, error) { return value, nil }, set),
|
||||
Scopes: []string{"app", "promptkit"},
|
||||
}
|
||||
}
|
||||
|
||||
func newApp() (*commandkit.App, error) {
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "composed-example", Version: "1.0"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appScope, err := commandkit.NewConfigKitScope[configContext, appConfiguration]("app", "Application campaigns.", func(ctx context.Context, selection commandkit.Selection, input configContext) (*configkit.Result[appConfiguration], error) {
|
||||
return loadApplicationConfig(ctx, selection, input.Cache)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
promptScope, err := commandkit.NewPromptKitScope(func(ctx context.Context, selection commandkit.Selection, input configContext) (*promptkit.Engine, error) {
|
||||
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configSuite := commandkit.ConfigSuite[configContext]{
|
||||
NewContext: func() configContext { return configContext{Cache: &invocationCache{}} },
|
||||
Selectors: []commandkit.Selector[configContext]{campaignSelector(func(input *configContext, value commandkit.Supplied[string]) {
|
||||
input.Campaign = value
|
||||
})},
|
||||
Scopes: []commandkit.ConfigScope[configContext]{appScope, promptScope},
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, configSuite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
definitionSuite, err := commandkit.NewPromptKitSuite(func(ctx context.Context, selection commandkit.Selection, input definitionContext) (*promptkit.Engine, error) {
|
||||
return loadPromptEngine(ctx, selection, input.Cache, input.Campaign)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definitionSuite.NewContext = func() definitionContext { return definitionContext{Cache: &invocationCache{}} }
|
||||
definitionSuite.Selectors = []commandkit.Selector[definitionContext]{campaignSelector(func(input *definitionContext, value commandkit.Supplied[string]) {
|
||||
input.Campaign = value
|
||||
})}
|
||||
if err := commandkit.RegisterPromptKit(app, definitionSuite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store := &sessionStore{records: map[string]session{"session-42": {ID: "session-42", Campaign: "spring", Summary: "A synthetic planning session."}}}
|
||||
restore, err := commandkit.NewCommand(commandkit.CommandSpec[restoreInput]{
|
||||
Name: "restore",
|
||||
Summary: "Restore one saved session.",
|
||||
Flags: []commandkit.Flag[restoreInput]{commandkit.ValueFlag[restoreInput](commandkit.FlagMeta{Name: "campaign", Usage: "Campaign owning the session."}, nil, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
|
||||
input.Campaign = value
|
||||
})},
|
||||
Arguments: []commandkit.Argument[restoreInput]{commandkit.ValueArgument[restoreInput](commandkit.ArgumentMeta{Name: "ID"}, true, func(value string) (string, error) { return value, nil }, func(input *restoreInput, value commandkit.Supplied[string]) {
|
||||
input.Session = value.Value
|
||||
})},
|
||||
Run: func(invocation commandkit.Invocation, input restoreInput) commandkit.Outcome {
|
||||
return restoreSession(invocation, store, input)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionGroup, err := commandkit.Group("session", "Manage saved sessions.", restore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := app.Register(sessionGroup); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func restoreSession(invocation commandkit.Invocation, store *sessionStore, input restoreInput) commandkit.Outcome {
|
||||
application, err := loadApplicationConfig(invocation.Context, commandkit.Selection{}, nil)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
name, _, err := selectedCampaign(application.Config, input.Campaign)
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
record, ok := store.records[input.Session]
|
||||
if !ok {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 4, Domain: "session_not_found", Cause: errors.New("saved session was not found")}
|
||||
}
|
||||
if record.Campaign != name {
|
||||
return commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 3, Domain: "campaign_mismatch", Cause: fmt.Errorf("session belongs to campaign %q", record.Campaign)}
|
||||
}
|
||||
store.active = record.ID
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "restored %s: %s\n", record.ID, record.Summary); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
}
|
||||
|
||||
func main() {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
|
||||
if outcome.Status != 0 {
|
||||
os.Exit(outcome.Status)
|
||||
}
|
||||
}
|
||||
60
examples/core/main.go
Normal file
60
examples/core/main.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
type greetInput struct {
|
||||
Count commandkit.Supplied[int]
|
||||
Tags commandkit.Supplied[[]string]
|
||||
}
|
||||
|
||||
func newApp() (*commandkit.App, error) {
|
||||
countDefault := 1
|
||||
tagDefaults := []string{"demo"}
|
||||
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
|
||||
Name: "greet",
|
||||
Summary: "Show presence-aware values and repeated labels.",
|
||||
Flags: []commandkit.Flag[greetInput]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
|
||||
input.Count = value
|
||||
}),
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
|
||||
input.Tags = value
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
count := fmt.Sprintf("default %d (not supplied)", input.Count.Value)
|
||||
if input.Count.Set {
|
||||
count = fmt.Sprintf("explicit %d", input.Count.Value)
|
||||
}
|
||||
tags := fmt.Sprintf("default %v (not supplied)", input.Tags.Value)
|
||||
if input.Tags.Set {
|
||||
tags = fmt.Sprintf("explicit %v", input.Tags.Value)
|
||||
}
|
||||
if _, err := fmt.Fprintf(invocation.Streams.Out, "Hello; count=%s; tags=%s\n", count, tags); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
workspace := commandkit.Group("workspace", "Workspace commands.", greet)
|
||||
return commandkit.New(commandkit.Identity{Name: "core-example", Version: "1.0"}, workspace)
|
||||
}
|
||||
|
||||
func main() {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
outcome := app.Execute(context.Background(), os.Args[1:], commandkit.Streams{Out: os.Stdout, Err: os.Stderr})
|
||||
if outcome.Status != 0 {
|
||||
os.Exit(outcome.Status)
|
||||
}
|
||||
}
|
||||
109
execute.go
109
execute.go
@@ -7,78 +7,85 @@ import (
|
||||
"io"
|
||||
|
||||
internalcobra "gitea.maximumdirect.net/eric/CommandKit/internal/cobra"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
commandmeta "gitea.maximumdirect.net/eric/CommandKit/internal/commandmeta"
|
||||
)
|
||||
|
||||
// Execute parses and dispatches one invocation. args excludes the executable
|
||||
// name. CommandKit writes diagnostics to streams.Err and leaves process exit to
|
||||
// the caller. A nil context returns an operational failure, and nil streams are
|
||||
// replaced with io.Discard.
|
||||
// name. Supplied values are converted before command input construction; help
|
||||
// does not construct caller inputs or run acquisition callbacks. CommandKit
|
||||
// writes diagnostics to streams.Err and leaves process exit to the caller. A
|
||||
// nil context returns an operational failure, and nil streams are replaced with
|
||||
// io.Discard.
|
||||
func (a *App) Execute(ctx context.Context, args []string, streams Streams) Outcome {
|
||||
if ctx == nil {
|
||||
return a.present(Failure(errors.New("invocation context must not be nil")), nil, streams)
|
||||
}
|
||||
streams = normalizeStreams(streams)
|
||||
current := newExecution(a, ctx, streams)
|
||||
routedSnapshot := current.snapshot
|
||||
parsed := current.parse(args)
|
||||
if parsed.Failure != nil {
|
||||
return current.present(Usage(errors.New(parsed.Failure.Message)), parsed.Path)
|
||||
}
|
||||
command, ok := a.commandAt(parsed.Path)
|
||||
if !ok || command.payload == nil {
|
||||
payload, ok := routedSnapshot.runners[parsed.Key]
|
||||
if !ok || payload == nil {
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
if parsed.HelpRequested || !command.payload.hasRun() {
|
||||
if _, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||
if parsed.HelpRequested || !payload.hasRun() {
|
||||
if _, err := payload.prepare(parsed.FlagValues, parsed.Operands, false); err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
return current.present(current.renderHelp(parsed.Path), parsed.Path)
|
||||
}
|
||||
buildInput, err := command.payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
run, err := payload.prepare(parsed.FlagValues, parsed.Operands, true)
|
||||
if err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
input := buildInput()
|
||||
if err := command.payload.checkInput(input); err != nil {
|
||||
return current.present(Usage(err), parsed.Path)
|
||||
}
|
||||
if len(parsed.Path) == 1 && parsed.Path[0] == "help" {
|
||||
help, ok := input.(*helpInput)
|
||||
if !ok {
|
||||
return current.present(Failure(errors.New("built-in help input has an unexpected type")), parsed.Path)
|
||||
}
|
||||
return current.present(current.renderHelpTargets(help.Targets.Value), parsed.Path)
|
||||
}
|
||||
outcome := command.payload.run(Invocation{Context: ctx, Streams: streams}, input)
|
||||
return current.present(outcome, parsed.Path)
|
||||
return current.present(run(current), parsed.Path)
|
||||
}
|
||||
|
||||
type execution struct {
|
||||
app *App
|
||||
ctx context.Context
|
||||
streams Streams
|
||||
options internalcobra.ParseOptions
|
||||
metadata *internalcobra.Command
|
||||
options commandmeta.ParseOptions
|
||||
snapshot *routingSnapshot
|
||||
session *internalcobra.Session
|
||||
}
|
||||
|
||||
type commandExecution struct {
|
||||
invocation Invocation
|
||||
renderHelpTargetsFunc func([]string) Outcome
|
||||
}
|
||||
|
||||
func (current *execution) commandContext() commandExecution {
|
||||
return commandExecution{
|
||||
invocation: Invocation{Context: current.ctx, Streams: current.streams},
|
||||
renderHelpTargetsFunc: current.renderHelpTargets,
|
||||
}
|
||||
}
|
||||
|
||||
func (current commandExecution) renderHelpTargets(targets []string) Outcome {
|
||||
return current.renderHelpTargetsFunc(targets)
|
||||
}
|
||||
|
||||
func newExecution(app *App, ctx context.Context, streams Streams) *execution {
|
||||
current := &execution{
|
||||
app: app,
|
||||
ctx: ctx,
|
||||
streams: streams,
|
||||
options: internalcobra.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
options: commandmeta.ParseOptions{Context: ctx, Out: streams.Out, Err: streams.Err},
|
||||
}
|
||||
if app != nil {
|
||||
current.metadata = app.bridgeTree()
|
||||
current.session = internalcobra.Prepare(current.metadata, current.options)
|
||||
current.snapshot = app.routeSnapshot()
|
||||
current.session = internalcobra.Prepare(current.snapshot.metadata, current.options)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func (current *execution) parse(args []string) internalcobra.Result {
|
||||
func (current *execution) parse(args []string) commandmeta.Result {
|
||||
if current.app == nil {
|
||||
return internalcobra.Result{Failure: &internalcobra.Failure{Message: "cannot parse commands on a nil app"}}
|
||||
return commandmeta.Result{Failure: &commandmeta.Failure{Message: "cannot parse commands on a nil app"}}
|
||||
}
|
||||
return current.session.Parse(args)
|
||||
}
|
||||
@@ -87,10 +94,10 @@ func (current *execution) presentationSession() *internalcobra.Session {
|
||||
if current.app == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := current.app.bridgeTree()
|
||||
if current.session == nil || current.session.Metadata() != metadata {
|
||||
current.metadata = metadata
|
||||
current.session = internalcobra.Prepare(metadata, current.options)
|
||||
snapshot := current.app.routeSnapshot()
|
||||
if current.session == nil || current.snapshot != snapshot {
|
||||
current.snapshot = snapshot
|
||||
current.session = internalcobra.Prepare(snapshot.metadata, current.options)
|
||||
}
|
||||
return current.session
|
||||
}
|
||||
@@ -136,34 +143,6 @@ func normalizeStreams(streams Streams) Streams {
|
||||
return streams
|
||||
}
|
||||
|
||||
func (a *App) commandAt(path []string) (Command, bool) {
|
||||
if a == nil {
|
||||
return Command{}, false
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return Command{}, true
|
||||
}
|
||||
command, ok := a.commands[path[0]]
|
||||
if !ok || (command.reserved && command.command.payload == nil) {
|
||||
return Command{}, false
|
||||
}
|
||||
current := command.command
|
||||
for _, name := range path[1:] {
|
||||
found := false
|
||||
for _, child := range current.children {
|
||||
if child.name == name {
|
||||
current = child
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Command{}, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func (a *App) present(outcome Outcome, path []string, streams Streams) Outcome {
|
||||
return a.presentWithUsageLine(outcome, path, streams, a.usageLine)
|
||||
}
|
||||
@@ -183,10 +162,10 @@ func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Strea
|
||||
}
|
||||
diagnostics := append([]Diagnostic(nil), outcome.Diagnostics...)
|
||||
if outcome.Cause != nil && !hasErrorDiagnostic(diagnostics) {
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, outcome.Cause.Error()))
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, outcome.Cause.Error()))
|
||||
}
|
||||
if outcome.Kind == OutcomeUsage {
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(configkit.SeverityError, usageLine(path)))
|
||||
diagnostics = append(diagnostics, MessageDiagnostic(SeverityError, usageLine(path)))
|
||||
}
|
||||
outcome.Diagnostics = diagnostics
|
||||
if len(diagnostics) == 0 {
|
||||
@@ -219,7 +198,7 @@ func (a *App) presentWithUsageLine(outcome Outcome, path []string, streams Strea
|
||||
|
||||
func (a *App) usageLine(path []string) string {
|
||||
if a != nil {
|
||||
if usage, failure := internalcobra.UsageLine(a.bridgeTree(), path); failure == nil {
|
||||
if usage, failure := internalcobra.UsageLine(a.routeSnapshot().metadata, path); failure == nil {
|
||||
return "Usage: " + usage
|
||||
}
|
||||
}
|
||||
@@ -236,7 +215,7 @@ func (a *App) usageFallback() string {
|
||||
|
||||
func hasErrorDiagnostic(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == configkit.SeverityError {
|
||||
if diagnostic.Write != nil && diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
@@ -24,7 +23,7 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
firstContext := context.WithValue(context.Background(), struct{}{}, "first")
|
||||
secondContext := context.WithValue(context.Background(), struct{}{}, "second")
|
||||
var firstOut, secondOut, firstDiagnostic, secondDiagnostic bytes.Buffer
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.name = value }),
|
||||
@@ -37,9 +36,6 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -64,13 +60,32 @@ func TestExecuteRunsTypedCommandsWithFreshInvocationState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOmittedInputFactoryUsesExactPointerZeroValue(t *testing.T) {
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[*int]{
|
||||
Name: "run",
|
||||
Run: func(_ commandkit.Invocation, value *int) commandkit.Outcome {
|
||||
if value != nil {
|
||||
return commandkit.Failure(errors.New("omitted input factory allocated a pointer"))
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome := app.Execute(context.Background(), []string{"run"}, commandkit.Streams{}); outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("pointer zero value outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T) {
|
||||
type input struct {
|
||||
name commandkit.Supplied[string]
|
||||
enabled commandkit.Supplied[bool]
|
||||
}
|
||||
var received []input
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "name"}, (*string)(nil), func(value string) (string, error) {
|
||||
@@ -83,9 +98,6 @@ func TestExecuteUsesFreshSessionForHelpAfterFlagBearingInvocation(t *testing.T)
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -115,14 +127,11 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
usageCause := errors.New("handler requests usage")
|
||||
var app *commandkit.App
|
||||
var calls int
|
||||
late, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
late := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "late",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
calls++
|
||||
@@ -132,10 +141,7 @@ func TestExecutePresentsHandlerUsageAfterRegisteringCommand(t *testing.T) {
|
||||
return commandkit.Usage(usageCause)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -156,7 +162,7 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
type input struct{ count commandkit.Supplied[int] }
|
||||
parseFailure := errors.New("bad count")
|
||||
checkFailure := errors.New("incompatible inputs")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
Flags: []commandkit.Flag[input]{commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Required: true}, nil, func(value string) (int, error) {
|
||||
if value == "bad" {
|
||||
@@ -168,9 +174,6 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
CheckInput: func(input) error { return checkFailure },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -192,8 +195,8 @@ func TestExecuteReturnsUsageForParsingRequirementsAndInputChecks(t *testing.T) {
|
||||
|
||||
func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
domainCause := errors.New("domain rejected the request")
|
||||
warning := commandkit.MessageDiagnostic(configkit.SeverityWarning, "review needed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
warning := commandkit.MessageDiagnostic(commandkit.SeverityWarning, "review needed")
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, domainCause)
|
||||
@@ -201,9 +204,6 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -216,7 +216,7 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
if diagnostic.String() != "review needed\ndomain rejected the request\n" || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning delivery = %q, %#v", diagnostic.String(), outcome)
|
||||
}
|
||||
warningOnly, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
warningOnly := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "warn",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
result := commandkit.Success()
|
||||
@@ -224,9 +224,6 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
return result
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, warningOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -240,20 +237,17 @@ func TestExecutePreservesDomainOutcomesAndWarningDiagnostics(t *testing.T) {
|
||||
|
||||
func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T) {
|
||||
cause := errors.New("already shown")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Domain("sample", 2, cause)
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: configkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
outcome.Diagnostics = []commandkit.Diagnostic{{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "presented\n")
|
||||
return err
|
||||
}}}
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -267,10 +261,7 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
diagnostic.Reset()
|
||||
completed := commandkit.Domain("sample", 2, cause)
|
||||
completed.DiagnosticDelivery = commandkit.DeliveryComplete
|
||||
command, err = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command = commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "done", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return completed }})
|
||||
app, err = commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -284,7 +275,7 @@ func TestExecuteDoesNotRepeatCompletedDiagnosticsOrPresentedCauses(t *testing.T)
|
||||
func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
operationFailure := context.Canceled
|
||||
writeFailure := errors.New("writer failed")
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
Run: func(commandkit.Invocation, struct{}) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(operationFailure)
|
||||
@@ -292,9 +283,6 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
return outcome
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -313,7 +301,7 @@ func TestExecuteRetainsOperationAndDiagnosticDeliveryFailures(t *testing.T) {
|
||||
func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||
type input struct{ id commandkit.Supplied[string] }
|
||||
factoryCalls, checkCalls, runCalls := 0, 0, 0
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "run",
|
||||
NewInput: func() input {
|
||||
factoryCalls++
|
||||
@@ -323,9 +311,6 @@ func TestExecuteHelpSkipsRequiredInputsAndRejectsNilContext(t *testing.T) {
|
||||
CheckInput: func(input) error { checkCalls++; return nil },
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -344,7 +329,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
type input struct{ topic commandkit.Supplied[string] }
|
||||
var received []input
|
||||
domainCause := errors.New("application help result")
|
||||
help, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
help := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "help",
|
||||
Description: "Explain a session topic.",
|
||||
Arguments: []commandkit.Argument[input]{commandkit.ValueArgument(
|
||||
@@ -357,13 +342,7 @@ func TestExecuteRunsApplicationOwnedNestedHelp(t *testing.T) {
|
||||
return commandkit.Domain("session-help", 7, domainCause)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := commandkit.Group("session", "Session commands.", help)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := commandkit.Group("session", "Session commands.", help)
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, session)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -401,7 +380,7 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
tags commandkit.Supplied[[]string]
|
||||
}
|
||||
factoryCalls, runCalls := 0, 0
|
||||
show, err := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
show := commandkit.NewCommand(commandkit.CommandSpec[input]{
|
||||
Name: "show",
|
||||
Summary: "Show one item.",
|
||||
Description: "Display the selected item in detail.",
|
||||
@@ -418,25 +397,9 @@ func TestHelpRendersRegisteredMetadataAndResolvesExactPaths(t *testing.T) {
|
||||
Arguments: []commandkit.Argument[input]{commandkit.RepeatArgument(commandkit.ArgumentMeta{Name: "ID", Usage: "item identifier"}, 1, 3, func(value string) (string, error) { return value, nil }, func(*input, commandkit.Supplied[[]string]) {})},
|
||||
Run: func(commandkit.Invocation, input) commandkit.Outcome { runCalls++; return commandkit.Success() },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := commandkit.Group("config", "Inspect configuration.", show)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deployParent, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deploy, err := commandkit.WithChildren(deployParent, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := commandkit.Group("config", "Inspect configuration.", show)
|
||||
now := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "now", Summary: "Deploy immediately.", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return commandkit.Success() }})
|
||||
deploy := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "deploy", Summary: "Deploy artifacts.", Children: []commandkit.Command{now}, Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { runCalls++; return commandkit.Success() }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, config, deploy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -559,10 +522,7 @@ func (shortWriter) Write(data []byte) (int, error) {
|
||||
}
|
||||
|
||||
func TestHelpEntryFormsPresentReportAndDiagnosticFailures(t *testing.T) {
|
||||
group, err := commandkit.Group("notes", "Notes metadata.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := commandkit.Group("notes", "Notes metadata.")
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, group)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -595,10 +555,7 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 2, Domain: "needs action", Cause: cause}, commandkit.OutcomeDomain, 2, "needs action", cause},
|
||||
{commandkit.Outcome{Kind: commandkit.OutcomeDomain, Status: 0, Domain: "complete"}, commandkit.OutcomeDomain, 0, "complete", nil},
|
||||
} {
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return tc.outcome }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -617,11 +574,8 @@ func TestExecuteNormalizesOutcomeDefaultsAndPreservesDomainValues(t *testing.T)
|
||||
func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
||||
cause := errors.New("operation failed")
|
||||
for _, base := range []commandkit.Outcome{commandkit.Success(), commandkit.Domain("needs action", 2, nil), commandkit.Failure(cause)} {
|
||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: configkit.SeverityWarning}, {Severity: configkit.SeverityError}}
|
||||
command, err := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.Diagnostics = []commandkit.Diagnostic{{}, {Severity: commandkit.SeverityWarning}, {Severity: commandkit.SeverityError}}
|
||||
command := commandkit.NewCommand(commandkit.CommandSpec[struct{}]{Name: "run", Run: func(commandkit.Invocation, struct{}) commandkit.Outcome { return base }})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -649,50 +603,3 @@ func TestNilDiagnosticWritersHaveNoOutputAndDoNotHideCauses(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentHelpDiscoversMetadataOnlyGroups(t *testing.T) {
|
||||
leaf, err := commandkit.Group("notes", "Read notes metadata.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent, err := commandkit.Group("topics", "Browse topic metadata.", leaf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, parent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loads := 0
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, struct{}) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
loads++
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
content string
|
||||
}{
|
||||
{nil, "Browse topic metadata."},
|
||||
{[]string{"topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "--help"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics"}, "Read notes metadata."},
|
||||
{[]string{"topics", "notes"}, "Read notes metadata."},
|
||||
{[]string{"help", "topics", "notes"}, "Read notes metadata."},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
result := app.Execute(context.Background(), tc.args, commandkit.Streams{Out: &out})
|
||||
if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out.String(), tc.content) || loads != 0 {
|
||||
t.Fatalf("%v: result=%#v output=%q loads=%d", tc.args, result, out.String(), loads)
|
||||
}
|
||||
if len(tc.args) == 1 && (!strings.Contains(out.String(), "Available Commands:") || !strings.Contains(out.String(), "[command]")) {
|
||||
t.Fatalf("missing child inventory/usage: %q", out.String())
|
||||
}
|
||||
}
|
||||
var diagnostic bytes.Buffer
|
||||
result := app.Execute(context.Background(), []string{"topics", "unknown"}, commandkit.Streams{Err: &diagnostic})
|
||||
if result.Kind != commandkit.OutcomeUsage || !strings.Contains(diagnostic.String(), "[command]") {
|
||||
t.Fatalf("result=%#v diagnostic=%q", result, diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
96
flag_values.go
Normal file
96
flag_values.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrFlagInput identifies a missing required captured flag selected for an input.
|
||||
var ErrFlagInput = errors.New("required flag input is missing")
|
||||
|
||||
// ErrFlagWiring identifies an invalid captured binding or destination.
|
||||
var ErrFlagWiring = errors.New("flag wiring is invalid")
|
||||
|
||||
// FlagValues stores invocation-local converted values and presence for captured
|
||||
// flags. It does not retain an input instance; values can be applied to
|
||||
// independently initialized destinations.
|
||||
type FlagValues[I any] struct {
|
||||
values map[*bindingIdentity]preparedValue[I]
|
||||
}
|
||||
|
||||
// CaptureFlags adapts flags declared for I into flags on Outer. During parsing,
|
||||
// each adapted flag stores its prepared value in target, associated with the
|
||||
// original inner binding identity. Copies of an original handle remain
|
||||
// selectable; an independently constructed lookalike does not.
|
||||
func CaptureFlags[Outer, I any](flags []Flag[I], target func(*Outer) *FlagValues[I]) []Flag[Outer] {
|
||||
captured := make([]Flag[Outer], len(flags))
|
||||
for index, original := range flags {
|
||||
inner := original
|
||||
identity := newBindingIdentity()
|
||||
binding := flagBinding[Outer]{
|
||||
identity: identity,
|
||||
meta: inner.binding.meta,
|
||||
deferredRequired: inner.binding.meta.Required,
|
||||
defaultText: inner.binding.defaultText,
|
||||
repeated: inner.binding.repeated,
|
||||
boolean: inner.binding.boolean,
|
||||
}
|
||||
if target != nil && inner.binding.identity != nil && inner.binding.valid && inner.binding.prepare != nil {
|
||||
binding.valid = true
|
||||
binding.prepare = func(raw []string) (preparedValue[Outer], error) {
|
||||
prepared, err := inner.binding.prepare(raw)
|
||||
if err != nil {
|
||||
return preparedValue[Outer]{}, err
|
||||
}
|
||||
return preparedValue[Outer]{
|
||||
identity: identity,
|
||||
supplied: prepared.supplied,
|
||||
apply: func(outer *Outer) {
|
||||
store := target(outer)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
if store.values == nil {
|
||||
store.values = make(map[*bindingIdentity]preparedValue[I])
|
||||
}
|
||||
store.values[inner.binding.identity] = prepared
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
captured[index] = Flag[Outer]{binding: binding}
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
// ApplyTo applies selected original captured flags to an initialized
|
||||
// destination. A missing captured binding or nil destination matches
|
||||
// ErrFlagWiring. A required selected value that was not supplied matches
|
||||
// ErrFlagInput. Repeated core-owned values are copied for each application.
|
||||
func (values FlagValues[I]) ApplyTo(target *I, selected ...Flag[I]) error {
|
||||
if target == nil {
|
||||
return fmt.Errorf("%w: destination is nil", ErrFlagWiring)
|
||||
}
|
||||
for _, flag := range selected {
|
||||
binding := flag.binding
|
||||
if binding.identity == nil {
|
||||
return fmt.Errorf("%w: selected flag has no identity", ErrFlagWiring)
|
||||
}
|
||||
prepared, exists := values.values[binding.identity]
|
||||
if !exists {
|
||||
return fmt.Errorf("%w: flag --%s was not captured", ErrFlagWiring, binding.meta.Name)
|
||||
}
|
||||
if binding.meta.Required && !prepared.supplied {
|
||||
return fmt.Errorf("flag --%s: %w", binding.meta.Name, ErrFlagInput)
|
||||
}
|
||||
prepared.apply(target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyInputError(err error) Outcome {
|
||||
if errors.Is(err, ErrFlagWiring) {
|
||||
return Failure(err)
|
||||
}
|
||||
return Usage(err)
|
||||
}
|
||||
144
flag_values_test.go
Normal file
144
flag_values_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type capturedInput struct {
|
||||
name Supplied[string]
|
||||
tags Supplied[[]string]
|
||||
}
|
||||
|
||||
type capturedOuter struct {
|
||||
flags FlagValues[capturedInput]
|
||||
}
|
||||
|
||||
func TestCaptureFlagsAppliesOnlyOriginalBindingWithoutReconversion(t *testing.T) {
|
||||
parseCalls := 0
|
||||
original := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) {
|
||||
parseCalls++
|
||||
return value, nil
|
||||
}, func(input *capturedInput, value Supplied[string]) { input.name = value })
|
||||
copied := original
|
||||
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
|
||||
prepared, err := captured[0].binding.prepare([]string{"chosen"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer := capturedOuter{}
|
||||
prepared.apply(&outer)
|
||||
var target capturedInput
|
||||
if err := outer.flags.ApplyTo(&target, copied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.name.Value != "chosen" || !target.name.Set || parseCalls != 1 {
|
||||
t.Fatalf("target=%#v parse calls=%d", target, parseCalls)
|
||||
}
|
||||
lookalike := ValueFlag(FlagMeta{Name: "name"}, nil, func(value string) (string, error) { return value, nil }, func(*capturedInput, Supplied[string]) {})
|
||||
if err := outer.flags.ApplyTo(&target, lookalike); !errors.Is(err, ErrFlagWiring) {
|
||||
t.Fatalf("lookalike binding error = %v", err)
|
||||
}
|
||||
if err := outer.flags.ApplyTo(nil, copied); !errors.Is(err, ErrFlagWiring) {
|
||||
t.Fatalf("nil target error = %v", err)
|
||||
}
|
||||
if err := (FlagValues[capturedInput]{}).ApplyTo(&target); err != nil {
|
||||
t.Fatalf("empty selection from zero store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFlagsDefersRequiredCheckToSelectedBindings(t *testing.T) {
|
||||
required := ValueFlag(FlagMeta{Name: "name", Required: true}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
|
||||
captured := CaptureFlags([]Flag[capturedInput]{required}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
|
||||
prepared, err := captured[0].binding.prepare(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer := capturedOuter{}
|
||||
prepared.apply(&outer)
|
||||
var target capturedInput
|
||||
if err := outer.flags.ApplyTo(&target); err != nil {
|
||||
t.Fatalf("unselected required flag failed: %v", err)
|
||||
}
|
||||
if err := outer.flags.ApplyTo(&target, required); !errors.Is(err, ErrFlagInput) {
|
||||
t.Fatalf("selected missing required flag error = %v", err)
|
||||
}
|
||||
optional := ValueFlag(FlagMeta{Name: "name"}, strptr("default"), func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[string]) { input.name = value })
|
||||
optionalCapture := CaptureFlags([]Flag[capturedInput]{optional}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
|
||||
optionalPrepared, err := optionalCapture[0].binding.prepare(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer = capturedOuter{}
|
||||
optionalPrepared.apply(&outer)
|
||||
target = capturedInput{}
|
||||
if err := outer.flags.ApplyTo(&target, optional); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.name.Value != "default" || target.name.Set {
|
||||
t.Fatalf("captured default value = %#v", target.name)
|
||||
}
|
||||
|
||||
badCapture := CaptureFlags[capturedOuter]([]Flag[capturedInput]{required}, nil)
|
||||
command := NewCommand(CommandSpec[capturedOuter]{Name: "broken", Flags: badCapture})
|
||||
if _, err := New(Identity{Name: "example"}, command); err == nil {
|
||||
t.Fatal("installation accepted a nil captured-flag accessor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFlagsCopiesRepeatedValuesOnEachApplication(t *testing.T) {
|
||||
original := RepeatFlag(FlagMeta{Name: "tag"}, nil, func(value string) (string, error) { return value, nil }, func(input *capturedInput, value Supplied[[]string]) { input.tags = value })
|
||||
captured := CaptureFlags([]Flag[capturedInput]{original}, func(input *capturedOuter) *FlagValues[capturedInput] { return &input.flags })
|
||||
prepared, err := captured[0].binding.prepare([]string{"a,b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outer := capturedOuter{}
|
||||
prepared.apply(&outer)
|
||||
var first, second capturedInput
|
||||
if err := outer.flags.ApplyTo(&first, original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := outer.flags.ApplyTo(&second, original); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.tags.Value[0] = "changed"
|
||||
if second.tags.Value[0] != "a,b" || second.tags.Value[1] != "c" || !second.tags.Set {
|
||||
t.Fatalf("second application shares repeat storage: %#v", second.tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturedFlagErrorClassificationPreservesWrappedCategories(t *testing.T) {
|
||||
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagWiring)); got.Kind != OutcomeOperational {
|
||||
t.Fatalf("wiring outcome = %#v", got)
|
||||
}
|
||||
if got := classifyInputError(fmt.Errorf("scope input: %w", ErrFlagInput)); got.Kind != OutcomeUsage {
|
||||
t.Fatalf("input outcome = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckInputClassifiesWrappedFlagWiringAsOperational(t *testing.T) {
|
||||
command := NewCommand(CommandSpec[struct{}]{
|
||||
Name: "run",
|
||||
CheckInput: func(struct{}) error { return fmt.Errorf("scope selector: %w", ErrFlagWiring) },
|
||||
Run: func(Invocation, struct{}) Outcome { t.Fatal("run called after wiring error"); return Success() },
|
||||
})
|
||||
app, err := New(Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome := app.Execute(t.Context(), []string{"run"}, Streams{}); outcome.Kind != OutcomeOperational || !errors.Is(outcome.Cause, ErrFlagWiring) {
|
||||
t.Fatalf("wrapped wiring outcome = %#v", outcome)
|
||||
}
|
||||
usage := NewCommand(CommandSpec[struct{}]{Name: "usage", CheckInput: func(struct{}) error { return fmt.Errorf("selected value: %w", ErrFlagInput) }, Run: func(Invocation, struct{}) Outcome { return Success() }})
|
||||
app, err = New(Identity{Name: "example"}, usage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if outcome := app.Execute(t.Context(), []string{"usage"}, Streams{}); outcome.Kind != OutcomeUsage || !errors.Is(outcome.Cause, ErrFlagInput) {
|
||||
t.Fatalf("wrapped input outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func strptr(value string) *string { return &value }
|
||||
8
go.mod
8
go.mod
@@ -3,22 +3,16 @@ module gitea.maximumdirect.net/eric/CommandKit
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 // indirect
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 // indirect
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 // indirect
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
|
||||
19
go.sum
19
go.sum
@@ -1,21 +1,9 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0 h1:2KwG10+kApWM3avOoYA+p768xcbHUp7tQ2rqizBNpyg=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.5.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0 h1:aTMn+50MEpHpHI2RoCCmJYsy7Ohrpa+RSva63Kq0/tU=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.3.0/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0 h1:oIFWEg6GPxtmX3Plqv0euP+FnK1LCk/t4xDy6oKCpP4=
|
||||
gitea.maximumdirect.net/eric/promptkit v0.12.0/go.mod h1:pFOOy5v1zH17stEi9rmHYmfbuLjnfVMcUhfmk+4VtJY=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0 h1:lzfCDbzJQRGzIlRPRprnAV9hVVL5zUQy80VMQ0kbYa0=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v3 v3.20260930.0/go.mod h1:HjS0f+3q0IdHCTEz+VVAEgIwIWJFAZUjuKdAtvtqKdg=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0 h1:h12ZTRldAmpLAomj0oQRGteFxHLN1LPE6XDl9zJNbm8=
|
||||
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v3 v3.20260930.0/go.mod h1:OEFBeKdjTsLzqJwLmXI2O5gG3r64FktcVJVb3mBrRfg=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
@@ -26,8 +14,5 @@ go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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=
|
||||
|
||||
20
integrations/ckconfig/COPYRIGHT
Normal file
20
integrations/ckconfig/COPYRIGHT
Normal file
@@ -0,0 +1,20 @@
|
||||
CommandKit ckconfig
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
This library is free software: you can redistribute it and/or modify it
|
||||
under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The GNU Lesser General Public License version 3 and its incorporated
|
||||
GNU General Public License version 3 text are provided in LICENSE.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
Third-party code and assets are subject to their respective license terms.
|
||||
304
integrations/ckconfig/LICENSE
Normal file
304
integrations/ckconfig/LICENSE
Normal file
@@ -0,0 +1,304 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license document.
|
||||
|
||||
4. Combined Works.
|
||||
You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
CommandKit
|
||||
Copyright (C) 2026 eric
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
CommandKit Copyright (C) 2026 eric
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
45
integrations/ckconfig/README.md
Normal file
45
integrations/ckconfig/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# CommandKit ckconfig
|
||||
|
||||
`ckconfig` is the optional ConfigKit integration for CommandKit. It adapts
|
||||
ConfigKit loaders and scopes into reusable `commandkit.Command` declarations;
|
||||
installing the integration is explicit and does not modify an application by
|
||||
itself.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig`. Its first
|
||||
release is prepared as `v0.1.0`. It uses the core `commandkit` package and
|
||||
ConfigKit; it does not import PromptKit. See the
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#configkit-scopes)
|
||||
for exact callback and lifetime contracts.
|
||||
|
||||
## Example
|
||||
|
||||
For one application configuration scope, install the convenience declaration:
|
||||
|
||||
```go
|
||||
app, err := commandkit.New(identity, ckconfig.AppCommand(loadAppConfig))
|
||||
```
|
||||
|
||||
Use `ckconfig.Command(ckconfig.Suite[Input, State]{...})` to define named
|
||||
scopes, selectors, input checks, and state shared by the selected acquisitions
|
||||
or comparison in a config invocation. `ckconfig.FromConfigKit` adapts a typed
|
||||
ConfigKit loader. The `config` command family exposes scope discovery and the
|
||||
validate, show, sources, explain, and diff operations supported by the declared
|
||||
scopes. Source reports include native destination, placement, unresolved
|
||||
destination, and source version-check evidence. Comparison returns ConfigKit's
|
||||
native records to the YAML report path; the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#compare-configuration-selections)
|
||||
owns its operator details.
|
||||
|
||||
The maintained [simple example](examples/simple) shows a complete consumer.
|
||||
The [CommandKit CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#configuration-scopes)
|
||||
describes its operator-facing behavior.
|
||||
|
||||
## License and attribution
|
||||
|
||||
This module is licensed under the [GNU Lesser General Public License v3.0 or
|
||||
later](COPYRIGHT). The accompanying [LICENSE](LICENSE) contains the full LGPL
|
||||
v3 and GPL v3 texts. See [third-party attribution](docs/oss/attribution.md)
|
||||
for incorporated and dependency notices.
|
||||
327
integrations/ckconfig/comparison_adapter_test.go
Normal file
327
integrations/ckconfig/comparison_adapter_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type adapterSettings struct {
|
||||
Greeting string `yaml:"greeting"`
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
|
||||
func adapterDefinition() configkit.Definition[adapterSettings] {
|
||||
return configkit.Definition[adapterSettings]{
|
||||
Defaults: func() adapterSettings { return adapterSettings{Greeting: "Hello"} },
|
||||
Validate: func(value *adapterSettings, validation *configkit.Validation) {
|
||||
if value.Greeting == "Hello" {
|
||||
validation.Warning(configkit.Keys("greeting"), "using the default greeting")
|
||||
}
|
||||
},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/secret" },
|
||||
}
|
||||
}
|
||||
|
||||
func adapterSettingsLoad(source configkit.Source) (*configkit.Result[adapterSettings], error) {
|
||||
return configkit.Load(source, configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||
}
|
||||
|
||||
func comparisonSide(selection Selection, greeting string) ComparisonSide[struct{ Label string }] {
|
||||
return ComparisonSide[struct{ Label string }]{
|
||||
Selection: selection,
|
||||
Input: struct{ Label string }{Label: greeting},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareReturnsNativeChangesAndLoadsEqualSelections(t *testing.T) {
|
||||
selection := Selection{Config: commandkit.Supplied[string]{Value: "same.yml", Set: true}}
|
||||
var calls []struct {
|
||||
selection Selection
|
||||
input struct{ Label string }
|
||||
state *int
|
||||
}
|
||||
scope := FromConfigKit[struct{ Label string }, *int, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, input struct{ Label string }, state *int) (*configkit.Result[adapterSettings], error) {
|
||||
calls = append(calls, struct {
|
||||
selection Selection
|
||||
input struct{ Label string }
|
||||
state *int
|
||||
}{selected, input, state})
|
||||
*state++
|
||||
return adapterSettingsLoad(configkit.Mapping(input.Label, map[string]any{"greeting": input.Label}))
|
||||
})
|
||||
state := new(int)
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
|
||||
Left: comparisonSide(selection, "before"),
|
||||
Right: comparisonSide(selection, "after"),
|
||||
}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection {
|
||||
t.Fatalf("loader calls = %#v", calls)
|
||||
}
|
||||
if calls[0].input.Label != "before" || calls[1].input.Label != "after" || calls[0].state != state || calls[1].state != state || *state != 2 {
|
||||
t.Fatalf("loader inputs/state = %#v, %#v, state=%d", calls[0], calls[1], *state)
|
||||
}
|
||||
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "before\n" || acquired.Value[0].Right.YAML != "after\n" {
|
||||
t.Fatalf("native differences = %#v", acquired.Value)
|
||||
}
|
||||
|
||||
equal, err := scope.Compare(context.Background(), ComparisonRequest[struct{ Label string }]{
|
||||
Left: comparisonSide(selection, "same"),
|
||||
Right: comparisonSide(selection, "same"),
|
||||
}, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(equal.Value) != 0 || len(calls) != 4 {
|
||||
t.Fatalf("equal comparison records/calls = %#v / %d", equal.Value, len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareIgnoresDefaultAndResolutionHistoryForEqualValues(t *testing.T) {
|
||||
type resolvedSettings struct {
|
||||
Greeting string `yaml:"greeting"`
|
||||
}
|
||||
definition := configkit.Definition[resolvedSettings]{
|
||||
Defaults: func() resolvedSettings { return resolvedSettings{Greeting: "resolved"} },
|
||||
Resolve: func(value *resolvedSettings, resolution *configkit.Resolution) error {
|
||||
if value.Greeting != "resolved" {
|
||||
value.Greeting = "resolved"
|
||||
resolution.Record(configkit.Keys("greeting"), configkit.ChangeNormalization, "canonical greeting")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
left, err := configkit.Load(configkit.NoSource(), configkit.Options[resolvedSettings]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := configkit.Load(configkit.Mapping("right-source", map[string]any{"greeting": "source value"}), configkit.Options[resolvedSettings]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, resolvedSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[resolvedSettings], error) {
|
||||
if selected.Config.Value == "left" {
|
||||
return left, nil
|
||||
}
|
||||
return right, nil
|
||||
})
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if left.Config != right.Config || left.Config.Greeting != "resolved" || len(acquired.Value) != 0 {
|
||||
t.Fatalf("equal resolved values yielded differences: left=%#v right=%#v records=%#v", left.Config, right.Config, acquired.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareUsesBaseAndOverlaySelections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
basePath := filepath.Join(dir, "base.yml")
|
||||
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
|
||||
if err := os.WriteFile(basePath, []byte("greeting: Hello\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(overlayPath, []byte("greeting: Goodbye\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loader := func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
options := configkit.LayeredOptions[adapterSettings]{Definition: adapterDefinition()}
|
||||
if selection.Overlay.Set {
|
||||
options.Overlay = selection.Overlay.Value
|
||||
options.OverlayFilesystem = &configkit.FilesystemContext{Root: dir, BaseDir: dir}
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{Source: configkit.File(selection.Config.Value)}, options)
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", loader)
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: basePath, Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{
|
||||
Config: commandkit.Supplied[string]{Value: basePath, Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "testing", Set: true},
|
||||
}},
|
||||
}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acquired.Value) != 1 || acquired.Value[0].Path.String() != "/greeting" || acquired.Value[0].Left.YAML != "Hello\n" || acquired.Value[0].Right.YAML != "Goodbye\n" {
|
||||
t.Fatalf("base/overlay differences = %#v", acquired.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareRetainsSideDiagnosticsAndLoadError(t *testing.T) {
|
||||
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, loadErr := configkit.Load(configkit.Mapping("right-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid right source unexpectedly loaded")
|
||||
}
|
||||
wrapper := fmt.Errorf("application loader context: %w", loadErr)
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
if selected.Config.Value == "left" {
|
||||
return left, nil
|
||||
}
|
||||
return nil, wrapper
|
||||
})
|
||||
acquired, gotErr := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if !errors.Is(gotErr, loadErr) {
|
||||
t.Fatalf("load error identity not preserved: %v", gotErr)
|
||||
}
|
||||
var typed *configkit.LoadError
|
||||
if !errors.As(gotErr, &typed) || typed != loadErr {
|
||||
t.Fatalf("load error type not preserved: %#v", typed)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 2 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning || acquired.Diagnostics[1].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("retained diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var leftText, rightText bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&leftText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := acquired.Diagnostics[1].Write(&rightText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(leftText.String(), `Configuration scope "app" left:`) || !strings.Contains(leftText.String(), `"left-source"`) {
|
||||
t.Fatalf("left diagnostic lost side/source identity: %q", leftText.String())
|
||||
}
|
||||
if !strings.Contains(rightText.String(), `Configuration scope "app" right:`) || !strings.Contains(rightText.String(), `"right-source"`) {
|
||||
t.Fatalf("right diagnostic lost side/source identity: %q", rightText.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
_, loadErr := configkit.Load(configkit.Mapping("left-source", map[string]any{"unknown": true}), configkit.Options[adapterSettings]{Definition: adapterDefinition()})
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid left source unexpectedly loaded")
|
||||
}
|
||||
rightCalled := false
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
if selected.Config.Value == "left" {
|
||||
return nil, loadErr
|
||||
}
|
||||
rightCalled = true
|
||||
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
|
||||
})
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if !errors.Is(err, loadErr) || rightCalled {
|
||||
t.Fatalf("left failure/right load = %v / %v", err, rightCalled)
|
||||
}
|
||||
var typed *configkit.LoadError
|
||||
if !errors.As(err, &typed) || typed != loadErr || len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("left failure identity/diagnostics = %v / %#v", err, acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareCancellationStopsBeforeRightLoad(t *testing.T) {
|
||||
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
rightCalled := false
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
if selection.Config.Value == "left" {
|
||||
cancel()
|
||||
return left, nil
|
||||
}
|
||||
rightCalled = true
|
||||
return adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"greeting": "Goodbye"}))
|
||||
})
|
||||
acquired, err := scope.Compare(ctx, ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if !errors.Is(err, context.Canceled) || rightCalled {
|
||||
t.Fatalf("cancellation/right load = %v / %v", err, rightCalled)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("left diagnostics lost on cancellation: %#v", acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitCompareRejectsNilResultAndRetainsNativeDiffFailureDiagnostics(t *testing.T) {
|
||||
t.Run("nil result", func(t *testing.T) {
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
return nil, nil
|
||||
})
|
||||
_, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{}, struct{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), `"app" left`) || !strings.Contains(err.Error(), "nil result") {
|
||||
t.Fatalf("nil left result error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("native diff error", func(t *testing.T) {
|
||||
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"greeting": "Hello"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
if selection.Config.Value == "left" {
|
||||
return left, nil
|
||||
}
|
||||
return &configkit.Result[adapterSettings]{}, nil
|
||||
})
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "native diff") {
|
||||
t.Fatalf("native diff error = %v", err)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("native diff failure lost diagnostics: %#v", acquired.Diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFromConfigKitComparePreservesConcealedNativeDifference(t *testing.T) {
|
||||
left, err := adapterSettingsLoad(configkit.Mapping("left-source", map[string]any{"secret": "old"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := adapterSettingsLoad(configkit.Mapping("right-source", map[string]any{"secret": "new"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, adapterSettings]("app", "Application configuration", func(_ context.Context, selected Selection, _ struct{}, _ struct{}) (*configkit.Result[adapterSettings], error) {
|
||||
if selected.Config.Value == "left" {
|
||||
return left, nil
|
||||
}
|
||||
return right, nil
|
||||
})
|
||||
acquired, err := scope.Compare(context.Background(), ComparisonRequest[struct{}]{
|
||||
Left: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ComparisonSide[struct{}]{Selection: Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(acquired.Value) != 1 || !acquired.Value[0].HiddenChanged || !acquired.Value[0].Left.Redacted || !acquired.Value[0].Right.Redacted || acquired.Value[0].Left.YAML != acquired.Value[0].Right.YAML {
|
||||
t.Fatalf("concealed native difference was altered: %#v", acquired.Value)
|
||||
}
|
||||
}
|
||||
105
integrations/ckconfig/comparison_contract_test.go
Normal file
105
integrations/ckconfig/comparison_contract_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestComparisonCapabilitiesAreIndependentAndCaptured(t *testing.T) {
|
||||
type input struct{ Value string }
|
||||
var inputCalls, stateCalls, acquireCalls, compareCalls int
|
||||
compare := func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
compareCalls++
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}
|
||||
operations := []Operation{Diff}
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { inputCalls++; return input{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[input, struct{}]{
|
||||
{Name: "app", Operations: operations, Compare: compare},
|
||||
{Name: "promptkit", Operations: []Operation{Diff, Show, Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}, Compare: compare},
|
||||
{Name: "alpha"},
|
||||
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
captured := captureSuite(suite)
|
||||
declaration := Command(suite)
|
||||
|
||||
// Capability metadata and callback references are copied during capture.
|
||||
suite.Scopes[0].Operations[0] = Show
|
||||
suite.Scopes[0].Compare = nil
|
||||
|
||||
app := newConfigTestApp(t, declaration)
|
||||
var output strings.Builder
|
||||
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("scope discovery outcome = %#v", outcome)
|
||||
}
|
||||
if got, want := output.String(), "app: diff\npromptkit: validate, show, diff\nalpha: \nplain: validate\n"; got != want {
|
||||
t.Fatalf("scope discovery = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// A family-wide diff command is available, but unsupported scopes fail before
|
||||
// suite factories or either provider runs.
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage {
|
||||
t.Fatalf("unsupported scope outcome = %#v", outcome)
|
||||
}
|
||||
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
|
||||
t.Fatalf("discovery or preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
|
||||
}
|
||||
if captured.byName["app"].scope.Compare == nil {
|
||||
t.Fatal("captured comparison callback was cleared by a source edit")
|
||||
}
|
||||
if _, err := captured.byName["app"].scope.Compare(context.Background(), ComparisonRequest[input]{}, struct{}{}); err != nil || compareCalls != 1 {
|
||||
t.Fatalf("captured comparison callback result=%v calls=%d", err, compareCalls)
|
||||
}
|
||||
|
||||
withoutComparison := newConfigTestApp(t, Command(Suite[input, struct{}]{Scopes: []Scope[input, struct{}]{{
|
||||
Name: "plain", Operations: []Operation{Validate},
|
||||
Acquire: func(context.Context, Request, input, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
},
|
||||
}}}))
|
||||
outcome = withoutComparison.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage {
|
||||
t.Fatalf("family without comparison exposed diff: %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonCallbackWiringAndRetainedAdapterIssues(t *testing.T) {
|
||||
newApp := func(scope Scope[struct{}, struct{}]) error {
|
||||
_, err := commandkit.New(commandkit.Identity{Name: "example"}, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Diff}}); err == nil {
|
||||
t.Fatalf("missing comparison callback error = %v", err)
|
||||
}
|
||||
if err := newApp(Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Validate}}); err == nil {
|
||||
t.Fatalf("missing ordinary acquisition callback error = %v", err)
|
||||
}
|
||||
|
||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
|
||||
scope.Operations = []Operation{Diff}
|
||||
scope.Compare = func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}
|
||||
retained := scope.issues[0]
|
||||
if err := newApp(scope); err == nil || !errors.Is(err, retained) {
|
||||
t.Fatalf("narrowed adapter lost its retained loader issue: %v", err)
|
||||
}
|
||||
}
|
||||
183
integrations/ckconfig/comparison_input_test.go
Normal file
183
integrations/ckconfig/comparison_input_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestComparisonSelectionInheritsFieldsIndependently(t *testing.T) {
|
||||
leftConfig := commandkit.Supplied[string]{Value: "before.yml", Set: true}
|
||||
leftOverlay := commandkit.Supplied[string]{Value: "base", Set: true}
|
||||
tests := []struct {
|
||||
name string
|
||||
left Selection
|
||||
right Selection
|
||||
want Selection
|
||||
}{
|
||||
{
|
||||
name: "omitted fields stay omitted",
|
||||
},
|
||||
{
|
||||
name: "both fields inherit supplied state",
|
||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
want: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
},
|
||||
{
|
||||
name: "explicit empty config overrides while overlay inherits",
|
||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
right: Selection{Config: commandkit.Supplied[string]{Set: true}},
|
||||
want: Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: leftOverlay},
|
||||
},
|
||||
{
|
||||
name: "explicit overlay overrides while config inherits",
|
||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
right: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
||||
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
||||
},
|
||||
{
|
||||
name: "explicit empty overlay remains supplied",
|
||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
right: Selection{Overlay: commandkit.Supplied[string]{Set: true}},
|
||||
want: Selection{Config: leftConfig, Overlay: commandkit.Supplied[string]{Set: true}},
|
||||
},
|
||||
{
|
||||
name: "both explicit right fields replace left",
|
||||
left: Selection{Config: leftConfig, Overlay: leftOverlay},
|
||||
right: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
|
||||
want: Selection{Config: commandkit.Supplied[string]{Value: "after.yml", Set: true}, Overlay: commandkit.Supplied[string]{Value: "new", Set: true}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := inheritComparisonSelection(test.left, test.right); got != test.want {
|
||||
t.Fatalf("inherited selection = %#v, want %#v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonHelpLimitsFlagsAndDefaultsFormatToYAML(t *testing.T) {
|
||||
scope := Scope[struct{}, struct{}]{
|
||||
Name: "app", Operations: []Operation{Diff, Show, Explain, Validate, Sources},
|
||||
Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}}))
|
||||
help := func(args ...string) string {
|
||||
t.Helper()
|
||||
var output strings.Builder
|
||||
app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
return output.String()
|
||||
}
|
||||
|
||||
diffHelp := help("config", "diff", "--help")
|
||||
if !strings.Contains(diffHelp, "--against-config") || !strings.Contains(diffHelp, "--against-overlay") {
|
||||
t.Fatalf("diff help omits right-side selection flags:\n%s", diffHelp)
|
||||
}
|
||||
if !strings.Contains(diffHelp, "default: yaml") {
|
||||
t.Fatalf("diff help does not show the YAML default:\n%s", diffHelp)
|
||||
}
|
||||
for _, operation := range []string{"validate", "show", "sources", "explain"} {
|
||||
operationHelp := help("config", operation, "--help")
|
||||
if strings.Contains(operationHelp, "--against-config") || strings.Contains(operationHelp, "--against-overlay") {
|
||||
t.Errorf("%s exposes comparison-only flags:\n%s", operation, operationHelp)
|
||||
}
|
||||
if operation == "show" && !strings.Contains(operationHelp, "default: yaml") {
|
||||
t.Errorf("show format default changed:\n%s", operationHelp)
|
||||
}
|
||||
if operation != "show" && !strings.Contains(operationHelp, "default: text") {
|
||||
t.Errorf("%s format default changed:\n%s", operation, operationHelp)
|
||||
}
|
||||
if operation != "explain" && strings.Contains(operationHelp, "--path") {
|
||||
t.Errorf("%s exposes the explain-only path flag:\n%s", operation, operationHelp)
|
||||
}
|
||||
}
|
||||
explainHelp := help("config", "explain", "--help")
|
||||
if !strings.Contains(explainHelp, "--path") {
|
||||
t.Fatalf("explain flags changed:\n%s", explainHelp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonPreparationAcceptsOnlyYAML(t *testing.T) {
|
||||
inputCalls, stateCalls := 0, 0
|
||||
prepared := captureSuite(Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
input := &configCommandInput[struct{}, struct{}]{
|
||||
selection: Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}},
|
||||
against: Selection{Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true}},
|
||||
format: commandkit.Supplied[string]{Value: "yaml", Set: true},
|
||||
}
|
||||
if err := prepareConfigOperation(prepared, Diff, input); err != nil {
|
||||
t.Fatalf("explicit YAML format rejected: %v", err)
|
||||
}
|
||||
if input.plan == nil || input.plan.format != "yaml" {
|
||||
t.Fatalf("comparison plan = %#v", input.plan)
|
||||
}
|
||||
if input.plan.scopeName != "app" {
|
||||
t.Fatalf("comparison default scope = %q, want app", input.plan.scopeName)
|
||||
}
|
||||
if want := (Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "before.yml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "candidate", Set: true},
|
||||
}); input.against != want {
|
||||
t.Fatalf("resolved right selection = %#v, want %#v", input.against, want)
|
||||
}
|
||||
|
||||
for _, format := range []string{"text", "json"} {
|
||||
input := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: format}}
|
||||
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
|
||||
t.Errorf("comparison format %q was accepted", format)
|
||||
}
|
||||
}
|
||||
if inputCalls != 2 || stateCalls != 0 {
|
||||
t.Fatalf("comparison preparation calls input/state: %d/%d", inputCalls, stateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonAvailabilityPreflightPrecedesFactories(t *testing.T) {
|
||||
inputCalls, stateCalls, acquireCalls, compareCalls := 0, 0, 0, 0
|
||||
prepared := captureSuite(Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
compareCalls++
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}},
|
||||
{Name: "plain", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
},
|
||||
})
|
||||
for _, scope := range []string{"missing", "all", "plain"} {
|
||||
input := &configCommandInput[struct{}, struct{}]{
|
||||
scope: commandkit.Supplied[string]{Value: scope, Set: true},
|
||||
format: commandkit.Supplied[string]{Value: "yaml"},
|
||||
}
|
||||
if err := prepareConfigOperation(prepared, Diff, input); err == nil {
|
||||
t.Errorf("comparison scope %q was accepted", scope)
|
||||
}
|
||||
}
|
||||
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 || compareCalls != 0 {
|
||||
t.Fatalf("failed preflight invoked suite work: input=%d state=%d acquire=%d compare=%d", inputCalls, stateCalls, acquireCalls, compareCalls)
|
||||
}
|
||||
}
|
||||
357
integrations/ckconfig/comparison_preparation_test.go
Normal file
357
integrations/ckconfig/comparison_preparation_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestComparisonPreparesEqualSelectionsWithSeparateTypedInputs(t *testing.T) {
|
||||
type input struct {
|
||||
Enabled commandkit.Supplied[bool]
|
||||
Limit commandkit.Supplied[int]
|
||||
Tags commandkit.Supplied[[]string]
|
||||
}
|
||||
var inputCalls, checkCalls, stateCalls int
|
||||
var parseCalls int
|
||||
var preparedInputs []*input
|
||||
var selections []Selection
|
||||
enabled := commandkit.BoolFlag[*input](commandkit.FlagMeta{Name: "enabled"}, true, func(value **input, supplied commandkit.Supplied[bool]) {
|
||||
(*value).Enabled = supplied
|
||||
})
|
||||
limit := commandkit.ValueFlag[*input](commandkit.FlagMeta{Name: "limit", Required: true}, (*int)(nil), func(raw string) (int, error) {
|
||||
parseCalls++
|
||||
return strconv.Atoi(raw)
|
||||
}, func(value **input, supplied commandkit.Supplied[int]) {
|
||||
(*value).Limit = supplied
|
||||
})
|
||||
tags := commandkit.RepeatFlag[*input](commandkit.FlagMeta{Name: "tag"}, nil, func(raw string) (string, error) {
|
||||
parseCalls++
|
||||
return raw, nil
|
||||
}, func(value **input, supplied commandkit.Supplied[[]string]) {
|
||||
(*value).Tags = supplied
|
||||
})
|
||||
suite := Suite[*input, struct{}]{
|
||||
NewInput: func() *input {
|
||||
inputCalls++
|
||||
return &input{}
|
||||
},
|
||||
NewState: func() struct{} {
|
||||
stateCalls++
|
||||
if checkCalls != 2 {
|
||||
t.Errorf("shared state created after %d input checks", checkCalls)
|
||||
}
|
||||
if len(preparedInputs) != 2 || preparedInputs[0] == preparedInputs[1] {
|
||||
t.Errorf("inputs were not initialized separately: %#v", preparedInputs)
|
||||
} else {
|
||||
left, right := preparedInputs[0].Tags.Value, preparedInputs[1].Tags.Value
|
||||
if len(left) != 2 || len(right) != 2 || &left[0] == &right[0] {
|
||||
t.Errorf("repeated values share storage: left=%#v right=%#v", left, right)
|
||||
} else {
|
||||
left[0] = "changed"
|
||||
if right[0] != "alpha" {
|
||||
t.Errorf("mutating one side changed the other: %#v", right)
|
||||
}
|
||||
}
|
||||
}
|
||||
return struct{}{}
|
||||
},
|
||||
Selectors: []Selector[*input]{{Binding: enabled}, {Binding: limit}, {Binding: tags}},
|
||||
CheckInput: func(scope string, selection Selection, value *input) error {
|
||||
checkCalls++
|
||||
preparedInputs = append(preparedInputs, value)
|
||||
selections = append(selections, selection)
|
||||
if scope != "app" || value == nil {
|
||||
return errors.New("unexpected scope input")
|
||||
}
|
||||
if !value.Enabled.Set || value.Enabled.Value {
|
||||
return errors.New("explicit false was not preserved")
|
||||
}
|
||||
if !value.Limit.Set || value.Limit.Value != 0 {
|
||||
return errors.New("explicit zero was not preserved")
|
||||
}
|
||||
if !value.Tags.Set || len(value.Tags.Value) != 2 || value.Tags.Value[0] != "alpha" || value.Tags.Value[1] != "beta" {
|
||||
return errors.New("repeated input was not preserved")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[*input, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[*input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
outcome := app.Execute(context.Background(), []string{
|
||||
"config", "diff", "--config", "same.yml", "--against-config", "same.yml", "--overlay=",
|
||||
"--enabled=false", "--limit=0", "--tag", "alpha", "--tag", "beta",
|
||||
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("valid comparison outcome = %#v", outcome)
|
||||
}
|
||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 {
|
||||
t.Fatalf("preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
||||
}
|
||||
if parseCalls != 3 {
|
||||
t.Fatalf("typed values were converted %d times, want once per supplied occurrence", parseCalls)
|
||||
}
|
||||
wantSelection := Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "same.yml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Set: true},
|
||||
}
|
||||
if len(selections) != 2 || selections[0] != wantSelection || selections[1] != wantSelection {
|
||||
t.Fatalf("side selections = %#v, want equal %#v values", selections, wantSelection)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonProviderReceivesCheckedPairAndSharedState(t *testing.T) {
|
||||
type input struct{ ID int }
|
||||
type state struct{ Label string }
|
||||
inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls := 0, 0, 0, 0, 0
|
||||
checked := make(map[Selection]input)
|
||||
shared := &state{Label: "shared"}
|
||||
var request ComparisonRequest[input]
|
||||
app := newConfigTestApp(t, Command(Suite[input, *state]{
|
||||
NewInput: func() input {
|
||||
inputCalls++
|
||||
return input{ID: inputCalls}
|
||||
},
|
||||
NewState: func() *state {
|
||||
stateCalls++
|
||||
if checkCalls != 2 {
|
||||
t.Errorf("shared state created after %d input checks", checkCalls)
|
||||
}
|
||||
return shared
|
||||
},
|
||||
CheckInput: func(scope string, selection Selection, value input) error {
|
||||
checkCalls++
|
||||
if scope != "app" {
|
||||
return errors.New("unexpected scope")
|
||||
}
|
||||
checked[selection] = value
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[input, *state]{{
|
||||
Name: "app", Operations: []Operation{Show, Diff},
|
||||
Acquire: func(context.Context, Request, input, *state) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, errors.New("ordinary acquisition reached during comparison")
|
||||
},
|
||||
Compare: func(_ context.Context, pair ComparisonRequest[input], value *state) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
compareCalls++
|
||||
if checkCalls != 2 || stateCalls != 1 || value != shared {
|
||||
t.Errorf("provider reached with checks=%d states=%d shared=%t", checkCalls, stateCalls, value == shared)
|
||||
}
|
||||
request = pair
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
}))
|
||||
outcome := app.Execute(context.Background(), []string{
|
||||
"config", "diff", "--config", "before.yml", "--against-overlay=",
|
||||
}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("comparison outcome = %#v", outcome)
|
||||
}
|
||||
left := Selection{Config: commandkit.Supplied[string]{Value: "before.yml", Set: true}}
|
||||
right := Selection{Config: left.Config, Overlay: commandkit.Supplied[string]{Set: true}}
|
||||
if request.Left.Selection != left || request.Right.Selection != right {
|
||||
t.Fatalf("provider selections = %#v", request)
|
||||
}
|
||||
if len(checked) != 2 || request.Left.Input != checked[left] || request.Right.Input != checked[right] {
|
||||
t.Fatalf("provider inputs = %#v, checked = %#v", request, checked)
|
||||
}
|
||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || compareCalls != 1 || acquireCalls != 0 {
|
||||
t.Fatalf("invocation calls input=%d check=%d state=%d compare=%d acquire=%d", inputCalls, checkCalls, stateCalls, compareCalls, acquireCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonDoesNotRetainPartialPlanAfterSideFailure(t *testing.T) {
|
||||
checkCalls := 0
|
||||
failure := errors.New("right input rejected")
|
||||
prepared := captureSuite(Suite[struct{}, struct{}]{
|
||||
CheckInput: func(string, Selection, struct{}) error {
|
||||
checkCalls++
|
||||
if checkCalls == 2 {
|
||||
return failure
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
commandInput := &configCommandInput[struct{}, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
||||
if err := prepareConfigOperation(prepared, Diff, commandInput); !errors.Is(err, failure) {
|
||||
t.Fatalf("right-side input failure = %v", err)
|
||||
}
|
||||
if commandInput.plan != nil || checkCalls != 2 {
|
||||
t.Fatalf("failed pair retained partial state: plan=%#v checks=%d", commandInput.plan, checkCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonNilInputFactoryUsesZeroValue(t *testing.T) {
|
||||
checkCalls := 0
|
||||
prepared := captureSuite(Suite[*int, struct{}]{
|
||||
CheckInput: func(_ string, _ Selection, value *int) error {
|
||||
checkCalls++
|
||||
if value != nil {
|
||||
return errors.New("nil factory did not supply the zero value")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[*int, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
||||
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := commandInput.plan.comparison.request
|
||||
if checkCalls != 2 || request.Left.Input != nil || request.Right.Input != nil {
|
||||
t.Fatalf("nil input value changed: checks=%d request=%#v", checkCalls, request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonPreservesCallerOwnedInputAliases(t *testing.T) {
|
||||
shared := new(int)
|
||||
inputCalls, checkCalls := 0, 0
|
||||
prepared := captureSuite(Suite[*int, struct{}]{
|
||||
NewInput: func() *int { inputCalls++; return shared },
|
||||
CheckInput: func(_ string, _ Selection, value *int) error {
|
||||
checkCalls++
|
||||
if value != shared {
|
||||
return errors.New("caller-owned alias was replaced")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[*int, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[*int], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
commandInput := &configCommandInput[*int, struct{}]{format: commandkit.Supplied[string]{Value: "yaml"}}
|
||||
if err := prepareConfigOperation(prepared, Diff, commandInput); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := commandInput.plan.comparison.request
|
||||
if inputCalls != 2 || checkCalls != 2 || request.Left.Input != shared || request.Right.Input != shared {
|
||||
t.Fatalf("caller-owned alias changed: calls=%d/%d request=%#v", inputCalls, checkCalls, request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonInputFailureStopsBeforeStateAndPreservesWiringClassification(t *testing.T) {
|
||||
var inputCalls, checkCalls, stateCalls int
|
||||
suite := Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
CheckInput: func(string, Selection, struct{}) error {
|
||||
checkCalls++
|
||||
if checkCalls == 2 {
|
||||
return commandkit.ErrFlagWiring
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, commandkit.ErrFlagWiring) {
|
||||
t.Fatalf("right-side wiring failure = %#v", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome.Cause.Error(), `scope "app" right input`) {
|
||||
t.Fatalf("right-side error lacks scope context: %v", outcome.Cause)
|
||||
}
|
||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 0 {
|
||||
t.Fatalf("failed preparation calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonMissingRequiredSelectorIsUsageBeforeState(t *testing.T) {
|
||||
type input struct{ Token commandkit.Supplied[string] }
|
||||
var inputCalls, checkCalls, stateCalls int
|
||||
token := commandkit.ValueFlag(commandkit.FlagMeta{Name: "token", Required: true}, (*string)(nil), parseConfigString, func(value *input, supplied commandkit.Supplied[string]) {
|
||||
value.Token = supplied
|
||||
})
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { inputCalls++; return input{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Selectors: []Selector[input]{{Binding: token, Scopes: []string{"app"}}},
|
||||
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
|
||||
Scopes: []Scope[input, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
|
||||
t.Fatalf("missing required selector = %#v", outcome)
|
||||
}
|
||||
if inputCalls != 1 || checkCalls != 0 || stateCalls != 0 {
|
||||
t.Fatalf("missing selector calls input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparisonHelpConvertsInapplicableSelectorsWithoutBuildingInputs(t *testing.T) {
|
||||
type input struct{ Tenant int }
|
||||
parseErr := errors.New("invalid tenant")
|
||||
var parseCalls, inputCalls, checkCalls, stateCalls int
|
||||
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*int)(nil), func(raw string) (int, error) {
|
||||
parseCalls++
|
||||
if raw == "bad" {
|
||||
return 0, parseErr
|
||||
}
|
||||
return strconv.Atoi(raw)
|
||||
}, func(value *input, supplied commandkit.Supplied[int]) {
|
||||
value.Tenant = supplied.Value
|
||||
})
|
||||
makeScope := func(name string) Scope[input, struct{}] {
|
||||
return Scope[input, struct{}]{Name: name, Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[input], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}}
|
||||
}
|
||||
suite := Suite[input, struct{}]{
|
||||
NewInput: func() input { inputCalls++; return input{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"promptkit"}}},
|
||||
CheckInput: func(string, Selection, input) error { checkCalls++; return nil },
|
||||
Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
var output strings.Builder
|
||||
app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=7", "--help"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if !strings.Contains(output.String(), "--tenant") || parseCalls != 1 {
|
||||
t.Fatalf("help did not convert the inapplicable supplied selector: calls=%d help=%q", parseCalls, output.String())
|
||||
}
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--scope", "app", "--tenant=bad", "--help"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || !errors.Is(outcome.Cause, parseErr) || parseCalls != 2 {
|
||||
t.Fatalf("malformed help selector outcome=%#v conversions=%d", outcome, parseCalls)
|
||||
}
|
||||
if inputCalls != 0 || checkCalls != 0 || stateCalls != 0 {
|
||||
t.Fatalf("help built suite work: input=%d check=%d state=%d", inputCalls, checkCalls, stateCalls)
|
||||
}
|
||||
}
|
||||
165
integrations/ckconfig/configkit_adapter.go
Normal file
165
integrations/ckconfig/configkit_adapter.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// FromConfigKit adapts a typed ConfigKit loader to all five native
|
||||
// configuration operations. The loader receives the operation's explicit
|
||||
// Selection, initialized input, and shared invocation state, and runs only when
|
||||
// an installed operation selects this scope. Comparison invokes it separately
|
||||
// for each reached side. Construction and help do not call it. A nil original
|
||||
// loader is retained as a construction issue, even if callers later narrow the
|
||||
// scope's operation list.
|
||||
func FromConfigKit[I, S, T any](
|
||||
name, summary string,
|
||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||
) Scope[I, S] {
|
||||
scope := Scope[I, S]{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Operations: []Operation{Validate, Show, Sources, Explain, Diff},
|
||||
}
|
||||
if load == nil {
|
||||
return WithScopeError(scope, fmt.Errorf("ConfigKit scope %q loader must not be nil", name))
|
||||
}
|
||||
|
||||
scope.Acquire = func(ctx context.Context, request Request, input I, state S) (commandkit.Acquired[View], error) {
|
||||
result, err := load(ctx, request.Selection, input, state)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
return commandkit.Acquired[View]{Diagnostics: configKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||
}
|
||||
return commandkit.Acquired[View]{}, err
|
||||
}
|
||||
if result == nil {
|
||||
return commandkit.Acquired[View]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
return commandkit.Acquired[View]{
|
||||
Value: View{Effective: result, Sources: result, Explanation: result},
|
||||
Diagnostics: configKitDiagnostics(name, sources, result.Diagnostics()),
|
||||
}, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("ConfigKit scope %q comparison before left load: %w", name, err)
|
||||
}
|
||||
|
||||
left, leftDiagnostics, err := loadComparisonSide(ctx, name, "left", load, request.Left, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison after left load: %w", name, err)
|
||||
}
|
||||
|
||||
right, rightDiagnostics, err := loadComparisonSide(ctx, name, "right", load, request.Right, state)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q comparison before native diff: %w", name, err)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("ConfigKit scope %q native diff: %w", name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func loadComparisonSide[I, S, T any](
|
||||
ctx context.Context,
|
||||
scope, side string,
|
||||
load func(context.Context, Selection, I, S) (*configkit.Result[T], error),
|
||||
request ComparisonSide[I],
|
||||
state S,
|
||||
) (*configkit.Result[T], []commandkit.Diagnostic, error) {
|
||||
result, err := load(ctx, request.Selection, request.Input, state)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
var diagnostics []commandkit.Diagnostic
|
||||
if errors.As(err, &loadError) && loadError != nil {
|
||||
diagnostics = configKitComparisonDiagnostics(scope, side, loadError.Sources(), loadError.Diagnostics())
|
||||
}
|
||||
return nil, diagnostics, fmt.Errorf("ConfigKit scope %q %s load: %w", scope, side, err)
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil, fmt.Errorf("ConfigKit scope %q %s loader returned a nil result", scope, side)
|
||||
}
|
||||
return result, configKitComparisonDiagnostics(scope, side, result.Sources(), result.Diagnostics()), nil
|
||||
}
|
||||
|
||||
func configKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitComparisonDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
return configKitDiagnosticsWithHeading("Configuration scope "+strconv.Quote(scope)+" "+side+":\n", sources, records)
|
||||
}
|
||||
|
||||
func configKitDiagnosticsWithHeading(heading string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
severity := commandkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the upstream snapshots so later calls to Write do not depend on
|
||||
// slices returned by the provider.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = append([]configkit.Diagnostic(nil), records...)
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, heading); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||
if err := writeDiagnosticText(writer, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||
written, err := io.WriteString(writer, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
162
integrations/ckconfig/contracts.go
Normal file
162
integrations/ckconfig/contracts.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// Selection carries the configuration and overlay choices supplied for a
|
||||
// configuration scope.
|
||||
type Selection struct {
|
||||
// Config is the caller-supplied configuration path, if present.
|
||||
Config commandkit.Supplied[string]
|
||||
// Overlay is the caller-supplied overlay path, if present.
|
||||
Overlay commandkit.Supplied[string]
|
||||
}
|
||||
|
||||
// Operation identifies a supported standard configuration operation.
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
// Validate checks the selected configuration and reports diagnostics.
|
||||
Validate Operation = "validate"
|
||||
// Show writes the selected configuration's effective form.
|
||||
Show Operation = "show"
|
||||
// Sources reports the selected configuration's input sources.
|
||||
Sources Operation = "sources"
|
||||
// Explain reports the history and provenance of a selected path.
|
||||
Explain Operation = "explain"
|
||||
// Diff compares two selections of the same configuration scope.
|
||||
Diff Operation = "diff"
|
||||
)
|
||||
|
||||
// Request describes one configuration operation and its selected inputs.
|
||||
type Request struct {
|
||||
// Operation identifies the requested inspection operation.
|
||||
Operation Operation
|
||||
// Selection carries the explicit config and overlay inputs.
|
||||
Selection Selection
|
||||
}
|
||||
|
||||
// ComparisonSide groups one configuration selection with its independently
|
||||
// initialized and checked typed input. The supplied-state fields of Selection
|
||||
// are preserved for the scope's loader.
|
||||
type ComparisonSide[I any] struct {
|
||||
// Selection carries the explicit config and overlay inputs for this side.
|
||||
Selection Selection
|
||||
// Input is initialized and checked for this side before comparison.
|
||||
Input I
|
||||
}
|
||||
|
||||
// ComparisonRequest carries the earlier and later selections supplied to a
|
||||
// scope's comparison callback. Each side has a separately prepared input; the
|
||||
// callback receives shared invocation state separately.
|
||||
type ComparisonRequest[I any] struct {
|
||||
// Left is the earlier configuration selection.
|
||||
Left ComparisonSide[I]
|
||||
// Right is the later configuration selection.
|
||||
Right ComparisonSide[I]
|
||||
}
|
||||
|
||||
// EffectiveWriter writes a configuration's native effective representation.
|
||||
type EffectiveWriter interface {
|
||||
WriteEffective(io.Writer) error
|
||||
}
|
||||
|
||||
// SourceReader reports the native source inventory for a configuration.
|
||||
type SourceReader interface {
|
||||
Sources() []configkit.SourceInfo
|
||||
}
|
||||
|
||||
// ExplanationReader reads and writes a configuration's native path explanation.
|
||||
type ExplanationReader interface {
|
||||
Explain(configkit.Path) (configkit.Explanation, bool)
|
||||
WriteExplanation(io.Writer, configkit.Path) error
|
||||
}
|
||||
|
||||
// View exposes native configuration facets without reconstructing their
|
||||
// reports.
|
||||
type View struct {
|
||||
Effective EffectiveWriter
|
||||
Sources SourceReader
|
||||
Explanation ExplanationReader
|
||||
}
|
||||
|
||||
// Scope declares the operations and providers for one named configuration.
|
||||
// Ordinary inspection operations use Acquire; Diff uses Compare and does not
|
||||
// require Acquire unless an ordinary operation is also declared. Construction
|
||||
// issues are retained privately until family assembly attaches them to its
|
||||
// command declaration.
|
||||
type Scope[I, S any] struct {
|
||||
// Name identifies the scope in CLI selection and reports.
|
||||
Name string
|
||||
// Summary describes the scope. Scope discovery reports name and operations only.
|
||||
Summary string
|
||||
// Operations selects the standard handlers exposed for this scope.
|
||||
Operations []Operation
|
||||
// Acquire loads the selected configuration for one ordinary inspection
|
||||
// invocation.
|
||||
Acquire func(context.Context, Request, I, S) (commandkit.Acquired[View], error)
|
||||
// Compare compares two selections for this scope using one shared state.
|
||||
// Its Acquired result carries native difference records and diagnostics;
|
||||
// returned errors retain their supported cause identity. It is required when
|
||||
// Diff is declared in Operations.
|
||||
Compare func(context.Context, ComparisonRequest[I], S) (commandkit.Acquired[[]configkit.Difference], error)
|
||||
|
||||
issues []error
|
||||
}
|
||||
|
||||
// Selector binds a typed flag to the configuration scopes that use it.
|
||||
type Selector[I any] struct {
|
||||
// Binding is the original typed flag handle captured by the suite.
|
||||
Binding commandkit.Flag[I]
|
||||
// Scopes lists the scope names that receive this flag's value.
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// Suite declares the typed input, shared state, selectors, and configuration
|
||||
// scopes used to assemble configuration commands.
|
||||
//
|
||||
// Input initialization is separate for each selected ordinary scope and each
|
||||
// reached comparison side; state is shared by the invocation's acquisitions or
|
||||
// comparison callback. Factory results and callback effects remain
|
||||
// caller-owned: Command does not deep-copy arbitrary objects or repair aliases
|
||||
// returned by factories. Core-owned repeated flag collections are copied when
|
||||
// applied to separate inputs. Factories are not invoked during construction,
|
||||
// installation, help, version, or scope discovery, and need not run after failure.
|
||||
type Suite[I, S any] struct {
|
||||
// NewInput is called once for each selected ordinary scope, or once for each
|
||||
// reached side of a comparison, after operation preflight and before applying
|
||||
// selectors and calling CheckInput.
|
||||
// If nil, input is the zero value of I, including nil for pointer types.
|
||||
NewInput func() I
|
||||
// NewState is called once when provider execution is reached, after all
|
||||
// selected inputs pass their checks. The result is shared by selected
|
||||
// acquisitions or the comparison callback.
|
||||
// If nil, state is the zero value of S, including nil for pointer types.
|
||||
NewState func() S
|
||||
// Selectors declares typed flags and their applicable scopes.
|
||||
Selectors []Selector[I]
|
||||
// CheckInput validates one initialized scope input after applicable values
|
||||
// are applied. Comparison sides are checked separately with their selection.
|
||||
CheckInput func(scope string, selection Selection, input I) error
|
||||
// Scopes declares the configuration scopes assembled by Command.
|
||||
Scopes []Scope[I, S]
|
||||
}
|
||||
|
||||
// WithScopeError returns a copy of scope that retains err as a construction
|
||||
// issue for ckconfig.Command to report during installation. Existing issues
|
||||
// remain available to family assembly, and scope is not mutated. A nil error
|
||||
// returns scope unchanged.
|
||||
func WithScopeError[I, S any](scope Scope[I, S], err error) Scope[I, S] {
|
||||
if err == nil {
|
||||
return scope
|
||||
}
|
||||
issues := make([]error, len(scope.issues), len(scope.issues)+1)
|
||||
copy(issues, scope.issues)
|
||||
scope.issues = append(issues, err)
|
||||
return scope
|
||||
}
|
||||
187
integrations/ckconfig/contracts_adapter_test.go
Normal file
187
integrations/ckconfig/contracts_adapter_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestWithScopeErrorCopiesAndRetainsCauses(t *testing.T) {
|
||||
first := &scopeCause{message: "first construction problem"}
|
||||
second := errors.New("second construction problem")
|
||||
original := Scope[struct{}, struct{}]{issues: []error{fmt.Errorf("wrapped: %w", first)}}
|
||||
|
||||
copyWithError := WithScopeError(original, second)
|
||||
if len(original.issues) != 1 || !errors.Is(original.issues[0], first) {
|
||||
t.Fatalf("original issues changed: %#v", original.issues)
|
||||
}
|
||||
if len(copyWithError.issues) != 2 || !errors.Is(copyWithError.issues[0], first) || !errors.Is(copyWithError.issues[1], second) {
|
||||
t.Fatalf("attached issues = %#v", copyWithError.issues)
|
||||
}
|
||||
if copyWithError.issues[0] != original.issues[0] {
|
||||
t.Fatal("existing cause identity was not preserved")
|
||||
}
|
||||
var retained *scopeCause
|
||||
if !errors.As(copyWithError.issues[0], &retained) || retained != first {
|
||||
t.Fatalf("retained concrete cause = %#v, want %#v", retained, first)
|
||||
}
|
||||
|
||||
narrowed := copyWithError
|
||||
narrowed.Operations = nil
|
||||
third := errors.New("third construction problem")
|
||||
withThird := WithScopeError(narrowed, third)
|
||||
unchanged := WithScopeError(withThird, nil)
|
||||
if len(unchanged.issues) != len(copyWithError.issues)+1 || !errors.Is(unchanged.issues[2], third) {
|
||||
t.Fatalf("repeated attachment lost issues: %#v", unchanged.issues)
|
||||
}
|
||||
}
|
||||
|
||||
type scopeCause struct{ message string }
|
||||
|
||||
func (e *scopeCause) Error() string { return e.message }
|
||||
|
||||
func TestFromConfigKitRetainsNilLoaderIssue(t *testing.T) {
|
||||
scope := FromConfigKit[struct{}, struct{}, struct{}]("app", "Application configuration", nil)
|
||||
scope.Operations = nil
|
||||
if scope.Acquire != nil {
|
||||
t.Fatal("nil loader produced an acquisition callback")
|
||||
}
|
||||
if len(scope.issues) != 1 || !strings.Contains(scope.issues[0].Error(), "loader") {
|
||||
t.Fatalf("retained nil-loader issues = %#v", scope.issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitPassesInvocationValuesAndNativeFacets(t *testing.T) {
|
||||
type input struct{ Label string }
|
||||
type state struct{ Calls int }
|
||||
type settings struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
selection := Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "chosen.yaml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "", Set: true},
|
||||
}
|
||||
gotContext, gotSelection, gotInput, gotState := context.Context(nil), Selection{}, input{}, (*state)(nil)
|
||||
result, err := configkit.Load(configkit.Mapping("source.yaml", map[string]any{"name": "native"}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := FromConfigKit[input, *state, settings]("app", "Application configuration", func(got context.Context, selected Selection, in input, st *state) (*configkit.Result[settings], error) {
|
||||
gotContext, gotSelection, gotInput, gotState = got, selected, in, st
|
||||
st.Calls++
|
||||
return result, nil
|
||||
})
|
||||
if len(scope.Operations) != 5 || scope.Operations[0] != Validate || scope.Operations[1] != Show || scope.Operations[2] != Sources || scope.Operations[3] != Explain || scope.Operations[4] != Diff {
|
||||
t.Fatalf("operations = %#v", scope.Operations)
|
||||
}
|
||||
if gotContext != nil {
|
||||
t.Fatal("loader ran during adapter construction")
|
||||
}
|
||||
stateValue := &state{}
|
||||
acquired, err := scope.Acquire(ctx, Request{Operation: Show, Selection: selection}, input{Label: "typed"}, stateValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotContext != ctx || gotSelection != selection || gotInput.Label != "typed" || gotState != stateValue || stateValue.Calls != 1 {
|
||||
t.Fatalf("loader arguments/context/state = %v, %#v, %#v, %p, %d", gotContext, gotSelection, gotInput, gotState, stateValue.Calls)
|
||||
}
|
||||
if acquired.Value.Effective != result || acquired.Value.Sources != result || acquired.Value.Explanation != result {
|
||||
t.Fatal("adapter did not expose the native result facets directly")
|
||||
}
|
||||
|
||||
var effective bytes.Buffer
|
||||
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(effective.String(), "name: native") {
|
||||
t.Fatalf("native effective output = %q", effective.String())
|
||||
}
|
||||
sources := acquired.Value.Sources.Sources()
|
||||
if len(sources) != 1 || sources[0].Label != "source.yaml" {
|
||||
t.Fatalf("native sources = %#v", sources)
|
||||
}
|
||||
path, err := configkit.ParsePath("/name")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := acquired.Value.Explanation.Explain(path); !ok {
|
||||
t.Fatal("native explanation facet did not resolve /name")
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := acquired.Value.Explanation.WriteExplanation(&explanation, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if explanation.Len() == 0 {
|
||||
t.Fatal("native explanation writer produced no output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKitRetainsLoadErrorAndDiagnostics(t *testing.T) {
|
||||
type settings struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
_, loadErr := configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[settings]{Definition: configkit.Definition[settings]{}})
|
||||
if loadErr == nil {
|
||||
t.Fatal("fixture did not produce a ConfigKit load error")
|
||||
}
|
||||
scope := FromConfigKit[struct{}, struct{}, settings]("app", "", func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[settings], error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
acquired, gotErr := scope.Acquire(context.Background(), Request{}, struct{}{}, struct{}{})
|
||||
if gotErr != loadErr {
|
||||
t.Fatalf("load error identity = %v, want original %v", gotErr, loadErr)
|
||||
}
|
||||
var typed *configkit.LoadError
|
||||
if !errors.As(gotErr, &typed) {
|
||||
t.Fatalf("load error type was not retained: %T", gotErr)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("failure diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||
t.Fatalf("diagnostic output lacks scope/source legend: %q", rendered.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitDiagnosticsMapSeverityAndPropagateWriterFailures(t *testing.T) {
|
||||
warning := configKitDiagnostics("app", nil, []configkit.Diagnostic{{Severity: configkit.SeverityWarning, Message: "safe warning"}})
|
||||
if len(warning) != 1 || warning[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("warning diagnostics = %#v", warning)
|
||||
}
|
||||
withError := configKitDiagnostics("app", nil, []configkit.Diagnostic{
|
||||
{Severity: configkit.SeverityWarning, Message: "safe warning"},
|
||||
{Severity: configkit.SeverityError, Message: "safe error"},
|
||||
})
|
||||
if withError[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("mixed diagnostics severity = %q", withError[0].Severity)
|
||||
}
|
||||
|
||||
if err := withError[0].Write(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v, want %v", err, io.ErrShortWrite)
|
||||
}
|
||||
wantErr := errors.New("writer failed")
|
||||
if err := withError[0].Write(failingWriter{err: wantErr}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("writer error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(value []byte) (int, error) { return len(value) - 1, nil }
|
||||
|
||||
type failingWriter struct{ err error }
|
||||
|
||||
func (w failingWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
43
integrations/ckconfig/diff.go
Normal file
43
integrations/ckconfig/diff.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
func runConfigDiff[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
if plan.comparison == nil || plan.comparison.scope == nil {
|
||||
return configOperationFailure(errors.New("configuration comparison was not prepared"), nil, false)
|
||||
}
|
||||
selected := plan.comparison
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
}
|
||||
compare := selected.scope.scope.Compare
|
||||
if compare == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no comparison callback", selected.scope.scope.Name), nil, false)
|
||||
}
|
||||
acquired, err := compare(invocation.Context, selected.request, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
report, err := renderDiffReport(acquired.Value)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
256
integrations/ckconfig/diff_execution_test.go
Normal file
256
integrations/ckconfig/diff_execution_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestConfigDiffExecutesWithDefaultAndExplicitYAMLAndInheritedSelections(t *testing.T) {
|
||||
type settings struct {
|
||||
Greeting string `yaml:"greeting"`
|
||||
}
|
||||
var selections []Selection
|
||||
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
|
||||
selections = append(selections, selection)
|
||||
value := "Hello"
|
||||
if selection.Config.Set && selection.Config.Value == "after.yml" {
|
||||
value = "Goodbye"
|
||||
}
|
||||
return configkit.Load(configkit.Mapping("selected configuration", map[string]any{"greeting": value}), configkit.Options[settings]{})
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before.yml", "--overlay", "base", "--against-config", "after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("default YAML comparison outcome = %#v", outcome)
|
||||
}
|
||||
if len(selections) != 2 || !selections[0].Config.Set || selections[0].Config.Value != "before.yml" || !selections[1].Config.Set || selections[1].Config.Value != "after.yml" || selections[1].Overlay != selections[0].Overlay {
|
||||
t.Fatalf("comparison selections = %#v", selections)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(output.Bytes(), &document); err != nil {
|
||||
t.Fatalf("default report is not YAML: %q: %v", output.String(), err)
|
||||
}
|
||||
changes := reportField(document.Content[0], "changes")
|
||||
if value := reportField(changes, "greeting"); value == nil || value.Value != "Goodbye" {
|
||||
t.Fatalf("public comparison output = %#v", document.Content[0])
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "yaml", "--config", "before.yml", "--against-config", "before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("explicit YAML comparison outcome = %#v", outcome)
|
||||
}
|
||||
var equal yaml.Node
|
||||
if err := yaml.Unmarshal(output.Bytes(), &equal); err != nil {
|
||||
t.Fatalf("explicit YAML report is not YAML: %q: %v", output.String(), err)
|
||||
}
|
||||
if len(reportField(equal.Content[0], "additions").Content) != 0 || len(reportField(equal.Content[0], "changes").Content) != 0 || len(reportField(equal.Content[0], "deletions").Content) != 0 {
|
||||
t.Fatalf("equal comparison did not return empty categories: %q", output.String())
|
||||
}
|
||||
|
||||
loadsBeforeText := len(selections)
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--format", "text"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || len(selections) != loadsBeforeText || output.Len() != 0 {
|
||||
t.Fatalf("text format outcome/work/output = %#v / %d / %q", outcome, len(selections)-loadsBeforeText, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffRunsComparisonOnlyScopeAndRejectsUnsupportedScopeBeforeWork(t *testing.T) {
|
||||
var inputCalls, stateCalls, acquireCalls, compareCalls int
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Diff}, Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
compareCalls++
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
}},
|
||||
{Name: "plain", Operations: []Operation{Show}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
},
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "app: diff") {
|
||||
t.Fatalf("comparison-only discovery = %#v, %q", outcome, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || compareCalls != 1 {
|
||||
t.Fatalf("comparison-only execution = %#v calls=%d", outcome, compareCalls)
|
||||
}
|
||||
inputsBefore, statesBefore := inputCalls, stateCalls
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--scope", "plain"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || inputCalls != inputsBefore || stateCalls != statesBefore || acquireCalls != 0 || compareCalls != 1 || output.Len() != 0 {
|
||||
t.Fatalf("unsupported scope outcome/work/output = %#v / %d,%d,%d,%d / %q", outcome, inputCalls, stateCalls, acquireCalls, compareCalls, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffProviderFailureRetainsDiagnosticsAndCause(t *testing.T) {
|
||||
wantErr := errors.New("provider unavailable")
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityError, Write: func(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, "provider warning\n")
|
||||
return err
|
||||
}}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, fmt.Errorf("comparison setup: %w", wantErr)
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, wantErr) || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("provider failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportPreparationFailureWritesNoReport(t *testing.T) {
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: []configkit.Difference{{
|
||||
Path: configkit.Keys("invalid"), Right: configkit.ValueSnapshot{Present: true, YAML: "bad: ["},
|
||||
}}}, nil
|
||||
},
|
||||
}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.ReportIncomplete || outcome.DeliveryError != nil || output.Len() != 0 {
|
||||
t.Fatalf("report preparation failure outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffCancellationAfterAcquisitionRetainsDiagnosticsWithoutWriting(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
diagnostic := commandkit.Diagnostic{Severity: commandkit.SeverityWarning, Write: func(io.Writer) error { return nil }}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
cancel()
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: []commandkit.Diagnostic{diagnostic}}, nil
|
||||
},
|
||||
}}}))
|
||||
defer cancel()
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(ctx, []string{"config", "diff"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, context.Canceled) || outcome.ReportIncomplete || output.Len() != 0 || len(outcome.Diagnostics) < 1 || outcome.Diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("cancelled comparison outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffOutputErrorsCarryIncompleteDeliveryOutcome(t *testing.T) {
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{
|
||||
Name: "app", Operations: []Operation{Diff},
|
||||
Compare: func(context.Context, ComparisonRequest[struct{}], struct{}) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}}}))
|
||||
wantErr := errors.New("output failed")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
writer io.Writer
|
||||
want error
|
||||
}{
|
||||
{name: "writer error", writer: failingOutput{err: wantErr}, want: wantErr},
|
||||
{name: "short write", writer: shortWriter{}, want: io.ErrShortWrite},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: test.writer, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.DeliveryError, test.want) || !errors.Is(outcome.Cause, test.want) || !outcome.ReportIncomplete {
|
||||
t.Fatalf("delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffRepeatedInvocationsPrepareFreshInputsAndState(t *testing.T) {
|
||||
type input struct {
|
||||
ID int
|
||||
Label string
|
||||
}
|
||||
type state struct{ ID int }
|
||||
label := commandkit.ValueFlag(commandkit.FlagMeta{Name: "label"}, (*string)(nil), parseConfigString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Label = value.Value })
|
||||
var inputID, stateID int
|
||||
var observed []struct {
|
||||
left, right input
|
||||
state *state
|
||||
}
|
||||
app := newConfigTestApp(t, Command(Suite[input, *state]{
|
||||
NewInput: func() input { inputID++; return input{ID: inputID} },
|
||||
NewState: func() *state { stateID++; return &state{ID: stateID} },
|
||||
Selectors: []Selector[input]{{Binding: label, Scopes: []string{"app"}}},
|
||||
Scopes: []Scope[input, *state]{{Name: "app", Operations: []Operation{Diff}, Compare: func(_ context.Context, request ComparisonRequest[input], shared *state) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
observed = append(observed, struct {
|
||||
left, right input
|
||||
state *state
|
||||
}{request.Left.Input, request.Right.Input, shared})
|
||||
left, err := configkit.Load[struct {
|
||||
Label string `yaml:"label"`
|
||||
}](configkit.Mapping("left", map[string]any{"label": request.Left.Input.Label}))
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, err
|
||||
}
|
||||
right, err := configkit.Load[struct {
|
||||
Label string `yaml:"label"`
|
||||
}](configkit.Mapping("right", map[string]any{"label": request.Right.Input.Label + "-right"}))
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, err
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences}, err
|
||||
}}},
|
||||
}))
|
||||
for range 2 {
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--label", "current"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("comparison invocation outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
if len(observed) != 2 || observed[0].left.ID == observed[0].right.ID || observed[1].left.ID == observed[1].right.ID || observed[0].left.ID == observed[1].left.ID || observed[0].right.ID == observed[1].right.ID {
|
||||
t.Fatalf("input instances were reused: %#v", observed)
|
||||
}
|
||||
if observed[0].left.Label != "current" || observed[0].right.Label != "current" || observed[1].left.Label != "current" || observed[1].right.Label != "current" {
|
||||
t.Fatalf("comparison inputs missed captured flag values: %#v", observed)
|
||||
}
|
||||
if observed[0].state == nil || observed[1].state == nil || observed[0].state == observed[1].state || observed[0].state.ID != 1 || observed[1].state.ID != 2 {
|
||||
t.Fatalf("state was not fresh per invocation: %#v", observed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffExecutesConcealedChangesAndEmptyResults(t *testing.T) {
|
||||
type settings struct {
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
app := newConfigTestApp(t, AppCommand(func(_ context.Context, selection Selection) (*configkit.Result[settings], error) {
|
||||
value := "same"
|
||||
if selection.Config.Set && selection.Config.Value == "after" {
|
||||
value = "changed-secret"
|
||||
}
|
||||
return configkit.Load(configkit.Mapping("selected", map[string]any{"secret": value}), configkit.Options[settings]{Definition: configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}})
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "after"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "[REDACTED]") || strings.Contains(output.String(), "changed-secret") {
|
||||
t.Fatalf("concealed comparison outcome/report = %#v / %q", outcome, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "before", "--against-config", "before"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(output.String(), "additions:") || !strings.Contains(output.String(), "changes:") || !strings.Contains(output.String(), "deletions:") {
|
||||
t.Fatalf("empty comparison outcome/report = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
194
integrations/ckconfig/diff_report.go
Normal file
194
integrations/ckconfig/diff_report.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func decodeSafeSnapshot(snapshot string) (*yaml.Node, error) {
|
||||
var document yaml.Node
|
||||
decoder := yaml.NewDecoder(strings.NewReader(snapshot))
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, fmt.Errorf("decode safe YAML snapshot: %w", err)
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) == 0 {
|
||||
return nil, fmt.Errorf("decode safe YAML snapshot: document has no value")
|
||||
}
|
||||
return document.Content[0], nil
|
||||
}
|
||||
|
||||
func decodeJSONPointer(pointer string) ([]string, error) {
|
||||
if pointer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if pointer[0] != '/' {
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: path must start with /", pointer)
|
||||
}
|
||||
|
||||
parts := strings.Split(pointer[1:], "/")
|
||||
for index, part := range parts {
|
||||
var decoded strings.Builder
|
||||
decoded.Grow(len(part))
|
||||
for offset := 0; offset < len(part); offset++ {
|
||||
if part[offset] != '~' {
|
||||
decoded.WriteByte(part[offset])
|
||||
continue
|
||||
}
|
||||
if offset+1 >= len(part) {
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: incomplete escape in token %d", pointer, index)
|
||||
}
|
||||
offset++
|
||||
switch part[offset] {
|
||||
case '0':
|
||||
decoded.WriteByte('~')
|
||||
case '1':
|
||||
decoded.WriteByte('/')
|
||||
default:
|
||||
return nil, fmt.Errorf("decode JSON pointer %q: invalid escape in token %d", pointer, index)
|
||||
}
|
||||
}
|
||||
parts[index] = decoded.String()
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// yamlValuePlacer indexes each mapping once while assembling one report.
|
||||
// Content remains the ordered representation used by the YAML encoder.
|
||||
type yamlValuePlacer struct {
|
||||
keys map[*yaml.Node]map[string]int
|
||||
}
|
||||
|
||||
func (placer *yamlValuePlacer) placeValue(category *yaml.Node, path []string, value *yaml.Node) (*yaml.Node, error) {
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("place YAML value: value node is nil")
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return value, nil
|
||||
}
|
||||
if category == nil || category.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("place YAML value at %q: category ancestor is not a mapping", pointerFromTokens(path[:1]))
|
||||
}
|
||||
|
||||
current := category
|
||||
for index, token := range path {
|
||||
keys := placer.mappingKeys(current)
|
||||
keyIndex, exists := keys[token]
|
||||
if index == len(path)-1 {
|
||||
if exists {
|
||||
current.Content[keyIndex+1] = value
|
||||
} else {
|
||||
keys[token] = len(current.Content)
|
||||
current.Content = append(current.Content, stringKey(token), value)
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
if !exists {
|
||||
child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
keys[token] = len(current.Content)
|
||||
current.Content = append(current.Content, stringKey(token), child)
|
||||
current = child
|
||||
continue
|
||||
}
|
||||
|
||||
child := current.Content[keyIndex+1]
|
||||
if child.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("place YAML value at %q: ancestor %q is not a mapping", pointerFromTokens(path), pointerFromTokens(path[:index+1]))
|
||||
}
|
||||
current = child
|
||||
}
|
||||
return category, nil
|
||||
}
|
||||
|
||||
func (placer *yamlValuePlacer) mappingKeys(mapping *yaml.Node) map[string]int {
|
||||
if keys, exists := placer.keys[mapping]; exists {
|
||||
return keys
|
||||
}
|
||||
if placer.keys == nil {
|
||||
placer.keys = make(map[*yaml.Node]map[string]int)
|
||||
}
|
||||
keys := make(map[string]int, len(mapping.Content)/2)
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
candidate := mapping.Content[index]
|
||||
if candidate.Kind == yaml.ScalarNode && candidate.Tag == "!!str" {
|
||||
if _, exists := keys[candidate.Value]; !exists {
|
||||
keys[candidate.Value] = index
|
||||
}
|
||||
}
|
||||
}
|
||||
placer.keys[mapping] = keys
|
||||
return keys
|
||||
}
|
||||
|
||||
func stringKey(value string) *yaml.Node {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
|
||||
}
|
||||
|
||||
func pointerFromTokens(tokens []string) string {
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
var pointer strings.Builder
|
||||
for _, token := range tokens {
|
||||
pointer.WriteByte('/')
|
||||
pointer.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
|
||||
}
|
||||
return pointer.String()
|
||||
}
|
||||
|
||||
func renderDiffReport(differences []configkit.Difference) ([]byte, error) {
|
||||
var placer yamlValuePlacer
|
||||
additions := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
changes := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
deletions := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||
|
||||
for _, difference := range differences {
|
||||
path := difference.Path.String()
|
||||
switch {
|
||||
case !difference.Left.Present && difference.Right.Present:
|
||||
tokens, err := decodeJSONPointer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
additions, err = placer.placeValue(additions, tokens, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render addition at %q: %w", path, err)
|
||||
}
|
||||
case difference.Left.Present && difference.Right.Present:
|
||||
tokens, err := decodeJSONPointer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
value, err := decodeSafeSnapshot(difference.Right.YAML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
changes, err = placer.placeValue(changes, tokens, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render change at %q: %w", path, err)
|
||||
}
|
||||
case difference.Left.Present && !difference.Right.Present:
|
||||
deletions.Content = append(deletions.Content, stringKey(path))
|
||||
}
|
||||
}
|
||||
|
||||
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
||||
stringKey("additions"), additions,
|
||||
stringKey("changes"), changes,
|
||||
stringKey("deletions"), deletions,
|
||||
}}
|
||||
document := &yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{root}}
|
||||
var output bytes.Buffer
|
||||
if err := encodeYAML(&output, document); err != nil {
|
||||
return nil, fmt.Errorf("encode configuration diff report: %w", err)
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
173
integrations/ckconfig/diff_report_render_test.go
Normal file
173
integrations/ckconfig/diff_report_render_test.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func renderAndDecode(t *testing.T, differences []configkit.Difference) (*yaml.Node, []byte) {
|
||||
t.Helper()
|
||||
report, err := renderDiffReport(differences)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(report, &document); err != nil {
|
||||
t.Fatalf("decode rendered report %q: %v", report, err)
|
||||
}
|
||||
return document.Content[0], report
|
||||
}
|
||||
|
||||
func reportField(mapping *yaml.Node, key string) *yaml.Node {
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
if mapping.Content[index].Value == key {
|
||||
return mapping.Content[index+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRenderDiffReportClassifiesAndOrdersNativeRecords(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("z-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "false\n"}},
|
||||
{Path: configkit.Keys("count"), Left: configkit.ValueSnapshot{Present: true, YAML: "1\n"}, Right: configkit.ValueSnapshot{Present: true, YAML: "9007199254740993\n"}},
|
||||
{Path: configkit.Keys("removed"), Left: configkit.ValueSnapshot{Present: true, YAML: "unused"}},
|
||||
{Path: configkit.Keys("a-added"), Right: configkit.ValueSnapshot{Present: true, YAML: "'123'\n"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
if root.Kind != yaml.MappingNode || len(root.Content) != 6 {
|
||||
t.Fatalf("report root = %#v", root)
|
||||
}
|
||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
||||
t.Fatalf("report category order = %#v", got)
|
||||
}
|
||||
additions := reportField(root, "additions")
|
||||
if additions.Kind != yaml.MappingNode || additions.Content[0].Value != "z-added" || additions.Content[2].Value != "a-added" {
|
||||
t.Fatalf("addition order/value = %#v", additions)
|
||||
}
|
||||
if additions.Content[1].Kind != yaml.ScalarNode || additions.Content[1].Tag != "!!bool" || additions.Content[1].Value != "false" {
|
||||
t.Fatalf("boolean addition lost its type: %#v", additions.Content[1])
|
||||
}
|
||||
if additions.Content[3].Tag != "!!str" || additions.Content[3].Value != "123" {
|
||||
t.Fatalf("numeric-looking string lost its type: %#v", additions.Content[3])
|
||||
}
|
||||
changes := reportField(root, "changes")
|
||||
if changes.Kind != yaml.MappingNode || changes.Content[1].Tag != "!!int" || changes.Content[1].Value != "9007199254740993" {
|
||||
t.Fatalf("change value/type = %#v", changes)
|
||||
}
|
||||
deletions := reportField(root, "deletions")
|
||||
if deletions.Kind != yaml.SequenceNode || len(deletions.Content) != 1 || deletions.Content[0].Tag != "!!str" || deletions.Content[0].Value != "/removed" {
|
||||
t.Fatalf("deletion records = %#v", deletions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportEmitsEmptyCategoriesAndRootValue(t *testing.T) {
|
||||
root, _ := renderAndDecode(t, nil)
|
||||
if got := []string{root.Content[0].Value, root.Content[2].Value, root.Content[4].Value}; !reflect.DeepEqual(got, []string{"additions", "changes", "deletions"}) {
|
||||
t.Fatalf("empty report keys = %#v", got)
|
||||
}
|
||||
if reportField(root, "additions").Kind != yaml.MappingNode || len(reportField(root, "additions").Content) != 0 || reportField(root, "changes").Kind != yaml.MappingNode || len(reportField(root, "changes").Content) != 0 || reportField(root, "deletions").Kind != yaml.SequenceNode || len(reportField(root, "deletions").Content) != 0 {
|
||||
t.Fatalf("empty report categories = %#v", root)
|
||||
}
|
||||
|
||||
root, _ = renderAndDecode(t, []configkit.Difference{{Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}}})
|
||||
additions := reportField(root, "additions")
|
||||
if additions.Tag != "!!null" || additions.Value != "null" {
|
||||
t.Fatalf("whole-root addition was wrapped: %#v", additions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportDistinguishesDeletionFromNullAndEmptyMappings(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("nullable"), Right: configkit.ValueSnapshot{Present: true, YAML: "null\n"}},
|
||||
{Path: configkit.Keys("settings", "first"), Left: configkit.ValueSnapshot{Present: true, YAML: "intentionally not parsed"}},
|
||||
{Path: configkit.Keys("settings", "second"), Left: configkit.ValueSnapshot{Present: true, YAML: "also not parsed"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
if got := reportField(root, "additions").Content; len(got) != 2 || got[0].Value != "nullable" || got[1].Tag != "!!null" {
|
||||
t.Fatalf("explicit null addition = %#v", got)
|
||||
}
|
||||
deletions := reportField(root, "deletions")
|
||||
if len(deletions.Content) != 2 || deletions.Content[0].Value != "/settings/first" || deletions.Content[1].Value != "/settings/second" {
|
||||
t.Fatalf("mapping-to-empty deletion records = %#v", deletions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportKeepsAtomicSequencesAndStructuralValues(t *testing.T) {
|
||||
differences := []configkit.Difference{
|
||||
{Path: configkit.Keys("items"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "- one\n- two\n"}},
|
||||
{Path: configkit.Keys("settings"), Left: configkit.ValueSnapshot{Present: true}, Right: configkit.ValueSnapshot{Present: true, YAML: "nested:\n enabled: true\n"}},
|
||||
}
|
||||
root, _ := renderAndDecode(t, differences)
|
||||
changes := reportField(root, "changes")
|
||||
if changes.Content[1].Kind != yaml.SequenceNode || len(changes.Content[1].Content) != 2 {
|
||||
t.Fatalf("sequence change was expanded or altered: %#v", changes.Content[1])
|
||||
}
|
||||
settings := reportField(changes, "settings")
|
||||
if settings.Kind != yaml.MappingNode || reportField(settings, "nested").Kind != yaml.MappingNode || reportField(reportField(settings, "nested"), "enabled").Tag != "!!bool" {
|
||||
t.Fatalf("structural replacement was altered: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportPreservesNativeMasksAndMarkerLookingStrings(t *testing.T) {
|
||||
type settings struct {
|
||||
Public string `yaml:"public"`
|
||||
Secret string `yaml:"secret"`
|
||||
}
|
||||
definition := configkit.Definition[settings]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }}
|
||||
load := func(public, secret string) *configkit.Result[settings] {
|
||||
result, err := configkit.Load(configkit.Mapping("configuration", map[string]any{"public": public, "secret": secret}), configkit.Options[settings]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
left := load("visible", "old-secret")
|
||||
right := load("[REDACTED]", "new-secret")
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 2 || differences[0].Path.String() != "/public" || differences[1].Path.String() != "/secret" || !differences[1].HiddenChanged || !differences[1].Right.Redacted {
|
||||
t.Fatalf("native changes/mask metadata = %#v", differences)
|
||||
}
|
||||
root, report := renderAndDecode(t, differences)
|
||||
changes := reportField(root, "changes")
|
||||
for _, key := range []string{"public", "secret"} {
|
||||
value := reportField(changes, key)
|
||||
if value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
|
||||
t.Fatalf("safe value for %q = %#v", key, value)
|
||||
}
|
||||
}
|
||||
if bytes.Contains(report, []byte("HiddenChanged")) || bytes.Contains(report, []byte("Redacted")) || strings.Contains(string(report), "old-secret") || strings.Contains(string(report), "new-secret") {
|
||||
t.Fatalf("report exposed native metadata or secret contents: %q", report)
|
||||
}
|
||||
|
||||
concealedOnly := load("[REDACTED]", "later-secret")
|
||||
concealedDifferences, err := configkit.Diff(right, concealedOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(concealedDifferences) != 1 || concealedDifferences[0].Path.String() != "/secret" || !concealedDifferences[0].HiddenChanged {
|
||||
t.Fatalf("concealed-only native records = %#v", concealedDifferences)
|
||||
}
|
||||
concealedRoot, _ := renderAndDecode(t, concealedDifferences)
|
||||
if value := reportField(reportField(concealedRoot, "changes"), "secret"); value == nil || value.Tag != "!!str" || value.Value != "[REDACTED]" {
|
||||
t.Fatalf("concealed-only safe value = %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDiffReportReportsMalformedPresentSnapshot(t *testing.T) {
|
||||
report, err := renderDiffReport([]configkit.Difference{{
|
||||
Path: configkit.Keys("setting"),
|
||||
Right: configkit.ValueSnapshot{Present: true, YAML: "setting: [unterminated\n"},
|
||||
}})
|
||||
if err == nil || report != nil || !strings.Contains(err.Error(), "/setting") {
|
||||
t.Fatalf("malformed snapshot report/error = %q / %v", report, err)
|
||||
}
|
||||
}
|
||||
146
integrations/ckconfig/diff_report_test.go
Normal file
146
integrations/ckconfig/diff_report_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestDecodeSafeSnapshotPreservesYAMLTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
kind yaml.Kind
|
||||
tag string
|
||||
value string
|
||||
}{
|
||||
{name: "null", text: "null\n", kind: yaml.ScalarNode, tag: "!!null", value: "null"},
|
||||
{name: "boolean", text: "true\n", kind: yaml.ScalarNode, tag: "!!bool", value: "true"},
|
||||
{name: "large integer", text: "9007199254740993\n", kind: yaml.ScalarNode, tag: "!!int", value: "9007199254740993"},
|
||||
{name: "numeric string", text: "'123'\n", kind: yaml.ScalarNode, tag: "!!str", value: "123"},
|
||||
{name: "boolean-like string", text: "'false'\n", kind: yaml.ScalarNode, tag: "!!str", value: "false"},
|
||||
{name: "empty mapping", text: "{}\n", kind: yaml.MappingNode, tag: "!!map"},
|
||||
{name: "empty sequence", text: "[]\n", kind: yaml.SequenceNode, tag: "!!seq"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
node, err := decodeSafeSnapshot(test.text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if node.Kind != test.kind || node.Tag != test.tag || node.Value != test.value {
|
||||
t.Fatalf("node kind/tag/value = %v/%q/%q, want %v/%q/%q", node.Kind, node.Tag, node.Value, test.kind, test.tag, test.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
nested, err := decodeSafeSnapshot("items:\n - name: example\n enabled: false\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nested.Kind != yaml.MappingNode || nested.Content[1].Kind != yaml.SequenceNode || nested.Content[1].Content[0].Content[3].Tag != "!!bool" {
|
||||
t.Fatalf("nested collection/scalar tags were not preserved: %#v", nested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONPointerPreservesSemanticTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
pointer string
|
||||
want []string
|
||||
}{
|
||||
{pointer: "", want: nil},
|
||||
{pointer: "/", want: []string{""}},
|
||||
{pointer: "/parent/child", want: []string{"parent", "child"}},
|
||||
{pointer: "/0/../a.b/50%", want: []string{"0", "..", "a.b", "50%"}},
|
||||
{pointer: "/a~1b/c~0d", want: []string{"a/b", "c~d"}},
|
||||
{pointer: "/~01", want: []string{"~1"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, err := decodeJSONPointer(test.pointer)
|
||||
if err != nil {
|
||||
t.Fatalf("decode %q: %v", test.pointer, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("decode %q = %#v, want %#v", test.pointer, got, test.want)
|
||||
}
|
||||
}
|
||||
for _, pointer := range []string{"missing-leading-slash", "/bad~", "/bad~2escape"} {
|
||||
if _, err := decodeJSONPointer(pointer); err == nil {
|
||||
t.Errorf("decode %q unexpectedly succeeded", pointer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueBuildsOrderedSemanticMappings(t *testing.T) {
|
||||
var placer yamlValuePlacer
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
paths := [][]string{
|
||||
{"parent", "0", "dot.name", "50%"},
|
||||
{"parent", "second"},
|
||||
{"a/b", "~1"},
|
||||
{""},
|
||||
}
|
||||
for index, path := range paths {
|
||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: string(rune('a' + index))}
|
||||
updated, err := placer.placeValue(category, path, value)
|
||||
if err != nil {
|
||||
t.Fatalf("place %q: %v", pointerFromTokens(path), err)
|
||||
}
|
||||
category = updated
|
||||
}
|
||||
|
||||
if len(category.Content) != 6 || category.Content[0].Value != "parent" || category.Content[2].Value != "a/b" || category.Content[4].Value != "" {
|
||||
t.Fatalf("mapping insertion order or empty key = %#v", category.Content)
|
||||
}
|
||||
parent := category.Content[1]
|
||||
if parent.Kind != yaml.MappingNode || len(parent.Content) != 4 || parent.Content[0].Value != "0" || parent.Content[2].Value != "second" {
|
||||
t.Fatalf("nested shared ancestor/order = %#v", parent)
|
||||
}
|
||||
numeric := parent.Content[1]
|
||||
dotted := numeric.Content[1]
|
||||
if numeric.Kind != yaml.MappingNode || numeric.Content[0].Value != "dot.name" || dotted.Kind != yaml.MappingNode || dotted.Content[0].Value != "50%" {
|
||||
t.Fatalf("semantic tokens were changed: numeric=%#v dotted=%#v", numeric, dotted)
|
||||
}
|
||||
if category.Content[1].Content[0].Tag != "!!str" || category.Content[2].Tag != "!!str" || category.Content[4].Tag != "!!str" {
|
||||
t.Fatal("mapping keys are not explicitly tagged as YAML strings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueHandlesRootAndEmptyKey(t *testing.T) {
|
||||
var placer yamlValuePlacer
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
root := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
|
||||
updated, err := placer.placeValue(category, nil, root)
|
||||
if err != nil || updated != root {
|
||||
t.Fatalf("root placement = %#v, %v", updated, err)
|
||||
}
|
||||
|
||||
category = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
value := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "empty-key"}
|
||||
updated, err = placer.placeValue(category, []string{""}, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated != category || len(category.Content) != 2 || category.Content[0].Value != "" || category.Content[0].Tag != "!!str" || category.Content[1] != value {
|
||||
t.Fatalf("slash path placement = %#v", category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceYAMLValueRejectsIncompatibleAncestors(t *testing.T) {
|
||||
var placer yamlValuePlacer
|
||||
category := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map", Content: []*yaml.Node{
|
||||
stringKey("parent"), {Kind: yaml.ScalarNode, Tag: "!!str", Value: "leaf"},
|
||||
}}
|
||||
_, err := placer.placeValue(category, []string{"parent", "child"}, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"})
|
||||
if err == nil || !strings.Contains(err.Error(), "/parent/child") || !strings.Contains(err.Error(), "/parent") {
|
||||
t.Fatalf("incompatible ancestor error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSafeSnapshotRejectsMalformedYAML(t *testing.T) {
|
||||
if _, err := decodeSafeSnapshot("value: [unterminated\n"); err == nil {
|
||||
t.Fatal("malformed snapshot unexpectedly decoded")
|
||||
}
|
||||
}
|
||||
6
integrations/ckconfig/doc.go
Normal file
6
integrations/ckconfig/doc.go
Normal file
@@ -0,0 +1,6 @@
|
||||
// Package ckconfig provides CommandKit's optional ConfigKit integration. Its
|
||||
// declarations install the standard config command family through the core
|
||||
// commandkit package. Source reports preserve ConfigKit's destination,
|
||||
// placement, and version-check evidence; importing ckconfig alone installs no
|
||||
// commands.
|
||||
package ckconfig
|
||||
191
integrations/ckconfig/docs/oss/attribution.md
Normal file
191
integrations/ckconfig/docs/oss/attribution.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Open Source Attribution Notices
|
||||
|
||||
## ConfigKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||
|
||||
Copyright 2006-2010 Kirill Simonov
|
||||
|
||||
Copyright 2011-2019 Canonical Ltd
|
||||
|
||||
Copyright 2025 The go-yaml Project Contributors
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) |
|
||||
[NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE).
|
||||
|
||||
[Source](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
|
||||
|
||||
## Cobra
|
||||
|
||||
Selected module: `github.com/spf13/cobra` at `v1.10.1`.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE.txt](https://github.com/spf13/cobra/blob/7da941c3547e93b8c9f70bbd3befca79c6335388/LICENSE.txt).
|
||||
|
||||
[Source](https://github.com/spf13/cobra/tree/7da941c3547e93b8c9f70bbd3befca79c6335388).
|
||||
|
||||
## pflag
|
||||
|
||||
Selected module: `github.com/spf13/pflag` at `v1.0.9`.
|
||||
|
||||
Copyright (c) 2012 Alex Ogier. All rights reserved.
|
||||
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://github.com/spf13/pflag/blob/10438578954bba2527fe5cae3684d4532b064bbe/LICENSE).
|
||||
|
||||
[Source](https://github.com/spf13/pflag/tree/10438578954bba2527fe5cae3684d4532b064bbe).
|
||||
|
||||
## go-md2man
|
||||
|
||||
Selected module: `github.com/cpuguy83/go-md2man/v2` at `v2.0.6` (transitive dependency).
|
||||
|
||||
Copyright (c) 2014 Brian Goff
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE.md](https://github.com/cpuguy83/go-md2man/blob/441631534462b74c098953dbd8d5f6210994b0bf/LICENSE.md).
|
||||
|
||||
[Source](https://github.com/cpuguy83/go-md2man/tree/441631534462b74c098953dbd8d5f6210994b0bf).
|
||||
|
||||
## mousetrap
|
||||
|
||||
Selected module: `github.com/inconshreveable/mousetrap` at `v1.1.0` (transitive dependency).
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/inconshreveable/mousetrap/blob/4e8053ee7ef85a6bd26368364a6d27f1641c1d21/LICENSE).
|
||||
|
||||
[Source](https://github.com/inconshreveable/mousetrap/tree/4e8053ee7ef85a6bd26368364a6d27f1641c1d21).
|
||||
|
||||
## Blackfriday
|
||||
|
||||
Selected module: `github.com/russross/blackfriday/v2` at `v2.1.0` (transitive dependency).
|
||||
|
||||
Copyright © 2011 Russ Ross
|
||||
|
||||
SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
|
||||
[LICENSE.txt](https://github.com/russross/blackfriday/blob/4c9bf9512682b995722660a4196c0013228e2049/LICENSE.txt).
|
||||
|
||||
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
|
||||
|
||||
## gocheck
|
||||
|
||||
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
|
||||
|
||||
Gocheck - A rich testing framework for Go
|
||||
|
||||
Copyright (c) 2010-2013 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
|
||||
[LICENSE](https://github.com/go-check/check/blob/20d25e280405/LICENSE).
|
||||
|
||||
[Source](https://github.com/go-check/check/commit/20d25e280405).
|
||||
|
||||
## Standard Repository Policies
|
||||
|
||||
Material in the CommandKit core dependency (paths relative to the repository
|
||||
root): `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||
`docs/policy/architecture.md`, `docs/policy/testing.md`,
|
||||
`docs/policy/documentation.md`, `docs/policy/release.md`, and
|
||||
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
|
||||
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
|
||||
the core module. The release policy uses baseline
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
## go-yaml v3
|
||||
|
||||
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
|
||||
|
||||
Copyright (c) 2006-2010 Kirill Simonov
|
||||
|
||||
Copyright (c) 2006-2011 Kirill Simonov
|
||||
|
||||
Copyright 2011-2016 Canonical Ltd.
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/LICENSE) |
|
||||
[NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE).
|
||||
|
||||
[Source](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51).
|
||||
|
||||
## ReleaseKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
||||
|
||||
## Goldmark
|
||||
|
||||
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright (c) 2019 Yusuke Inuzuka
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
|
||||
|
||||
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
|
||||
|
||||
## Go modules
|
||||
|
||||
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
|
||||
73
integrations/ckconfig/docs/oss/licenses/Apache-2.0.txt
Normal file
73
integrations/ckconfig/docs/oss/licenses/Apache-2.0.txt
Normal file
@@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
9
integrations/ckconfig/docs/oss/licenses/BSD-2-Clause.txt
Normal file
9
integrations/ckconfig/docs/oss/licenses/BSD-2-Clause.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Copyright (c) <year> <owner>
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
11
integrations/ckconfig/docs/oss/licenses/BSD-3-Clause.txt
Normal file
11
integrations/ckconfig/docs/oss/licenses/BSD-3-Clause.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Copyright (c) <year> <owner>.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
170
integrations/ckconfig/docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
170
integrations/ckconfig/docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
@@ -0,0 +1,170 @@
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
|
||||
|
||||
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
|
||||
|
||||
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Section 1 – Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
||||
|
||||
Section 2 – Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||
|
||||
B. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section 6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||
|
||||
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
|
||||
|
||||
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
|
||||
|
||||
Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified form), You must:
|
||||
|
||||
A. retain the following if it is supplied by the Licensor with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||
|
||||
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
||||
|
||||
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
|
||||
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
|
||||
|
||||
Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
||||
|
||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
|
||||
|
||||
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
||||
|
||||
Section 6 – Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
|
||||
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
|
||||
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
Section 7 – Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
||||
|
||||
Section 8 – Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
||||
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
18
integrations/ckconfig/docs/oss/licenses/MIT.txt
Normal file
18
integrations/ckconfig/docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
45
integrations/ckconfig/docs/releases/v0.1.0.md
Normal file
45
integrations/ckconfig/docs/releases/v0.1.0.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# CommandKit ckconfig v0.1.0
|
||||
|
||||
## Summary
|
||||
|
||||
The initial independently versioned ConfigKit integration provides configuration
|
||||
inspection and native comparison through CommandKit command declarations. It
|
||||
uses CommandKit v0.2.0 and ConfigKit v0.8.0 and is distributed as a source-only
|
||||
Go module.
|
||||
|
||||
## Features
|
||||
|
||||
- `ckconfig.AppCommand`, `ckconfig.Command`, and `ckconfig.FromConfigKit` adapt
|
||||
application loaders and named scopes into explicitly installed declarations.
|
||||
- The `config` family exposes scopes, validation, effective YAML, sources, and
|
||||
path explanation according to each scope's declared capabilities.
|
||||
- `config diff` compares two selections through ConfigKit's native API and emits
|
||||
YAML additions, right-side changes, and deletion paths. Comparison can be
|
||||
provided independently of ordinary configuration acquisition; differences
|
||||
alone do not make the command fail.
|
||||
- Source reports expose native destination, placement, unresolved destination,
|
||||
and version-check evidence alongside the source inventory.
|
||||
- Typed selectors, per-side comparison inputs, and invocation state support
|
||||
application-specific acquisition without transferring configuration semantics
|
||||
to CommandKit.
|
||||
|
||||
## Requirements and adoption
|
||||
|
||||
**Go 1.26.0 or newer is required.** Add
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig@v0.1.0`, then pass
|
||||
the returned declaration to `commandkit.New` or `App.Register`. Importing the
|
||||
module does not install commands.
|
||||
|
||||
Applications migrating from the ConfigKit helpers in CommandKit v0.1.0 must
|
||||
move to the separate module and update scope, loader, binding, and installation
|
||||
wiring for the core v0.2.0 API. Update report consumers for the added source
|
||||
evidence and any newly installed comparison commands. ConfigKit remains the
|
||||
authority on configuration loading, inspection, and comparison semantics.
|
||||
|
||||
See the [module README](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/README.md),
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/docs/consumers/guide.md),
|
||||
and [CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/docs/cli.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckconfig/v0.1.0/integrations/ckconfig/docs/oss/attribution.md)
|
||||
@@ -4,6 +4,7 @@ Run the maintained example with:
|
||||
|
||||
```sh
|
||||
go run . config show
|
||||
go run . config diff --config fixtures/before.yml --against-config fixtures/after.yml
|
||||
go run . greet
|
||||
go run . greet --count=0 --tag=first --tag second
|
||||
```
|
||||
@@ -11,4 +12,6 @@ go run . greet --count=0 --tag=first --tag second
|
||||
The `config` family comes from one typed ConfigKit scope. The custom `greet`
|
||||
command uses CommandKit's typed bindings and shows the difference between an
|
||||
omitted `--count` and an explicitly supplied zero, plus ordered repeated tags.
|
||||
The configuration is synthetic and stays in memory.
|
||||
With no `--config`, application settings use the in-memory default. A supplied
|
||||
configuration and overlay are passed to ConfigKit unchanged. The comparison
|
||||
command loads the two local fixtures and reports `changes.greeting: Goodbye`.
|
||||
1
integrations/ckconfig/examples/simple/fixtures/after.yml
Normal file
1
integrations/ckconfig/examples/simple/fixtures/after.yml
Normal file
@@ -0,0 +1 @@
|
||||
greeting: Goodbye
|
||||
@@ -0,0 +1 @@
|
||||
greeting: Hello
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
@@ -19,38 +20,37 @@ type greetInput struct {
|
||||
Tags commandkit.Supplied[[]string]
|
||||
}
|
||||
|
||||
func loadAppConfiguration(context.Context, commandkit.Selection, struct{}) (*configkit.Result[appConfiguration], error) {
|
||||
return configkit.Load[appConfiguration](configkit.Bytes("simple example", []byte("greeting: hello\n")))
|
||||
func loadAppConfiguration(_ context.Context, selection ckconfig.Selection) (*configkit.Result[appConfiguration], error) {
|
||||
source := configkit.Mapping("simple application settings", map[string]any{
|
||||
"greeting": "Hello",
|
||||
})
|
||||
if selection.Config.Set {
|
||||
source = configkit.File(selection.Config.Value)
|
||||
}
|
||||
options := configkit.Options[appConfiguration]{}
|
||||
if selection.Overlay.Set {
|
||||
options.Overlay = selection.Overlay.Value
|
||||
options.Filesystem = &configkit.FilesystemContext{Root: ".", BaseDir: "."}
|
||||
}
|
||||
return configkit.Load(source, options)
|
||||
}
|
||||
|
||||
func newApp() (*commandkit.App, error) {
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "simple-example", Version: "1.0"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, appConfiguration]("app", "Application settings.", loadAppConfiguration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
countDefault := 1
|
||||
tagDefaults := []string{"example"}
|
||||
greet, err := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
|
||||
greet := commandkit.NewCommand(commandkit.CommandSpec[greetInput]{
|
||||
Name: "greet",
|
||||
Summary: "Show flag presence and repeated values.",
|
||||
Flags: []commandkit.Flag[greetInput]{
|
||||
commandkit.ValueFlag[greetInput](commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "count", Usage: "Greeting count."}, &countDefault, strconv.Atoi, func(input *greetInput, value commandkit.Supplied[int]) {
|
||||
input.Count = value
|
||||
}),
|
||||
commandkit.RepeatFlag[greetInput](commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "tag", Usage: "A repeatable label."}, tagDefaults, func(value string) (string, error) { return value, nil }, func(input *greetInput, value commandkit.Supplied[[]string]) {
|
||||
input.Tags = value
|
||||
}),
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input greetInput) commandkit.Outcome {
|
||||
configuration, err := loadAppConfiguration(invocation.Context, commandkit.Selection{}, struct{}{})
|
||||
configuration, err := loadAppConfiguration(invocation.Context, ckconfig.Selection{})
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
@@ -68,13 +68,11 @@ func newApp() (*commandkit.App, error) {
|
||||
return commandkit.Success()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := app.Register(greet); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
return commandkit.New(
|
||||
commandkit.Identity{Name: "simple-example", Version: "1.0"},
|
||||
ckconfig.AppCommand(loadAppConfiguration),
|
||||
greet,
|
||||
)
|
||||
}
|
||||
|
||||
func main() {
|
||||
81
integrations/ckconfig/examples/simple/main_test.go
Normal file
81
integrations/ckconfig/examples/simple/main_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestSimpleAppComparesFixturesAndKeepsDefaultCommands(t *testing.T) {
|
||||
app, err := newApp()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/after.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("fixture comparison outcome = %#v", outcome)
|
||||
}
|
||||
var report yaml.Node
|
||||
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
|
||||
t.Fatalf("comparison report is not YAML: %q: %v", output.String(), err)
|
||||
}
|
||||
changes := field(report.Content[0], "changes")
|
||||
greeting := field(changes, "greeting")
|
||||
if greeting == nil || greeting.Value != "Goodbye" {
|
||||
t.Fatalf("fixture comparison report = %q", output.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "diff", "--config", "fixtures/before.yml", "--against-config", "fixtures/before.yml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("equal fixture comparison outcome = %#v", outcome)
|
||||
}
|
||||
if reportNode, err := decodeReport(output.Bytes()); err != nil || len(field(reportNode, "additions").Content) != 0 || len(field(reportNode, "changes").Content) != 0 || len(field(reportNode, "deletions").Content) != 0 {
|
||||
t.Fatalf("equal fixture report = %q, decode error %v", output.String(), err)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "greeting: Hello\n" {
|
||||
t.Fatalf("default show outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = app.Execute(context.Background(), []string{"greet"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(output.String()) == 0 || output.String()[:5] != "Hello" {
|
||||
t.Fatalf("default greeting outcome/output = %#v / %q", outcome, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleLoaderDoesNotReplaceExplicitEmptyConfiguration(t *testing.T) {
|
||||
_, err := loadAppConfiguration(context.Background(), ckconfig.Selection{Config: commandkit.Supplied[string]{Set: true}})
|
||||
if err == nil {
|
||||
t.Fatal("explicit empty configuration fell back to the default source")
|
||||
}
|
||||
}
|
||||
|
||||
func field(mapping *yaml.Node, name string) *yaml.Node {
|
||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for index := 0; index+1 < len(mapping.Content); index += 2 {
|
||||
if mapping.Content[index].Value == name {
|
||||
return mapping.Content[index+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeReport(data []byte) (*yaml.Node, error) {
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return document.Content[0], nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package commandkit
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -6,41 +6,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func newConfigExplainCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) {
|
||||
flags := configInputFlags(prepared.suite, "text")
|
||||
flags = append(flags, ValueFlag[configInput[C]](FlagMeta{
|
||||
Name: "path",
|
||||
Usage: "Configuration path as a JSON Pointer; an empty value selects the root.",
|
||||
Required: true,
|
||||
}, nil, parseString, func(input *configInput[C], value Supplied[string]) {
|
||||
input.Path = value
|
||||
}))
|
||||
spec := CommandSpec[configInput[C]]{
|
||||
Name: string(ConfigExplain),
|
||||
Summary: "Explain the effective and historical value at a configuration path.",
|
||||
Flags: flags,
|
||||
CheckInput: func(input configInput[C]) error {
|
||||
if _, err := preflightConfig(prepared, input, ConfigExplain); err != nil {
|
||||
return err
|
||||
}
|
||||
if !input.Path.Set {
|
||||
return fmt.Errorf("--path is required")
|
||||
}
|
||||
if _, err := configkit.ParsePath(input.Path.Value); err != nil {
|
||||
return fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Run: func(invocation Invocation, input configInput[C]) Outcome {
|
||||
return runConfigExplanation(invocation, prepared, input)
|
||||
},
|
||||
}
|
||||
return NewCommand(spec)
|
||||
}
|
||||
|
||||
type explanationReport struct {
|
||||
Scope string `yaml:"scope"`
|
||||
Path string `yaml:"path"`
|
||||
@@ -82,70 +51,56 @@ type secretReferenceDTO struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func runConfigExplanation[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome {
|
||||
func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return Failure(err)
|
||||
return configOperationFailure(err, nil, false)
|
||||
}
|
||||
scopeNames, err := selectConfigScopes(prepared, input, ConfigExplain)
|
||||
if err != nil {
|
||||
return Failure(err)
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
}
|
||||
if !input.Path.Set {
|
||||
return Failure(fmt.Errorf("--path is required"))
|
||||
}
|
||||
path, err := configkit.ParsePath(input.Path.Value)
|
||||
if err != nil {
|
||||
return Usage(fmt.Errorf("invalid configuration path %q: %w", input.Path.Value, err))
|
||||
}
|
||||
name := scopeNames[0]
|
||||
contexts := contextsForScopes(prepared, input, scopeNames)
|
||||
scope, ok := findConfigScope(prepared, name)
|
||||
if !ok || scope.Acquire == nil {
|
||||
return Failure(fmt.Errorf("configuration scope %q has no acquisition callback", name))
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return explanationFailure(err, nil, false)
|
||||
}
|
||||
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigExplain, Selection: input.Selection}, contexts[name])
|
||||
diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...)
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Explain,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return explanationFailure(acquireErr, diagnostics, false)
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return explanationFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if acquired.Value.Explanation == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
|
||||
}
|
||||
|
||||
if acquired.Value.Explanation == nil {
|
||||
return explanationFailure(fmt.Errorf("configuration scope %q did not provide an explanation reader", name), diagnostics, false)
|
||||
}
|
||||
explanation, found := acquired.Value.Explanation.Explain(path)
|
||||
explanation, found := acquired.Value.Explanation.Explain(plan.path)
|
||||
if !found {
|
||||
return explanationFailure(fmt.Errorf("configuration path %q is not known in scope %q", path.String(), name), diagnostics, false)
|
||||
return configOperationFailure(fmt.Errorf("configuration path %q is not known in scope %q", plan.path.String(), name), diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return explanationFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if input.Format != "yaml" {
|
||||
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, path, diagnostics)
|
||||
if plan.format != "yaml" {
|
||||
return writeNativeExplanation(invocation.Streams.Out, acquired.Value.Explanation, plan.path, diagnostics)
|
||||
}
|
||||
var report bytes.Buffer
|
||||
if err := encodeYAML(&report, makeExplanationReport(name, explanation)); err != nil {
|
||||
return explanationFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return explanationFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
outcome := explanationFailure(err, diagnostics, true)
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := Success()
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []Diagnostic) Outcome {
|
||||
func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path configkit.Path, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
checked := &checkedWriter{writer: writer}
|
||||
renderErr := reader.WriteExplanation(checked, path)
|
||||
if checked.failure != nil {
|
||||
@@ -156,11 +111,11 @@ func writeNativeExplanation(writer io.Writer, reader ExplanationReader, path con
|
||||
}
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := explanationFailure(renderErr, diagnostics, true)
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := Success()
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
@@ -213,18 +168,3 @@ func originReport(origin configkit.Origin) explanationOriginDTO {
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func locationReports(locations []configkit.Location) []locationDTO {
|
||||
report := make([]locationDTO, 0, len(locations))
|
||||
for _, location := range locations {
|
||||
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func explanationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
|
||||
outcome := Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
return outcome
|
||||
}
|
||||
334
integrations/ckconfig/family.go
Normal file
334
integrations/ckconfig/family.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type scopeMetadata struct {
|
||||
Name string `yaml:"name"`
|
||||
Operations []Operation `yaml:"operations"`
|
||||
}
|
||||
|
||||
type scopeInventory struct {
|
||||
Scopes []scopeMetadata `yaml:"scopes"`
|
||||
}
|
||||
|
||||
type preparedScope[I, S any] struct {
|
||||
scope Scope[I, S]
|
||||
bindings []commandkit.Flag[I]
|
||||
}
|
||||
|
||||
type preparedSuite[I, S any] struct {
|
||||
newInput func() I
|
||||
newState func() S
|
||||
checkInput func(string, Selection, I) error
|
||||
scopes []*preparedScope[I, S]
|
||||
byName map[string]*preparedScope[I, S]
|
||||
bindingsByOperation map[Operation][]commandkit.Flag[I]
|
||||
scopeChoices string
|
||||
operations []Operation
|
||||
inventory scopeInventory
|
||||
issues []error
|
||||
}
|
||||
|
||||
// Command eagerly captures a configuration suite as a root-only config family.
|
||||
// It copies suite slices, retains callback references, and returns a reusable
|
||||
// declaration; installation does not invoke factories or providers. During an
|
||||
// inspection, it initializes input separately for each selected scope. A
|
||||
// comparison initializes and checks both sides before creating shared state
|
||||
// and calling the pair provider. Suite documents factory timing, zero values,
|
||||
// shared state, and caller ownership of returned objects.
|
||||
func Command[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
prepared := captureSuite(suite)
|
||||
if len(prepared.scopes) == 0 {
|
||||
prepared.issues = append(prepared.issues, errors.New("configuration suite must declare at least one named scope"))
|
||||
return commandkit.WithError(commandkit.NoCommand(), errors.Join(prepared.issues...))
|
||||
}
|
||||
|
||||
children := make([]commandkit.Command, 0, len(prepared.operations)+1)
|
||||
for _, operation := range prepared.operations {
|
||||
children = append(children, newConfigOperationCommand(prepared, operation))
|
||||
}
|
||||
children = append(children, newConfigScopesCommand(prepared))
|
||||
declaration := commandkit.RootGroup("config", "Inspect configuration.", children...)
|
||||
if len(prepared.issues) > 0 {
|
||||
declaration = commandkit.WithError(declaration, errors.Join(prepared.issues...))
|
||||
}
|
||||
return declaration
|
||||
}
|
||||
|
||||
// AppCommand builds the state-free, input-free convenience suite for application
|
||||
// configuration. The application consumer supplies only its ConfigKit loader,
|
||||
// which runs for ordinary operations and separately for each reached comparison
|
||||
// side after input preflight.
|
||||
func AppCommand[T any](load func(context.Context, Selection) (*configkit.Result[T], error)) commandkit.Command {
|
||||
var adapterLoad func(context.Context, Selection, struct{}, struct{}) (*configkit.Result[T], error)
|
||||
if load != nil {
|
||||
adapterLoad = func(ctx context.Context, selection Selection, _ struct{}, _ struct{}) (*configkit.Result[T], error) {
|
||||
return load(ctx, selection)
|
||||
}
|
||||
}
|
||||
adapted := FromConfigKit[struct{}, struct{}, T]("app", "Application configuration.", adapterLoad)
|
||||
return Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{adapted}})
|
||||
}
|
||||
|
||||
func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] {
|
||||
prepared := &preparedSuite[I, S]{
|
||||
newInput: suite.NewInput,
|
||||
newState: suite.NewState,
|
||||
checkInput: suite.CheckInput,
|
||||
byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)),
|
||||
bindingsByOperation: make(map[Operation][]commandkit.Flag[I]),
|
||||
}
|
||||
selectors := make([]Selector[I], len(suite.Selectors))
|
||||
for index, selector := range suite.Selectors {
|
||||
selector.Scopes = append([]string(nil), selector.Scopes...)
|
||||
selectors[index] = selector
|
||||
}
|
||||
|
||||
seenNames := make(map[string]struct{}, len(suite.Scopes))
|
||||
for _, original := range suite.Scopes {
|
||||
scope := original
|
||||
scope.Operations = normalizeOperations(scope.Operations, &prepared.issues, scope.Name)
|
||||
scope.issues = append([]error(nil), original.issues...)
|
||||
if scope.Name == "" {
|
||||
prepared.issues = append(prepared.issues, errors.New("configuration scope name must not be empty"))
|
||||
}
|
||||
if scope.Name == "all" {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope name %q is reserved", scope.Name))
|
||||
}
|
||||
if _, exists := seenNames[scope.Name]; exists {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("duplicate configuration scope %q", scope.Name))
|
||||
} else {
|
||||
seenNames[scope.Name] = struct{}{}
|
||||
}
|
||||
for _, issue := range scope.issues {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q: %w", scope.Name, issue))
|
||||
}
|
||||
requiresAcquire, requiresCompare := false, false
|
||||
for _, operation := range scope.Operations {
|
||||
if operation == Diff {
|
||||
requiresCompare = true
|
||||
} else {
|
||||
requiresAcquire = true
|
||||
}
|
||||
}
|
||||
if requiresAcquire && scope.Acquire == nil {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name))
|
||||
}
|
||||
if requiresCompare && scope.Compare == nil {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares diff without a comparison callback", scope.Name))
|
||||
}
|
||||
prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope})
|
||||
}
|
||||
sort.SliceStable(prepared.scopes, func(i, j int) bool {
|
||||
return scopeOrder(prepared.scopes[i].scope.Name) < scopeOrder(prepared.scopes[j].scope.Name)
|
||||
})
|
||||
names := make([]string, 0, len(prepared.scopes))
|
||||
for _, scope := range prepared.scopes {
|
||||
prepared.byName[scope.scope.Name] = scope
|
||||
names = append(names, scope.scope.Name)
|
||||
}
|
||||
prepared.scopeChoices = joinNames(names)
|
||||
|
||||
for index, selector := range selectors {
|
||||
seen := make(map[string]struct{}, len(selector.Scopes))
|
||||
uniqueScopes := make([]string, 0, len(selector.Scopes))
|
||||
for _, name := range selector.Scopes {
|
||||
if _, exists := seenNames[name]; !exists {
|
||||
prepared.issues = append(prepared.issues, fmt.Errorf("configuration selector %d references unknown scope %q", index, name))
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
uniqueScopes = append(uniqueScopes, name)
|
||||
}
|
||||
selectors[index].Scopes = uniqueScopes
|
||||
if len(selector.Scopes) == 0 {
|
||||
for scopeIndex := range prepared.scopes {
|
||||
if len(prepared.scopes[scopeIndex].scope.Operations) > 0 {
|
||||
prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, name := range uniqueScopes {
|
||||
if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 {
|
||||
scope.bindings = append(scope.bindings, selector.Binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||
for _, scope := range prepared.scopes {
|
||||
if containsOperation(scope.scope.Operations, operation) {
|
||||
prepared.operations = append(prepared.operations, operation)
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, selector := range selectors {
|
||||
for _, scope := range prepared.scopes {
|
||||
if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) {
|
||||
prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, scope := range prepared.scopes {
|
||||
prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{
|
||||
Name: scope.scope.Name,
|
||||
Operations: append([]Operation(nil), scope.scope.Operations...),
|
||||
})
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
func normalizeOperations(operations []Operation, issues *[]error, scope string) []Operation {
|
||||
seen := make(map[Operation]struct{}, len(operations))
|
||||
for _, operation := range operations {
|
||||
switch operation {
|
||||
case Validate, Show, Sources, Explain, Diff:
|
||||
default:
|
||||
*issues = append(*issues, fmt.Errorf("configuration scope %q declares unknown operation %q", scope, operation))
|
||||
}
|
||||
seen[operation] = struct{}{}
|
||||
}
|
||||
ordered := make([]Operation, 0, len(seen))
|
||||
for _, operation := range []Operation{Validate, Show, Sources, Explain, Diff} {
|
||||
if _, exists := seen[operation]; exists {
|
||||
ordered = append(ordered, operation)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func scopeOrder(name string) string {
|
||||
switch name {
|
||||
case "app":
|
||||
return "\x00"
|
||||
case "promptkit":
|
||||
return "\x01"
|
||||
default:
|
||||
return "\x02" + name
|
||||
}
|
||||
}
|
||||
|
||||
func containsOperation(operations []Operation, requested Operation) bool {
|
||||
for _, operation := range operations {
|
||||
if operation == requested {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selectorApplies(scopes []string, name string) bool {
|
||||
if len(scopes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func joinNames(names []string) string {
|
||||
ordered := append([]string(nil), names...)
|
||||
sort.Strings(ordered)
|
||||
if len(ordered) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(ordered, ", ")
|
||||
}
|
||||
|
||||
func newConfigScopesCommand[I, S any](prepared *preparedSuite[I, S]) commandkit.Command {
|
||||
formatDefault := "text"
|
||||
spec := commandkit.CommandSpec[scopeInventoryInput]{
|
||||
Name: "scopes",
|
||||
Summary: "List configuration scopes and supported operations.",
|
||||
Flags: []commandkit.Flag[scopeInventoryInput]{commandkit.ValueFlag(
|
||||
commandkit.FlagMeta{Name: "format", Usage: "Output format: text or yaml."}, &formatDefault, parseConfigString,
|
||||
func(input *scopeInventoryInput, value commandkit.Supplied[string]) { input.Format = value.Value },
|
||||
)},
|
||||
CheckInput: func(input scopeInventoryInput) error { return checkConfigFormat(input.Format) },
|
||||
Run: func(invocation commandkit.Invocation, input scopeInventoryInput) commandkit.Outcome {
|
||||
var output []byte
|
||||
var err error
|
||||
if input.Format == "yaml" {
|
||||
output, err = yaml.Marshal(prepared.inventory)
|
||||
} else {
|
||||
output = []byte(formatScopeInventory(prepared.inventory))
|
||||
}
|
||||
if err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, output); err != nil {
|
||||
return incompleteConfigReport(err)
|
||||
}
|
||||
return commandkit.Success()
|
||||
},
|
||||
}
|
||||
return commandkit.NewCommand(spec)
|
||||
}
|
||||
|
||||
type scopeInventoryInput struct{ Format string }
|
||||
|
||||
type scopeInventoryYAML struct {
|
||||
Scopes []scopeMetadata `yaml:"scopes"`
|
||||
}
|
||||
|
||||
func formatScopeInventory(inventory scopeInventory) string {
|
||||
var output strings.Builder
|
||||
for _, scope := range inventory.Scopes {
|
||||
output.WriteString(scope.Name)
|
||||
output.WriteString(": ")
|
||||
for index, operation := range scope.Operations {
|
||||
if index > 0 {
|
||||
output.WriteString(", ")
|
||||
}
|
||||
output.WriteString(string(operation))
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func checkConfigFormat(format string) error {
|
||||
if format != "text" && format != "yaml" {
|
||||
return fmt.Errorf("unsupported configuration format %q", format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfigString(value string) (string, error) { return value, nil }
|
||||
|
||||
func writeConfigReport(writer io.Writer, content []byte) error {
|
||||
written, err := writer.Write(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(content) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func incompleteConfigReport(err error) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(err)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
407
integrations/ckconfig/family_reports_test.go
Normal file
407
integrations/ckconfig/family_reports_test.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) {
|
||||
inputCalls, stateCalls, acquireCalls := 0, 0, 0
|
||||
suite := Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { inputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { stateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "zeta"},
|
||||
{Name: "promptkit", Operations: []Operation{}},
|
||||
{Name: "app", Summary: "Application settings."},
|
||||
{Name: "alpha"},
|
||||
},
|
||||
}
|
||||
for index := range suite.Scopes {
|
||||
suite.Scopes[index].Acquire = func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}
|
||||
}
|
||||
// An unused malformed binding has no generated command to validate.
|
||||
suite.Selectors = []Selector[struct{}]{{Binding: commandkit.Flag[struct{}]{}, Scopes: []string{"app"}}}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "scopes"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("discovery outcome = %#v; diagnostics %q", outcome, diagnostic.String())
|
||||
}
|
||||
if got, want := output.String(), "app: \npromptkit: \nalpha: \nzeta: \n"; got != want {
|
||||
t.Fatalf("discovery output = %q, want %q", got, want)
|
||||
}
|
||||
if inputCalls != 0 || stateCalls != 0 || acquireCalls != 0 {
|
||||
t.Fatalf("discovery invoked suite work: input=%d state=%d acquire=%d", inputCalls, stateCalls, acquireCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationSelectorsAreCapturedForSupportedScopes(t *testing.T) {
|
||||
type input struct{ Tenant, Campaign commandkit.Supplied[string] }
|
||||
tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), parseConfigString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Tenant = value })
|
||||
campaign := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseConfigString,
|
||||
func(target *input, value commandkit.Supplied[string]) { target.Campaign = value })
|
||||
calls := 0
|
||||
suite := Suite[input, struct{}]{
|
||||
Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"app"}}, {Binding: campaign, Scopes: []string{"promptkit"}}},
|
||||
Scopes: []Scope[input, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
|
||||
calls++
|
||||
if !value.Tenant.Set || value.Tenant.Value != "team" || value.Campaign.Set {
|
||||
t.Errorf("app input = %#v", value)
|
||||
}
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []Operation{Sources}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
|
||||
calls++
|
||||
if !value.Campaign.Set || value.Campaign.Value != "spring" || value.Tenant.Set {
|
||||
t.Errorf("promptkit input = %#v", value)
|
||||
}
|
||||
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{}}}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
// Captured capabilities, applicability, and handles survive source edits.
|
||||
suite.Scopes[0].Operations[0] = Sources
|
||||
suite.Selectors[0].Scopes[0] = "promptkit"
|
||||
suite.Selectors[1].Binding = commandkit.Flag[input]{}
|
||||
for _, args := range [][]string{
|
||||
{"config", "validate", "--tenant", "team"},
|
||||
{"config", "sources", "--scope", "promptkit", "--campaign", "spring"},
|
||||
} {
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("%v: %#v", args, outcome)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"config", "validate", "--campaign", "spring"},
|
||||
{"config", "sources", "--scope", "promptkit", "--tenant", "team"},
|
||||
} {
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage {
|
||||
t.Fatalf("selector for another operation accepted: %v: %#v", args, outcome)
|
||||
}
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("acquisition calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T) {
|
||||
type inspectInput struct {
|
||||
Campaign commandkit.Supplied[string]
|
||||
Retained string
|
||||
}
|
||||
type runState struct{ ID int }
|
||||
var inputCalls, stateCalls, checkCalls int
|
||||
var checked []*inspectInput
|
||||
var acquired []*inspectInput
|
||||
var states []*runState
|
||||
var checkErr error
|
||||
commandkitFlag := commandkit.ValueFlag[*inspectInput, string](
|
||||
commandkit.FlagMeta{Name: "campaign", Usage: "Campaign selector.", Required: true},
|
||||
(*string)(nil), func(value string) (string, error) { return value, nil },
|
||||
func(input **inspectInput, value commandkit.Supplied[string]) { (*input).Campaign = value },
|
||||
)
|
||||
makeScope := func(name string) Scope[*inspectInput, *runState] {
|
||||
return Scope[*inspectInput, *runState]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, request Request, input *inspectInput, state *runState) (commandkit.Acquired[View], error) {
|
||||
if request.Operation != Validate || !request.Selection.Config.Set || request.Selection.Config.Value != "chosen.yaml" {
|
||||
t.Errorf("request = %#v", request)
|
||||
}
|
||||
acquired = append(acquired, input)
|
||||
states = append(states, state)
|
||||
return commandkit.Acquired[View]{Diagnostics: []commandkit.Diagnostic{commandkit.MessageDiagnostic(commandkit.SeverityWarning, name+" warning")}}, nil
|
||||
}}
|
||||
}
|
||||
suite := Suite[*inspectInput, *runState]{
|
||||
NewInput: func() *inspectInput {
|
||||
inputCalls++
|
||||
return &inspectInput{Retained: fmt.Sprintf("initial-%d", inputCalls)}
|
||||
},
|
||||
NewState: func() *runState {
|
||||
stateCalls++
|
||||
if checkCalls != 2 {
|
||||
t.Errorf("shared state created after %d input checks", checkCalls)
|
||||
}
|
||||
return &runState{ID: stateCalls}
|
||||
},
|
||||
Selectors: []Selector[*inspectInput]{{Binding: commandkitFlag, Scopes: []string{"promptkit"}}},
|
||||
CheckInput: func(name string, selection Selection, input *inspectInput) error {
|
||||
checkCalls++
|
||||
checked = append(checked, input)
|
||||
if !selection.Config.Set || input.Retained == "" {
|
||||
return fmt.Errorf("missing initialized input for %s", name)
|
||||
}
|
||||
if name == "app" && input.Campaign.Set {
|
||||
return fmt.Errorf("inapplicable selector was applied to app")
|
||||
}
|
||||
if name == "promptkit" && (!input.Campaign.Set || input.Campaign.Value != "spring") {
|
||||
return fmt.Errorf("promptkit campaign = %#v", input.Campaign)
|
||||
}
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []Scope[*inspectInput, *runState]{makeScope("promptkit"), makeScope("app")},
|
||||
}
|
||||
app := newConfigTestApp(t, Command(suite))
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--config", "chosen.yaml", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(diagnostic.String(), "app warning") || !strings.Contains(diagnostic.String(), "promptkit warning") {
|
||||
t.Fatalf("validation outcome=%#v diagnostics=%q", outcome, diagnostic.String())
|
||||
}
|
||||
if got, want := output.String(), "app: valid\npromptkit: valid\n"; got != want {
|
||||
t.Fatalf("validation output = %q, want %q", got, want)
|
||||
}
|
||||
if inputCalls != 2 || checkCalls != 2 || stateCalls != 1 || len(acquired) != 2 {
|
||||
t.Fatalf("factory/callback counts input=%d check=%d state=%d acquire=%d", inputCalls, checkCalls, stateCalls, len(acquired))
|
||||
}
|
||||
if checked[0] != acquired[0] || checked[1] != acquired[1] || checked[0] == checked[1] {
|
||||
t.Fatalf("scope inputs were not retained independently: checked=%p,%p acquired=%p,%p", checked[0], checked[1], acquired[0], acquired[1])
|
||||
}
|
||||
if states[0] == nil || states[0] != states[1] || states[0].ID != 1 {
|
||||
t.Fatalf("shared state values = %#v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationChecksAggregateCoverageBeforeFactories(t *testing.T) {
|
||||
newInputCalls, newStateCalls, acquireCalls := 0, 0, 0
|
||||
makeScope := func(name string, operations []Operation) Scope[struct{}, struct{}] {
|
||||
return Scope[struct{}, struct{}]{Name: name, Operations: operations, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}}
|
||||
}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{
|
||||
NewInput: func() struct{} { newInputCalls++; return struct{}{} },
|
||||
NewState: func() struct{} { newStateCalls++; return struct{}{} },
|
||||
Scopes: []Scope[struct{}, struct{}]{makeScope("app", []Operation{Validate}), makeScope("promptkit", []Operation{Show})},
|
||||
}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || newInputCalls != 0 || newStateCalls != 0 || acquireCalls != 0 || output.Len() != 0 {
|
||||
t.Fatalf("unsupported aggregate outcome=%#v counts=%d/%d/%d output=%q", outcome, newInputCalls, newStateCalls, acquireCalls, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredSelectorAppliesOnlyToItsSelectedScope(t *testing.T) {
|
||||
type input struct{ Value commandkit.Supplied[string] }
|
||||
var calls []string
|
||||
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), func(value string) (string, error) { return value, nil }, func(target *input, value commandkit.Supplied[string]) { target.Value = value })
|
||||
makeScope := func(name string) Scope[input, struct{}] {
|
||||
return Scope[input, struct{}]{Name: name, Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) {
|
||||
calls = append(calls, name)
|
||||
if name == "app" && value.Value.Set {
|
||||
t.Errorf("tenant selector applied to app: %#v", value)
|
||||
}
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}}
|
||||
}
|
||||
app := newConfigTestApp(t, Command(Suite[input, struct{}]{Selectors: []Selector[input]{{Binding: flag, Scopes: []string{"promptkit"}}}, Scopes: []Scope[input, struct{}]{makeScope("app"), makeScope("promptkit")}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "app"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || len(calls) != 1 || calls[0] != "app" {
|
||||
t.Fatalf("unselected required selector outcome=%#v calls=%v", outcome, calls)
|
||||
}
|
||||
calls = nil
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || len(calls) != 0 || !errors.Is(outcome.Cause, commandkit.ErrFlagInput) {
|
||||
t.Fatalf("selected required selector outcome=%#v calls=%v", outcome, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShowAcquiresAllViewsBeforeRenderingAndFramesNativeYAML(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
var acquisitionOutputLengths []int
|
||||
makeScope := func(name, native string, acquireErr error) Scope[struct{}, *int] {
|
||||
return Scope[struct{}, *int]{Name: name, Operations: []Operation{Show}, Acquire: func(_ context.Context, _ Request, _ struct{}, state *int) (commandkit.Acquired[View], error) {
|
||||
*state++
|
||||
acquisitionOutputLengths = append(acquisitionOutputLengths, output.Len())
|
||||
if acquireErr != nil {
|
||||
return commandkit.Acquired[View]{}, acquireErr
|
||||
}
|
||||
return commandkit.Acquired[View]{Value: View{Effective: effectiveValue(native)}}, nil
|
||||
}}
|
||||
}
|
||||
providerErr := errors.New("second provider failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{
|
||||
makeScope("app", "---\nname: app\n", nil),
|
||||
makeScope("promptkit", "---\nname: promptkit\n", providerErr),
|
||||
}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || outcome.Cause != providerErr || output.Len() != 0 {
|
||||
t.Fatalf("aggregate provider failure outcome=%#v output=%q", outcome, output.String())
|
||||
}
|
||||
if len(acquisitionOutputLengths) != 2 || acquisitionOutputLengths[0] != 0 || acquisitionOutputLengths[1] != 0 {
|
||||
t.Fatalf("show rendered before acquisition completed: %v", acquisitionOutputLengths)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
acquisitionOutputLengths = nil
|
||||
app = newConfigTestApp(t, Command(Suite[struct{}, *int]{NewState: func() *int { return new(int) }, Scopes: []Scope[struct{}, *int]{
|
||||
makeScope("app", "---\nname: app\n", nil),
|
||||
makeScope("promptkit", "name: promptkit\n", nil),
|
||||
}}))
|
||||
outcome = app.Execute(context.Background(), []string{"config", "show", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("aggregate show outcome = %#v", outcome)
|
||||
}
|
||||
want := "---\n# scope: app\nname: app\n---\n# scope: promptkit\nname: promptkit\n"
|
||||
if output.String() != want {
|
||||
t.Fatalf("aggregate show output = %q, want %q", output.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesReadsNativeInventoryOnceAndPreservesZeroValues(t *testing.T) {
|
||||
reader := &sourceInventory{values: []configkit.SourceInfo{{
|
||||
ID: "synthetic", Label: "", Path: "", Layer: configkit.Layer("base"), Kind: configkit.SourceKind("mapping"), UnderlayIndex: 0,
|
||||
ImportedFrom: &configkit.Location{SourceID: "", Line: 0, Column: 0},
|
||||
}}}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Sources: reader}}, nil
|
||||
}}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 1 {
|
||||
t.Fatalf("sources outcome=%#v source calls=%d", outcome, reader.calls)
|
||||
}
|
||||
for _, fragment := range []string{"sources:", "id: synthetic", "label: \"\"", "path: \"\"", "underlay_index: 0", "imported_from:", "line: 0", "column: 0"} {
|
||||
if !strings.Contains(output.String(), fragment) {
|
||||
t.Errorf("source report missing %q: %s", fragment, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainParsesBeforeAcquisitionAndPreservesNativeSafeHistory(t *testing.T) {
|
||||
reader := &nativeExplanation{
|
||||
path: configkit.Path{},
|
||||
value: configkit.Explanation{
|
||||
Path: configkit.Path{},
|
||||
Effective: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||
History: []configkit.ValueChange{{
|
||||
Before: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||
After: configkit.ValueSnapshot{Present: true, Redacted: true},
|
||||
Reasons: []string{"override"},
|
||||
Dependencies: []configkit.Path{},
|
||||
}},
|
||||
SecretReference: &configkit.SecretReferenceInfo{Name: "APP_TOKEN"},
|
||||
}, found: true,
|
||||
}
|
||||
acquireCalls := 0
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
acquireCalls++
|
||||
return commandkit.Acquired[View]{Value: View{Explanation: reader}}, nil
|
||||
}}}}))
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "explain", "--path=", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || acquireCalls != 1 || reader.path.String() != "" {
|
||||
t.Fatalf("root explanation outcome=%#v calls=%d path=%q", outcome, acquireCalls, reader.path.String())
|
||||
}
|
||||
if strings.Contains(output.String(), "APP_TOKEN_VALUE") || !strings.Contains(output.String(), "redacted: true") || !strings.Contains(output.String(), "history:") || !strings.Contains(output.String(), "dependencies: []") || !strings.Contains(output.String(), "name: APP_TOKEN") {
|
||||
t.Fatalf("safe explanation report = %s", output.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
acquireCalls = 0
|
||||
outcome = app.Execute(context.Background(), []string{"config", "explain", "--path", "/bad~2"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeUsage || acquireCalls != 0 || output.Len() != 0 {
|
||||
t.Fatalf("malformed pointer was not rejected before acquisition: outcome=%#v calls=%d", outcome, acquireCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportWriteFailureTracksIncompleteDeliveryAndCancellation(t *testing.T) {
|
||||
writeErr := errors.New("output failed")
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{values: []configkit.SourceInfo{}}}}, nil
|
||||
}}}}))
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: failingOutput{err: writeErr}, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, writeErr) || outcome.DeliveryError != writeErr || !outcome.ReportIncomplete {
|
||||
t.Fatalf("source delivery failure outcome = %#v", outcome)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
calls := 0
|
||||
app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
calls++
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []Operation{Validate}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
calls++
|
||||
cancel()
|
||||
return commandkit.Acquired[View]{}, nil
|
||||
}},
|
||||
}}))
|
||||
var output bytes.Buffer
|
||||
outcome = app.Execute(ctx, []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if !errors.Is(outcome.Cause, context.Canceled) || !outcome.ReportIncomplete || calls != 2 || output.String() != "app: valid\n" {
|
||||
t.Fatalf("cancelled aggregate validation outcome=%#v calls=%d output=%q", outcome, calls, output.String())
|
||||
}
|
||||
}
|
||||
|
||||
type effectiveValue string
|
||||
|
||||
func (value effectiveValue) WriteEffective(writer io.Writer) error {
|
||||
_, err := io.WriteString(writer, string(value))
|
||||
return err
|
||||
}
|
||||
|
||||
type sourceInventory struct {
|
||||
values []configkit.SourceInfo
|
||||
calls int
|
||||
}
|
||||
|
||||
func (reader *sourceInventory) Sources() []configkit.SourceInfo {
|
||||
reader.calls++
|
||||
return reader.values
|
||||
}
|
||||
|
||||
type nativeExplanation struct {
|
||||
path configkit.Path
|
||||
value configkit.Explanation
|
||||
found bool
|
||||
}
|
||||
|
||||
func (reader *nativeExplanation) Explain(path configkit.Path) (configkit.Explanation, bool) {
|
||||
reader.path = path
|
||||
return reader.value, reader.found
|
||||
}
|
||||
|
||||
func (reader *nativeExplanation) WriteExplanation(writer io.Writer, path configkit.Path) error {
|
||||
reader.path = path
|
||||
_, err := fmt.Fprintf(writer, "Explanation for %q\n", path.String())
|
||||
return err
|
||||
}
|
||||
|
||||
type failingOutput struct{ err error }
|
||||
|
||||
func (writer failingOutput) Write(value []byte) (int, error) {
|
||||
if len(value) == 0 {
|
||||
return 0, writer.err
|
||||
}
|
||||
return 1, writer.err
|
||||
}
|
||||
|
||||
func newConfigTestApp(t *testing.T, command commandkit.Command) *commandkit.App {
|
||||
t.Helper()
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"}, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
15
integrations/ckconfig/go.mod
Normal file
15
integrations/ckconfig/go.mod
Normal file
@@ -0,0 +1,15 @@
|
||||
module gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/cobra v1.10.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
)
|
||||
16
integrations/ckconfig/go.sum
Normal file
16
integrations/ckconfig/go.sum
Normal file
@@ -0,0 +1,16 @@
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0 h1:UNBjSq6XDZHdPqvZLOL3N5R1E1ol0jnfldgradU7eqw=
|
||||
gitea.maximumdirect.net/eric/CommandKit v0.2.0/go.mod h1:47DQ6Mo/yZqUVn5PAFRmPck91Zhf1jfaHDMoEZvUzsQ=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0 h1:xCIhYnOHjw7rFvB+vIremPKGeROOxKTomnUEUipuQno=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.8.0/go.mod h1:C82sbyKHYMV1OwaD2GJanFrwTfcZfjlW2JsGze5b0kA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
247
integrations/ckconfig/input.go
Normal file
247
integrations/ckconfig/input.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type configCommandInput[I, S any] struct {
|
||||
selection Selection
|
||||
against Selection
|
||||
scope commandkit.Supplied[string]
|
||||
format commandkit.Supplied[string]
|
||||
path commandkit.Supplied[string]
|
||||
values commandkit.FlagValues[I]
|
||||
plan *configOperationPlan[I, S]
|
||||
}
|
||||
|
||||
type selectedScopeInput[I, S any] struct {
|
||||
scope *preparedScope[I, S]
|
||||
input I
|
||||
}
|
||||
|
||||
type preparedComparison[I, S any] struct {
|
||||
scope *preparedScope[I, S]
|
||||
request ComparisonRequest[I]
|
||||
}
|
||||
|
||||
type configOperationPlan[I, S any] struct {
|
||||
prepared *preparedSuite[I, S]
|
||||
operation Operation
|
||||
selection Selection
|
||||
scopeName string
|
||||
format string
|
||||
path configkit.Path
|
||||
scopes []selectedScopeInput[I, S]
|
||||
comparison *preparedComparison[I, S]
|
||||
}
|
||||
|
||||
func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operation Operation) commandkit.Command {
|
||||
defaultScope := "app"
|
||||
defaultFormat := "text"
|
||||
if operation == Show || operation == Diff {
|
||||
defaultFormat = "yaml"
|
||||
}
|
||||
flags := []commandkit.Flag[*configCommandInput[I, S]]{
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "scope", Usage: "Configuration scope name or all where supported."}, &defaultScope, parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).scope = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Output format."}, &defaultFormat, parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).format = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration source."}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Overlay source."}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
}
|
||||
if operation == Diff {
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-config", Usage: "Configuration source for the right side."}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).against.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "against-overlay", Usage: "Overlay source for the right side."}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) {
|
||||
(*input).against.Overlay = value
|
||||
}),
|
||||
)
|
||||
}
|
||||
if operation == Explain {
|
||||
flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString,
|
||||
func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }))
|
||||
}
|
||||
flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] {
|
||||
if input == nil || *input == nil {
|
||||
return nil
|
||||
}
|
||||
return &(*input).values
|
||||
})...)
|
||||
|
||||
name, summary := operationDeclaration(operation)
|
||||
return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{
|
||||
Name: name, Summary: summary,
|
||||
NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} },
|
||||
Flags: flags,
|
||||
CheckInput: func(input *configCommandInput[I, S]) error {
|
||||
if input == nil {
|
||||
return fmt.Errorf("configuration input must not be nil")
|
||||
}
|
||||
return prepareConfigOperation(prepared, operation, input)
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input *configCommandInput[I, S]) commandkit.Outcome {
|
||||
return runConfigOperation(invocation, operation, input)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func operationDeclaration(operation Operation) (string, string) {
|
||||
switch operation {
|
||||
case Validate:
|
||||
return "validate", "Validate configuration."
|
||||
case Show:
|
||||
return "show", "Show effective configuration."
|
||||
case Sources:
|
||||
return "sources", "List configuration sources."
|
||||
case Explain:
|
||||
return "explain", "Explain a configuration path."
|
||||
case Diff:
|
||||
return "diff", "Compare two configuration selections."
|
||||
default:
|
||||
return string(operation), "Inspect configuration."
|
||||
}
|
||||
}
|
||||
|
||||
func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error {
|
||||
format := input.format.Value
|
||||
if err := checkConfigFormat(format); err != nil {
|
||||
return err
|
||||
}
|
||||
if operation == Show && format != "yaml" {
|
||||
return fmt.Errorf("unsupported configuration show format %q", format)
|
||||
}
|
||||
if operation == Diff && format != "yaml" {
|
||||
return fmt.Errorf("unsupported configuration diff format %q", format)
|
||||
}
|
||||
|
||||
scopeName := "app"
|
||||
if input.scope.Set {
|
||||
scopeName = input.scope.Value
|
||||
}
|
||||
if scopeName == "" {
|
||||
return fmt.Errorf("configuration scope must not be empty")
|
||||
}
|
||||
if scopeName == "all" && operation != Validate && operation != Show {
|
||||
return fmt.Errorf("scope all is supported only for validate and show")
|
||||
}
|
||||
|
||||
var selected []*preparedScope[I, S]
|
||||
if scopeName == "all" {
|
||||
selected = prepared.scopes
|
||||
} else if scope, exists := prepared.byName[scopeName]; exists {
|
||||
selected = []*preparedScope[I, S]{scope}
|
||||
} else {
|
||||
return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices)
|
||||
}
|
||||
|
||||
for _, scope := range selected {
|
||||
if !containsOperation(scope.scope.Operations, operation) {
|
||||
return fmt.Errorf("configuration scope %q does not support %s (available: %s)", scope.scope.Name, operation, joinOperations(scope.scope.Operations))
|
||||
}
|
||||
}
|
||||
|
||||
var path configkit.Path
|
||||
if operation == Explain {
|
||||
if !input.path.Set {
|
||||
return fmt.Errorf("configuration explanation requires --path")
|
||||
}
|
||||
parsed, err := configkit.ParsePath(input.path.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration path: %w", err)
|
||||
}
|
||||
path = parsed
|
||||
}
|
||||
|
||||
plan := &configOperationPlan[I, S]{
|
||||
prepared: prepared,
|
||||
operation: operation,
|
||||
selection: input.selection,
|
||||
scopeName: scopeName,
|
||||
format: format,
|
||||
path: path,
|
||||
scopes: make([]selectedScopeInput[I, S], 0, len(selected)),
|
||||
}
|
||||
if operation == Diff {
|
||||
input.against = inheritComparisonSelection(input.selection, input.against)
|
||||
scope := selected[0]
|
||||
left, err := prepareScopeInput(prepared, input.values, scope, input.selection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration scope %q left input: %w", scope.scope.Name, err)
|
||||
}
|
||||
right, err := prepareScopeInput(prepared, input.values, scope, input.against)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration scope %q right input: %w", scope.scope.Name, err)
|
||||
}
|
||||
plan.comparison = &preparedComparison[I, S]{
|
||||
scope: scope,
|
||||
request: ComparisonRequest[I]{
|
||||
Left: ComparisonSide[I]{Selection: input.selection, Input: left},
|
||||
Right: ComparisonSide[I]{Selection: input.against, Input: right},
|
||||
},
|
||||
}
|
||||
input.plan = plan
|
||||
return nil
|
||||
}
|
||||
for _, scope := range selected {
|
||||
value, err := prepareScopeInput(prepared, input.values, scope, input.selection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err)
|
||||
}
|
||||
plan.scopes = append(plan.scopes, selectedScopeInput[I, S]{scope: scope, input: value})
|
||||
}
|
||||
input.plan = plan
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareScopeInput[I, S any](prepared *preparedSuite[I, S], values commandkit.FlagValues[I], scope *preparedScope[I, S], selection Selection) (I, error) {
|
||||
var value I
|
||||
if prepared.newInput != nil {
|
||||
value = prepared.newInput()
|
||||
}
|
||||
// Availability was checked before this helper, so every binding for the
|
||||
// selected scope belongs to the operation's captured union.
|
||||
if err := values.ApplyTo(&value, scope.bindings...); err != nil {
|
||||
return value, err
|
||||
}
|
||||
if prepared.checkInput != nil {
|
||||
if err := prepared.checkInput(scope.scope.Name, selection, value); err != nil {
|
||||
return value, err
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func inheritComparisonSelection(left, right Selection) Selection {
|
||||
if !right.Config.Set {
|
||||
right.Config = left.Config
|
||||
}
|
||||
if !right.Overlay.Set {
|
||||
right.Overlay = left.Overlay
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func joinOperations(operations []Operation) string {
|
||||
values := make([]string, len(operations))
|
||||
for index, operation := range operations {
|
||||
values[index] = string(operation)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
49
integrations/ckconfig/operations.go
Normal file
49
integrations/ckconfig/operations.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Operation, input *configCommandInput[I, S]) commandkit.Outcome {
|
||||
if input == nil || input.plan == nil {
|
||||
return commandkit.Failure(errors.New("configuration operation was not prepared"))
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return commandkit.Failure(err)
|
||||
}
|
||||
plan := input.plan
|
||||
var state S
|
||||
if plan.prepared.newState != nil {
|
||||
state = plan.prepared.newState()
|
||||
}
|
||||
switch operation {
|
||||
case Validate:
|
||||
return runConfigValidation(invocation, plan, state)
|
||||
case Show:
|
||||
return runConfigShow(invocation, plan, state)
|
||||
case Sources:
|
||||
return runConfigSources(invocation, plan, state)
|
||||
case Explain:
|
||||
return runConfigExplanation(invocation, plan, state)
|
||||
case Diff:
|
||||
return runConfigDiff(invocation, plan, state)
|
||||
default:
|
||||
return commandkit.Failure(fmt.Errorf("unsupported configuration operation %q", operation))
|
||||
}
|
||||
}
|
||||
|
||||
func configOperationFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
return outcome
|
||||
}
|
||||
|
||||
func configDeliveryFailure(cause error, diagnostics []commandkit.Diagnostic, reportStarted bool) commandkit.Outcome {
|
||||
outcome := configOperationFailure(cause, diagnostics, reportStarted)
|
||||
outcome.DeliveryError = cause
|
||||
return outcome
|
||||
}
|
||||
13
integrations/ckconfig/releasekit.json
Normal file
13
integrations/ckconfig/releasekit.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"product": "CommandKit ckconfig",
|
||||
"module_dir": "integrations/ckconfig",
|
||||
"remote": "origin",
|
||||
"expected_push_url": "https://gitea.maximumdirect.net/eric/CommandKit.git",
|
||||
"validation": {
|
||||
"shared_files": [
|
||||
"COPYRIGHT",
|
||||
"LICENSE",
|
||||
"docs/oss/attribution.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,51 @@
|
||||
package commandkit
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func newConfigShowCommand[C any](prepared *preparedConfigSuite[C]) (Command, error) {
|
||||
spec := CommandSpec[configInput[C]]{
|
||||
Name: string(ConfigShow),
|
||||
Summary: "Show effective configuration as YAML.",
|
||||
Flags: configInputFlags(prepared.suite, "yaml"),
|
||||
CheckInput: func(input configInput[C]) error {
|
||||
_, err := preflightConfig(prepared, input, ConfigShow)
|
||||
return err
|
||||
},
|
||||
Run: func(invocation Invocation, input configInput[C]) Outcome {
|
||||
return runConfigShow(invocation, prepared, input)
|
||||
},
|
||||
}
|
||||
return NewCommand(spec)
|
||||
}
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
)
|
||||
|
||||
type effectiveDocument struct {
|
||||
name string
|
||||
view ConfigView
|
||||
view View
|
||||
}
|
||||
|
||||
func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C], input configInput[C]) Outcome {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
scopeNames, err := selectConfigScopes(prepared, input, ConfigShow)
|
||||
if err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
contexts := contextsForScopes(prepared, input, scopeNames)
|
||||
documents := make([]effectiveDocument, 0, len(scopeNames))
|
||||
diagnostics := make([]Diagnostic, 0)
|
||||
for _, name := range scopeNames {
|
||||
scope, ok := findConfigScope(prepared, name)
|
||||
if !ok || scope.Acquire == nil {
|
||||
return showFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, false)
|
||||
}
|
||||
func runConfigShow[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
documents := make([]effectiveDocument, 0, len(plan.scopes))
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return showFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigShow, Selection: input.Selection}, contexts[name])
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, false)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Show,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return showFailure(acquireErr, diagnostics, false)
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return showFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
|
||||
if acquired.Value.Effective == nil {
|
||||
return showFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", name), diagnostics, false)
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide an effective configuration writer", selected.scope.scope.Name), diagnostics, false)
|
||||
}
|
||||
documents = append(documents, effectiveDocument{name: name, view: acquired.Value})
|
||||
documents = append(documents, effectiveDocument{name: selected.scope.scope.Name, view: acquired.Value})
|
||||
}
|
||||
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return showFailure(err, diagnostics, false)
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if len(documents) == 1 && input.Scope != "all" {
|
||||
if len(documents) == 1 && plan.scopeName != "all" {
|
||||
checked := &checkedWriter{writer: invocation.Streams.Out}
|
||||
renderErr := documents[0].view.Effective.WriteEffective(checked)
|
||||
if checked.failure != nil {
|
||||
@@ -77,11 +56,11 @@ func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C
|
||||
}
|
||||
}
|
||||
if renderErr != nil {
|
||||
outcome := showFailure(renderErr, diagnostics, true)
|
||||
outcome := configOperationFailure(renderErr, diagnostics, true)
|
||||
outcome.DeliveryError = checked.failure
|
||||
return outcome
|
||||
}
|
||||
outcome := Success()
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
@@ -89,38 +68,53 @@ func runConfigShow[C any](invocation Invocation, prepared *preparedConfigSuite[C
|
||||
written := false
|
||||
for index, document := range documents {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return showFailure(err, diagnostics, written)
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
var native bytes.Buffer
|
||||
if err := document.view.Effective.WriteEffective(&native); err != nil {
|
||||
return showFailure(err, diagnostics, written)
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return showFailure(err, diagnostics, written)
|
||||
return configOperationFailure(err, diagnostics, written)
|
||||
}
|
||||
framed, hasDirective := frameEffectiveYAML(document.name, native.Bytes())
|
||||
if index > 0 && hasDirective {
|
||||
if err := writeReport(invocation.Streams.Out, []byte("...\n")); err != nil {
|
||||
outcome := showFailure(err, diagnostics, true)
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
if err := writeConfigReport(invocation.Streams.Out, []byte("...\n")); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
}
|
||||
if index < len(documents)-1 && len(framed) > 0 && framed[len(framed)-1] != '\n' {
|
||||
framed = append(framed, '\n')
|
||||
}
|
||||
if err := writeReport(invocation.Streams.Out, framed); err != nil {
|
||||
outcome := showFailure(err, diagnostics, true)
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
if err := writeConfigReport(invocation.Streams.Out, framed); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
written = true
|
||||
}
|
||||
outcome := Success()
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
type checkedWriter struct {
|
||||
writer io.Writer
|
||||
failure error
|
||||
}
|
||||
|
||||
func (writer *checkedWriter) Write(content []byte) (int, error) {
|
||||
if writer.failure != nil {
|
||||
return 0, writer.failure
|
||||
}
|
||||
written, err := writer.writer.Write(content)
|
||||
if err == nil && written != len(content) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
writer.failure = err
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
type yamlPreamble struct {
|
||||
markerStart int
|
||||
markerEnd int
|
||||
@@ -166,8 +160,8 @@ func analyzeYAMLPreamble(native []byte) yamlPreamble {
|
||||
func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) {
|
||||
analysis := analyzeYAMLPreamble(native)
|
||||
label := scope
|
||||
for _, r := range scope {
|
||||
if unicode.IsControl(r) {
|
||||
for _, character := range scope {
|
||||
if unicode.IsControl(character) {
|
||||
label = strconv.Quote(scope)
|
||||
break
|
||||
}
|
||||
@@ -175,8 +169,6 @@ func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) {
|
||||
comment := []byte("# scope: " + label + "\n")
|
||||
if analysis.markerFound {
|
||||
out := make([]byte, 0, len(native)+len(comment))
|
||||
// Inline content may be a block scalar; place the label before
|
||||
// its marker rather than interrupting the native value.
|
||||
if analysis.inlineMarker {
|
||||
out = append(out, native[:analysis.markerStart]...)
|
||||
out = append(out, comment...)
|
||||
@@ -205,13 +197,6 @@ func frameEffectiveYAML(scope string, native []byte) ([]byte, bool) {
|
||||
return result, analysis.hasDirective
|
||||
}
|
||||
|
||||
func showFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
|
||||
outcome := Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
return outcome
|
||||
}
|
||||
|
||||
func isYAMLDocumentStart(line []byte) bool {
|
||||
return bytes.HasPrefix(line, []byte("---")) && (len(line) == 3 || line[3] == ' ' || line[3] == '\t')
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package commandkit
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go.yaml.in/yaml/v4"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestEffectiveYAMLFramingPreservesOneNativeDocument(t *testing.T) {
|
||||
186
integrations/ckconfig/sources.go
Normal file
186
integrations/ckconfig/sources.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type sourceReport struct {
|
||||
Scope string `yaml:"scope"`
|
||||
Sources []sourceDTO `yaml:"sources"`
|
||||
}
|
||||
|
||||
type sourceDTO struct {
|
||||
ID string `yaml:"id"`
|
||||
Label string `yaml:"label"`
|
||||
Path string `yaml:"path"`
|
||||
Destination string `yaml:"destination"`
|
||||
Placement string `yaml:"placement"`
|
||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
||||
Layer string `yaml:"layer"`
|
||||
Kind string `yaml:"kind"`
|
||||
UnderlayIndex int `yaml:"underlay_index"`
|
||||
ImportedFrom *locationDTO `yaml:"imported_from"`
|
||||
VersionCheck *sourceVersionCheckDTO `yaml:"version_check"`
|
||||
}
|
||||
|
||||
type locationDTO struct {
|
||||
SourceID string `yaml:"source_id"`
|
||||
Line int `yaml:"line"`
|
||||
Column int `yaml:"column"`
|
||||
}
|
||||
|
||||
type sourceVersionCheckDTO struct {
|
||||
Path string `yaml:"path"`
|
||||
Version versionDTO `yaml:"version"`
|
||||
Location *locationDTO `yaml:"location"`
|
||||
Redacted bool `yaml:"redacted"`
|
||||
}
|
||||
|
||||
type versionDTO struct {
|
||||
Present bool `yaml:"present"`
|
||||
Null bool `yaml:"null"`
|
||||
Text string `yaml:"text"`
|
||||
}
|
||||
|
||||
func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
selected := plan.scopes[0]
|
||||
name := selected.scope.scope.Name
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, nil, false)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Sources,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, false)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if acquired.Value.Sources == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false)
|
||||
}
|
||||
|
||||
sources := acquired.Value.Sources.Sources()
|
||||
document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))}
|
||||
for _, source := range sources {
|
||||
placement := "mapping"
|
||||
if source.PlacementMode == configkit.PlacementModeSequenceItem {
|
||||
placement = "sequence_item"
|
||||
}
|
||||
item := sourceDTO{
|
||||
ID: source.ID,
|
||||
Label: source.Label,
|
||||
Path: source.Path,
|
||||
Destination: source.Destination.String(),
|
||||
Placement: placement,
|
||||
DestinationUnresolved: source.DestinationUnresolved,
|
||||
Layer: string(source.Layer),
|
||||
Kind: string(source.Kind),
|
||||
UnderlayIndex: source.UnderlayIndex,
|
||||
}
|
||||
if source.ImportedFrom != nil {
|
||||
item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column}
|
||||
}
|
||||
if source.VersionCheck != nil {
|
||||
check := source.VersionCheck
|
||||
item.VersionCheck = &sourceVersionCheckDTO{
|
||||
Path: check.Path.String(),
|
||||
Version: versionDTO{
|
||||
Present: check.Version.Present,
|
||||
Null: check.Version.Null,
|
||||
Text: check.Version.Text,
|
||||
},
|
||||
Redacted: check.Redacted,
|
||||
}
|
||||
if check.Location != nil {
|
||||
item.VersionCheck.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column}
|
||||
}
|
||||
}
|
||||
document.Sources = append(document.Sources, item)
|
||||
}
|
||||
var report bytes.Buffer
|
||||
if plan.format == "yaml" {
|
||||
if err := encodeYAML(&report, document); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
} else {
|
||||
writeSourceReportText(&report, document)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, false)
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, report.Bytes()); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func writeSourceReportText(writer *bytes.Buffer, document sourceReport) {
|
||||
_, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope))
|
||||
if len(document.Sources) == 0 {
|
||||
_, _ = writer.WriteString("Sources: []\n")
|
||||
return
|
||||
}
|
||||
_, _ = writer.WriteString("Sources:\n")
|
||||
for _, source := range document.Sources {
|
||||
_, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID))
|
||||
_, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label))
|
||||
_, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path))
|
||||
_, _ = fmt.Fprintf(writer, " destination: %s\n", strconv.Quote(source.Destination))
|
||||
_, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement))
|
||||
_, _ = fmt.Fprintf(writer, " destination_unresolved: %t\n", source.DestinationUnresolved)
|
||||
_, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer))
|
||||
_, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind))
|
||||
_, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex)
|
||||
if source.ImportedFrom == nil {
|
||||
_, _ = writer.WriteString(" imported_from: null\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n",
|
||||
strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column)
|
||||
}
|
||||
if source.VersionCheck == nil {
|
||||
_, _ = writer.WriteString(" version_check: null\n")
|
||||
continue
|
||||
}
|
||||
check := source.VersionCheck
|
||||
_, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path))
|
||||
_, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n",
|
||||
check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text))
|
||||
if check.Location == nil {
|
||||
_, _ = writer.WriteString(" location: null\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n",
|
||||
strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column)
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeYAML(writer io.Writer, document any) error {
|
||||
encoder := yaml.NewEncoder(writer)
|
||||
encoder.SetIndent(2)
|
||||
return encoder.Encode(document)
|
||||
}
|
||||
|
||||
func locationReports(locations []configkit.Location) []locationDTO {
|
||||
report := make([]locationDTO, 0, len(locations))
|
||||
for _, location := range locations {
|
||||
report = append(report, locationDTO{SourceID: location.SourceID, Line: location.Line, Column: location.Column})
|
||||
}
|
||||
return report
|
||||
}
|
||||
134
integrations/ckconfig/sources_test.go
Normal file
134
integrations/ckconfig/sources_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) {
|
||||
servicePath, err := configkit.ParsePath("/services")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherPath, err := configkit.ParsePath("/other")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versionPath, err := configkit.ParsePath("/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nullVersionPath, err := configkit.ParsePath("/metadata/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reader := &sourceInventory{values: []configkit.SourceInfo{
|
||||
{
|
||||
ID: "root", Label: "root source", Path: "root.yml",
|
||||
PlacementMode: configkit.PlacementModeMapping,
|
||||
},
|
||||
{
|
||||
ID: "item", Label: "sequence item", Path: "item.yml",
|
||||
Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
DestinationUnresolved: true,
|
||||
VersionCheck: &configkit.SourceVersionCheck{
|
||||
Path: versionPath,
|
||||
Version: configkit.Version{Present: true, Text: "v2"},
|
||||
Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "null", Label: "null version", Path: "null.yml",
|
||||
Destination: otherPath, PlacementMode: configkit.PlacementModeMapping,
|
||||
VersionCheck: &configkit.SourceVersionCheck{
|
||||
Path: nullVersionPath,
|
||||
Version: configkit.Version{Present: true, Null: true},
|
||||
Redacted: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{
|
||||
{Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) {
|
||||
return commandkit.Acquired[View]{Value: View{Sources: reader}}, nil
|
||||
}},
|
||||
}}))
|
||||
|
||||
var yamlOutput bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &yamlOutput, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 1 {
|
||||
t.Fatalf("YAML source report outcome=%#v calls=%d", outcome, reader.calls)
|
||||
}
|
||||
var report struct {
|
||||
Scope string `yaml:"scope"`
|
||||
Sources []struct {
|
||||
ID string `yaml:"id"`
|
||||
Destination string `yaml:"destination"`
|
||||
Placement string `yaml:"placement"`
|
||||
DestinationUnresolved bool `yaml:"destination_unresolved"`
|
||||
VersionCheck *struct {
|
||||
Path string `yaml:"path"`
|
||||
Version struct {
|
||||
Present bool `yaml:"present"`
|
||||
Null bool `yaml:"null"`
|
||||
Text string `yaml:"text"`
|
||||
} `yaml:"version"`
|
||||
Location *struct {
|
||||
SourceID string `yaml:"source_id"`
|
||||
Line int `yaml:"line"`
|
||||
Column int `yaml:"column"`
|
||||
} `yaml:"location"`
|
||||
Redacted bool `yaml:"redacted"`
|
||||
} `yaml:"version_check"`
|
||||
} `yaml:"sources"`
|
||||
}
|
||||
if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil {
|
||||
t.Fatalf("decode source report: %v\n%s", err, yamlOutput.String())
|
||||
}
|
||||
if report.Scope != "app" || len(report.Sources) != 3 {
|
||||
t.Fatalf("source report scope/order = %q, %#v", report.Scope, report.Sources)
|
||||
}
|
||||
if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) {
|
||||
t.Fatalf("source order = %v", got)
|
||||
}
|
||||
if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationUnresolved || root.VersionCheck != nil {
|
||||
t.Errorf("root source projection = %#v", root)
|
||||
}
|
||||
item := report.Sources[1]
|
||||
if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationUnresolved {
|
||||
t.Errorf("sequence item projection = %#v", item)
|
||||
}
|
||||
if check := item.VersionCheck; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted {
|
||||
t.Errorf("text version evidence projection = %#v", check)
|
||||
}
|
||||
nullSource := report.Sources[2]
|
||||
if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationUnresolved {
|
||||
t.Errorf("mapping projection = %#v", nullSource)
|
||||
}
|
||||
if check := nullSource.VersionCheck; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted {
|
||||
t.Errorf("null/redacted version evidence projection = %#v", check)
|
||||
}
|
||||
|
||||
var textOutput bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "text"}, commandkit.Streams{Out: &textOutput, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 {
|
||||
t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`id: "root"`, `destination: ""`, `placement: "mapping"`, "destination_unresolved: false", "version_check: null",
|
||||
`destination: "/services"`, `placement: "sequence_item"`, "destination_unresolved: true", `path: "/version"`, `text: "v2"`,
|
||||
`destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true",
|
||||
} {
|
||||
if !strings.Contains(textOutput.String(), fragment) {
|
||||
t.Errorf("text report missing %q:\n%s", fragment, textOutput.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
76
integrations/ckconfig/validate.go
Normal file
76
integrations/ckconfig/validate.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package ckconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func runConfigValidation[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome {
|
||||
diagnostics := make([]commandkit.Diagnostic, 0)
|
||||
writtenRecord := false
|
||||
for _, selected := range plan.scopes {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
}
|
||||
if selected.scope.scope.Acquire == nil {
|
||||
return configOperationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", selected.scope.scope.Name), diagnostics, writtenRecord)
|
||||
}
|
||||
acquired, acquireErr := selected.scope.scope.Acquire(invocation.Context, Request{
|
||||
Operation: Validate,
|
||||
Selection: plan.selection,
|
||||
}, selected.input, state)
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return configOperationFailure(acquireErr, diagnostics, writtenRecord)
|
||||
}
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
}
|
||||
|
||||
record, err := validationSuccessRecord(selected.scope.scope.Name, plan.format)
|
||||
if err != nil {
|
||||
return configOperationFailure(err, diagnostics, writtenRecord)
|
||||
}
|
||||
if plan.format == "yaml" && !writtenRecord {
|
||||
if err := writeConfigReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
}
|
||||
if err := writeConfigReport(invocation.Streams.Out, record); err != nil {
|
||||
return configDeliveryFailure(err, diagnostics, true)
|
||||
}
|
||||
writtenRecord = true
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
||||
if format == "text" {
|
||||
return []byte(scope + ": valid\n"), nil
|
||||
}
|
||||
encoded, err := yaml.Marshal(struct {
|
||||
Name string `yaml:"name"`
|
||||
Valid bool `yaml:"valid"`
|
||||
}{Name: scope, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(encoded), "\n"), "\n")
|
||||
var record bytes.Buffer
|
||||
for index, line := range lines {
|
||||
if index == 0 {
|
||||
record.WriteString(" - ")
|
||||
} else {
|
||||
record.WriteString(" ")
|
||||
}
|
||||
record.WriteString(line)
|
||||
record.WriteByte('\n')
|
||||
}
|
||||
return record.Bytes(), nil
|
||||
}
|
||||
20
integrations/ckprompt/COPYRIGHT
Normal file
20
integrations/ckprompt/COPYRIGHT
Normal file
@@ -0,0 +1,20 @@
|
||||
CommandKit ckprompt
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
This library is free software: you can redistribute it and/or modify it
|
||||
under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The GNU Lesser General Public License version 3 and its incorporated
|
||||
GNU General Public License version 3 text are provided in LICENSE.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
Third-party code and assets are subject to their respective license terms.
|
||||
304
integrations/ckprompt/LICENSE
Normal file
304
integrations/ckprompt/LICENSE
Normal file
@@ -0,0 +1,304 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license document.
|
||||
|
||||
4. Combined Works.
|
||||
You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
“This License” refers to version 3 of the GNU General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||
|
||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||
|
||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
CommandKit
|
||||
Copyright (C) 2026 eric
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
CommandKit Copyright (C) 2026 eric
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
41
integrations/ckprompt/README.md
Normal file
41
integrations/ckprompt/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# CommandKit ckprompt
|
||||
|
||||
`ckprompt` is the optional PromptKit integration for CommandKit. It adapts
|
||||
PromptKit engine configuration, native comparison, five catalogs, named
|
||||
inspection, requirements discovery and validation, and native smoke tests.
|
||||
Importing it does not install either command family.
|
||||
|
||||
## Module
|
||||
|
||||
The module path is
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt`. Its first
|
||||
release is prepared as `v0.1.0`. It uses `commandkit`, `ckconfig`, and
|
||||
PromptKit. See the
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md#promptkit-configuration-and-definitions)
|
||||
for callback, input, state, and acquisition contracts.
|
||||
|
||||
## Example
|
||||
|
||||
Add `ckprompt.ConfigScope(loadEngine)` to the scopes passed to
|
||||
`ckconfig.Command` when the application wants PromptKit engine configuration
|
||||
and native comparison in the `config` family. Install
|
||||
`ckprompt.Command(ckprompt.FromEngine(loadEngine))` separately for catalogs,
|
||||
named inspections, and requirements discovery. `FromEngine` supplies eight
|
||||
PromptKit-backed callbacks: three inspections and five catalogs. Applications
|
||||
may narrow, wrap, or replace them before capture or provide custom callbacks
|
||||
with `ckprompt.Suite`. Add concrete request validation and smoke testing
|
||||
explicitly with `WithRequestValidation` and `WithSmokeTesting`. Inspection,
|
||||
requirements, and catalog operations do not execute prompts or contact model
|
||||
providers; application loaders and callbacks retain their own effects. Smoke
|
||||
testing invokes the selected native PromptKit operation; the
|
||||
application retains ownership of client and configuration choices. See the
|
||||
[CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/cli.md#promptkit-smoke-testing)
|
||||
for operator commands and report semantics. The maintained
|
||||
[composed example](examples/composed) shows complete application wiring.
|
||||
|
||||
## License and attribution
|
||||
|
||||
This module is licensed under the [GNU Lesser General Public License v3.0 or
|
||||
later](COPYRIGHT). The accompanying [LICENSE](LICENSE) contains the full LGPL
|
||||
v3 and GPL v3 texts. See [third-party attribution](docs/oss/attribution.md)
|
||||
for incorporated and dependency notices.
|
||||
434
integrations/ckprompt/adapters.go
Normal file
434
integrations/ckprompt/adapters.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package ckprompt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
// PromptRequest selects one named prompt for inspection.
|
||||
type PromptRequest struct {
|
||||
// Selection carries the configuration and overlay values supplied to the command.
|
||||
Selection ckconfig.Selection
|
||||
// ID is the selected named prompt.
|
||||
ID string
|
||||
}
|
||||
|
||||
// ProfileRequest selects one named profile for inspection.
|
||||
type ProfileRequest struct {
|
||||
// Selection carries the configuration and overlay values supplied to the command.
|
||||
Selection ckconfig.Selection
|
||||
// ID is the selected named profile.
|
||||
ID string
|
||||
}
|
||||
|
||||
// CatalogRequest selects the configuration and native namespace filter for a
|
||||
// prompt, profile, or backend catalog operation. A zero Filter preserves
|
||||
// PromptKit's native default selection.
|
||||
type CatalogRequest struct {
|
||||
// Selection carries the configuration and overlay supplied to the command.
|
||||
Selection ckconfig.Selection
|
||||
// Filter is forwarded unchanged to each filterable native catalog. Its zero
|
||||
// value preserves PromptKit's default namespace selection.
|
||||
Filter promptkit.NamespaceFilter
|
||||
}
|
||||
|
||||
// NamespaceRequest selects the configuration and native resource kinds for a
|
||||
// namespace catalog operation. An empty Kinds slice preserves PromptKit's
|
||||
// native default selection.
|
||||
type NamespaceRequest struct {
|
||||
// Selection carries the configuration and overlay supplied to the command.
|
||||
Selection ckconfig.Selection
|
||||
// Kinds is forwarded unchanged to the native namespace catalog. An empty
|
||||
// slice preserves PromptKit's default kind selection.
|
||||
Kinds []promptkit.ResourceKind
|
||||
}
|
||||
|
||||
// RequirementsRequest selects a prompt context for native requirements
|
||||
// inspection.
|
||||
type RequirementsRequest struct {
|
||||
// Selection carries the configuration and overlay values supplied to the command.
|
||||
Selection ckconfig.Selection
|
||||
// Inspection forwards PromptKit's native prompt, version, profile, variable,
|
||||
// and session discovery context. Optional version, profile, and session
|
||||
// strings retain PromptKit's empty-value semantics.
|
||||
Inspection promptkit.RequirementInspectionRequest
|
||||
}
|
||||
|
||||
// Suite describes application-owned input, shared state, and optional catalog,
|
||||
// named inspection, requirements discovery, concrete request validation, and
|
||||
// smoke testing capabilities. Its private issues retain adapter construction
|
||||
// failures.
|
||||
// Factory results and callback effects remain caller-owned: Command does not
|
||||
// deep-copy arbitrary objects or repair aliases returned by factories.
|
||||
// A reached operation prepares one input, applies its captured selectors, and
|
||||
// checks the input before creating state and calling the selected provider.
|
||||
// Factories are not invoked during construction, installation, help, or version,
|
||||
// and need not run after failure.
|
||||
type Suite[I, S any] struct {
|
||||
// NewInput is called once when inspection, requirements, validation, smoke,
|
||||
// or catalog input preparation is reached, before applying selectors and
|
||||
// calling CheckInput.
|
||||
// If nil, input is the zero value of I, including nil for pointer types.
|
||||
NewInput func() I
|
||||
// NewState is called once when an inspection, validation, smoke, or catalog
|
||||
// callback is reached, after input checks pass. If nil, state is the zero
|
||||
// value of S, including nil for pointer types.
|
||||
NewState func() S
|
||||
// Selectors declares the original typed flags accepted by inspection,
|
||||
// requirements, validation, smoke, and catalog commands.
|
||||
Selectors []commandkit.Flag[I]
|
||||
// CheckInput validates selected inspection, requirements, validation, smoke,
|
||||
// or catalog input before state creation.
|
||||
CheckInput func(ckconfig.Selection, I) error
|
||||
// InspectPrompt inspects the selected prompt after input and state preparation.
|
||||
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
|
||||
// InspectProfile inspects the selected profile after input and state preparation.
|
||||
InspectProfile func(context.Context, ProfileRequest, I, S) (commandkit.Acquired[*promptkit.ProfileInspection], error)
|
||||
// InspectRequirements inspects native PromptKit requirements after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements show` without disabling validation or other
|
||||
// capabilities.
|
||||
InspectRequirements func(context.Context, RequirementsRequest, I, S) (commandkit.Acquired[*promptkit.RequirementInspection], error)
|
||||
// ValidateRequest validates a concrete PromptKit request after input and
|
||||
// state preparation. It is optional; a nil callback omits
|
||||
// `promptkit requirements validate` without disabling discovery or other
|
||||
// capabilities. The acquisition carries diagnostics and an empty value on
|
||||
// success; PromptKit errors are returned unchanged where supported.
|
||||
ValidateRequest func(context.Context, ValidationRequest, I, S) (commandkit.Acquired[struct{}], error)
|
||||
// SmokeTest runs one concrete native smoke-test operation after input and
|
||||
// state preparation. It is optional; nil omits only the smoke-test command.
|
||||
// The acquisition retains a native report even when the callback also
|
||||
// returns an error. For `test all`, CommandKit calls the captured callback
|
||||
// sequentially for each selected kind after preparing input and state once.
|
||||
SmokeTest func(context.Context, SmokeRequest, I, S) (commandkit.Acquired[*promptkit.SmokeTestReport], error)
|
||||
// ListPrompts lists prompt identities after input and state preparation.
|
||||
// It is optional independently of the other catalogs; nil omits only this
|
||||
// catalog command.
|
||||
ListPrompts func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[[]promptkit.PromptIdentity], error)
|
||||
// ListProfiles lists profile IDs after input and state preparation. It is
|
||||
// optional independently of the other catalogs; nil omits only this catalog
|
||||
// command.
|
||||
ListProfiles func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[[]string], error)
|
||||
// ListBackends lists backend IDs after input and state preparation. It is
|
||||
// optional independently of the other catalogs; nil omits only this catalog
|
||||
// command.
|
||||
ListBackends func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[[]string], error)
|
||||
// ListNamespaces lists native namespace names after input and state
|
||||
// preparation. It is optional independently of the other catalogs; nil omits
|
||||
// only this catalog command.
|
||||
ListNamespaces func(context.Context, NamespaceRequest, I, S) (commandkit.Acquired[[]string], error)
|
||||
// ListCollections lists collection information after input and state
|
||||
// preparation. It is optional independently of the other catalogs; nil
|
||||
// omits only this catalog command.
|
||||
ListCollections func(context.Context, ckconfig.Selection, I, S) (commandkit.Acquired[[]promptkit.CollectionInfo], error)
|
||||
|
||||
issues []error
|
||||
}
|
||||
|
||||
// WithRequestValidation attaches PromptKit-backed concrete request validation
|
||||
// to suite while preserving its other capabilities and retained issues. Those
|
||||
// issues remain attached if capabilities are narrowed before Command captures
|
||||
// the suite. The
|
||||
// builder, when present, runs before the engine loader and receives the seed,
|
||||
// input, state, and invocation context. Its returned request is passed to
|
||||
// PromptKit unchanged; the loader receives the seed's original configuration
|
||||
// selection. A nil builder forwards the seed request unchanged. A builder
|
||||
// failure prevents engine loading and preserves its supported error identity.
|
||||
// Native validation errors are returned with supported identity, and
|
||||
// configuration diagnostics from engine loading remain in the acquisition. A
|
||||
// nil loader is retained as a suite wiring issue and does not install a
|
||||
// validation callback. Callback references and objects remain caller-owned.
|
||||
func WithRequestValidation[I, S any](
|
||||
suite Suite[I, S],
|
||||
load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error),
|
||||
build func(context.Context, ValidationRequest, I, S) (promptkit.RunRequest, error),
|
||||
) Suite[I, S] {
|
||||
if load == nil {
|
||||
issues := append([]error(nil), suite.issues...)
|
||||
suite.issues = append(issues, errors.New("PromptKit engine loader is nil"))
|
||||
return suite
|
||||
}
|
||||
|
||||
suite.ValidateRequest = func(ctx context.Context, request ValidationRequest, input I, state S) (commandkit.Acquired[struct{}], error) {
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation before building: %w", err)
|
||||
}
|
||||
|
||||
concreteRequest := request.Request
|
||||
if build != nil {
|
||||
built, err := build(ctx, request, input, state)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[struct{}]{}, err
|
||||
}
|
||||
concreteRequest = built
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{}, fmt.Errorf("PromptKit request validation after building: %w", err)
|
||||
}
|
||||
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation before native validation: %w", err)
|
||||
}
|
||||
|
||||
if err := engine.ValidateRequest(ctx, concreteRequest); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit request validation after native validation: %w", err)
|
||||
}
|
||||
return commandkit.Acquired[struct{}]{Diagnostics: diagnostics}, nil
|
||||
}
|
||||
return suite
|
||||
}
|
||||
|
||||
type engineLoader[I, S any] func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)
|
||||
|
||||
// ConfigScope adapts PromptKit's captured configuration inspection to the
|
||||
// standard configuration commands, including native comparison when the scope
|
||||
// declares Diff. The loader is not called during declaration construction or
|
||||
// installation; it is called only when a config operation selects the
|
||||
// promptkit scope, with that operation's input and shared state. PromptKit
|
||||
// diagnostics and supported error identity are returned through ckconfig's
|
||||
// acquisition and comparison contracts.
|
||||
func ConfigScope[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) ckconfig.Scope[I, S] {
|
||||
scope := ckconfig.Scope[I, S]{
|
||||
Name: "promptkit",
|
||||
Summary: "PromptKit engine configuration.",
|
||||
Operations: []ckconfig.Operation{ckconfig.Validate, ckconfig.Show, ckconfig.Sources, ckconfig.Explain, ckconfig.Diff},
|
||||
}
|
||||
if load == nil {
|
||||
return ckconfig.WithScopeError(scope, errors.New("PromptKit configuration loader is nil"))
|
||||
}
|
||||
|
||||
scope.Acquire = func(ctx context.Context, request ckconfig.Request, input I, state S) (commandkit.Acquired[ckconfig.View], error) {
|
||||
_, inspection, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[ckconfig.View]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
return commandkit.Acquired[ckconfig.View]{
|
||||
Value: ckconfig.View{
|
||||
Effective: inspection,
|
||||
Sources: inspection,
|
||||
Explanation: inspection,
|
||||
},
|
||||
Diagnostics: diagnostics,
|
||||
}, nil
|
||||
}
|
||||
scope.Compare = func(ctx context.Context, request ckconfig.ComparisonRequest[I], state S) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, fmt.Errorf("PromptKit scope %q comparison before left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, leftInspection, leftDiagnostics, err := loadPromptKitEngine(ctx, request.Left.Selection, request.Left.Input, state, "left", load)
|
||||
diagnostics := append([]commandkit.Diagnostic(nil), leftDiagnostics...)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q left comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison after left load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
_, rightInspection, rightDiagnostics, err := loadPromptKitEngine(ctx, request.Right.Selection, request.Right.Input, state, "right", load)
|
||||
diagnostics = append(diagnostics, rightDiagnostics...)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q right comparison load: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
if err := promptKitContextError(ctx); err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q comparison before native diff: %w", scope.Name, err)
|
||||
}
|
||||
|
||||
differences, err := leftInspection.Diff(rightInspection)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]configkit.Difference]{Diagnostics: diagnostics}, fmt.Errorf("PromptKit scope %q native configuration diff: %w", scope.Name, err)
|
||||
}
|
||||
return commandkit.Acquired[[]configkit.Difference]{Value: differences, Diagnostics: diagnostics}, nil
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func promptKitContextError(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// FromEngine supplies eight PromptKit-backed callbacks: prompt, profile,
|
||||
// backend, namespace, and collection catalogs, plus named prompt and profile
|
||||
// inspection and requirements discovery.
|
||||
// The requirements callback forwards RequirementsRequest.Inspection to
|
||||
// Engine.InspectRequirements. Concrete request validation is an independent
|
||||
// capability and can be composed explicitly with WithRequestValidation. Smoke
|
||||
// testing is also an explicit capability installed with WithSmokeTesting.
|
||||
// FromEngine does not call the loader during construction. Each selected
|
||||
// operation passes its context, supplied selection, initialized input, and
|
||||
// shared invocation state to the loader before calling PromptKit's public
|
||||
// listing or inspection method. Applications can narrow, wrap, or replace these
|
||||
// callbacks before Command captures them. A nil loader is retained as an
|
||||
// adapter issue. Each callback returns loader diagnostics with the native
|
||||
// operation's value and error.
|
||||
func FromEngine[I, S any](load func(context.Context, ckconfig.Selection, I, S) (*promptkit.Engine, error)) Suite[I, S] {
|
||||
if load == nil {
|
||||
return Suite[I, S]{issues: []error{errors.New("PromptKit engine loader is nil")}}
|
||||
}
|
||||
|
||||
return Suite[I, S]{
|
||||
ListPrompts: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]promptkit.PromptIdentity], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
prompts, err := engine.ListPrompts(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]promptkit.PromptIdentity]{Value: prompts, Diagnostics: diagnostics}, err
|
||||
},
|
||||
ListProfiles: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
profiles, err := engine.ListProfiles(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: profiles, Diagnostics: diagnostics}, err
|
||||
},
|
||||
ListBackends: func(ctx context.Context, request CatalogRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
backends, err := engine.ListBackends(ctx, request.Filter)
|
||||
return commandkit.Acquired[[]string]{Value: backends, Diagnostics: diagnostics}, err
|
||||
},
|
||||
ListNamespaces: func(ctx context.Context, request NamespaceRequest, input I, state S) (commandkit.Acquired[[]string], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]string]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
namespaces, err := engine.ListNamespaces(ctx, request.Kinds...)
|
||||
return commandkit.Acquired[[]string]{Value: namespaces, Diagnostics: diagnostics}, err
|
||||
},
|
||||
ListCollections: func(ctx context.Context, selection ckconfig.Selection, input I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
collections, err := engine.ListCollections(ctx)
|
||||
return commandkit.Acquired[[]promptkit.CollectionInfo]{Value: collections, Diagnostics: diagnostics}, err
|
||||
},
|
||||
InspectPrompt: func(ctx context.Context, request PromptRequest, input I, state S) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
inspection, err := engine.InspectPrompt(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
},
|
||||
InspectProfile: func(ctx context.Context, request ProfileRequest, input I, state S) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
inspection, err := engine.InspectProfile(ctx, request.ID)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
},
|
||||
InspectRequirements: func(ctx context.Context, request RequirementsRequest, input I, state S) (commandkit.Acquired[*promptkit.RequirementInspection], error) {
|
||||
engine, _, diagnostics, err := loadPromptKitEngine(ctx, request.Selection, input, state, "", load)
|
||||
if err != nil {
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Diagnostics: diagnostics}, err
|
||||
}
|
||||
inspection, err := engine.InspectRequirements(ctx, request.Inspection)
|
||||
return commandkit.Acquired[*promptkit.RequirementInspection]{Value: inspection, Diagnostics: diagnostics}, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadPromptKitEngine[I, S any](
|
||||
ctx context.Context,
|
||||
selection ckconfig.Selection,
|
||||
input I,
|
||||
state S,
|
||||
side string,
|
||||
load engineLoader[I, S],
|
||||
) (*promptkit.Engine, promptkit.ConfigurationInspection, []commandkit.Diagnostic, error) {
|
||||
engine, err := load(ctx, selection, input, state)
|
||||
if err != nil {
|
||||
var loadErr *configkit.LoadError
|
||||
if errors.As(err, &loadErr) {
|
||||
return nil, promptkit.ConfigurationInspection{}, promptKitDiagnostics("promptkit", side, loadErr.Sources(), loadErr.Diagnostics()), err
|
||||
}
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
}
|
||||
if engine == nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, errors.New("PromptKit configuration loader returned a nil engine")
|
||||
}
|
||||
inspection, err := engine.InspectConfiguration()
|
||||
if err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, nil, err
|
||||
}
|
||||
diagnostics := promptKitDiagnostics("promptkit", side, inspection.Sources(), inspection.Diagnostics())
|
||||
if ctx != nil {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, promptkit.ConfigurationInspection{}, diagnostics, err
|
||||
}
|
||||
}
|
||||
return engine, inspection, diagnostics, nil
|
||||
}
|
||||
|
||||
func promptKitDiagnostics(scope, side string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []commandkit.Diagnostic {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
severity := commandkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = commandkit.SeverityError
|
||||
break
|
||||
}
|
||||
}
|
||||
// Snapshot provider-owned slices before deferring their report to invocation.
|
||||
sources = append([]configkit.SourceInfo(nil), sources...)
|
||||
records = append([]configkit.Diagnostic(nil), records...)
|
||||
for index := range records {
|
||||
if records[index].Primary != nil {
|
||||
primary := *records[index].Primary
|
||||
records[index].Primary = &primary
|
||||
}
|
||||
records[index].Related = append([]configkit.Location(nil), records[index].Related...)
|
||||
}
|
||||
return []commandkit.Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
heading := "Configuration scope " + strconv.Quote(scope)
|
||||
if side != "" {
|
||||
heading += " (" + side + ")"
|
||||
}
|
||||
if _, err := fmt.Fprintf(writer, "%s:\n", heading); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if _, err := io.WriteString(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
if _, err := fmt.Fprintf(writer, " %s: label=%s, path=%s\n", strconv.Quote(source.ID), strconv.Quote(source.Label), strconv.Quote(source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
849
integrations/ckprompt/adapters_test.go
Normal file
849
integrations/ckprompt/adapters_test.go
Normal file
@@ -0,0 +1,849 @@
|
||||
package ckprompt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
const adapterPrompt = `prompts:
|
||||
briefing:
|
||||
version: "1"
|
||||
default_profile: default
|
||||
inputs:
|
||||
- name: topic
|
||||
required: true
|
||||
content_type: text/plain
|
||||
description: "Topic"
|
||||
- name: audience
|
||||
required: false
|
||||
content_type: text/plain
|
||||
description: ""
|
||||
messages:
|
||||
- role: user
|
||||
content: "Hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
empty:
|
||||
version: "1"
|
||||
default_profile: default
|
||||
messages:
|
||||
- role: user
|
||||
content: "Empty inputs"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
backends:
|
||||
local:
|
||||
endpoint: "https://example.invalid/v1"
|
||||
profiles:
|
||||
default:
|
||||
backend: local
|
||||
model: example
|
||||
temperature: 0
|
||||
max_tokens: 0
|
||||
api_key_env: PROMPTKIT_ADAPTER_TEST_KEY
|
||||
extra_params:
|
||||
token: "caller-owned-value"
|
||||
nested:
|
||||
visible: true
|
||||
null_value: null
|
||||
sequence: [first, null, 3]
|
||||
requires-key:
|
||||
backend: local
|
||||
model: example
|
||||
api_key_required: true
|
||||
`
|
||||
|
||||
const adapterRequirementsPrompt = `prompts:
|
||||
briefing:
|
||||
version: "1"
|
||||
default_profile: default
|
||||
session_id: '{{input "session-only"}} {{.session_variable}}'
|
||||
messages:
|
||||
- role: user
|
||||
content: '{{if eq .audience "internal"}}{{input "internal-notes"}}{{else}}{{input "public-notes"}}{{end}} {{.audience}}'
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
backends:
|
||||
local:
|
||||
endpoint: "https://example.invalid/v1"
|
||||
profiles:
|
||||
default:
|
||||
backend: local
|
||||
model: example
|
||||
`
|
||||
|
||||
func adapterEngine(t *testing.T) *promptkit.Engine {
|
||||
return adapterEngineFrom(t, "adapter fixture", adapterPrompt)
|
||||
}
|
||||
|
||||
func adapterEngineFrom(t *testing.T, label, configuration string, options ...promptkit.Option) *promptkit.Engine {
|
||||
t.Helper()
|
||||
inputs, err := promptkit.FilesystemInputs(label, fstest.MapFS{
|
||||
"config/prompt.yml": &fstest.MapFile{Data: []byte(configuration)},
|
||||
}, "config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{
|
||||
Underlays: []configkit.Underlay{{Inputs: inputs}},
|
||||
}}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("construct PromptKit fixture: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
type recordingSmokeClient struct {
|
||||
request promptkit.GenerateRequest
|
||||
response *promptkit.GenerateResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (client *recordingSmokeClient) Generate(_ context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
||||
client.calls++
|
||||
client.request = request
|
||||
return client.response, nil
|
||||
}
|
||||
|
||||
func TestWithSmokeTestingForwardsAuthoritativeNativeRequestAndRetainsReport(t *testing.T) {
|
||||
client := &recordingSmokeClient{
|
||||
response: &promptkit.GenerateResponse{Content: "fixture response"},
|
||||
}
|
||||
engine := adapterEngineFrom(t, "smoke adapter fixture", adapterPrompt, promptkit.WithLLMClient(client))
|
||||
type input struct{ label string }
|
||||
type state struct{ label string }
|
||||
type contextKey struct{}
|
||||
wantContext := context.WithValue(context.Background(), contextKey{}, "smoke invocation")
|
||||
wantInput := input{label: "typed input"}
|
||||
wantState := state{label: "shared state"}
|
||||
wantSelection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "selected.yml", Set: true}}
|
||||
loaderCalls, builderCalls := 0, 0
|
||||
seed := promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "unused", APIKey: "seed credential"}
|
||||
built := promptkit.SmokeTestRequest{
|
||||
Kind: promptkit.SmokeTestTextInference,
|
||||
PromptID: "briefing",
|
||||
APIKey: "builder credential",
|
||||
Execution: &promptkit.ExecutionTargetOverride{Model: "builder-model"},
|
||||
}
|
||||
suite := WithSmokeTesting(Suite[input, state]{},
|
||||
func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if ctx.Value(contextKey{}) != "smoke invocation" || selection != wantSelection || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
|
||||
}
|
||||
return engine, nil
|
||||
},
|
||||
func(ctx context.Context, request SmokeRequest, gotInput input, gotState state) (promptkit.SmokeTestRequest, error) {
|
||||
builderCalls++
|
||||
if ctx.Value(contextKey{}) != "smoke invocation" || request.Selection != wantSelection || request.Request.Kind != promptkit.SmokeTestAuthentication || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("builder args = ctx=%v request=%#v input=%#v state=%#v", ctx.Value(contextKey{}), request, gotInput, gotState)
|
||||
}
|
||||
return built, nil
|
||||
},
|
||||
)
|
||||
if suite.SmokeTest == nil {
|
||||
t.Fatal("WithSmokeTesting did not install a callback")
|
||||
}
|
||||
|
||||
acquired, err := suite.SmokeTest(wantContext, SmokeRequest{Selection: wantSelection, Request: seed}, wantInput, wantState)
|
||||
if err != nil || acquired.Value == nil || len(acquired.Diagnostics) != 0 || loaderCalls != 1 || builderCalls != 1 {
|
||||
t.Fatalf("smoke acquisition = (%#v, %v), loaders/builders=%d/%d", acquired, err, loaderCalls, builderCalls)
|
||||
}
|
||||
if acquired.Value.Kind != promptkit.SmokeTestTextInference || client.calls == 0 || client.request.Target.Model != "builder-model" || client.request.APIKey != "builder credential" {
|
||||
t.Fatalf("native smoke request/report = request %#v report %#v calls=%d", client.request, acquired.Value, client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSmokeTestingNilBuilderForwardsSeedAndBuilderFailureSkipsLoader(t *testing.T) {
|
||||
client := &recordingSmokeClient{response: &promptkit.GenerateResponse{Content: "fixture response"}}
|
||||
engine := adapterEngineFrom(t, "smoke adapter fixture", adapterPrompt, promptkit.WithLLMClient(client))
|
||||
seed := promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestTextInference, PromptID: "briefing", APIKey: "seed credential"}
|
||||
loaderCalls := 0
|
||||
suite := WithSmokeTesting(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
return engine, nil
|
||||
}, nil)
|
||||
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{Request: seed}, struct{}{}, struct{}{})
|
||||
if err != nil || acquired.Value == nil || client.calls == 0 || client.request.APIKey != "seed credential" {
|
||||
t.Fatalf("nil-builder smoke request = (%#v, %v), client=%#v", acquired, err, client)
|
||||
}
|
||||
acquired, err = suite.SmokeTest(context.Background(), SmokeRequest{Request: promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestKind("unknown")}}, struct{}{}, struct{}{})
|
||||
if !errors.Is(err, promptkit.ErrInvalidRequest) || acquired.Value != nil {
|
||||
t.Fatalf("native smoke error = (%#v, %v), want invalid request", acquired, err)
|
||||
}
|
||||
|
||||
buildErr := errors.New("request builder failed")
|
||||
loaderCalls = 0
|
||||
suite = WithSmokeTesting(Suite[struct{}, struct{}]{}, func(context.Context, ckconfig.Selection, struct{}, struct{}) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
return engine, nil
|
||||
}, func(context.Context, SmokeRequest, struct{}, struct{}) (promptkit.SmokeTestRequest, error) {
|
||||
return promptkit.SmokeTestRequest{}, buildErr
|
||||
})
|
||||
acquired, err = suite.SmokeTest(context.Background(), SmokeRequest{}, struct{}{}, struct{}{})
|
||||
if err != buildErr || acquired.Value != nil || loaderCalls != 0 {
|
||||
t.Fatalf("builder failure acquisition = (%#v, %v), loader calls=%d", acquired, err, loaderCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSmokeTestingRetainsNilLoaderIssueAndChecksCancellationBeforeBuild(t *testing.T) {
|
||||
sentinel := errors.New("existing suite issue")
|
||||
suite := WithSmokeTesting(Suite[int, int]{issues: []error{sentinel}}, nil, nil)
|
||||
if suite.SmokeTest != nil || len(suite.issues) != 2 || suite.issues[0] != sentinel || !strings.Contains(suite.issues[1].Error(), "engine loader is nil") {
|
||||
t.Fatalf("nil-loader smoke wiring = %#v", suite)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
buildCalls, loadCalls := 0, 0
|
||||
suite = WithSmokeTesting(Suite[int, int]{}, func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
loadCalls++
|
||||
return nil, nil
|
||||
}, func(context.Context, SmokeRequest, int, int) (promptkit.SmokeTestRequest, error) {
|
||||
buildCalls++
|
||||
return promptkit.SmokeTestRequest{}, nil
|
||||
})
|
||||
acquired, err := suite.SmokeTest(ctx, SmokeRequest{}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value != nil || buildCalls != 0 || loadCalls != 0 {
|
||||
t.Fatalf("pre-canceled smoke = (%#v, %v), builders/loaders=%d/%d", acquired, err, buildCalls, loadCalls)
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
buildCalls, loadCalls = 0, 0
|
||||
suite = WithSmokeTesting(Suite[int, int]{}, func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
loadCalls++
|
||||
return nil, nil
|
||||
}, func(context.Context, SmokeRequest, int, int) (promptkit.SmokeTestRequest, error) {
|
||||
buildCalls++
|
||||
cancel()
|
||||
return promptkit.SmokeTestRequest{}, nil
|
||||
})
|
||||
acquired, err = suite.SmokeTest(ctx, SmokeRequest{}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value != nil || buildCalls != 1 || loadCalls != 0 {
|
||||
t.Fatalf("canceled-after-build smoke = (%#v, %v), builders/loaders=%d/%d", acquired, err, buildCalls, loadCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSmokeTestingRetainsLoaderDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid smoke configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
}
|
||||
suite := WithSmokeTesting(Suite[int, int]{}, func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, loadErr
|
||||
}, nil)
|
||||
acquired, err := suite.SmokeTest(context.Background(), SmokeRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || acquired.Value != nil || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("smoke load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeUsesNativeInspectionFacetsAndInvocationValues(t *testing.T) {
|
||||
type input struct{ token string }
|
||||
type state struct{ token string }
|
||||
type contextKey struct{}
|
||||
|
||||
engine := adapterEngine(t)
|
||||
wantContext := context.WithValue(context.Background(), contextKey{}, "invocation")
|
||||
wantInput := input{token: "input"}
|
||||
wantState := state{token: "state"}
|
||||
loaderCalls := 0
|
||||
scope := ConfigScope(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if ctx.Value(contextKey{}) != "invocation" || !selection.Config.Set || selection.Config.Value != "prompt-config.yml" || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
|
||||
}
|
||||
return engine, nil
|
||||
})
|
||||
if scope.Name != "promptkit" || len(scope.Operations) != 5 || scope.Acquire == nil {
|
||||
t.Fatalf("configuration scope = %#v", scope)
|
||||
}
|
||||
acquired, err := scope.Acquire(wantContext, ckconfig.Request{
|
||||
Operation: ckconfig.Show,
|
||||
Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "prompt-config.yml", Set: true}},
|
||||
}, wantInput, wantState)
|
||||
if err != nil || loaderCalls != 1 {
|
||||
t.Fatalf("scope acquisition = (%#v, %v), calls=%d", acquired, err, loaderCalls)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "briefing:") {
|
||||
t.Fatalf("native effective report = %q, %v", effective.String(), err)
|
||||
}
|
||||
if sources := acquired.Value.Sources.Sources(); len(sources) == 0 || sources[0].ID == "" {
|
||||
t.Fatalf("native sources = %#v", sources)
|
||||
}
|
||||
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Path{}); !ok || !explanation.Effective.Present {
|
||||
t.Fatalf("native root explanation = %#v, found=%t", explanation, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeRetainsNilLoaderIssueAfterCapabilitiesAreCleared(t *testing.T) {
|
||||
scope := ConfigScope[int, int](nil)
|
||||
if scope.Acquire != nil {
|
||||
t.Fatal("nil loader installed an acquisition callback")
|
||||
}
|
||||
scope.Operations = nil
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declaration := ckconfig.Command(ckconfig.Suite[int, int]{Scopes: []ckconfig.Scope[int, int]{scope}})
|
||||
if err := app.Register(declaration); err == nil || !strings.Contains(err.Error(), "PromptKit configuration loader is nil") {
|
||||
t.Fatalf("narrowed nil-loader scope registered: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareUsesNativeDiffAndLoadsBothEqualSelections(t *testing.T) {
|
||||
leftEngine := adapterEngine(t)
|
||||
rightConfig := strings.Replace(adapterPrompt, `content: "Hello"`, `content: "Goodbye"`, 1)
|
||||
if rightConfig == adapterPrompt {
|
||||
t.Fatal("comparison fixture did not change the prompt content")
|
||||
}
|
||||
rightEngine := adapterEngineFrom(t, "right comparison fixture", rightConfig)
|
||||
type input struct{ label string }
|
||||
type call struct {
|
||||
selection ckconfig.Selection
|
||||
input input
|
||||
state *int
|
||||
}
|
||||
var calls []call
|
||||
scope := ConfigScope(func(_ context.Context, selection ckconfig.Selection, gotInput input, state *int) (*promptkit.Engine, error) {
|
||||
calls = append(calls, call{selection: selection, input: gotInput, state: state})
|
||||
if gotInput.label == "before" {
|
||||
return leftEngine, nil
|
||||
}
|
||||
return rightEngine, nil
|
||||
})
|
||||
if scope.Compare == nil {
|
||||
t.Fatalf("configuration scope does not declare comparison: %#v", scope)
|
||||
}
|
||||
for _, operation := range []ckconfig.Operation{ckconfig.Validate, ckconfig.Show, ckconfig.Sources, ckconfig.Explain, ckconfig.Diff} {
|
||||
if !slices.Contains(scope.Operations, operation) {
|
||||
t.Fatalf("configuration scope is missing operation %q: %#v", operation, scope.Operations)
|
||||
}
|
||||
}
|
||||
|
||||
selection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "same-selection.yml", Set: true}}
|
||||
state := new(int)
|
||||
request := ckconfig.ComparisonRequest[input]{
|
||||
Left: ckconfig.ComparisonSide[input]{Selection: selection, Input: input{label: "before"}},
|
||||
Right: ckconfig.ComparisonSide[input]{Selection: selection, Input: input{label: "after"}},
|
||||
}
|
||||
leftInspection, err := leftEngine.InspectConfiguration()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rightInspection, err := rightEngine.InspectConfiguration()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := leftInspection.Diff(rightInspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(want) == 0 {
|
||||
t.Fatal("native PromptKit fixture comparison produced no differences")
|
||||
}
|
||||
|
||||
got, err := scope.Compare(context.Background(), request, state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Value, want) {
|
||||
t.Fatalf("adapter differences = %#v, want native PromptKit result %#v", got.Value, want)
|
||||
}
|
||||
if len(calls) != 2 || calls[0].selection != selection || calls[1].selection != selection || calls[0].input.label != "before" || calls[1].input.label != "after" || calls[0].state != state || calls[1].state != state {
|
||||
t.Fatalf("loader calls = %#v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareStopsAfterLeftLoadFailure(t *testing.T) {
|
||||
loadErr := errors.New("left engine load failed")
|
||||
rightCalled := false
|
||||
scope := ConfigScope(func(_ context.Context, selection ckconfig.Selection, _ struct{}, _ struct{}) (*promptkit.Engine, error) {
|
||||
if selection.Config.Value == "left" {
|
||||
return nil, loadErr
|
||||
}
|
||||
rightCalled = true
|
||||
return adapterEngine(t), nil
|
||||
})
|
||||
request := ckconfig.ComparisonRequest[struct{}]{
|
||||
Left: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}
|
||||
|
||||
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
||||
if !errors.Is(err, loadErr) || rightCalled || acquired.Value != nil {
|
||||
t.Fatalf("left failure = (%#v, %v), right called=%t", acquired, err, rightCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareRetainsRightLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid right prompt config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid right configuration unexpectedly loaded")
|
||||
}
|
||||
leftEngine := adapterEngine(t)
|
||||
var calls []string
|
||||
scope := ConfigScope(func(_ context.Context, selection ckconfig.Selection, _ struct{}, _ struct{}) (*promptkit.Engine, error) {
|
||||
calls = append(calls, selection.Config.Value)
|
||||
if selection.Config.Value == "left" {
|
||||
return leftEngine, nil
|
||||
}
|
||||
return nil, loadErr
|
||||
})
|
||||
request := ckconfig.ComparisonRequest[struct{}]{
|
||||
Left: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}
|
||||
|
||||
acquired, err := scope.Compare(context.Background(), request, struct{}{})
|
||||
var retained *configkit.LoadError
|
||||
if !errors.Is(err, loadErr) || !errors.As(err, &retained) || retained != loadErr || len(calls) != 2 || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("right failure = (%#v, %v), calls=%#v", acquired, err, calls)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if writeErr := acquired.Diagnostics[0].Write(&output); writeErr != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (right):`) {
|
||||
t.Fatalf("right diagnostic = %q, %v", output.String(), writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeCompareCancellationAfterLeftLoadStopsRight(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
leftEngine := adapterEngine(t)
|
||||
rightCalled := false
|
||||
scope := ConfigScope(func(_ context.Context, selection ckconfig.Selection, _ struct{}, _ struct{}) (*promptkit.Engine, error) {
|
||||
if selection.Config.Value == "left" {
|
||||
cancel()
|
||||
return leftEngine, nil
|
||||
}
|
||||
rightCalled = true
|
||||
return adapterEngine(t), nil
|
||||
})
|
||||
request := ckconfig.ComparisonRequest[struct{}]{
|
||||
Left: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "left", Set: true}}},
|
||||
Right: ckconfig.ComparisonSide[struct{}]{Selection: ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "right", Set: true}}},
|
||||
}
|
||||
|
||||
acquired, err := scope.Compare(ctx, request, struct{}{})
|
||||
if !errors.Is(err, context.Canceled) || rightCalled || acquired.Value != nil {
|
||||
t.Fatalf("cancellation = (%#v, %v), right called=%t", acquired, err, rightCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEnginePassesRequestsAndRetainsNilLoaderIssue(t *testing.T) {
|
||||
type input struct{ value string }
|
||||
type state struct{ value string }
|
||||
type contextKey struct{}
|
||||
|
||||
engine := adapterEngine(t)
|
||||
wantContext := context.WithValue(context.Background(), contextKey{}, "prompt invocation")
|
||||
wantInput := input{value: "typed input"}
|
||||
wantState := state{value: "shared state"}
|
||||
loaderCalls := 0
|
||||
suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if ctx.Value(contextKey{}) != "prompt invocation" || !selection.Overlay.Set || selection.Overlay.Value != "overlay.yml" || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
|
||||
}
|
||||
return engine, nil
|
||||
})
|
||||
selection := ckconfig.Selection{Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true}}
|
||||
prompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing"}, wantInput, wantState)
|
||||
if err != nil || prompt.Value == nil || prompt.Value.PromptID != "briefing" || prompt.Value.PromptVersion == nil || *prompt.Value.PromptVersion != "1" || loaderCalls != 1 {
|
||||
t.Fatalf("prompt inspection = (%#v, %v), calls=%d", prompt, err, loaderCalls)
|
||||
}
|
||||
secondPrompt, err := suite.InspectPrompt(wantContext, PromptRequest{Selection: selection, ID: "briefing"}, wantInput, wantState)
|
||||
if err != nil || secondPrompt.Value == nil || secondPrompt.Value.PromptID != "briefing" || loaderCalls != 2 {
|
||||
t.Fatalf("ID-only prompt inspection = (%#v, %v), calls=%d", secondPrompt, err, loaderCalls)
|
||||
}
|
||||
profile, err := suite.InspectProfile(wantContext, ProfileRequest{Selection: selection, ID: "default"}, wantInput, wantState)
|
||||
if err != nil || profile.Value == nil || profile.Value.ProfileID != "default" || loaderCalls != 3 {
|
||||
t.Fatalf("profile inspection = (%#v, %v), calls=%d", profile, err, loaderCalls)
|
||||
}
|
||||
|
||||
empty := FromEngine[int, int](nil)
|
||||
empty.ListPrompts = nil
|
||||
empty.ListProfiles = nil
|
||||
empty.InspectPrompt = nil
|
||||
empty.InspectProfile = nil
|
||||
empty.InspectRequirements = nil
|
||||
if len(empty.issues) != 1 || !strings.Contains(empty.issues[0].Error(), "PromptKit engine loader is nil") {
|
||||
t.Fatalf("nil-loader issue was lost when callbacks were cleared: %#v", empty.issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineInspectsRequirementsWithNativeRequest(t *testing.T) {
|
||||
type input struct{ value string }
|
||||
type state struct{ value string }
|
||||
type contextKey struct{}
|
||||
|
||||
engine := adapterEngineFrom(t, "requirements adapter fixture", adapterRequirementsPrompt)
|
||||
wantContext := context.WithValue(context.Background(), contextKey{}, "requirements invocation")
|
||||
wantInput := input{value: "typed input"}
|
||||
wantState := state{value: "shared state"}
|
||||
selection := ckconfig.Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "prompt.yml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "overlay.yml", Set: true},
|
||||
}
|
||||
request := RequirementsRequest{
|
||||
Selection: selection,
|
||||
Inspection: promptkit.RequirementInspectionRequest{
|
||||
PromptID: "briefing",
|
||||
ProfileID: "default",
|
||||
Vars: map[string]string{"audience": "internal"},
|
||||
SessionID: "session-42",
|
||||
},
|
||||
}
|
||||
want, err := engine.InspectRequirements(wantContext, request.Inspection)
|
||||
if err != nil {
|
||||
t.Fatalf("direct native requirements inspection: %v", err)
|
||||
}
|
||||
|
||||
loaderCalls := 0
|
||||
suite := FromEngine(func(ctx context.Context, gotSelection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if ctx.Value(contextKey{}) != "requirements invocation" || !reflect.DeepEqual(gotSelection, selection) || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), gotSelection, gotInput, gotState)
|
||||
}
|
||||
return engine, nil
|
||||
})
|
||||
acquired, err := suite.InspectRequirements(wantContext, request, wantInput, wantState)
|
||||
if err != nil || acquired.Value == nil || !reflect.DeepEqual(acquired.Value, want) || loaderCalls != 1 {
|
||||
t.Fatalf("requirements inspection = (%#v, %v), calls=%d; want native result %#v", acquired, err, loaderCalls, want)
|
||||
}
|
||||
if acquired.Value.PromptID != "briefing" || acquired.Value.PromptVersion == nil || *acquired.Value.PromptVersion != "1" || acquired.Value.ProfileID != "default" {
|
||||
t.Fatalf("requirements selection = %#v", acquired.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRequirementsRetainLoadDiagnosticsAndSelectionErrors(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid requirements configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
}
|
||||
loadFailure := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
acquired, err := loadFailure.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 || acquired.Value != nil {
|
||||
t.Fatalf("requirements load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
}
|
||||
|
||||
nilEngine := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, nil
|
||||
})
|
||||
acquired, err = nilEngine.InspectRequirements(context.Background(), RequirementsRequest{}, 0, 0)
|
||||
if err == nil || acquired.Value != nil || !strings.Contains(err.Error(), "nil engine") {
|
||||
t.Fatalf("nil engine result = (%#v, %v)", acquired, err)
|
||||
}
|
||||
|
||||
engine := adapterEngine(t)
|
||||
selectionFailure := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return engine, nil
|
||||
})
|
||||
acquired, err = selectionFailure.InspectRequirements(context.Background(), RequirementsRequest{
|
||||
Inspection: promptkit.RequirementInspectionRequest{PromptID: "missing", ProfileID: "default"},
|
||||
}, 0, 0)
|
||||
if !errors.Is(err, promptkit.ErrPromptNotFound) || acquired.Value != nil {
|
||||
t.Fatalf("native selection failure = (%#v, %v), want ErrPromptNotFound", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRequirementsHonorCancellationAfterLoad(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
engine := adapterEngine(t)
|
||||
suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
cancel()
|
||||
return engine, nil
|
||||
})
|
||||
acquired, err := suite.InspectRequirements(ctx, RequirementsRequest{
|
||||
Inspection: promptkit.RequirementInspectionRequest{PromptID: "briefing", ProfileID: "default"},
|
||||
}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value != nil {
|
||||
t.Fatalf("cancelled requirements inspection = (%#v, %v)", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineListsNativeCatalogsWithInvocationValues(t *testing.T) {
|
||||
type input struct{ label string }
|
||||
type state struct{ label string }
|
||||
type contextKey struct{}
|
||||
|
||||
engine := adapterEngine(t)
|
||||
wantContext := context.WithValue(context.Background(), contextKey{}, "catalog invocation")
|
||||
wantSelection := ckconfig.Selection{Config: commandkit.Supplied[string]{Value: "selected.yml", Set: true}}
|
||||
wantInput := input{label: "typed input"}
|
||||
wantState := state{label: "shared state"}
|
||||
loaderCalls := 0
|
||||
suite := FromEngine(func(ctx context.Context, selection ckconfig.Selection, gotInput input, gotState state) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if ctx.Value(contextKey{}) != "catalog invocation" || selection != wantSelection || gotInput != wantInput || gotState != wantState {
|
||||
t.Errorf("loader args = ctx=%v selection=%#v input=%#v state=%#v", ctx.Value(contextKey{}), selection, gotInput, gotState)
|
||||
}
|
||||
return engine, nil
|
||||
})
|
||||
if suite.ListPrompts == nil || suite.ListProfiles == nil || suite.ListBackends == nil || suite.ListNamespaces == nil || suite.ListCollections == nil {
|
||||
t.Fatalf("FromEngine did not supply all five catalog callbacks: %#v", suite)
|
||||
}
|
||||
catalogRequest := CatalogRequest{Selection: wantSelection, Filter: promptkit.NamespaceFilter{Mode: promptkit.NamespaceNamed, Name: "missing"}}
|
||||
namespaceRequest := NamespaceRequest{Selection: wantSelection, Kinds: []promptkit.ResourceKind{promptkit.ResourcePrompt}}
|
||||
wantPrompts, err := engine.ListPrompts(wantContext, catalogRequest.Filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantProfiles, err := engine.ListProfiles(wantContext, catalogRequest.Filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBackends, err := engine.ListBackends(wantContext, catalogRequest.Filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantNamespaces, err := engine.ListNamespaces(wantContext, namespaceRequest.Kinds...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantCollections, err := engine.ListCollections(wantContext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompts, err := suite.ListPrompts(wantContext, catalogRequest, wantInput, wantState)
|
||||
if err != nil || !reflect.DeepEqual(prompts.Value, wantPrompts) || len(prompts.Diagnostics) != 0 || loaderCalls != 1 {
|
||||
t.Fatalf("prompt catalog = (%#v, %v), calls=%d, want %#v", prompts, err, loaderCalls, wantPrompts)
|
||||
}
|
||||
profiles, err := suite.ListProfiles(wantContext, catalogRequest, wantInput, wantState)
|
||||
if err != nil || !reflect.DeepEqual(profiles.Value, wantProfiles) || len(profiles.Diagnostics) != 0 || loaderCalls != 2 {
|
||||
t.Fatalf("profile catalog = (%#v, %v), calls=%d, want %#v", profiles, err, loaderCalls, wantProfiles)
|
||||
}
|
||||
backends, err := suite.ListBackends(wantContext, catalogRequest, wantInput, wantState)
|
||||
if err != nil || !reflect.DeepEqual(backends.Value, wantBackends) || len(backends.Diagnostics) != 0 || loaderCalls != 3 {
|
||||
t.Fatalf("backend catalog = (%#v, %v), calls=%d, want %#v", backends, err, loaderCalls, wantBackends)
|
||||
}
|
||||
namespaces, err := suite.ListNamespaces(wantContext, namespaceRequest, wantInput, wantState)
|
||||
if err != nil || !reflect.DeepEqual(namespaces.Value, wantNamespaces) || len(namespaces.Diagnostics) != 0 || loaderCalls != 4 {
|
||||
t.Fatalf("namespace catalog = (%#v, %v), calls=%d, want %#v", namespaces, err, loaderCalls, wantNamespaces)
|
||||
}
|
||||
collections, err := suite.ListCollections(wantContext, wantSelection, wantInput, wantState)
|
||||
if err != nil || !reflect.DeepEqual(collections.Value, wantCollections) || len(collections.Diagnostics) != 0 || loaderCalls != 5 {
|
||||
t.Fatalf("collection catalog = (%#v, %v), calls=%d, want %#v", collections, err, loaderCalls, wantCollections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineCatalogsRetainLoadDiagnosticsAndErrorIdentity(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid catalog configuration", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
}
|
||||
suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
call func() (int, error)
|
||||
}{
|
||||
{name: "prompts", call: func() (int, error) {
|
||||
acquired, err := suite.ListPrompts(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Errorf("prompt load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
}},
|
||||
{name: "profiles", call: func() (int, error) {
|
||||
acquired, err := suite.ListProfiles(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Errorf("profile load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
}},
|
||||
{name: "backends", call: func() (int, error) {
|
||||
acquired, err := suite.ListBackends(context.Background(), CatalogRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Errorf("backend load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
}},
|
||||
{name: "namespaces", call: func() (int, error) {
|
||||
acquired, err := suite.ListNamespaces(context.Background(), NamespaceRequest{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Errorf("namespace load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
}},
|
||||
{name: "collections", call: func() (int, error) {
|
||||
acquired, err := suite.ListCollections(context.Background(), ckconfig.Selection{}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Errorf("collection load failure = (%#v, %v)", acquired, err)
|
||||
}
|
||||
return len(acquired.Diagnostics), err
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
diagnosticCount, err := test.call()
|
||||
if !errors.Is(err, loadErr) || diagnosticCount == 0 {
|
||||
t.Fatalf("catalog failure = (%d diagnostics, %v)", diagnosticCount, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineCatalogsHonorCancellationAfterLoad(t *testing.T) {
|
||||
engine := adapterEngine(t)
|
||||
loaderCalls := 0
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
call func(Suite[int, int], context.Context) error
|
||||
}{
|
||||
{name: "prompts", call: func(suite Suite[int, int], ctx context.Context) error {
|
||||
_, err := suite.ListPrompts(ctx, CatalogRequest{}, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{name: "profiles", call: func(suite Suite[int, int], ctx context.Context) error {
|
||||
_, err := suite.ListProfiles(ctx, CatalogRequest{}, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{name: "backends", call: func(suite Suite[int, int], ctx context.Context) error {
|
||||
_, err := suite.ListBackends(ctx, CatalogRequest{}, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{name: "namespaces", call: func(suite Suite[int, int], ctx context.Context) error {
|
||||
_, err := suite.ListNamespaces(ctx, NamespaceRequest{}, 0, 0)
|
||||
return err
|
||||
}},
|
||||
{name: "collections", call: func(suite Suite[int, int], ctx context.Context) error {
|
||||
_, err := suite.ListCollections(ctx, ckconfig.Selection{}, 0, 0)
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelSuite := FromEngine(func(_ context.Context, _ ckconfig.Selection, _, _ int) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
cancel()
|
||||
return engine, nil
|
||||
})
|
||||
if err := test.call(cancelSuite, ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("catalog cancellation = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if loaderCalls != 5 {
|
||||
t.Fatalf("catalog loaders called = %d, want one per provider", loaderCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterAcquisitionPreservesLoadAndContextErrors(t *testing.T) {
|
||||
loadErr := errors.New("loader failure")
|
||||
suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
|
||||
if err != loadErr || len(acquired.Diagnostics) != 0 {
|
||||
t.Fatalf("loader failure = (%#v, %v), want original error", acquired, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
engine := adapterEngine(t)
|
||||
called := false
|
||||
suite = FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
called = true
|
||||
return engine, nil
|
||||
})
|
||||
acquired, err = suite.InspectProfile(ctx, ProfileRequest{ID: "default"}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value != nil || !called {
|
||||
t.Fatalf("cancelled profile acquisition = (%#v, %v), loader called=%t", acquired, err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromEngineRetainsConfigKitLoadDiagnostics(t *testing.T) {
|
||||
_, loadErr := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit config", []byte("broken: [")))
|
||||
if loadErr == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
}
|
||||
suite := FromEngine[int, int](func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
return nil, loadErr
|
||||
})
|
||||
|
||||
acquired, err := suite.InspectProfile(context.Background(), ProfileRequest{ID: "default"}, 0, 0)
|
||||
var retained *configkit.LoadError
|
||||
if !errors.As(err, &retained) || retained != loadErr || len(acquired.Diagnostics) == 0 {
|
||||
t.Fatalf("load failure = (%#v, %v), want original ConfigKit error and diagnostics", acquired, err)
|
||||
}
|
||||
if acquired.Diagnostics[0].Severity != commandkit.SeverityError {
|
||||
t.Fatalf("load diagnostics = %#v, want error severity", acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigScopeHonorsCancellationAfterLoad(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
engine := adapterEngine(t)
|
||||
scope := ConfigScope(func(context.Context, ckconfig.Selection, int, int) (*promptkit.Engine, error) {
|
||||
cancel()
|
||||
return engine, nil
|
||||
})
|
||||
|
||||
acquired, err := scope.Acquire(ctx, ckconfig.Request{Operation: ckconfig.Show}, 0, 0)
|
||||
if !errors.Is(err, context.Canceled) || acquired.Value.Effective != nil {
|
||||
t.Fatalf("cancelled scope acquisition = (%#v, %v), want cancellation and no value", acquired, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptKitDiagnosticsRemainDeferredAndPropagateWriterErrors(t *testing.T) {
|
||||
writeErr := errors.New("diagnostic writer failed")
|
||||
sources := []configkit.SourceInfo{{ID: "source"}}
|
||||
records := []configkit.Diagnostic{{
|
||||
Severity: configkit.SeverityWarning,
|
||||
Message: "safe warning",
|
||||
}}
|
||||
diagnostics := promptKitDiagnostics("promptkit", "", sources, records)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Severity != commandkit.SeverityWarning {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if err := diagnostics[0].Write(failingDiagnosticWriter{err: writeErr}); !errors.Is(err, writeErr) {
|
||||
t.Fatalf("diagnostic write error = %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := diagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit":`) || !strings.Contains(output.String(), "safe warning") || !strings.Contains(output.String(), `"source"`) {
|
||||
t.Fatalf("diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
|
||||
sideDiagnostics := promptKitDiagnostics("promptkit", "left", sources, records)
|
||||
output.Reset()
|
||||
if err := sideDiagnostics[0].Write(&output); err != nil || !strings.Contains(output.String(), `Configuration scope "promptkit" (left):`) {
|
||||
t.Fatalf("comparison-side diagnostic text = %q, %v", output.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingDiagnosticWriter struct{ err error }
|
||||
|
||||
func (writer failingDiagnosticWriter) Write([]byte) (int, error) { return 0, writer.err }
|
||||
|
||||
var _ io.Writer = failingDiagnosticWriter{}
|
||||
826
integrations/ckprompt/command.go
Normal file
826
integrations/ckprompt/command.go
Normal file
@@ -0,0 +1,826 @@
|
||||
package ckprompt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
type definitionInput[I any] struct {
|
||||
selection ckconfig.Selection
|
||||
id string
|
||||
format string
|
||||
captured commandkit.FlagValues[I]
|
||||
selected I
|
||||
}
|
||||
|
||||
type catalogInput[I any] struct {
|
||||
selection ckconfig.Selection
|
||||
namespace commandkit.Supplied[string]
|
||||
allNamespaces commandkit.Supplied[bool]
|
||||
kinds []promptkit.ResourceKind
|
||||
filter promptkit.NamespaceFilter
|
||||
format string
|
||||
captured commandkit.FlagValues[I]
|
||||
selected I
|
||||
}
|
||||
|
||||
type requirementsInput[I any] struct {
|
||||
selection ckconfig.Selection
|
||||
inspection promptkit.RequirementInspectionRequest
|
||||
format string
|
||||
captured commandkit.FlagValues[I]
|
||||
selected I
|
||||
}
|
||||
|
||||
type variableAssignment struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
type inputAssignment struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
// Command captures the optional PromptKit catalogs, named inspection,
|
||||
// requirements discovery, concrete request validation, and smoke-test
|
||||
// callbacks and adds only supplied operations. Discovery and validation share the
|
||||
// `requirements` group and remain independently optional. An absent suite
|
||||
// claims no namespace; attached construction issues remain reportable. It adds
|
||||
// no configuration family; callers compose ConfigScope independently.
|
||||
// Construction, installation, help, and version do not run suite factories or
|
||||
// providers. Command captures suite callbacks and selector metadata;
|
||||
// callback-referenced objects and effects remain caller-owned. Suite documents
|
||||
// invocation input and state initialization, zero values, and caller ownership
|
||||
// of returned objects.
|
||||
// Catalog commands accept config/overlay selection and suite selectors, and
|
||||
// render YAML with `yaml` as the only format and default. Named-show formats are
|
||||
// unchanged.
|
||||
func Command[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
if suite.InspectPrompt == nil && suite.InspectProfile == nil && suite.InspectRequirements == nil && suite.ValidateRequest == nil && suite.SmokeTest == nil && suite.ListPrompts == nil && suite.ListProfiles == nil && suite.ListBackends == nil && suite.ListNamespaces == nil && suite.ListCollections == nil {
|
||||
command := commandkit.NoCommand()
|
||||
for _, issue := range suite.issues {
|
||||
command = commandkit.WithError(command, issue)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
suite.Selectors = append([]commandkit.Flag[I](nil), suite.Selectors...)
|
||||
rootCommands := make([]commandkit.Command, 0, 3)
|
||||
showCommands := make([]commandkit.Command, 0, 2)
|
||||
if suite.InspectPrompt != nil {
|
||||
showCommands = append(showCommands, newDefinitionCommand(suite, "prompt", true))
|
||||
}
|
||||
if suite.InspectProfile != nil {
|
||||
showCommands = append(showCommands, newDefinitionCommand(suite, "profile", false))
|
||||
}
|
||||
if len(showCommands) != 0 {
|
||||
rootCommands = append(rootCommands, commandkit.Group("show", "Show a named PromptKit definition.", showCommands...))
|
||||
}
|
||||
listCommands := make([]commandkit.Command, 0, 5)
|
||||
if suite.ListPrompts != nil {
|
||||
listCommands = append(listCommands, newCatalogListCommand(suite, "prompts"))
|
||||
}
|
||||
if suite.ListProfiles != nil {
|
||||
listCommands = append(listCommands, newCatalogListCommand(suite, "profiles"))
|
||||
}
|
||||
if suite.ListBackends != nil {
|
||||
listCommands = append(listCommands, newCatalogListCommand(suite, "backends"))
|
||||
}
|
||||
if suite.ListNamespaces != nil {
|
||||
listCommands = append(listCommands, newCatalogListCommand(suite, "namespaces"))
|
||||
}
|
||||
if suite.ListCollections != nil {
|
||||
listCommands = append(listCommands, newCatalogListCommand(suite, "collections"))
|
||||
}
|
||||
if len(listCommands) != 0 {
|
||||
rootCommands = append(rootCommands, commandkit.Group("list", "List PromptKit catalogs.", listCommands...))
|
||||
}
|
||||
requirementsCommands := make([]commandkit.Command, 0, 2)
|
||||
if suite.InspectRequirements != nil {
|
||||
requirementsCommands = append(requirementsCommands, newRequirementsCommand(suite))
|
||||
}
|
||||
if suite.ValidateRequest != nil {
|
||||
requirementsCommands = append(requirementsCommands, newValidationCommand(suite))
|
||||
}
|
||||
if len(requirementsCommands) != 0 {
|
||||
rootCommands = append(rootCommands, commandkit.Group("requirements", "Inspect and validate prompt requirements.", requirementsCommands...))
|
||||
}
|
||||
if suite.SmokeTest != nil {
|
||||
rootCommands = append(rootCommands, newSmokeTestCommand(suite))
|
||||
}
|
||||
command := commandkit.RootGroup("promptkit", "Inspect, list, validate requests, and run PromptKit smoke tests.", rootCommands...)
|
||||
for _, issue := range suite.issues {
|
||||
command = commandkit.WithError(command, issue)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func newCatalogListCommand[I, S any](suite Suite[I, S], name string) commandkit.Command {
|
||||
flags := commandkit.CaptureFlags(suite.Selectors, func(input **catalogInput[I]) *commandkit.FlagValues[I] {
|
||||
if input == nil || *input == nil {
|
||||
return nil
|
||||
}
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).selection.Overlay = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Catalog format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
)
|
||||
if name == "prompts" || name == "profiles" || name == "backends" {
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "namespace", Usage: "Select one native namespace."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[string]) { (*input).namespace = value }),
|
||||
commandkit.BoolFlag(commandkit.FlagMeta{Name: "all-namespaces", Usage: "Select identities from every namespace."}, false,
|
||||
func(input **catalogInput[I], value commandkit.Supplied[bool]) { (*input).allNamespaces = value }),
|
||||
)
|
||||
}
|
||||
if name == "namespaces" {
|
||||
flags = append(flags,
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "kind", Usage: "Resource kind to include."}, []promptkit.ResourceKind(nil),
|
||||
func(value string) (promptkit.ResourceKind, error) { return promptkit.ResourceKind(value), nil },
|
||||
func(input **catalogInput[I], value commandkit.Supplied[[]promptkit.ResourceKind]) {
|
||||
(*input).kinds = value.Value
|
||||
}),
|
||||
)
|
||||
}
|
||||
return commandkit.NewCommand(commandkit.CommandSpec[*catalogInput[I]]{
|
||||
Name: name,
|
||||
Summary: "List PromptKit " + name + ".",
|
||||
NewInput: func() *catalogInput[I] {
|
||||
return &catalogInput[I]{}
|
||||
},
|
||||
Flags: flags,
|
||||
CheckInput: func(input *catalogInput[I]) error {
|
||||
if input == nil {
|
||||
return errors.New("PromptKit catalog input must not be nil")
|
||||
}
|
||||
if err := checkCatalogFormat(input.format); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareCatalogFilter(input, name); err != nil {
|
||||
return err
|
||||
}
|
||||
selected, err := prepareCatalogInput(suite, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.selected = selected
|
||||
return nil
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input *catalogInput[I]) commandkit.Outcome {
|
||||
switch name {
|
||||
case "prompts":
|
||||
return runCatalogList(invocation, suite, input, suite.ListPrompts, encodePromptCatalog)
|
||||
case "profiles":
|
||||
return runCatalogList(invocation, suite, input, suite.ListProfiles, encodeProfileCatalog)
|
||||
case "backends":
|
||||
return runCatalogList(invocation, suite, input, suite.ListBackends, encodeProfileCatalog)
|
||||
case "namespaces":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]string], error) {
|
||||
return suite.ListNamespaces(ctx, NamespaceRequest{Selection: request.Selection, Kinds: input.kinds}, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeProfileCatalog)
|
||||
case "collections":
|
||||
list := func(ctx context.Context, request CatalogRequest, value I, state S) (commandkit.Acquired[[]promptkit.CollectionInfo], error) {
|
||||
return suite.ListCollections(ctx, request.Selection, value, state)
|
||||
}
|
||||
return runCatalogList(invocation, suite, input, list, encodeCollectionCatalog)
|
||||
default:
|
||||
return definitionFailure(fmt.Errorf("unknown PromptKit catalog %q", name), nil)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func prepareCatalogFilter[I any](input *catalogInput[I], name string) error {
|
||||
input.filter = promptkit.NamespaceFilter{Mode: promptkit.NamespaceDefault}
|
||||
if name != "prompts" && name != "profiles" && name != "backends" {
|
||||
return nil
|
||||
}
|
||||
if input.allNamespaces.Set && input.allNamespaces.Value && input.namespace.Set {
|
||||
return errors.New("--namespace cannot be combined with --all-namespaces")
|
||||
}
|
||||
if input.allNamespaces.Set && input.allNamespaces.Value {
|
||||
input.filter.Mode = promptkit.NamespaceAll
|
||||
return nil
|
||||
}
|
||||
if input.namespace.Set {
|
||||
if input.namespace.Value == "" {
|
||||
input.filter.Mode = promptkit.NamespaceUnqualified
|
||||
} else {
|
||||
input.filter.Mode = promptkit.NamespaceNamed
|
||||
input.filter.Name = input.namespace.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkCatalogFormat(format string) error {
|
||||
if format != "yaml" {
|
||||
return fmt.Errorf("unsupported catalog format %q", format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRequirementsFormat(format string) error {
|
||||
if format != "yaml" {
|
||||
return fmt.Errorf("unsupported requirements format %q; expected yaml", format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareCatalogInput[I, S any](suite Suite[I, S], input *catalogInput[I]) (I, error) {
|
||||
return prepareSuiteInput(suite, input.selection, input.captured)
|
||||
}
|
||||
|
||||
func newDefinitionCommand[I, S any](suite Suite[I, S], name string, prompt bool) commandkit.Command {
|
||||
flags := commandkit.CaptureFlags(suite.Selectors, func(input **definitionInput[I]) *commandkit.FlagValues[I] {
|
||||
if input == nil || *input == nil {
|
||||
return nil
|
||||
}
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).selection.Config = value }),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: text or yaml."}, stringDefault("text"), parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
)
|
||||
spec := commandkit.CommandSpec[*definitionInput[I]]{
|
||||
Name: name,
|
||||
Summary: "Inspect a named PromptKit " + name + ".",
|
||||
NewInput: func() *definitionInput[I] {
|
||||
return &definitionInput[I]{}
|
||||
},
|
||||
Flags: flags,
|
||||
Arguments: []commandkit.Argument[*definitionInput[I]]{
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id", Usage: "Prompt or profile ID."}, true, parseDefinitionString,
|
||||
func(input **definitionInput[I], value commandkit.Supplied[string]) { (*input).id = value.Value }),
|
||||
},
|
||||
CheckInput: func(input *definitionInput[I]) error {
|
||||
if err := checkDefinitionFormat(input.format); err != nil {
|
||||
return err
|
||||
}
|
||||
selected, err := prepareDefinitionInput(suite, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.selected = selected
|
||||
return nil
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input *definitionInput[I]) commandkit.Outcome {
|
||||
return runDefinitionInspection(invocation, suite, input, prompt)
|
||||
},
|
||||
}
|
||||
return commandkit.NewCommand(spec)
|
||||
}
|
||||
|
||||
func newRequirementsCommand[I, S any](suite Suite[I, S]) commandkit.Command {
|
||||
flags := commandkit.CaptureFlags(suite.Selectors, func(input **requirementsInput[I]) *commandkit.FlagValues[I] {
|
||||
if input == nil || *input == nil {
|
||||
return nil
|
||||
}
|
||||
return &(*input).captured
|
||||
})
|
||||
flags = append(flags,
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "config", Usage: "Configuration input path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Config = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "overlay", Usage: "Configuration overlay path."}, (*string)(nil), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).selection.Overlay = value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "profile", Usage: "Profile identity for discovery."}, stringDefault(""), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).inspection.ProfileID = value.Value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "session-id", Usage: "Session context for discovery."}, stringDefault(""), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).inspection.SessionID = value.Value
|
||||
}),
|
||||
commandkit.ValueFlag(commandkit.FlagMeta{Name: "format", Usage: "Report format: yaml."}, stringDefault("yaml"), parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) { (*input).format = value.Value }),
|
||||
commandkit.RepeatFlag(commandkit.FlagMeta{Name: "var", Usage: "Caller variable assignment KEY=VALUE."}, []variableAssignment(nil), parseVariableAssignment,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[[]variableAssignment]) {
|
||||
variables := make(map[string]string, len(value.Value))
|
||||
for _, assignment := range value.Value {
|
||||
variables[assignment.key] = assignment.value
|
||||
}
|
||||
(*input).inspection.Vars = variables
|
||||
}),
|
||||
)
|
||||
|
||||
spec := commandkit.CommandSpec[*requirementsInput[I]]{
|
||||
Name: "show",
|
||||
Summary: "Inspect requirements for a named PromptKit prompt.",
|
||||
NewInput: func() *requirementsInput[I] {
|
||||
return &requirementsInput[I]{}
|
||||
},
|
||||
Flags: flags,
|
||||
Arguments: []commandkit.Argument[*requirementsInput[I]]{
|
||||
commandkit.ValueArgument(commandkit.ArgumentMeta{Name: "id", Usage: "Prompt ID."}, true, parseDefinitionString,
|
||||
func(input **requirementsInput[I], value commandkit.Supplied[string]) {
|
||||
(*input).inspection.PromptID = value.Value
|
||||
}),
|
||||
},
|
||||
CheckInput: func(input *requirementsInput[I]) error {
|
||||
if err := checkRequirementsFormat(input.format); err != nil {
|
||||
return err
|
||||
}
|
||||
selected, err := prepareSuiteInput(suite, input.selection, input.captured)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.selected = selected
|
||||
return nil
|
||||
},
|
||||
Run: func(invocation commandkit.Invocation, input *requirementsInput[I]) commandkit.Outcome {
|
||||
return runRequirementsInspection(invocation, suite, input)
|
||||
},
|
||||
}
|
||||
return commandkit.NewCommand(spec)
|
||||
}
|
||||
|
||||
func prepareDefinitionInput[I, S any](suite Suite[I, S], input *definitionInput[I]) (I, error) {
|
||||
return prepareSuiteInput(suite, input.selection, input.captured)
|
||||
}
|
||||
|
||||
func prepareSuiteInput[I, S any](suite Suite[I, S], selection ckconfig.Selection, captured commandkit.FlagValues[I]) (I, error) {
|
||||
var selected I
|
||||
if suite.NewInput != nil {
|
||||
selected = suite.NewInput()
|
||||
}
|
||||
if err := captured.ApplyTo(&selected, suite.Selectors...); err != nil {
|
||||
return selected, err
|
||||
}
|
||||
if suite.CheckInput != nil {
|
||||
if err := suite.CheckInput(selection, selected); err != nil {
|
||||
return selected, err
|
||||
}
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func runCatalogList[T any, I, S any](
|
||||
invocation commandkit.Invocation,
|
||||
suite Suite[I, S],
|
||||
input *catalogInput[I],
|
||||
list func(context.Context, CatalogRequest, I, S) (commandkit.Acquired[T], error),
|
||||
encode func(T) ([]byte, error),
|
||||
) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
if suite.NewState != nil {
|
||||
state = suite.NewState()
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
|
||||
acquired, err := list(invocation.Context, CatalogRequest{Selection: input.selection, Filter: input.filter}, input.selected, state)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
output, err := encode(acquired.Value)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
}
|
||||
|
||||
func runDefinitionInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *definitionInput[I], prompt bool) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
if suite.NewState != nil {
|
||||
state = suite.NewState()
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
|
||||
if prompt {
|
||||
request := PromptRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectPrompt(invocation.Context, request, input.selected, state)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit prompt inspection returned nil"), acquired.Diagnostics)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
return renderPromptReport(invocation, promptReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
}
|
||||
|
||||
request := ProfileRequest{Selection: input.selection, ID: input.id}
|
||||
acquired, err := suite.InspectProfile(invocation.Context, request, input.selected, state)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit profile inspection returned nil"), acquired.Diagnostics)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
return renderProfileReport(invocation, profileReportFromInspection(acquired.Value), input.format, acquired.Diagnostics)
|
||||
}
|
||||
|
||||
type promptInputReport struct {
|
||||
Name string `yaml:"name"`
|
||||
Required bool `yaml:"required"`
|
||||
ContentType string `yaml:"content_type"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
type promptOutputContractReport struct {
|
||||
Format string `yaml:"format"`
|
||||
ValidationMode string `yaml:"validation_mode"`
|
||||
SchemaPath string `yaml:"schema_path"`
|
||||
RepairAttempts int `yaml:"repair_attempts"`
|
||||
}
|
||||
|
||||
type promptInspectionReport struct {
|
||||
PromptID string `yaml:"prompt_id"`
|
||||
PromptVersion *string `yaml:"version,omitempty"`
|
||||
PromptHash string `yaml:"prompt_hash"`
|
||||
DefaultProfileID string `yaml:"default_profile_id"`
|
||||
Inputs []promptInputReport `yaml:"inputs"`
|
||||
OutputContract promptOutputContractReport `yaml:"output_contract"`
|
||||
}
|
||||
|
||||
type requirementsInspectionReport struct {
|
||||
PromptID string `yaml:"prompt_id"`
|
||||
PromptVersion *string `yaml:"version,omitempty"`
|
||||
ProfileID string `yaml:"profile_id"`
|
||||
Complete bool `yaml:"complete"`
|
||||
Inputs []requirementInputReport `yaml:"inputs"`
|
||||
Variables []requirementVariableReport `yaml:"variables"`
|
||||
Issues []requirementIssueReport `yaml:"issues"`
|
||||
}
|
||||
|
||||
type requirementInputReport struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
Declared bool `yaml:"declared"`
|
||||
ContentType *string `yaml:"content_type"`
|
||||
Description *string `yaml:"description"`
|
||||
Explanation string `yaml:"explanation"`
|
||||
}
|
||||
|
||||
type requirementVariableReport struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
Supplied bool `yaml:"supplied"`
|
||||
Explanation string `yaml:"explanation"`
|
||||
}
|
||||
|
||||
type requirementIssueReport struct {
|
||||
Code string `yaml:"code"`
|
||||
InputName string `yaml:"input_name"`
|
||||
VariableName string `yaml:"variable_name"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
type modelParamsReport struct {
|
||||
BackendID string `yaml:"backend_id"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Model string `yaml:"model"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
MaxTokens int `yaml:"max_tokens"`
|
||||
TopP float64 `yaml:"top_p"`
|
||||
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||
ServiceTier string `yaml:"service_tier"`
|
||||
ReasoningEffort string `yaml:"reasoning_effort"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
ExtraParams map[string]any `yaml:"extra_params"`
|
||||
}
|
||||
|
||||
type profileInspectionReport struct {
|
||||
ProfileID string `yaml:"profile_id"`
|
||||
EffectiveModelParams modelParamsReport `yaml:"effective_model_params"`
|
||||
APIKeyRequired bool `yaml:"api_key_required"`
|
||||
}
|
||||
|
||||
func promptReportFromInspection(inspection *promptkit.PromptInspection) promptInspectionReport {
|
||||
report := promptInspectionReport{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
PromptHash: inspection.PromptHash,
|
||||
DefaultProfileID: inspection.DefaultProfileID,
|
||||
Inputs: make([]promptInputReport, 0, len(inspection.Inputs)),
|
||||
OutputContract: promptOutputContractReport{
|
||||
Format: string(inspection.OutputContract.Format),
|
||||
ValidationMode: string(inspection.OutputContract.ValidationMode),
|
||||
SchemaPath: inspection.OutputContract.SchemaPath,
|
||||
RepairAttempts: inspection.OutputContract.RepairAttempts,
|
||||
},
|
||||
}
|
||||
for _, input := range inspection.Inputs {
|
||||
report.Inputs = append(report.Inputs, promptInputReport{Name: input.Name, Required: input.Required, ContentType: input.ContentType, Description: input.Description})
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func requirementsReportFromInspection(inspection *promptkit.RequirementInspection) requirementsInspectionReport {
|
||||
report := requirementsInspectionReport{
|
||||
PromptID: inspection.PromptID,
|
||||
PromptVersion: inspection.PromptVersion,
|
||||
ProfileID: inspection.ProfileID,
|
||||
Complete: inspection.Complete,
|
||||
Inputs: make([]requirementInputReport, 0, len(inspection.Inputs)),
|
||||
Variables: make([]requirementVariableReport, 0, len(inspection.Variables)),
|
||||
Issues: make([]requirementIssueReport, 0, len(inspection.Issues)),
|
||||
}
|
||||
for _, input := range inspection.Inputs {
|
||||
report.Inputs = append(report.Inputs, requirementInputReport{
|
||||
Name: input.Name,
|
||||
Kind: string(input.Kind),
|
||||
Declared: input.Declared,
|
||||
ContentType: input.ContentType,
|
||||
Description: input.Description,
|
||||
Explanation: input.Explanation,
|
||||
})
|
||||
}
|
||||
for _, variable := range inspection.Variables {
|
||||
report.Variables = append(report.Variables, requirementVariableReport{
|
||||
Name: variable.Name,
|
||||
Kind: string(variable.Kind),
|
||||
Supplied: variable.Supplied,
|
||||
Explanation: variable.Explanation,
|
||||
})
|
||||
}
|
||||
for _, issue := range inspection.Issues {
|
||||
report.Issues = append(report.Issues, requirementIssueReport{
|
||||
Code: string(issue.Code),
|
||||
InputName: issue.InputName,
|
||||
VariableName: issue.VariableName,
|
||||
Description: issue.Description,
|
||||
})
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func profileReportFromInspection(inspection *promptkit.ProfileInspection) profileInspectionReport {
|
||||
params := inspection.EffectiveModelParams
|
||||
extra := params.ExtraParams
|
||||
if extra == nil {
|
||||
extra = map[string]any{}
|
||||
}
|
||||
return profileInspectionReport{
|
||||
ProfileID: inspection.ProfileID,
|
||||
EffectiveModelParams: modelParamsReport{
|
||||
BackendID: params.BackendID, Endpoint: params.Endpoint, Model: params.Model,
|
||||
Temperature: params.Temperature, MaxTokens: params.MaxTokens, TopP: params.TopP,
|
||||
TimeoutSeconds: params.TimeoutSeconds, ServiceTier: params.ServiceTier,
|
||||
ReasoningEffort: params.ReasoningEffort, APIKeyEnv: params.APIKeyEnv, ExtraParams: extra,
|
||||
},
|
||||
APIKeyRequired: inspection.APIKeyRequired,
|
||||
}
|
||||
}
|
||||
|
||||
func runRequirementsInspection[I, S any](invocation commandkit.Invocation, suite Suite[I, S], input *requirementsInput[I]) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
var state S
|
||||
if suite.NewState != nil {
|
||||
state = suite.NewState()
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, nil)
|
||||
}
|
||||
}
|
||||
|
||||
request := RequirementsRequest{Selection: input.selection, Inspection: input.inspection}
|
||||
acquired, err := suite.InspectRequirements(invocation.Context, request, input.selected, state)
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
if acquired.Value == nil {
|
||||
return definitionFailure(errors.New("PromptKit requirements inspection returned nil"), acquired.Diagnostics)
|
||||
}
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
}
|
||||
output, err := encodePromptYAMLBytes(requirementsReportFromInspection(acquired.Value))
|
||||
if err != nil {
|
||||
return definitionFailure(err, acquired.Diagnostics)
|
||||
}
|
||||
return writePromptReport(invocation, output, acquired.Diagnostics)
|
||||
}
|
||||
|
||||
func renderPromptReport(invocation commandkit.Invocation, report promptInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
}
|
||||
} else {
|
||||
output.WriteString(formatPromptText(report))
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
}
|
||||
|
||||
func renderProfileReport(invocation commandkit.Invocation, report profileInspectionReport, format string, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
var output bytes.Buffer
|
||||
if format == "yaml" {
|
||||
if err := encodePromptYAML(&output, report); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
}
|
||||
} else {
|
||||
text, err := formatProfileText(report)
|
||||
if err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
}
|
||||
output.WriteString(text)
|
||||
}
|
||||
return writePromptReport(invocation, output.Bytes(), diagnostics)
|
||||
}
|
||||
|
||||
func formatPromptText(report promptInspectionReport) string {
|
||||
var output strings.Builder
|
||||
fmt.Fprintf(&output, "Prompt ID: %s\n", strconv.Quote(report.PromptID))
|
||||
if report.PromptVersion != nil {
|
||||
fmt.Fprintf(&output, "Prompt version: %s\n", strconv.Quote(*report.PromptVersion))
|
||||
}
|
||||
fmt.Fprintf(&output, "Prompt hash: %s\n", strconv.Quote(report.PromptHash))
|
||||
fmt.Fprintf(&output, "Default profile ID: %s\n", strconv.Quote(report.DefaultProfileID))
|
||||
output.WriteString("Inputs:\n")
|
||||
for _, input := range report.Inputs {
|
||||
fmt.Fprintf(&output, " - name: %s\n required: %t\n content_type: %s\n description: %s\n", strconv.Quote(input.Name), input.Required, strconv.Quote(input.ContentType), strconv.Quote(input.Description))
|
||||
}
|
||||
if len(report.Inputs) == 0 {
|
||||
output.WriteString(" []\n")
|
||||
}
|
||||
fmt.Fprintf(&output, "Output contract:\n format: %s\n validation_mode: %s\n schema_path: %s\n repair_attempts: %d\n", strconv.Quote(report.OutputContract.Format), strconv.Quote(report.OutputContract.ValidationMode), strconv.Quote(report.OutputContract.SchemaPath), report.OutputContract.RepairAttempts)
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func formatProfileText(report profileInspectionReport) (string, error) {
|
||||
params := report.EffectiveModelParams
|
||||
var output strings.Builder
|
||||
fmt.Fprintf(&output, "Profile ID: %s\n", strconv.Quote(report.ProfileID))
|
||||
output.WriteString("Effective model params:\n")
|
||||
fmt.Fprintf(&output, " backend_id: %s\n endpoint: %s\n model: %s\n temperature: %s\n max_tokens: %d\n top_p: %s\n timeout_seconds: %d\n service_tier: %s\n reasoning_effort: %s\n api_key_env: %s\n", strconv.Quote(params.BackendID), strconv.Quote(params.Endpoint), strconv.Quote(params.Model), strconv.FormatFloat(params.Temperature, 'g', -1, 64), params.MaxTokens, strconv.FormatFloat(params.TopP, 'g', -1, 64), params.TimeoutSeconds, strconv.Quote(params.ServiceTier), strconv.Quote(params.ReasoningEffort), strconv.Quote(params.APIKeyEnv))
|
||||
output.WriteString(" extra_params:\n")
|
||||
var nested strings.Builder
|
||||
if err := encodePromptYAML(&nested, params.ExtraParams); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(nested.String(), "\n"), "\n") {
|
||||
output.WriteString(" ")
|
||||
output.WriteString(line)
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
fmt.Fprintf(&output, "API key required: %t\n", report.APIKeyRequired)
|
||||
return output.String(), nil
|
||||
}
|
||||
|
||||
func encodePromptYAML(writer io.Writer, value any) error {
|
||||
encoder := yaml.NewEncoder(writer)
|
||||
encoder.SetIndent(2)
|
||||
return encoder.Encode(value)
|
||||
}
|
||||
|
||||
type promptCatalogEntry struct {
|
||||
ID string `yaml:"id"`
|
||||
Version *string `yaml:"version,omitempty"`
|
||||
}
|
||||
|
||||
func encodePromptCatalog(prompts []promptkit.PromptIdentity) ([]byte, error) {
|
||||
entries := make([]promptCatalogEntry, len(prompts))
|
||||
for index, prompt := range prompts {
|
||||
entries[index] = promptCatalogEntry{ID: prompt.ID, Version: prompt.Version}
|
||||
}
|
||||
return encodePromptYAMLBytes(entries)
|
||||
}
|
||||
|
||||
func encodeProfileCatalog(profiles []string) ([]byte, error) {
|
||||
if profiles == nil {
|
||||
profiles = []string{}
|
||||
}
|
||||
return encodePromptYAMLBytes(profiles)
|
||||
}
|
||||
|
||||
type collectionCatalogEntry struct {
|
||||
Namespace string `yaml:"namespace"`
|
||||
Version *string `yaml:"version,omitempty"`
|
||||
}
|
||||
|
||||
func encodeCollectionCatalog(collections []promptkit.CollectionInfo) ([]byte, error) {
|
||||
entries := make([]collectionCatalogEntry, len(collections))
|
||||
for index, collection := range collections {
|
||||
entries[index] = collectionCatalogEntry{Namespace: collection.Namespace, Version: collection.ReleaseVersion}
|
||||
}
|
||||
return encodePromptYAMLBytes(entries)
|
||||
}
|
||||
|
||||
func encodePromptYAMLBytes(value any) ([]byte, error) {
|
||||
var output bytes.Buffer
|
||||
if err := encodePromptYAML(&output, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
if invocation.Context != nil {
|
||||
if err := invocation.Context.Err(); err != nil {
|
||||
return definitionFailure(err, diagnostics)
|
||||
}
|
||||
}
|
||||
written, err := invocation.Streams.Out.Write(output)
|
||||
if err == nil && written != len(output) {
|
||||
err = io.ErrShortWrite
|
||||
}
|
||||
if err != nil {
|
||||
outcome := definitionFailure(err, diagnostics)
|
||||
outcome.ReportIncomplete = true
|
||||
outcome.DeliveryError = err
|
||||
return outcome
|
||||
}
|
||||
outcome := commandkit.Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func definitionFailure(cause error, diagnostics []commandkit.Diagnostic) commandkit.Outcome {
|
||||
outcome := commandkit.Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func checkDefinitionFormat(format string) error {
|
||||
if format != "text" && format != "yaml" {
|
||||
return fmt.Errorf("unsupported format %q; expected text or yaml", format)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDefinitionString(value string) (string, error) { return value, nil }
|
||||
|
||||
func parseVariableAssignment(raw string) (variableAssignment, error) {
|
||||
key, value, found := strings.Cut(raw, "=")
|
||||
if !found {
|
||||
return variableAssignment{}, fmt.Errorf("variable assignment %q must contain =", raw)
|
||||
}
|
||||
return variableAssignment{key: key, value: value}, nil
|
||||
}
|
||||
|
||||
func parseInputAssignment(raw string) (inputAssignment, error) {
|
||||
name, path, found := strings.Cut(raw, "=")
|
||||
if !found {
|
||||
return inputAssignment{}, fmt.Errorf("input assignment %q must contain =", raw)
|
||||
}
|
||||
return inputAssignment{name: name, path: path}, nil
|
||||
}
|
||||
|
||||
func stringDefault(value string) *string { return &value }
|
||||
1265
integrations/ckprompt/command_test.go
Normal file
1265
integrations/ckprompt/command_test.go
Normal file
File diff suppressed because it is too large
Load Diff
256
integrations/ckprompt/comparison_composition_test.go
Normal file
256
integrations/ckprompt/comparison_composition_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package ckprompt_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestPublicConfigDiffUsesPromptKitPairAndPreparedInputs(t *testing.T) {
|
||||
leftEngine := comparisonEngine(t, "Hello")
|
||||
rightEngine := comparisonEngine(t, "Goodbye")
|
||||
_, badConfigError := configkit.Load[map[string]any](configkit.Bytes("invalid PromptKit configuration", []byte("broken: [")))
|
||||
if badConfigError == nil {
|
||||
t.Fatal("invalid configuration unexpectedly loaded")
|
||||
}
|
||||
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(input **compositionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
|
||||
var events []string
|
||||
var checked []*compositionInput
|
||||
var checkedSelections []ckconfig.Selection
|
||||
var loadedInputs []*compositionInput
|
||||
var loadedStates []*compositionState
|
||||
var loadedSelections []ckconfig.Selection
|
||||
loadCalls := 0
|
||||
loadEngine := func(_ context.Context, selection ckconfig.Selection, input *compositionInput, state *compositionState) (*promptkit.Engine, error) {
|
||||
loadCalls++
|
||||
events = append(events, "load")
|
||||
loadedInputs = append(loadedInputs, input)
|
||||
loadedStates = append(loadedStates, state)
|
||||
loadedSelections = append(loadedSelections, selection)
|
||||
if !input.Campaign.Set || input.Campaign.Value != "spring" {
|
||||
t.Errorf("loader campaign input = %#v", input.Campaign)
|
||||
}
|
||||
if strings.HasPrefix(selection.Config.Value, "bad") {
|
||||
return nil, badConfigError
|
||||
}
|
||||
if loadCalls%2 == 1 {
|
||||
return leftEngine, nil
|
||||
}
|
||||
return rightEngine, nil
|
||||
}
|
||||
appScope := ckconfig.Scope[*compositionInput, *compositionState]{
|
||||
Name: "app", Operations: []ckconfig.Operation{ckconfig.Diff},
|
||||
Compare: func(_ context.Context, _ ckconfig.ComparisonRequest[*compositionInput], _ *compositionState) (commandkit.Acquired[[]configkit.Difference], error) {
|
||||
return commandkit.Acquired[[]configkit.Difference]{}, nil
|
||||
},
|
||||
}
|
||||
promptScope := ckprompt.ConfigScope(loadEngine)
|
||||
configCommand := ckconfig.Command(ckconfig.Suite[*compositionInput, *compositionState]{
|
||||
NewInput: func() *compositionInput { return &compositionInput{} },
|
||||
NewState: func() *compositionState {
|
||||
events = append(events, "state")
|
||||
return &compositionState{token: len(events)}
|
||||
},
|
||||
Selectors: []ckconfig.Selector[*compositionInput]{{Binding: flag, Scopes: []string{"app", "promptkit"}}},
|
||||
CheckInput: func(scope string, selection ckconfig.Selection, input *compositionInput) error {
|
||||
if scope == "promptkit" {
|
||||
events = append(events, "check")
|
||||
checked = append(checked, input)
|
||||
checkedSelections = append(checkedSelections, selection)
|
||||
if !input.Campaign.Set || input.Campaign.Value != "spring" {
|
||||
t.Errorf("checked campaign input = %#v", input.Campaign)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Scopes: []ckconfig.Scope[*compositionInput, *compositionState]{appScope, promptScope},
|
||||
})
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "comparison"}, configCommand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rightArgs []string
|
||||
rightValue string
|
||||
rightSet bool
|
||||
}{
|
||||
{name: "right selection is explicit", rightArgs: []string{"--against-config", "after.yml"}, rightValue: "after.yml", rightSet: true},
|
||||
{name: "right selection inherits", rightValue: "before.yml", rightSet: true},
|
||||
{name: "empty right selection is supplied", rightArgs: []string{"--against-config", ""}, rightSet: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
events = nil
|
||||
checked = nil
|
||||
checkedSelections = nil
|
||||
loadedInputs = nil
|
||||
loadedStates = nil
|
||||
loadedSelections = nil
|
||||
args := []string{"config", "diff", "--scope", "promptkit", "--config", "before.yml", "--campaign", "spring"}
|
||||
args = append(args, test.rightArgs...)
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("comparison outcome = %#v, output %q", outcome, output.String())
|
||||
}
|
||||
var report struct {
|
||||
Additions any `yaml:"additions"`
|
||||
Changes map[string]any `yaml:"changes"`
|
||||
Deletions any `yaml:"deletions"`
|
||||
}
|
||||
if err := yaml.Unmarshal(output.Bytes(), &report); err != nil {
|
||||
t.Fatalf("decode YAML report %q: %v", output.String(), err)
|
||||
}
|
||||
if len(report.Changes) == 0 {
|
||||
t.Fatalf("PromptKit change missing from report: %q", output.String())
|
||||
}
|
||||
if len(checked) != 2 || checked[0] == checked[1] || len(loadedInputs) != 2 || loadedInputs[0] != checked[0] || loadedInputs[1] != checked[1] {
|
||||
t.Fatalf("separately prepared side inputs were not passed through: checked=%#v loaded=%#v", checked, loadedInputs)
|
||||
}
|
||||
if len(loadedStates) != 2 || loadedStates[0] == nil || loadedStates[0] != loadedStates[1] {
|
||||
t.Fatalf("comparison state was not shared: %#v", loadedStates)
|
||||
}
|
||||
if len(checkedSelections) != 2 || len(loadedSelections) != 2 {
|
||||
t.Fatalf("side selections: checked=%#v loaded=%#v", checkedSelections, loadedSelections)
|
||||
}
|
||||
for _, selections := range [][]ckconfig.Selection{checkedSelections, loadedSelections} {
|
||||
left, right := selections[0].Config, selections[1].Config
|
||||
if !left.Set || left.Value != "before.yml" || right.Set != test.rightSet || right.Value != test.rightValue {
|
||||
t.Errorf("left/right config selection = %#v / %#v", left, right)
|
||||
}
|
||||
}
|
||||
if len(events) < 4 || events[0] != "check" || events[1] != "check" || events[2] != "state" || events[3] != "load" {
|
||||
t.Fatalf("checks/state/load order = %#v", events)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if loadCalls != 6 {
|
||||
t.Fatalf("PromptKit loader calls = %d, want six reached sides", loadCalls)
|
||||
}
|
||||
var appOutput bytes.Buffer
|
||||
appOutcome := app.Execute(context.Background(), []string{"config", "diff"}, commandkit.Streams{Out: &appOutput, Err: io.Discard})
|
||||
if appOutcome.Kind != commandkit.OutcomeSuccess || loadCalls != 6 {
|
||||
t.Fatalf("bare diff should use app scope without loading PromptKit: outcome=%#v loads=%d", appOutcome, loadCalls)
|
||||
}
|
||||
unsupported := app.Execute(context.Background(), []string{"config", "diff", "--scope", "all"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if unsupported.Kind != commandkit.OutcomeUsage || loadCalls != 6 {
|
||||
t.Fatalf("comparison scope all should be rejected before PromptKit loading: outcome=%#v loads=%d", unsupported, loadCalls)
|
||||
}
|
||||
|
||||
var diagnostics bytes.Buffer
|
||||
failure := app.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit", "--config", "bad-left.yml", "--against-config", "right.yml", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: &diagnostics})
|
||||
if failure.Kind != commandkit.OutcomeOperational || !strings.Contains(diagnostics.String(), `Configuration scope "promptkit" (left):`) {
|
||||
t.Fatalf("installed load failure = %#v, diagnostics %q", failure, diagnostics.String())
|
||||
}
|
||||
var loadFailure *configkit.LoadError
|
||||
if !errors.As(failure.Cause, &loadFailure) || !errors.Is(failure.Cause, badConfigError) {
|
||||
t.Fatalf("installed failure lost ConfigKit error identity: %#v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicConfigDiffDiscoveryAndNarrowedCapabilities(t *testing.T) {
|
||||
loads := 0
|
||||
engine := comparisonEngine(t, "Hello")
|
||||
loadEngine := func(_ context.Context, _ ckconfig.Selection, _ *compositionInput, _ *compositionState) (*promptkit.Engine, error) {
|
||||
loads++
|
||||
return engine, nil
|
||||
}
|
||||
scope := ckprompt.ConfigScope(loadEngine)
|
||||
scope.Operations = []ckconfig.Operation{ckconfig.Diff, ckconfig.Show}
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "comparison"}, ckconfig.Command(ckconfig.Suite[*compositionInput, *compositionState]{Scopes: []ckconfig.Scope[*compositionInput, *compositionState]{scope}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"config", "diff", "--help"},
|
||||
{"config", "scopes"},
|
||||
{"version"},
|
||||
} {
|
||||
var output bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Errorf("%v outcome = %#v", args, outcome)
|
||||
}
|
||||
if strings.Join(args, " ") == "config scopes" {
|
||||
for _, operation := range []string{"diff", "show"} {
|
||||
if !strings.Contains(output.String(), operation) {
|
||||
t.Errorf("scope discovery omitted %q: %s", operation, output.String())
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{"validate", "sources", "explain"} {
|
||||
if strings.Contains(output.String(), operation) {
|
||||
t.Errorf("scope discovery included narrowed operation %q: %s", operation, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if loads != 0 {
|
||||
t.Fatalf("help, version, or scope discovery loaded %d engines", loads)
|
||||
}
|
||||
|
||||
showOnly := ckprompt.ConfigScope(loadEngine)
|
||||
showOnly.Operations = []ckconfig.Operation{ckconfig.Show}
|
||||
showApp, err := commandkit.New(commandkit.Identity{Name: "comparison"}, ckconfig.Command(ckconfig.Suite[*compositionInput, *compositionState]{Scopes: []ckconfig.Scope[*compositionInput, *compositionState]{showOnly}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
result := showApp.Execute(context.Background(), []string{"config", "show", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if result.Kind != commandkit.OutcomeSuccess || loads != 1 || !strings.Contains(output.String(), "prompts:") {
|
||||
t.Fatalf("Show after clearing Diff = %#v, loads=%d, output=%q", result, loads, output.String())
|
||||
}
|
||||
output.Reset()
|
||||
result = showApp.Execute(context.Background(), []string{"config", "diff", "--scope", "promptkit"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if result.Kind != commandkit.OutcomeUsage || loads != 1 {
|
||||
t.Fatalf("cleared Diff remained callable: %#v, loads=%d", result, loads)
|
||||
}
|
||||
}
|
||||
|
||||
func comparisonEngine(t *testing.T, greeting string) *promptkit.Engine {
|
||||
t.Helper()
|
||||
configuration := `prompts:
|
||||
briefing:
|
||||
version: "1"
|
||||
default_profile: default
|
||||
messages:
|
||||
- role: user
|
||||
content: "Hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
backends:
|
||||
local:
|
||||
endpoint: "https://example.invalid/v1"
|
||||
profiles:
|
||||
default:
|
||||
backend: local
|
||||
model: example
|
||||
`
|
||||
configuration = strings.Replace(configuration, `content: "Hello"`, `content: "`+greeting+`"`, 1)
|
||||
inputs, err := promptkit.FilesystemInputs("comparison fixture", fstest.MapFS{
|
||||
"config/prompts.yml": &fstest.MapFile{Data: []byte(configuration)},
|
||||
}, "config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct comparison engine: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
160
integrations/ckprompt/composition_test.go
Normal file
160
integrations/ckprompt/composition_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package ckprompt_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig"
|
||||
"gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
promptkit "gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
type compositionInput struct{ Campaign commandkit.Supplied[string] }
|
||||
type compositionState struct{ token int }
|
||||
|
||||
func TestPublicDefinitionCommandsCaptureSelectorHandles(t *testing.T) {
|
||||
newFlag := func() commandkit.Flag[compositionInput] {
|
||||
return commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil),
|
||||
func(value string) (string, error) { return value, nil },
|
||||
func(input *compositionInput, value commandkit.Supplied[string]) { input.Campaign = value })
|
||||
}
|
||||
selectors := []commandkit.Flag[compositionInput]{newFlag()}
|
||||
calls := 0
|
||||
checkInput := func(input compositionInput) {
|
||||
t.Helper()
|
||||
calls++
|
||||
if !input.Campaign.Set || input.Campaign.Value != "spring" {
|
||||
t.Errorf("captured selector value = %#v", input.Campaign)
|
||||
}
|
||||
}
|
||||
declaration := ckprompt.Command(ckprompt.Suite[compositionInput, struct{}]{
|
||||
Selectors: selectors,
|
||||
InspectPrompt: func(_ context.Context, request ckprompt.PromptRequest, input compositionInput, _ struct{}) (commandkit.Acquired[*promptkit.PromptInspection], error) {
|
||||
checkInput(input)
|
||||
return commandkit.Acquired[*promptkit.PromptInspection]{Value: &promptkit.PromptInspection{PromptID: request.ID}}, nil
|
||||
},
|
||||
InspectProfile: func(_ context.Context, request ckprompt.ProfileRequest, input compositionInput, _ struct{}) (commandkit.Acquired[*promptkit.ProfileInspection], error) {
|
||||
checkInput(input)
|
||||
return commandkit.Acquired[*promptkit.ProfileInspection]{Value: &promptkit.ProfileInspection{ProfileID: request.ID}}, nil
|
||||
},
|
||||
})
|
||||
first, err := commandkit.New(commandkit.Identity{Name: "first"}, declaration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A separately constructed lookalike must not replace the captured binding.
|
||||
selectors[0] = newFlag()
|
||||
second, err := commandkit.New(commandkit.Identity{Name: "second"}, declaration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, app := range []*commandkit.App{first, second} {
|
||||
for _, resource := range []string{"prompt", "profile"} {
|
||||
outcome := app.Execute(context.Background(), []string{"promptkit", "show", resource, "briefing", "--campaign", "spring"}, commandkit.Streams{Out: io.Discard, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess {
|
||||
t.Fatalf("%s after selector edit: %#v", resource, outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
if calls != 4 {
|
||||
t.Fatalf("inspection calls = %d, want 4", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicConfigurationAndDefinitionFamiliesRemainIndependent(t *testing.T) {
|
||||
inputs, err := promptkit.FilesystemInputs("composition fixture", fstest.MapFS{
|
||||
"config/prompts.yml": &fstest.MapFile{Data: []byte(`prompts:
|
||||
briefing:
|
||||
version: "1"
|
||||
default_profile: default
|
||||
messages:
|
||||
- role: user
|
||||
content: "Hello"
|
||||
output:
|
||||
format: text
|
||||
validation_mode: none
|
||||
backends:
|
||||
local:
|
||||
endpoint: "https://example.invalid/v1"
|
||||
profiles:
|
||||
default:
|
||||
backend: local
|
||||
model: example
|
||||
`)}}, "config")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Underlays: []configkit.Underlay{{Inputs: inputs}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("construct engine: %v", err)
|
||||
}
|
||||
|
||||
flag := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign"}, (*string)(nil), func(value string) (string, error) { return value, nil },
|
||||
func(input **compositionInput, value commandkit.Supplied[string]) { (*input).Campaign = value })
|
||||
newInputCalls, newStateCalls := 0, 0
|
||||
newInput := func() *compositionInput { newInputCalls++; return &compositionInput{} }
|
||||
newState := func() *compositionState { newStateCalls++; return &compositionState{token: newStateCalls} }
|
||||
var appInput, engineInput *compositionInput
|
||||
var appState, configEngineState *compositionState
|
||||
appAcquisitions, engineScopeAcquisitions, loaderCalls := 0, 0, 0
|
||||
loadEngine := func(_ context.Context, _ ckconfig.Selection, input *compositionInput, state *compositionState) (*promptkit.Engine, error) {
|
||||
loaderCalls++
|
||||
if !input.Campaign.Set || input.Campaign.Value != "spring" {
|
||||
t.Errorf("engine loader input = %#v", input)
|
||||
}
|
||||
if loaderCalls == 1 {
|
||||
engineInput, configEngineState = input, state
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
appScope := ckconfig.Scope[*compositionInput, *compositionState]{
|
||||
Name: "app", Operations: []ckconfig.Operation{ckconfig.Validate},
|
||||
Acquire: func(_ context.Context, _ ckconfig.Request, input *compositionInput, state *compositionState) (commandkit.Acquired[ckconfig.View], error) {
|
||||
appAcquisitions++
|
||||
appInput, appState = input, state
|
||||
return commandkit.Acquired[ckconfig.View]{}, nil
|
||||
},
|
||||
}
|
||||
engineScope := ckprompt.ConfigScope(loadEngine)
|
||||
acquireEngineScope := engineScope.Acquire
|
||||
engineScope.Acquire = func(ctx context.Context, request ckconfig.Request, input *compositionInput, state *compositionState) (commandkit.Acquired[ckconfig.View], error) {
|
||||
engineScopeAcquisitions++
|
||||
return acquireEngineScope(ctx, request, input, state)
|
||||
}
|
||||
configCommand := ckconfig.Command(ckconfig.Suite[*compositionInput, *compositionState]{
|
||||
NewInput: newInput, NewState: newState,
|
||||
Selectors: []ckconfig.Selector[*compositionInput]{{Binding: flag, Scopes: []string{"app", "promptkit"}}},
|
||||
Scopes: []ckconfig.Scope[*compositionInput, *compositionState]{appScope, engineScope},
|
||||
})
|
||||
definitions := ckprompt.FromEngine(loadEngine)
|
||||
definitions.NewInput = newInput
|
||||
definitions.NewState = newState
|
||||
definitions.Selectors = []commandkit.Flag[*compositionInput]{flag}
|
||||
command, err := commandkit.New(commandkit.Identity{Name: "composition"}, configCommand, ckprompt.Command(definitions))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
outcome := command.Execute(context.Background(), []string{"config", "validate", "--scope", "all", "--campaign", "spring"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || appAcquisitions != 1 || engineScopeAcquisitions != 1 || loaderCalls != 1 {
|
||||
t.Fatalf("configuration run = (%#v, output=%q, counts=%d/%d/%d)", outcome, output.String(), appAcquisitions, engineScopeAcquisitions, loaderCalls)
|
||||
}
|
||||
if appInput == nil || engineInput == nil || appInput == engineInput || appState == nil || appState != configEngineState || appState.token != 1 {
|
||||
t.Fatalf("scope values were not isolated/shared as selected: inputs=%p/%p states=%p/%p", appInput, engineInput, appState, configEngineState)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
outcome = command.Execute(context.Background(), []string{"promptkit", "show", "profile", "default", "--campaign", "spring", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || appAcquisitions != 1 || engineScopeAcquisitions != 1 || loaderCalls != 2 || !bytes.Contains(output.Bytes(), []byte("profile_id: default")) {
|
||||
t.Fatalf("independent profile run = (%#v, output=%q, counts=%d/%d/%d)", outcome, output.String(), appAcquisitions, engineScopeAcquisitions, loaderCalls)
|
||||
}
|
||||
if newInputCalls != 3 || newStateCalls != 2 {
|
||||
t.Fatalf("invocation factory calls input/state=%d/%d", newInputCalls, newStateCalls)
|
||||
}
|
||||
}
|
||||
5
integrations/ckprompt/doc.go
Normal file
5
integrations/ckprompt/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package ckprompt provides optional PromptKit configuration comparison,
|
||||
// prompt catalogs, named inspections, requirements discovery and validation,
|
||||
// and native smoke testing for CommandKit. Importing ckprompt installs neither
|
||||
// family; callers compose its declarations with core and ckconfig explicitly.
|
||||
package ckprompt
|
||||
268
integrations/ckprompt/docs/oss/attribution.md
Normal file
268
integrations/ckprompt/docs/oss/attribution.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# Open Source Attribution Notices
|
||||
|
||||
## PromptKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/promptkit` at `v0.16.0`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/promptkit/src/commit/9178f3eb91c2b2138caa78e22be93c048b4bc992).
|
||||
|
||||
## ConfigKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ConfigKit` at `v0.8.0` (direct dependency).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/137769f3f18a370c68444242fb5d88e620e307e5).
|
||||
|
||||
## ReleaseKit
|
||||
|
||||
Selected module: `gitea.maximumdirect.net/eric/ReleaseKit` at `v0.4.2` (transitive Go tool requirement; not a runtime import).
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
License and notices: [LGPL v3.0](../../LICENSE) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/ReleaseKit/src/commit/7d033c5ca1c95aec7db9690293638a0a1ac887b8).
|
||||
|
||||
## JSON Schema
|
||||
|
||||
Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2` (transitive dependency).
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/santhosh-tekuri/jsonschema/blob/29cbed948d24a04700eb94436416b18a07953b71/LICENSE).
|
||||
|
||||
[Source](https://github.com/santhosh-tekuri/jsonschema/tree/29cbed948d24a04700eb94436416b18a07953b71).
|
||||
|
||||
## Regular expression engine
|
||||
|
||||
Selected module: `github.com/dlclark/regexp2` at `v1.11.0` (transitive dependency).
|
||||
|
||||
Copyright (c) Doug Clark
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/dlclark/regexp2/blob/05e6ac2ab64bbfb3b0ece82156906edbe794b140/LICENSE).
|
||||
|
||||
[Source](https://github.com/dlclark/regexp2/tree/05e6ac2ab64bbfb3b0ece82156906edbe794b140).
|
||||
|
||||
## Go text
|
||||
|
||||
Selected module: `golang.org/x/text` at `v0.14.0` (transitive dependency).
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/LICENSE) |
|
||||
[PATENTS](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353/PATENTS).
|
||||
|
||||
[Source](https://go.googlesource.com/text/+/6c97a165dd661335ff7bce6104a008558123c353).
|
||||
|
||||
## Standard Repository Policies
|
||||
|
||||
Material in the CommandKit core dependency (paths relative to the repository
|
||||
root): `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||
`docs/policy/architecture.md`, `docs/policy/testing.md`,
|
||||
`docs/policy/documentation.md`, `docs/policy/release.md`, and
|
||||
`docs/policy/oss.md`; the root `AGENTS.md` orientation after the knowledge-graph
|
||||
instructions; and the Policy Reading Guide in `docs/development.md`, adapted for
|
||||
the core module. The release policy uses baseline
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; the remaining material uses baseline
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843`.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||
|
||||
Copyright 2006-2010 Kirill Simonov
|
||||
|
||||
Copyright 2011-2019 Canonical Ltd
|
||||
|
||||
Copyright 2025 The go-yaml Project Contributors
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) |
|
||||
[NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE).
|
||||
|
||||
[Source](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
|
||||
|
||||
## go-yaml v3
|
||||
|
||||
Selected module: `gopkg.in/yaml.v3` at `v3.0.1` (transitive dependency).
|
||||
|
||||
Copyright (c) 2006-2010 Kirill Simonov
|
||||
|
||||
Copyright (c) 2006-2011 Kirill Simonov
|
||||
|
||||
Copyright 2011-2016 Canonical Ltd.
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/LICENSE) |
|
||||
[NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE).
|
||||
|
||||
[Source](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51).
|
||||
|
||||
## Go modules
|
||||
|
||||
Selected module: `golang.org/x/mod` at `v0.40.0` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
|
||||
|
||||
## Go tools
|
||||
|
||||
Selected module: `golang.org/x/tools` at `v0.6.0` (transitive dependency).
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/tools/+/d0863f03daeff79ab917de5768285bb077f77b20/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/tools/+/d0863f03daeff79ab917de5768285bb077f77b20).
|
||||
|
||||
## Go system
|
||||
|
||||
Selected module: `golang.org/x/sys` at `v0.5.0` (transitive dependency).
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://go.googlesource.com/sys/+/90c8f94a055257f9ab343137cbada4e658750fbb/LICENSE).
|
||||
|
||||
[Source](https://go.googlesource.com/sys/+/90c8f94a055257f9ab343137cbada4e658750fbb).
|
||||
|
||||
## Goldmark
|
||||
|
||||
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6` (ReleaseKit tool dependency).
|
||||
|
||||
Copyright (c) 2019 Yusuke Inuzuka
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
|
||||
|
||||
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
|
||||
|
||||
## Cobra
|
||||
|
||||
Selected module: `github.com/spf13/cobra` at `v1.10.1` (core dependency).
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE.txt](https://github.com/spf13/cobra/blob/7da941c3547e93b8c9f70bbd3befca79c6335388/LICENSE.txt).
|
||||
|
||||
[Source](https://github.com/spf13/cobra/tree/7da941c3547e93b8c9f70bbd3befca79c6335388).
|
||||
|
||||
## pflag
|
||||
|
||||
Selected module: `github.com/spf13/pflag` at `v1.0.9` (core dependency).
|
||||
|
||||
Copyright (c) 2012 Alex Ogier. All rights reserved.
|
||||
|
||||
Copyright (c) 2012 The Go Authors. All rights reserved.
|
||||
|
||||
SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||
[LICENSE](https://github.com/spf13/pflag/blob/10438578954bba2527fe5cae3684d4532b064bbe/LICENSE).
|
||||
|
||||
[Source](https://github.com/spf13/pflag/tree/10438578954bba2527fe5cae3684d4532b064bbe).
|
||||
|
||||
## go-md2man
|
||||
|
||||
Selected module: `github.com/cpuguy83/go-md2man/v2` at `v2.0.6` (transitive core dependency).
|
||||
|
||||
Copyright (c) 2014 Brian Goff
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
|
||||
License and notices: [MIT](licenses/MIT.txt) |
|
||||
[LICENSE.md](https://github.com/cpuguy83/go-md2man/blob/441631534462b74c098953dbd8d5f6210994b0bf/LICENSE.md).
|
||||
|
||||
[Source](https://github.com/cpuguy83/go-md2man/tree/441631534462b74c098953dbd8d5f6210994b0bf).
|
||||
|
||||
## mousetrap
|
||||
|
||||
Selected module: `github.com/inconshreveable/mousetrap` at `v1.1.0` (transitive core dependency).
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[LICENSE](https://github.com/inconshreveable/mousetrap/blob/4e8053ee7ef85a6bd26368364a6d27f1641c1d21/LICENSE).
|
||||
|
||||
[Source](https://github.com/inconshreveable/mousetrap/tree/4e8053ee7ef85a6bd26368364a6d27f1641c1d21).
|
||||
|
||||
## Blackfriday
|
||||
|
||||
Selected module: `github.com/russross/blackfriday/v2` at `v2.1.0` (transitive core dependency).
|
||||
|
||||
Copyright © 2011 Russ Ross
|
||||
|
||||
SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
|
||||
[LICENSE.txt](https://github.com/russross/blackfriday/blob/4c9bf9512682b995722660a4196c0013228e2049/LICENSE.txt).
|
||||
|
||||
[Source](https://github.com/russross/blackfriday/tree/4c9bf9512682b995722660a4196c0013228e2049).
|
||||
|
||||
## gocheck
|
||||
|
||||
Selected module: `gopkg.in/check.v1` at `v0.0.0-20161208181325-20d25e280405` (transitive test dependency).
|
||||
|
||||
Gocheck - A rich testing framework for Go
|
||||
|
||||
Copyright (c) 2010-2013 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
License and notices: [BSD 2-Clause](licenses/BSD-2-Clause.txt) |
|
||||
[LICENSE](https://github.com/go-check/check/blob/20d25e280405/LICENSE).
|
||||
|
||||
[Source](https://github.com/go-check/check/commit/20d25e280405).
|
||||
73
integrations/ckprompt/docs/oss/licenses/Apache-2.0.txt
Normal file
73
integrations/ckprompt/docs/oss/licenses/Apache-2.0.txt
Normal file
@@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
9
integrations/ckprompt/docs/oss/licenses/BSD-2-Clause.txt
Normal file
9
integrations/ckprompt/docs/oss/licenses/BSD-2-Clause.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
Copyright (c) <year> <owner>
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
11
integrations/ckprompt/docs/oss/licenses/BSD-3-Clause.txt
Normal file
11
integrations/ckprompt/docs/oss/licenses/BSD-3-Clause.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
Copyright (c) <year> <owner>.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
170
integrations/ckprompt/docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
170
integrations/ckprompt/docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
@@ -0,0 +1,170 @@
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
|
||||
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
|
||||
|
||||
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
|
||||
|
||||
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
|
||||
|
||||
Creative Commons Attribution-ShareAlike 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Section 1 – Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
|
||||
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
|
||||
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
|
||||
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
||||
|
||||
Section 2 – Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||
|
||||
B. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section 6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||
|
||||
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
|
||||
|
||||
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
|
||||
|
||||
Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified form), You must:
|
||||
|
||||
A. retain the following if it is supplied by the Licensor with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||
|
||||
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
||||
|
||||
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
|
||||
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
|
||||
|
||||
Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
||||
|
||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
|
||||
|
||||
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
||||
|
||||
Section 6 – Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
|
||||
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
|
||||
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
Section 7 – Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
||||
|
||||
Section 8 – Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
||||
|
||||
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
18
integrations/ckprompt/docs/oss/licenses/MIT.txt
Normal file
18
integrations/ckprompt/docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
59
integrations/ckprompt/docs/releases/v0.1.0.md
Normal file
59
integrations/ckprompt/docs/releases/v0.1.0.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# CommandKit ckprompt v0.1.0
|
||||
|
||||
## Summary
|
||||
|
||||
The initial independently versioned PromptKit integration exposes native
|
||||
configuration inspection and comparison, definition inspection, requirements,
|
||||
catalogs, and smoke testing. This source-only Go module uses CommandKit v0.2.0,
|
||||
ckconfig v0.1.0, PromptKit v0.16.0, and ConfigKit v0.8.0.
|
||||
|
||||
## Features
|
||||
|
||||
- `ckprompt.ConfigScope` exposes PromptKit engine configuration and native
|
||||
comparison through the `config` family, including `config diff --scope promptkit`.
|
||||
- `ckprompt.Command` installs independently optional capabilities. `FromEngine`
|
||||
supplies named prompt/profile inspection, requirements discovery, and five
|
||||
catalogs: prompts, profiles, backends, namespaces, and collections.
|
||||
- `promptkit list` reports native inventories as YAML, with namespace filters
|
||||
for prompts, profiles, and backends, and kind filters for namespace discovery.
|
||||
- `promptkit requirements show PROMPT_ID` reports PromptKit's native input and
|
||||
variable requirements, completeness, and issues with optional caller context.
|
||||
- Opt-in `WithRequestValidation` adds
|
||||
`promptkit requirements validate PROMPT_ID`, forwarding variables and
|
||||
file-backed inputs to PromptKit's native request preparation and returning a
|
||||
YAML success confirmation.
|
||||
- Opt-in `WithSmokeTesting` adds `promptkit test KIND`, exposing native
|
||||
`reachability`, `authentication`, `text_inference`, and `structured_output`
|
||||
checks. `promptkit test all` runs those checks sequentially, continues after
|
||||
individual errors, stops on cancellation, and retains acquired reports and
|
||||
operation errors. Native observations are projected into YAML without an
|
||||
additional readiness classification.
|
||||
- Validation and smoke adapters accept optional typed request builders;
|
||||
applications retain configuration, client, and request-construction ownership.
|
||||
|
||||
## Requirements and adoption
|
||||
|
||||
**Go 1.26.0 or newer is required.** Add
|
||||
`gitea.maximumdirect.net/eric/CommandKit/integrations/ckprompt@v0.1.0` and
|
||||
explicitly install the desired declarations. Adding a PromptKit configuration
|
||||
scope and installing the `promptkit` family remain separate choices. Request
|
||||
validation and smoke testing each require explicit opt-in.
|
||||
|
||||
Applications migrating from CommandKit v0.1.0 must move to the separate
|
||||
integration modules and update loader, callback, and installation wiring for
|
||||
the captured declaration API. Align engine loading and fixtures with PromptKit
|
||||
v0.16.0's direct prompt definitions and collection configuration.
|
||||
|
||||
Prompt selection now uses an ID only: remove `--version` from prompt inspection
|
||||
invocations. YAML prompt inspection uses optional `version` in place of
|
||||
`prompt_version`; update report consumers accordingly. Prompt and collection
|
||||
catalogs also report optional descriptive versions, omitting the field when
|
||||
absent. PromptKit owns selection, requirements, validation, and smoke semantics.
|
||||
|
||||
See the [module README](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/README.md),
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/docs/consumers/guide.md),
|
||||
and [CLI reference](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/docs/cli.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/LICENSE) |
|
||||
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/CommandKit/src/tag/integrations/ckprompt/v0.1.0/integrations/ckprompt/docs/oss/attribution.md)
|
||||
94
integrations/ckprompt/examples/composed/README.md
Normal file
94
integrations/ckprompt/examples/composed/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Composed consumer
|
||||
|
||||
This example composes application configuration, PromptKit configuration,
|
||||
PromptKit comparison and catalogs, definition and requirements inspection, and
|
||||
one application-owned command:
|
||||
|
||||
```sh
|
||||
go run . config scopes
|
||||
go run . config validate --scope all --campaign spring --limit=0
|
||||
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml
|
||||
go run . config diff --scope app --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring --limit=0
|
||||
go run . config diff --scope promptkit --config fixtures/before.yml --against-config fixtures/after.yml --campaign spring
|
||||
go run . promptkit list prompts --config fixtures/before.yml --campaign spring
|
||||
go run . promptkit list profiles --config fixtures/after.yml --campaign autumn --format yaml
|
||||
go run . promptkit list backends --campaign spring
|
||||
go run . promptkit list namespaces --campaign spring
|
||||
go run . promptkit list collections --campaign spring
|
||||
go run . promptkit list prompts --namespace field-notes --campaign spring
|
||||
go run . promptkit show prompt briefing --campaign spring --format yaml
|
||||
go run . promptkit show profile spring --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring
|
||||
go run . promptkit requirements show briefing --campaign spring --var mode=full --format yaml
|
||||
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt
|
||||
go run . promptkit requirements validate briefing --campaign spring --input topic=fixtures/topic.txt --var mode=full --input notes=fixtures/notes.txt --format yaml
|
||||
go run . promptkit requirements validate briefing --campaign spring
|
||||
go run . promptkit test text_inference --campaign spring --prompt briefing --profile spring
|
||||
go run . promptkit test all --campaign spring --prompt briefing --profile spring
|
||||
go run . session restore session-42 --campaign spring
|
||||
```
|
||||
|
||||
The sources and session store are small in-memory fixtures. `--campaign` is a
|
||||
typed selector shared by the app and PromptKit configuration scopes. Their
|
||||
`ckconfig.Suite[*inspectionInput, *runCache]` uses `NewInput` to initialize each
|
||||
selected scope's input separately and `NewState` to create one cache shared by
|
||||
that invocation's acquisitions. Prompt/profile inspection prepares its input and
|
||||
cache independently for its invocation, using the same application and engine
|
||||
loader functions. See the
|
||||
[consumer guide](https://gitea.maximumdirect.net/eric/CommandKit/src/branch/main/docs/consumers/guide.md)
|
||||
and the suites' GoDoc for initialization timing and ownership.
|
||||
|
||||
The application-scope comparison reports the changed default campaign and the
|
||||
updated `campaigns.spring.model`. The PromptKit-scope comparison selects spring
|
||||
on both sides and reports the native engine change under the spring profile.
|
||||
The application input check requires `--limit=0` when `--campaign` is supplied.
|
||||
Catalogs use the same selection-aware loader: the prompt catalog includes
|
||||
`briefing` version `1`, and the profile catalog includes the selected `autumn`
|
||||
profile. Prompt inspection uses an ID only; its optional version is descriptive
|
||||
metadata in the report, not a selector. The synthetic `field-notes` collection
|
||||
provides one backend, profile, and prompt for collection discovery and native
|
||||
namespace filtering. The example's shared run cache keys successful results by
|
||||
the complete supplied configuration/overlay selection and the supplied
|
||||
campaign, including presence. The campaign override is applied through ConfigKit
|
||||
before loading. These are choices for this example's loader and do not define a
|
||||
CommandKit cache policy.
|
||||
|
||||
Smoke testing is explicitly wired with `ckprompt.WithSmokeTesting` and its
|
||||
optional request builder is nil, so CommandKit forwards the selected native
|
||||
request. Applications that need to adjust the request can provide a typed
|
||||
builder that starts with the seed and returns the chosen native request:
|
||||
|
||||
```go
|
||||
definitions = ckprompt.WithSmokeTesting(definitions, loadEngine,
|
||||
func(ctx context.Context, seed ckprompt.SmokeRequest, input *inspectionInput, state *runCache) (promptkit.SmokeTestRequest, error) {
|
||||
request := seed.Request
|
||||
request.ProfileID = "spring"
|
||||
return request, nil
|
||||
})
|
||||
```
|
||||
|
||||
The example injects a small in-memory `promptkit.LLMClient` through its shared
|
||||
run state. Catalog, inspection, and requirements commands do not call it;
|
||||
inference smoke commands do. No model service or credentials are required.
|
||||
Native HTTP checks may retain unsupported or not-applicable observations because
|
||||
the example does not configure a network probe. An aggregate smoke command can
|
||||
return an operational outcome for a native diagnostic while still delivering
|
||||
the complete aggregate report.
|
||||
|
||||
The first requirements command reports incomplete discovery: PromptKit records
|
||||
the conditional `notes` input and a missing `mode` issue. Supplying `mode=full`
|
||||
selects that branch, classifies the undeclared `notes` input as required, and
|
||||
completes discovery. Both commands return successful reports; the result does
|
||||
not execute the prompt or establish input, credential, or execution readiness.
|
||||
See the [CLI reference](../../../../docs/cli.md#promptkit-requirements) for the
|
||||
command and report contract.
|
||||
|
||||
The validation commands exercise concrete PromptKit request preparation.
|
||||
The first uses the builder's campaign-specific default topic variable and the
|
||||
operator's topic file. The second also selects the notes branch and supplies its
|
||||
file. Both succeed and report only `prompt_id` and `valid: true`; they do not
|
||||
execute the prompt. The final command intentionally fails because the prompt's
|
||||
required `topic` artifact is not supplied. The builder defaults variables from
|
||||
the selected campaign but leaves artifact bindings to the operator. The
|
||||
`requirements show` commands remain discovery reports: partial discovery can
|
||||
succeed even when a concrete request would fail validation.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user