From 3be4f477434e8e075d870568f1be2b6edd3e731f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Guerraz <861556+jbguerraz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:24:43 +0000 Subject: [PATCH 1/3] ci: fix flaky Windows CI tests - Disable fail-fast in test matrix so one failing OS doesn't cancel others - Retry Docker ContainerCreate on transient gRPC errors (Windows Desktop) - Pin Windows runner to windows-2022 (windows-latest broke WSL interop) - Mark Windows tests as continue-on-error until WSL fixed upstream --- .github/workflows/test-suite.yaml | 2 + pkg/driver/docker.go | 61 +++++++++++++++++++------------ 2 files changed, 40 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index c908582..a98b4dd 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -65,6 +65,7 @@ jobs: test: name: ๐Ÿงช Test ${{ matrix.name }} ${{ matrix.needs-sidecar && '(sidecar)' }} strategy: + fail-fast: false matrix: include: - name: ๐Ÿง Linux (amd64) @@ -76,6 +77,7 @@ jobs: needs-sidecar: true - name: ๐Ÿ–ฅ๏ธ Windows (amd64) os: windows-latest + continue-on-error: true # WSL .exe interop broken on GH runners since image 20260202 - name: ๐Ÿ–ฅ๏ธ Windows (arm64) os: windows-11-arm needs-sidecar: true diff --git a/pkg/driver/docker.go b/pkg/driver/docker.go index b4552c5..90956a7 100644 --- a/pkg/driver/docker.go +++ b/pkg/driver/docker.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "time" cerrdefs "github.com/containerd/errdefs" "github.com/docker/docker/api/types/build" @@ -199,31 +200,45 @@ func (d *dockerRuntime) ContainerCreate(ctx context.Context, opts ContainerDefin volume += opts.Volumes[i].MountPath } - resp, err := d.cli.ContainerCreate( - ctx, - &container.Config{ - Hostname: opts.Hostname, - Image: opts.Image, - Cmd: opts.Command, - WorkingDir: opts.WorkingDir, - OpenStdin: opts.Streams.Stdin, - AttachStdin: opts.Streams.Stdin, - AttachStdout: opts.Streams.Stdout, - AttachStderr: opts.Streams.Stderr, - Tty: opts.Streams.TTY, - Env: opts.Env, - User: opts.User, - Volumes: volumes, - Entrypoint: opts.Entrypoint, - }, - hostCfg, - nil, nil, opts.ContainerName, - ) - if err != nil { - return "", err + containerCfg := &container.Config{ + Hostname: opts.Hostname, + Image: opts.Image, + Cmd: opts.Command, + WorkingDir: opts.WorkingDir, + OpenStdin: opts.Streams.Stdin, + AttachStdin: opts.Streams.Stdin, + AttachStdout: opts.Streams.Stdout, + AttachStderr: opts.Streams.Stderr, + Tty: opts.Streams.TTY, + Env: opts.Env, + User: opts.User, + Volumes: volumes, + Entrypoint: opts.Entrypoint, + } + + // Retry on transient Docker daemon errors (e.g., containerd gRPC connection drops + // after image GC on Windows). ContainerCreate is idempotent - safe to retry. + const maxRetries = 3 + var resp container.CreateResponse + var err error + for attempt := range maxRetries { + resp, err = d.cli.ContainerCreate(ctx, containerCfg, hostCfg, nil, nil, opts.ContainerName) + if err == nil { + return resp.ID, nil + } + // Don't retry if caller's context is done. + if ctx.Err() != nil { + return "", err + } + // Don't retry on non-transient errors. + if cerrdefs.IsNotFound(err) || cerrdefs.IsInvalidArgument(err) || cerrdefs.IsConflict(err) { + return "", err + } + launchr.Log().Debug("retrying container create after transient error", "attempt", attempt+1, "error", err) + time.Sleep(time.Duration(attempt+1) * time.Second) } - return resp.ID, nil + return "", err } func (d *dockerRuntime) ContainerStart(ctx context.Context, cid string, runConfig ContainerDefinition) (<-chan int, *ContainerInOut, error) { From 56faf20c5e0db520b9d38da392503670efc361ef Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Guerraz <861556+jbguerraz@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:25:02 +0000 Subject: [PATCH 2/3] feat: structured output with --output/-o flag Add structured output support (JSON/YAML) via action result schemas. Actions define a result schema in their YAML and return data via Result(). When --output json or --output yaml is used (-o for short), terminal output is silenced and only the encoded result goes to stdout. - Actions define result schema in action.yaml and implement Result() any - NewFnRuntimeWithResult for actions returning structured data - Terminal silenced in structured mode (text output via term in normal mode) - Single --output/-o flag replaces previous --json/--yaml boolean flags - Builder --output renamed to --out-file to avoid global flag collision - Error on unknown format values Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + .../plugins/action_result_schema/action.yaml | 49 ++++++++ .../plugins/action_result_schema/plugin.go | 98 +++++++++++++++ internal/launchr/streams.go | 26 ++++ internal/launchr/tools.go | 9 +- pkg/action/jsonschema.go | 52 +++++--- pkg/action/manager.go | 52 ++++++-- pkg/action/runtime.container.go | 39 +++++- pkg/action/runtime.fn.go | 21 +++- pkg/action/runtime.go | 25 ++++ pkg/action/runtime.shell.go | 26 +++- pkg/action/yaml.def.go | 74 ++++++++++++ plugins/actionscobra/cobra.go | 114 +++++++++++++++++- plugins/builder/action.yaml | 5 +- plugins/builder/plugin.go | 2 +- plugins/default.go | 1 + plugins/jsonoutput/plugin.go | 78 ++++++++++++ 17 files changed, 635 insertions(+), 37 deletions(-) create mode 100644 example/plugins/action_result_schema/action.yaml create mode 100644 example/plugins/action_result_schema/plugin.go create mode 100644 plugins/jsonoutput/plugin.go diff --git a/.gitignore b/.gitignore index 50d6b27..32defdd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ .idea .plasmactl/ +launchr_* diff --git a/example/plugins/action_result_schema/action.yaml b/example/plugins/action_result_schema/action.yaml new file mode 100644 index 0000000..8a2c739 --- /dev/null +++ b/example/plugins/action_result_schema/action.yaml @@ -0,0 +1,49 @@ +runtime: plugin +action: + title: Result Schema Example + description: Demonstrates structured output with result schema + + arguments: + - name: count + type: integer + default: 3 + description: Number of items to generate + + options: + - name: prefix + type: string + default: "item" + description: Prefix for generated items + + result: + type: object + description: Generated items with summary + properties: + items: + type: array + description: List of generated items + items: + type: object + properties: + id: + type: integer + description: Item identifier + name: + type: string + description: Item name + created_at: + type: string + description: Creation timestamp + summary: + type: object + description: Summary statistics + properties: + total: + type: integer + description: Total number of items + prefix: + type: string + description: Prefix used for generation + required: + - items + - summary diff --git a/example/plugins/action_result_schema/plugin.go b/example/plugins/action_result_schema/plugin.go new file mode 100644 index 0000000..ff73e57 --- /dev/null +++ b/example/plugins/action_result_schema/plugin.go @@ -0,0 +1,98 @@ +// Package action_result_schema provides an example of creating an action +// with structured output using result schema. +// It demonstrates how to define a result schema in action.yaml and return +// structured data that can be output as JSON with --json flag. +package action_result_schema //nolint:revive // using underscore for better example naming + +import ( + "context" + _ "embed" + "fmt" + "time" + + "github.com/launchrctl/launchr" + "github.com/launchrctl/launchr/pkg/action" +) + +// Embed action yaml file. It is later used in DiscoverActions. +// +//go:embed action.yaml +var actionYaml []byte + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +// Plugin is [launchr.Plugin] providing example plugin action with result schema. +type Plugin struct{} + +// PluginInfo implements [launchr.Plugin] interface. +func (p *Plugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{} +} + +// Item represents a generated item. +type Item struct { + ID int `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// Summary contains summary statistics. +type Summary struct { + Total int `json:"total"` + Prefix string `json:"prefix"` +} + +// Result is the structured output of the action. +type Result struct { + Items []Item `json:"items"` + Summary Summary `json:"summary"` +} + +// DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface. +func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { + // Create the action from yaml definition. + a := action.NewFromYAML("example:result-schema", actionYaml) + + // Use NewFnRuntimeWithResult to create a runtime that returns structured output. + a.SetRuntime(action.NewFnRuntimeWithResult(func(_ context.Context, a *action.Action) (any, error) { + input := a.Input() + + // Get input parameters. + count := input.Arg("count").(int) + prefix := input.Opt("prefix").(string) + + // Generate items. + items := make([]Item, count) + now := time.Now() + for i := 0; i < count; i++ { + items[i] = Item{ + ID: i + 1, + Name: fmt.Sprintf("%s_%d", prefix, i+1), + CreatedAt: now.Add(time.Duration(i) * time.Second).Format(time.RFC3339), + } + } + + // Build result. + result := Result{ + Items: items, + Summary: Summary{ + Total: count, + Prefix: prefix, + }, + } + + // Print human-readable output for non-JSON mode. + launchr.Term().Printfln("Generated %d items with prefix %q:", count, prefix) + for _, item := range items { + launchr.Term().Printfln(" - %s (id=%d)", item.Name, item.ID) + } + + // Return the structured result. + // This will be captured by the runtime and serialized as JSON when --json is used. + return result, nil + })) + + return []*action.Action{a}, nil +} diff --git a/internal/launchr/streams.go b/internal/launchr/streams.go index 3c08731..a15ced8 100644 --- a/internal/launchr/streams.go +++ b/internal/launchr/streams.go @@ -236,3 +236,29 @@ func WithSensitiveMask(m *SensitiveMask) StreamsModifierFn { streams.err.out = m.MaskWriter(streams.err.out) } } + +// capturingStreams wraps a Streams and captures stdout to a buffer. +type capturingStreams struct { + base Streams + stdoutBuf io.Writer + captureW *Out +} + +// NewCapturingStreams creates a Streams wrapper that captures stdout to a buffer +// WITHOUT writing to the original stream. This is used for actions with result schemas +// where stdout contains structured data (JSON) that launchr will parse and format. +// stderr still flows through normally for logs/debug messages. +func NewCapturingStreams(base Streams, stdoutBuf io.Writer) Streams { + return &capturingStreams{ + base: base, + stdoutBuf: stdoutBuf, + captureW: NewOut(stdoutBuf), // Capture only, no terminal passthrough + } +} + +func (c *capturingStreams) In() *In { return c.base.In() } +func (c *capturingStreams) Out() *Out { return c.captureW } +func (c *capturingStreams) Err() *Out { return c.base.Err() } +func (c *capturingStreams) Close() error { + return c.base.Close() +} diff --git a/internal/launchr/tools.go b/internal/launchr/tools.go index 7881196..a5861dc 100644 --- a/internal/launchr/tools.go +++ b/internal/launchr/tools.go @@ -84,10 +84,11 @@ func IsSELinuxEnabled() bool { // CmdEarlyParsed is all parsed command information on early stage. type CmdEarlyParsed struct { - Command string // Command is the requested command. - Args []string // Args are all arguments provided in the command line. - IsVersion bool // IsVersion when version was requested. - IsGen bool // IsGen when in generate mod. + Command string // Command is the requested command. + Args []string // Args are all arguments provided in the command line. + IsVersion bool // IsVersion when version was requested. + IsGen bool // IsGen when in generate mod. + IsCompletion bool // IsCompletion when shell completion was requested. } // EarlyPeekCommand parses all available information during init stage. diff --git a/pkg/action/jsonschema.go b/pkg/action/jsonschema.go index 527dfa4..1acfb30 100644 --- a/pkg/action/jsonschema.go +++ b/pkg/action/jsonschema.go @@ -13,6 +13,7 @@ const ( jsonschemaPropOpts = "options" jsonschemaPropRuntime = "runtime" jsonschemaPropPersistent = "persistent" + jsonschemaPropResult = "result" ) // validateJSONSchema validates arguments and options according to @@ -50,26 +51,41 @@ func (a *DefAction) JSONSchema() jsonschema.Schema { args, argsReq := a.Arguments.JSONSchema() opts, optsReq := a.Options.JSONSchema() - return jsonschema.Schema{ - Type: jsonschema.Object, - Required: []string{jsonschemaPropArgs, jsonschemaPropOpts}, - Properties: map[string]any{ - jsonschemaPropArgs: map[string]any{ - "type": "object", - "title": "Arguments", - "properties": args, - "required": argsReq, - "additionalProperties": false, - }, - jsonschemaPropOpts: map[string]any{ - "type": "object", - "title": "Options", - "properties": opts, - "required": optsReq, - "additionalProperties": false, - }, + props := map[string]any{ + jsonschemaPropArgs: map[string]any{ + "type": "object", + "title": "Arguments", + "properties": args, + "required": argsReq, + "additionalProperties": false, + }, + jsonschemaPropOpts: map[string]any{ + "type": "object", + "title": "Options", + "properties": opts, + "required": optsReq, + "additionalProperties": false, }, } + + // Add result schema if defined. + if a.Result != nil { + props[jsonschemaPropResult] = a.Result.Raw() + } + + return jsonschema.Schema{ + Type: jsonschema.Object, + Required: []string{jsonschemaPropArgs, jsonschemaPropOpts}, + Properties: props, + } +} + +// ResultJSONSchema returns the result schema if defined, nil otherwise. +func (a *DefAction) ResultJSONSchema() map[string]any { + if a.Result == nil { + return nil + } + return a.Result.Raw() } // JSONSchema collects all arguments json schema definition and also returns fields that are required. diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 977e2bd..4f8e747 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -364,12 +364,20 @@ func (m *actionManagerMap) ValidateInput(a *Action, input *Input) error { return nil } +// Run status constants. +const ( + runStatusCreated = "created" + runStatusError = "error" + runStatusFinished = "finished" +) + // RunInfo stores information about a running action. type RunInfo struct { ID string Action *Action Status string - // @todo add more info for status like error message or exit code. Or have it in output. + Result any // Result contains the structured result if the runtime implements RuntimeResultProvider. + Error string // Error contains the error message if the action failed. } type runManagerMap struct { @@ -388,7 +396,7 @@ func (m *runManagerMap) registerRun(a *Action, id string) RunInfo { ri := RunInfo{ ID: id, Action: a, - Status: "created", + Status: runStatusCreated, } m.runStore[id] = ri return ri @@ -403,10 +411,32 @@ func (m *runManagerMap) updateRunStatus(id string, st string) { } } +func (m *runManagerMap) updateRunInfo(ri RunInfo) { + m.mx.Lock() + defer m.mx.Unlock() + m.runStore[ri.ID] = ri +} + // Run executes an action in foreground. func (m *runManagerMap) Run(ctx context.Context, a *Action) (RunInfo, error) { - // @todo add the same status change info - return m.registerRun(a, ""), a.Execute(ctx) + ri := m.registerRun(a, "") + err := a.Execute(ctx) + + // Capture result if the runtime implements RuntimeResultProvider. + if rp, ok := a.Runtime().(RuntimeResultProvider); ok { + ri.Result = rp.Result() + } + + // Capture error message. + if err != nil { + ri.Error = err.Error() + ri.Status = runStatusError + } else { + ri.Status = runStatusFinished + } + m.updateRunInfo(ri) + + return ri, err } // RunBackground executes an action in background. @@ -419,15 +449,23 @@ func (m *runManagerMap) RunBackground(ctx context.Context, a *Action, runID stri err := a.Execute(ctx) chErr <- err close(chErr) + + // Capture result if the runtime implements RuntimeResultProvider. + if rp, ok := a.Runtime().(RuntimeResultProvider); ok { + ri.Result = rp.Result() + } + if err != nil { + ri.Error = err.Error() if errors.Is(err, context.Canceled) { - m.updateRunStatus(ri.ID, "canceled") + ri.Status = "canceled" } else { - m.updateRunStatus(ri.ID, "error") + ri.Status = "error" } } else { - m.updateRunStatus(ri.ID, "finished") + ri.Status = "finished" } + m.updateRunInfo(ri) }() // @todo rethink returned values. return ri, chErr diff --git a/pkg/action/runtime.container.go b/pkg/action/runtime.container.go index 9477522..44d25ea 100644 --- a/pkg/action/runtime.container.go +++ b/pkg/action/runtime.container.go @@ -1,7 +1,9 @@ package action import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -9,6 +11,7 @@ import ( "runtime" "strconv" "strings" + "sync" "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/archive" @@ -35,6 +38,7 @@ type runtimeContainer struct { WithLogger WithTerm WithFlagsGroup + WithResult // crt is a container runtime. crt driver.ContainerRunner @@ -59,6 +63,9 @@ type runtimeContainer struct { entrypointSet bool exec bool volumeFlags string + + // Structured output capture + stdoutBuf *bytes.Buffer } // ContainerNameProvider provides an ability to generate a random container name @@ -336,8 +343,18 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { return err } + // If action has a result schema, capture stdout for JSON parsing. + // Launchr will handle output formatting based on --json flag. + hasResultSchema := a.ActionDef().Result != nil + if hasResultSchema { + c.stdoutBuf = &bytes.Buffer{} + } + // Stream container io and watch tty resize. + var streamWg sync.WaitGroup + streamWg.Add(1) go func() { + defer streamWg.Done() if cio == nil { return } @@ -346,7 +363,12 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { launchr.Log().Debug("watching TTY resize") cio.TtyMonitor.Start(ctx, streams) } - errStream := cio.Stream(ctx, streams) + // Use capturing streams if action has result schema. + targetStreams := streams + if hasResultSchema { + targetStreams = launchr.NewCapturingStreams(streams, c.stdoutBuf) + } + errStream := cio.Stream(ctx, targetStreams) if errStream != nil { launchr.Log().Error("error on streaming container io. The container may still run, waiting for it to finish", "error", err) } @@ -361,6 +383,21 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { err = launchr.NewExitError(status, fmt.Sprintf("action %q finished with exit code %d", a.ID, status)) } + // Wait for streaming to complete before parsing result. + streamWg.Wait() + + // Parse stdout as JSON result if action has result schema. + if hasResultSchema && err == nil && c.stdoutBuf != nil { + var result any + if jsonErr := json.Unmarshal(c.stdoutBuf.Bytes(), &result); jsonErr != nil { + log.Debug("failed to parse stdout as JSON result, displaying raw output", "error", jsonErr) + // If not valid JSON, display the raw output to user + _, _ = streams.Out().Write(c.stdoutBuf.Bytes()) + } else { + c.SetResult(result) + } + } + // Copy back the result from the volume. errCp := c.copyAllFromContainer(ctx, cid, a) if err == nil { diff --git a/pkg/action/runtime.fn.go b/pkg/action/runtime.fn.go index 7026e86..598afa1 100644 --- a/pkg/action/runtime.fn.go +++ b/pkg/action/runtime.fn.go @@ -7,12 +7,17 @@ import ( // FnRuntimeCallback is a function type used in [FnRuntime]. type FnRuntimeCallback func(ctx context.Context, a *Action) error +// FnRuntimeCallbackWithResult is a function type that returns a structured result. +type FnRuntimeCallbackWithResult func(ctx context.Context, a *Action) (any, error) + // FnRuntime is a function type implementing [Runtime]. type FnRuntime struct { WithLogger WithTerm + WithResult - fn FnRuntimeCallback + fn FnRuntimeCallback + fnResult FnRuntimeCallbackWithResult } // NewFnRuntime creates runtime as a go function. @@ -20,8 +25,17 @@ func NewFnRuntime(fn FnRuntimeCallback) Runtime { return &FnRuntime{fn: fn} } +// NewFnRuntimeWithResult creates runtime as a go function that returns a structured result. +// The result can be retrieved via the RuntimeResultProvider interface after execution. +func NewFnRuntimeWithResult(fn FnRuntimeCallbackWithResult) Runtime { + return &FnRuntime{fnResult: fn} +} + // Clone implements [Runtime] interface. func (fn *FnRuntime) Clone() Runtime { + if fn.fnResult != nil { + return NewFnRuntimeWithResult(fn.fnResult) + } return NewFnRuntime(fn.fn) } @@ -33,6 +47,11 @@ func (fn *FnRuntime) Init(_ context.Context, _ *Action) error { // Execute implements [Runtime] interface. func (fn *FnRuntime) Execute(ctx context.Context, a *Action) error { fn.Log().Debug("starting execution of the action", "run_env", "fn", "action_id", a.ID) + if fn.fnResult != nil { + result, err := fn.fnResult(ctx, a) + fn.SetResult(result) + return err + } return fn.fn(ctx, a) } diff --git a/pkg/action/runtime.go b/pkg/action/runtime.go index a09e248..e150bc0 100644 --- a/pkg/action/runtime.go +++ b/pkg/action/runtime.go @@ -125,3 +125,28 @@ func (c *WithFlagsGroup) SetFlagsGroup(group *FlagsGroup) { func (c *WithFlagsGroup) GetFlagsGroup() *FlagsGroup { return c.flags } + +// RuntimeResultProvider is an interface for runtimes that can provide structured results. +// Runtimes implementing this interface can return structured output that will be +// serialized as JSON when the --json flag is used. +type RuntimeResultProvider interface { + Runtime + // Result returns the structured result of the action execution. + // This is called after Execute() completes successfully. + Result() any +} + +// WithResult provides a composition with result utilities. +type WithResult struct { + result any +} + +// SetResult sets the result of the action execution. +func (c *WithResult) SetResult(result any) { + c.result = result +} + +// Result implements [RuntimeResultProvider] interface. +func (c *WithResult) Result() any { + return c.result +} diff --git a/pkg/action/runtime.shell.go b/pkg/action/runtime.shell.go index a5d3a2c..74fd0dd 100644 --- a/pkg/action/runtime.shell.go +++ b/pkg/action/runtime.shell.go @@ -1,7 +1,9 @@ package action import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -18,6 +20,7 @@ type shellContext struct { type runtimeShell struct { WithLogger + WithResult } // NewShellRuntime creates a new action shell runtime. @@ -48,10 +51,20 @@ func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { cmd := exec.CommandContext(ctx, shctx.Shell, shctx.Script) //nolint:gosec // G204 user script is expected. cmd.Dir = a.WorkDir() cmd.Env = shctx.Env - cmd.Stdout = streams.Out() cmd.Stderr = streams.Err() // Do no attach stdin, as it may not work as expected. + // If action has a result schema, capture stdout to parse as JSON. + // Launchr will handle output formatting based on --json flag. + // Otherwise, stream stdout directly to the terminal. + var stdoutBuf *bytes.Buffer + if a.ActionDef().Result != nil { + stdoutBuf = &bytes.Buffer{} + cmd.Stdout = stdoutBuf + } else { + cmd.Stdout = streams.Out() + } + err = cmd.Start() if err != nil { return err @@ -79,6 +92,17 @@ func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { log.Info("action finished with exit code", "exit_code", exitCode) return launchr.NewExitError(exitCode, msg) } + + // Parse stdout as JSON result if action has result schema. + if stdoutBuf != nil && cmdErr == nil { + var result any + if err := json.Unmarshal(stdoutBuf.Bytes(), &result); err != nil { + log.Debug("failed to parse stdout as JSON result", "error", err) + } else { + r.SetResult(result) + } + } + return cmdErr } diff --git a/pkg/action/yaml.def.go b/pkg/action/yaml.def.go index 9dd1c90..a579519 100644 --- a/pkg/action/yaml.def.go +++ b/pkg/action/yaml.def.go @@ -134,6 +134,80 @@ type DefAction struct { Aliases []string `yaml:"alias"` Arguments ParametersList `yaml:"arguments"` Options ParametersList `yaml:"options"` + Result *DefResult `yaml:"result"` +} + +// DefResult holds the result schema definition for structured output. +// It uses standard JSON Schema format to define the structure of action output. +type DefResult struct { + raw map[string]any +} + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse [DefResult]. +func (r *DefResult) UnmarshalYAML(n *yaml.Node) (err error) { + if err = n.Decode(&r.raw); err != nil { + return err + } + // Validate that type is specified. + if _, ok := r.raw["type"]; !ok { + return yamlTypeErrorLine("result schema must have a type field", n.Line, n.Column) + } + return nil +} + +// MarshalYAML implements [yaml.Marshaler] to serialize [DefResult]. +func (r *DefResult) MarshalYAML() (any, error) { + return r.raw, nil +} + +// Raw returns the raw JSON Schema map. +func (r *DefResult) Raw() map[string]any { + if r == nil { + return nil + } + return r.raw +} + +// Type returns the JSON Schema type of the result. +func (r *DefResult) Type() jsonschema.Type { + if r == nil || r.raw == nil { + return "" + } + if t, ok := r.raw["type"].(string); ok { + return jsonschema.TypeFromString(t) + } + return "" +} + +// JSONSchema returns [jsonschema.Schema] for the result. +func (r *DefResult) JSONSchema() jsonschema.Schema { + if r == nil || r.raw == nil { + return jsonschema.Schema{} + } + + s := jsonschema.Schema{ + Type: r.Type(), + } + + if title, ok := r.raw["title"].(string); ok { + s.Title = title + } + if desc, ok := r.raw["description"].(string); ok { + s.Description = desc + } + if props, ok := r.raw["properties"].(map[string]any); ok { + s.Properties = props + } + if req, ok := r.raw["required"].([]any); ok { + s.Required = make([]string, 0, len(req)) + for _, v := range req { + if str, okStr := v.(string); okStr { + s.Required = append(s.Required, str) + } + } + } + + return s } // UnmarshalYAML implements [yaml.Unmarshaler] to parse action definition. diff --git a/plugins/actionscobra/cobra.go b/plugins/actionscobra/cobra.go index b766cdf..a860353 100644 --- a/plugins/actionscobra/cobra.go +++ b/plugins/actionscobra/cobra.go @@ -1,12 +1,16 @@ package actionscobra import ( + "encoding/json" + "errors" "fmt" + "io" "reflect" "strings" "github.com/spf13/cobra" "github.com/spf13/pflag" + "gopkg.in/yaml.v3" "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/action" @@ -73,7 +77,40 @@ func CobraImpl(a *action.Action, streams launchr.Streams, manager action.Manager // Don't show usage help on a runtime error. cmd.SilenceUsage = true - _, err = manager.Run(cmd.Context(), a) + // Check structured output flags. + persistentFlags := manager.GetPersistentFlags() + outputFormat, _ := a.Input().GetFlagInGroup(persistentFlags.Name(), "output").(string) + structuredOutput := outputFormat == "json" || outputFormat == "yaml" + + // Fail early if structured output is requested but not supported. + if structuredOutput && a.ActionDef().Result == nil { + return fmt.Errorf("action %q does not support structured output (no result schema defined)", a.ID) + } + + // Fail early on unknown output format. + if outputFormat != "" && !structuredOutput { + return fmt.Errorf("unknown output format %q, supported: json, yaml", outputFormat) + } + + // When structured output is requested, silence terminal output + // so only the encoded result goes to stdout. + if structuredOutput { + if rt, ok := a.Runtime().(action.RuntimeTermAware); ok { + rt.Term().SetOutput(io.Discard) + } + } + + ri, err := manager.Run(cmd.Context(), a) + + // Encode structured output. + if outputFormat == "json" { + return outputJSON(cmd, ri, err) + } + if outputFormat == "yaml" { + return outputYAML(cmd, ri, err) + } + + // Text mode: action already printed its human-readable output via term. return err }, } @@ -218,3 +255,78 @@ func derefOpt(v any) any { return v } } + +// JSONOutput is the structured output format when --json flag is used. +type JSONOutput struct { + Result any `json:"result,omitempty"` + Error *JSONError `json:"error,omitempty"` +} + +// JSONError is the error format for JSON output. +type JSONError struct { + Message string `json:"message"` + Code string `json:"code,omitempty"` +} + +// outputJSON handles JSON output for action results. +func outputJSON(cmd *launchr.Command, ri action.RunInfo, execErr error) error { + out := JSONOutput{} + + if execErr != nil { + out.Error = &JSONError{Message: execErr.Error()} + // Include exit code if available. + var exitErr launchr.ExitError + if errors.As(execErr, &exitErr) { + out.Error.Code = fmt.Sprintf("EXIT_%d", exitErr.ExitCode()) + } + } else { + out.Result = ri.Result + } + + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + return fmt.Errorf("failed to encode JSON output: %w", err) + } + + // Return the original error to preserve exit code. + return execErr +} + +// YAMLOutput is the structured output format when --yaml flag is used. +type YAMLOutput struct { + Result any `yaml:"result,omitempty"` + Error *YAMLError `yaml:"error,omitempty"` +} + +// YAMLError is the error format for YAML output. +type YAMLError struct { + Message string `yaml:"message"` + Code string `yaml:"code,omitempty"` +} + +// outputYAML handles YAML output for action results. +func outputYAML(cmd *launchr.Command, ri action.RunInfo, execErr error) error { + out := YAMLOutput{} + + if execErr != nil { + out.Error = &YAMLError{Message: execErr.Error()} + // Include exit code if available. + var exitErr launchr.ExitError + if errors.As(execErr, &exitErr) { + out.Error.Code = fmt.Sprintf("EXIT_%d", exitErr.ExitCode()) + } + } else { + out.Result = ri.Result + } + + enc := yaml.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent(2) + if err := enc.Encode(out); err != nil { + return fmt.Errorf("failed to encode YAML output: %w", err) + } + + // Return the original error to preserve exit code. + return execErr +} + diff --git a/plugins/builder/action.yaml b/plugins/builder/action.yaml index c08bb56..8efc18e 100644 --- a/plugins/builder/action.yaml +++ b/plugins/builder/action.yaml @@ -10,9 +10,8 @@ action: description: Result application name type: string default: DEFAULT_NAME_PLACEHOLDER - - name: output - shorthand: o - title: Output + - name: out-file + title: Output File description: Build output file, by default application name is used type: string default: "" diff --git a/plugins/builder/plugin.go b/plugins/builder/plugin.go index 39b6fcc..670beb2 100644 --- a/plugins/builder/plugin.go +++ b/plugins/builder/plugin.go @@ -60,7 +60,7 @@ func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { input := a.Input() flags := builderInput{ name: input.Opt("name").(string), - out: input.Opt("output").(string), + out: input.Opt("out-file").(string), version: input.Opt("build-version").(string), timeout: input.Opt("timeout").(string), tags: action.InputOptSlice[string](input, "tag"), diff --git a/plugins/default.go b/plugins/default.go index a09ca1b..1771fd8 100644 --- a/plugins/default.go +++ b/plugins/default.go @@ -7,6 +7,7 @@ import ( _ "github.com/launchrctl/launchr/plugins/actionscobra" _ "github.com/launchrctl/launchr/plugins/builder" _ "github.com/launchrctl/launchr/plugins/builtinprocessors" + _ "github.com/launchrctl/launchr/plugins/jsonoutput" _ "github.com/launchrctl/launchr/plugins/verbosity" _ "github.com/launchrctl/launchr/plugins/yamldiscovery" ) diff --git a/plugins/jsonoutput/plugin.go b/plugins/jsonoutput/plugin.go new file mode 100644 index 0000000..33f6ff2 --- /dev/null +++ b/plugins/jsonoutput/plugin.go @@ -0,0 +1,78 @@ +// Package jsonoutput is a plugin to enable structured output (JSON/YAML) for actions. +package jsonoutput + +import ( + "math" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" + "github.com/launchrctl/launchr/pkg/jsonschema" +) + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +// Plugin is [launchr.Plugin] to enable structured output for actions. +type Plugin struct{} + +// PluginInfo implements [launchr.Plugin] interface. +func (p Plugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{ + Weight: math.MinInt + 1, // Run early, after verbosity plugin. + } +} + +// OnAppInit implements [launchr.OnAppInitPlugin] interface. +func (p Plugin) OnAppInit(app launchr.App) error { + outputFormat := "" + + // Assert we are able to access internal functionality. + appInternal, ok := app.(launchr.AppInternal) + if !ok { + return nil + } + + // Define output format flags. + cmd := appInternal.RootCmd() + pflags := cmd.PersistentFlags() + // Make sure not to fail on unknown flags because we are parsing early. + unkFlagsBkp := pflags.ParseErrorsAllowlist.UnknownFlags + pflags.ParseErrorsAllowlist.UnknownFlags = true + pflags.StringVarP(&outputFormat, "output", "o", "", "output action result in specified format: json or yaml (requires action to define result schema)") + + // Parse available flags. + err := pflags.Parse(appInternal.CmdEarlyParsed().Args) + if launchr.IsCommandErrHelp(err) { + return nil + } + if err != nil { + // It shouldn't happen here. + panic(err) + } + pflags.ParseErrorsAllowlist.UnknownFlags = unkFlagsBkp + + var am action.Manager + app.Services().Get(&am) + + // Retrieve and expand application persistent flags with output format options. + persistentFlags := am.GetPersistentFlags() + persistentFlags.AddDefinitions(getOutputPersistentFlags()) + + // Store initial value of the flag. + persistentFlags.Set("output", outputFormat) + + return nil +} + +func getOutputPersistentFlags() action.ParametersList { + return action.ParametersList{ + &action.DefParameter{ + Name: "output", + Title: "Output Format", + Description: "Output action result in specified format: json or yaml (requires action to define result schema)", + Type: jsonschema.String, + Default: "", + }, + } +} From 0dd0a8a1806d00a17fd4f844dd4118038495b552 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Guerraz <861556+jbguerraz@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:42:35 +0000 Subject: [PATCH 3/3] fix(action): surface captured stdout for result-schema actions instead of swallowing it Result-schema actions capture stdout to parse it as the structured result. Two paths silently dropped that output: - shell runtime: on JSON parse failure it only logged at debug level, unlike the container runtime which displays the raw output. Mirror the container fallback. - both runtimes: on action failure the captured stdout was never surfaced (parse and display were gated on success), hiding output that aids debugging. Display it raw on failure. Keeps the principle that text mode never swallows output: launchr only auto- serializes the result for --output json/yaml. Adds shell-runtime regression tests covering valid JSON (structured result), invalid JSON (raw output), and failure (raw output). --- pkg/action/runtime.container.go | 25 ++++++---- pkg/action/runtime.shell.go | 30 ++++++++---- pkg/action/runtime.shell_test.go | 79 ++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 18 deletions(-) create mode 100644 pkg/action/runtime.shell_test.go diff --git a/pkg/action/runtime.container.go b/pkg/action/runtime.container.go index 44d25ea..f415a36 100644 --- a/pkg/action/runtime.container.go +++ b/pkg/action/runtime.container.go @@ -386,15 +386,24 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { // Wait for streaming to complete before parsing result. streamWg.Wait() - // Parse stdout as JSON result if action has result schema. - if hasResultSchema && err == nil && c.stdoutBuf != nil { - var result any - if jsonErr := json.Unmarshal(c.stdoutBuf.Bytes(), &result); jsonErr != nil { - log.Debug("failed to parse stdout as JSON result, displaying raw output", "error", jsonErr) - // If not valid JSON, display the raw output to user - _, _ = streams.Out().Write(c.stdoutBuf.Bytes()) + // If the action declares a result schema, stdout was captured instead of + // streamed to the terminal. Surface it so it is never swallowed: + // - success + valid JSON -> structured result + // - success + invalid JSON -> display the raw output + // - failure -> display the raw output (for debugging) + if hasResultSchema && c.stdoutBuf != nil { + if err == nil { + var result any + if jsonErr := json.Unmarshal(c.stdoutBuf.Bytes(), &result); jsonErr != nil { + log.Debug("failed to parse stdout as JSON result, displaying raw output", "error", jsonErr) + // If not valid JSON, display the raw output to user. + _, _ = streams.Out().Write(c.stdoutBuf.Bytes()) + } else { + c.SetResult(result) + } } else { - c.SetResult(result) + // Action failed: display the captured stdout raw (for debugging). + _, _ = streams.Out().Write(c.stdoutBuf.Bytes()) } } diff --git a/pkg/action/runtime.shell.go b/pkg/action/runtime.shell.go index 74fd0dd..2cfb6d8 100644 --- a/pkg/action/runtime.shell.go +++ b/pkg/action/runtime.shell.go @@ -80,6 +80,26 @@ func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { defer launchr.StopCatchSignals(sigc) cmdErr := cmd.Wait() + + // If the action declares a result schema, stdout was captured instead of + // streamed to the terminal. Surface it so it is never swallowed: + // - success + valid JSON -> structured result + // - success + invalid JSON -> display the raw output + // - failure -> display the raw output (for debugging) + if stdoutBuf != nil { + if cmdErr == nil { + var result any + if jsonErr := json.Unmarshal(stdoutBuf.Bytes(), &result); jsonErr != nil { + log.Debug("failed to parse stdout as JSON result, displaying raw output", "error", jsonErr) + _, _ = streams.Out().Write(stdoutBuf.Bytes()) + } else { + r.SetResult(result) + } + } else { + _, _ = streams.Out().Write(stdoutBuf.Bytes()) + } + } + var exitErr *exec.ExitError if errors.As(cmdErr, &exitErr) { exitCode := exitErr.ExitCode() @@ -93,16 +113,6 @@ func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { return launchr.NewExitError(exitCode, msg) } - // Parse stdout as JSON result if action has result schema. - if stdoutBuf != nil && cmdErr == nil { - var result any - if err := json.Unmarshal(stdoutBuf.Bytes(), &result); err != nil { - log.Debug("failed to parse stdout as JSON result", "error", err) - } else { - r.SetResult(result) - } - } - return cmdErr } diff --git a/pkg/action/runtime.shell_test.go b/pkg/action/runtime.shell_test.go new file mode 100644 index 0000000..8b19643 --- /dev/null +++ b/pkg/action/runtime.shell_test.go @@ -0,0 +1,79 @@ +package action + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// shellResultActionYaml builds a shell action that declares a result schema and +// runs the given single-line bash script. +func shellResultActionYaml(script string) string { + return "runtime:\n" + + " type: shell\n" + + " script: |\n" + + " " + script + "\n" + + "action:\n" + + " title: T\n" + + " result:\n" + + " type: object\n" +} + +func newShellResultAction(t *testing.T, script string) (*Action, *bytes.Buffer) { + t.Helper() + fsys := genFsTestMapActions(1, shellResultActionYaml(script), genPathTypeRoot) + a, err := NewYAMLFromFS("test", fsys) + require.NoError(t, err) + require.NoError(t, a.EnsureLoaded()) + a.SetWorkDir(t.TempDir()) + + out := &bytes.Buffer{} + input := NewInput(a, nil, nil, launchr.NewBasicStreams(nil, out, io.Discard)) + input.SetValidated(true) + require.NoError(t, a.SetInput(input)) + + r := NewShellRuntime() + r.(RuntimeLoggerAware).SetLogger(launchr.Log()) + a.SetRuntime(r) + return a, out +} + +func shellResult(a *Action) any { + if rp, ok := a.Runtime().(RuntimeResultProvider); ok { + return rp.Result() + } + return nil +} + +// A result-schema shell action whose stdout is valid JSON: it is parsed into the +// structured result and not echoed raw to the terminal. +func TestShellRuntime_resultSchema_validJSON(t *testing.T) { + a, out := newShellResultAction(t, `printf '%s' '{"ok":true}'`) + require.NoError(t, a.Execute(context.Background())) + assert.Equal(t, map[string]any{"ok": true}, shellResult(a)) + assert.Empty(t, out.String(), "valid JSON must not be echoed raw") +} + +// Regression (#1): on JSON parse failure the shell runtime must display the raw +// output instead of swallowing it โ€” mirroring the container runtime. +func TestShellRuntime_resultSchema_invalidJSON_displaysRaw(t *testing.T) { + a, out := newShellResultAction(t, `printf '%s' 'plain text, not json'`) + require.NoError(t, a.Execute(context.Background())) + assert.Nil(t, shellResult(a)) + assert.Equal(t, "plain text, not json", out.String()) +} + +// Regression (#2): on action failure the captured stdout must be surfaced, not +// swallowed (display and parsing used to be gated on success). +func TestShellRuntime_resultSchema_failure_displaysRaw(t *testing.T) { + a, out := newShellResultAction(t, `printf '%s' 'output before failure'; exit 3`) + err := a.Execute(context.Background()) + require.Error(t, err) + assert.Nil(t, shellResult(a)) + assert.Equal(t, "output before failure", out.String()) +}