Add application configuration inspection commands

This commit is contained in:
2026-09-26 18:15:28 +00:00
parent 9ac7006a79
commit 3d7c9aa324
11 changed files with 296 additions and 9 deletions

View File

@@ -0,0 +1,86 @@
package cli
import (
"encoding/json"
"flag"
"fmt"
"io"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
)
type configurationSource struct {
ID string `json:"id"`
Label string `json:"label"`
Layer configkit.Layer `json:"layer"`
Path string `json:"path,omitempty"`
}
func configCommand(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "config parse error: configuration mode is required")
return ExitRuntimeError
}
mode := args[0]
if mode != "validate" && mode != "show" && mode != "sources" {
fmt.Fprintln(stderr, "config parse error: unknown configuration mode")
return ExitRuntimeError
}
fs := flag.NewFlagSet("config "+mode, flag.ContinueOnError)
fs.SetOutput(io.Discard)
var path, overlay string
registerConfigSelectionFlags(fs, &path, &overlay)
registerConfigurationOverrides(fs)
if err := fs.Parse(args[1:]); err != nil {
writeApplicationError(stderr, "config parse", err)
return ExitRuntimeError
}
if fs.NArg() != 0 {
fmt.Fprintln(stderr, "config parse error: positional arguments are not accepted")
return ExitRuntimeError
}
result, err := appconfig.Load(appconfig.LoadOptions{
Path: path, Explicit: flagWasSet(fs, "config"), Overlay: overlay,
Overrides: applicationOverrides(fs),
})
if err != nil {
writeApplicationError(stderr, "config", err)
return ExitRuntimeError
}
if err := writeApplicationWarnings(stderr, result.Diagnostics()); err != nil {
return ExitRuntimeError
}
switch mode {
case "validate":
return ExitOK
case "show":
err = result.WriteEffective(stdout)
case "sources":
sources := make([]configurationSource, 0, len(result.Sources()))
for _, source := range result.Sources() {
sources = append(sources, configurationSource{
ID: source.ID, Label: source.Label, Layer: source.Layer, Path: source.Path,
})
}
err = json.NewEncoder(stdout).Encode(sources)
}
if err != nil {
fmt.Fprintf(stderr, "config output error: %v\n", err)
return ExitRuntimeError
}
return ExitOK
}
func registerConfigurationOverrides(fs *flag.FlagSet) {
fs.String("prompt-dir", "", "prompt-definition directory override")
fs.String("profile-dir", "", "profile directory override")
fs.String("schema-dir", "", "schema directory override")
fs.String("addr", "", "HTTP listen address override")
fs.String("artifact-root", "", "HTTP artifact root override")
fs.Int64("max-request-bytes", 0, "HTTP request byte limit override")
fs.Int64("max-artifact-bytes", 0, "HTTP artifact byte limit override")
fs.Int64("max-response-bytes", 0, "HTTP response byte limit override")
}

View File

@@ -0,0 +1,128 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
)
func TestConfigCommandsInspectApplicationWithoutEngine(t *testing.T) {
path := writeAppConfigFile(t, "{}\n")
for _, mode := range []string{"validate", "show", "sources"} {
code, stdout, stderr := runCLICommand(t, configCommand, []string{mode, "--config", path})
if code != ExitOK || stderr != "" {
t.Fatalf("%s failed: code=%d stdout=%q stderr=%q", mode, code, stdout, stderr)
}
switch mode {
case "validate":
if stdout != "" {
t.Fatalf("validation wrote stdout: %q", stdout)
}
case "show":
if !strings.Contains(stdout, "render_format: text") || !strings.Contains(stdout, "max_request_bytes: 16777216") {
t.Fatalf("effective defaults missing: %q", stdout)
}
case "sources":
var sources []configurationSource
if err := json.Unmarshal([]byte(stdout), &sources); err != nil || len(sources) != 1 || sources[0].Layer != "base" {
t.Fatalf("unexpected source records: %q (%v)", stdout, err)
}
}
}
}
func TestConfigCommandsExposeComposedSourcesAndResolvedValues(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o755); err != nil {
t.Fatal(err)
}
base := filepath.Join(dir, "config.yml")
for path, body := range map[string]string{
base: "configkit:\n imports:\n - path: fragment.yml\nprompt_dir: ' ./before/../prompts '\nprofile_dir: ./profiles\n",
filepath.Join(dir, "fragment.yml"): "server:\n addr: :8081\n",
filepath.Join(dir, "overlays", "local.yml"): "server:\n addr: :9090\n",
} {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
args := []string{"--config", base, "--config-overlay", "local", "--profile-dir=", "--addr", ":7070"}
code, stdout, stderr := runCLICommand(t, configCommand, append([]string{"show"}, args...))
if code != ExitOK || stderr != "" || !strings.Contains(stdout, "prompt_dir: prompts") || !strings.Contains(stdout, "profile_dir: ''") || !strings.Contains(stdout, "addr: :7070") {
t.Fatalf("resolved effective output changed: code=%d out=%q err=%q", code, stdout, stderr)
}
code, stdout, stderr = runCLICommand(t, configCommand, append([]string{"sources"}, args...))
if code != ExitOK || stderr != "" || !strings.HasSuffix(stdout, "\n") {
t.Fatalf("source command failed: code=%d out=%q err=%q", code, stdout, stderr)
}
var sources []configurationSource
if err := json.Unmarshal([]byte(stdout), &sources); err != nil || len(sources) != 5 {
t.Fatalf("unexpected composed source records: %+v (%v)", sources, err)
}
for i, layer := range []string{"base", "base", "overlay", "cli", "cli"} {
if string(sources[i].Layer) != layer {
t.Fatalf("source %d layer = %q, want %q", i, sources[i].Layer, layer)
}
}
}
func TestConfigInspectionKeepsReferencesAndOrdinaryParameters(t *testing.T) {
const name = "SCRIPTORIUM_CONFIG_TEST_API_KEY"
const secret = "synthetic-secret-value-should-not-appear"
path := writeAppConfigFile(t, `backends:
synthetic:
endpoint: http://127.0.0.1:1/v1
api_key_env: SCRIPTORIUM_CONFIG_TEST_API_KEY
extra_params:
visible_parameter: synthetic-ordinary-value
`)
t.Setenv(name, secret)
for _, present := range []bool{true, false} {
if !present {
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
}
code, stdout, stderr := runCLICommand(t, configCommand, []string{"show", "--config", path})
if code != ExitOK || stderr != "" || !strings.Contains(stdout, name) || !strings.Contains(stdout, "synthetic-ordinary-value") || strings.Contains(stdout, secret) {
t.Fatalf("credential boundary changed with present=%t: code=%d out=%q err=%q", present, code, stdout, stderr)
}
}
result, err := appconfig.Load(appconfig.LoadOptions{Path: path, Explicit: true})
if err != nil {
t.Fatal(err)
}
settings := appconfig.Settings(result.Config)
if len(settings.Backends) != 1 || settings.Backends[0].ExtraParams["visible_parameter"] != "synthetic-ordinary-value" {
t.Fatalf("runtime backend parameters changed: %+v", settings.Backends)
}
}
type errorOutputWriter struct{}
func (errorOutputWriter) Write([]byte) (int, error) { return 0, errors.New("output unavailable") }
func TestConfigCommandFailuresDoNotProduceSuccessfulOutput(t *testing.T) {
path := writeAppConfigFile(t, "server:\n max_request_bytes: -1\n")
for _, mode := range []string{"validate", "show", "sources"} {
code, stdout, stderr := runCLICommand(t, configCommand, []string{mode, "--config", path})
if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "validate.error") {
t.Fatalf("%s misclassified invalid config: code=%d out=%q err=%q", mode, code, stdout, stderr)
}
}
path = writeAppConfigFile(t, "{}\n")
var stderr bytes.Buffer
if code := configCommand([]string{"show", "--config", path}, errorOutputWriter{}, &stderr); code != ExitRuntimeError || !strings.Contains(stderr.String(), "output error") {
t.Fatalf("writer error not reported: code=%d stderr=%q", code, stderr.String())
}
if code := configCommand([]string{"sources", "--config", path}, errorOutputWriter{}, io.Discard); code != ExitRuntimeError {
t.Fatalf("source writer error returned %d", code)
}
}

View File

@@ -137,6 +137,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
return serveCommand(args[1:], stderr)
case "inspect":
return inspectCommand(args[1:], stdout, stderr)
case "config":
return configCommand(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
@@ -752,10 +754,11 @@ func runErrorMessage(err error) string {
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect|config> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
fmt.Fprintln(w, " config: scriptorium config <validate|show|sources> [--config PATH] [--config-overlay NAME] [application overrides]")
}