diff --git a/README.md b/README.md index 85514e0..1bc8dbd 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Perfect for running CLI tools, automation scripts, or administrative commands se Install the `cobra-lambda` CLI tool to invoke remote Cobra applications hosted in Lambda: ```bash -go install github.com/JayJamieson/cobra-lambda@latest +go install github.com/JayJamieson/cobra-lambda/cmd/clctl@latest ``` Or build from source: @@ -29,7 +29,7 @@ Or build from source: ```bash git clone https://github.com/JayJamieson/cobra-lambda.git cd cobra-lambda -go build -o cobra-lambda . +go build -o clctl cmd/clctl/main.go ``` ### Go Library (for wrapping Cobra apps in Lambda) @@ -106,13 +106,13 @@ Use the CLI client to invoke your Lambda-hosted Cobra app: ```bash # Basic invocation -cobra-lambda --name my-lambda-function +clctl --name my-lambda-function # With arguments and flags -cobra-lambda --name my-lambda-function --name Alice +clctl --name my-lambda-function --name Alice # Pass any Cobra CLI arguments -cobra-lambda --name my-lambda-function subcommand --flag value arg1 arg2 +clctl --name my-lambda-function subcommand --flag value arg1 arg2 ``` The CLI forwards all arguments after `--name [function-name]` to your Lambda function. @@ -187,26 +187,26 @@ For concurrent executions, create separate wrapper instances per goroutine, or r ## CLI Client Usage -The `cobra-lambda` CLI tool invokes remote Lambda functions: +The `clctl` CLI tool invokes remote Lambda functions: ```bash -cobra-lambda --name [cobra-args...] +clctl --name [cobra-args...] ``` ### Examples ```bash # Simple invocation -cobra-lambda --name my-cli-app +clctl --name my-cli-app # With flags -cobra-lambda --name my-cli-app --verbose --output json +clctl --name my-cli-app --verbose --output json # With subcommands -cobra-lambda --name my-cli-app deploy --environment prod +clctl --name my-cli-app deploy --environment prod # Help -cobra-lambda --help +clctl --help ``` ### AWS Configuration @@ -217,18 +217,86 @@ The CLI uses the AWS SDK for Go v2 and respects standard AWS configuration: - Region from `AWS_REGION` environment variable or AWS config - IAM permissions required: `lambda:InvokeFunction` -## Testing +## Local Development with cldebug -Run the wrapper package test suite: +The `cldebug` cli allows you to test and debug your Lambda Cobra CLI applications locally without deploying to AWS. It starts your Lambda function as an RPC server and invokes it locally, simulating the Lambda runtime environment. + +### Installing cldebug + +Install `cldebug` cli: + +```bash +go install github.com/JayJamieson/cobra-lambda/cmd/cldebug@latest +``` + +Or build from source: + +```bash +git clone https://github.com/JayJamieson/cobra-lambda.git +cd cobra-lambda +go build -o cldebug cmd/cldebug/main.go +``` + +### Usage Examples + +#### 1. Invoke a compiled Lambda binary + +First, build your Lambda function: + +```bash +go build -o bootstrap ./iac/go-demo/main.go +``` + +Then invoke it locally with `cldebug`: ```bash -cd wrapper -go test -v +cldebug ./bootstrap arg1 arg2 --flag value +``` + +#### 2. Invoke a Go file directly with go run + +For rapid development, you can run your Lambda source file directly without building: + +```bash +cldebug --go-run ./iac/go-demo/main.go arg1 arg2 --flag value +``` + +#### 3. Debug mode + +Enable debug logging to see what's happening under the hood: + +```bash +cldebug --debug ./bootstrap arg1 arg2 +``` + +This will show: +- Lambda process startup +- RPC connection details +- Invocation payload +- Process cleanup steps + +### Full Example + +Using the example from `iac/go-demo/`: + +```bash +# Build the Lambda function +cd iac/go-demo +go build -o bootstrap main.go + +# Test locally with cldebug +cldebug ./bootstrap hello world + +# Or use go run for faster iteration +cldebug --go-run main.go hello world + +# With debug output +cldebug --debug ./bootstrap hello world ``` ## How It Works -1. **Client Side**: The `cobra-lambda` CLI tool extracts the `--name` flag to identify the target Lambda function, then forwards all remaining arguments as a JSON array payload. +1. **Client Side**: The `clctl` CLI tool extracts the `--name` flag to identify the target Lambda function, then forwards all remaining arguments as a JSON array payload. 2. **Lambda Side**: The wrapper package: - Intercepts `os.Stdout` and `os.Stderr` using pipes @@ -239,7 +307,7 @@ go test -v 3. **Response**: The client displays the returned output, making the remote execution feel like a local CLI invocation. -## Examples in This Repository +## Examples See the `iac/` directory for complete examples: diff --git a/flag.go b/cli/flag/flag.go similarity index 77% rename from flag.go rename to cli/flag/flag.go index f696c88..64098b4 100644 --- a/flag.go +++ b/cli/flag/flag.go @@ -1,4 +1,4 @@ -package main +package flag import ( "errors" @@ -7,21 +7,7 @@ import ( var ErrHelp = errors.New("flag: help requested") -var HelpMessage = `Cobra Lambda -Usage of cobra-lambda: - With arguements: - - cl - cobra-lambda --name [function name] -arg1 123 -arg2 foo --arg3 - - Without arguments: - cl - cobra-lambda --name [function name] - -Arguments after --name will be forwarded to remote cli named [function name] -` - -func parseFuncName(args []string) (string, bool, error) { +func ParseFuncName(args []string) (string, bool, error) { if len(args) == 0 { return "", false, nil } diff --git a/cli/runner.go b/cli/runner.go new file mode 100644 index 0000000..c91ccf2 --- /dev/null +++ b/cli/runner.go @@ -0,0 +1,127 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +type RunMode int + +const ( + // ModeBinary runs a compiled Lambda binary + ModeBinary RunMode = iota + // ModeGoRun uses 'go run' to execute the Lambda function + ModeGoRun +) + +type Runner struct { + Mode RunMode + Debug bool + ServerPort string +} + +type CommandConfig struct { + LambdaPath string + LambdaArgs []string +} + +func NewRunner(mode RunMode, debug bool, serverPort string) *Runner { + return &Runner{ + Mode: mode, + Debug: debug, + ServerPort: serverPort, + } +} + +// ParseArgs parses remaining arguments after flag parsing based on the run mode +// args should be the result of flag.Args() after flag.Parse() +func (r *Runner) ParseArgs(args []string) (*CommandConfig, error) { + config := &CommandConfig{} + + r.Debugf("Parsing arguments: %v", args) + + if len(args) < 1 { + return nil, fmt.Errorf("missing lambda path argument") + } + + switch r.Mode { + case ModeBinary: + + config.LambdaPath = args[0] + if len(args) > 1 { + config.LambdaArgs = args[1:] + } + + case ModeGoRun: + isGoFile := strings.HasSuffix(args[0], ".go") + + if !isGoFile { + return nil, fmt.Errorf("invalid go file: %s", args[0]) + } + + config.LambdaPath = args[0] + if len(args) > 1 { + config.LambdaArgs = args[1:] + } + + default: + return nil, fmt.Errorf("unknown run mode: %d", r.Mode) + } + + return config, nil +} + +func (r *Runner) CreateCommand(config *CommandConfig) (*exec.Cmd, error) { + var cmd *exec.Cmd + + if _, err := os.Stat(config.LambdaPath); os.IsNotExist(err) { + return nil, fmt.Errorf("not found at %s", config.LambdaPath) + } + + switch r.Mode { + case ModeBinary: + cmd = exec.Command(config.LambdaPath, config.LambdaArgs...) + + case ModeGoRun: + // Construct args: go run [lambda-args...] + args := append([]string{"run", config.LambdaPath}, config.LambdaArgs...) + cmd = exec.Command("go", args...) + default: + return nil, fmt.Errorf("unknown run mode: %d", r.Mode) + } + + // needed in order to properly kill lambda process when run with go run + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + + cmd.Env = append(os.Environ(), fmt.Sprintf("_LAMBDA_SERVER_PORT=%s", r.ServerPort)) + + return cmd, nil +} + +func (r *Runner) KillProcessGroup(cmd *exec.Cmd, signal syscall.Signal) error { + if cmd.Process == nil { + return fmt.Errorf("process not started") + } + + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err != nil { + return fmt.Errorf("failed to get process group ID: %w", err) + } + + if err := syscall.Kill(-pgid, signal); err != nil { + return fmt.Errorf("failed to send signal %v: %w", signal, err) + } + + return nil +} + +func (r *Runner) Debugf(format string, args ...any) { + if r.Debug { + fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...) + } +} diff --git a/main.go b/cmd/clctl/main.go similarity index 60% rename from main.go rename to cmd/clctl/main.go index 1e4aa9b..9ecf9fc 100644 --- a/main.go +++ b/cmd/clctl/main.go @@ -6,10 +6,25 @@ import ( "fmt" "os" + "github.com/JayJamieson/cobra-lambda/cli/flag" "github.com/JayJamieson/cobra-lambda/wrapper" lambda "github.com/JayJamieson/go-lambda-invoke" ) +var HelpMessage = `Cobra Lambda +Usage of cobra-lambda: + With arguements: + + clctl + cobra-lambda --name [function name] -arg1 123 -arg2 foo --arg3 + + Without arguments: + clctl + cobra-lambda --name [function name] + +Arguments after --name will be forwarded to remote cli named [function name] +` + func main() { ctx := context.Background() @@ -25,9 +40,9 @@ func main() { os.Exit(1) } - funcName, ok, err := parseFuncName(os.Args[1:]) + funcName, ok, err := flag.ParseFuncName(os.Args[1:]) - if err != nil && errors.Is(err, ErrHelp) { + if err != nil && errors.Is(err, flag.ErrHelp) { fmt.Print(HelpMessage) os.Exit(0) } @@ -37,7 +52,7 @@ func main() { os.Exit(1) } - output := &ExecutionOutput{} + output := &wrapper.CobraLambdaOutput{} err = lambda.InvokeSync(ctx, client, &lambda.InvokeInput{ Name: funcName, diff --git a/cmd/cldebug/main.go b/cmd/cldebug/main.go new file mode 100644 index 0000000..8fbb204 --- /dev/null +++ b/cmd/cldebug/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "net" + "net/rpc" + "os" + "syscall" + "time" + + "github.com/JayJamieson/cobra-lambda/cli" + "github.com/JayJamieson/cobra-lambda/wrapper" + "github.com/aws/aws-lambda-go/lambda/messages" +) + +const ( + lambdaServerPort = "8001" + helpMessage = `Usage: rpc [flags] [lambda-path] [args...] + rpc [flags] [lambda-path] [args...] + +Runs a Go Lambda function locally over RPC. + +Flags: + --debug Enable debug logging + --go-run Use 'go run' instead of compiled binary (requires '--' separator) + +Arguments: + lambda-path Path to the compiled Lambda binary or source file + args... Arguments to pass to the Lambda function + +Examples: + # Run compiled binary + rpc ./lambda-binary arg1 arg2 + + # Run with go run + rpc --go-run cmd/lambda/main.go arg1 arg2 + + # Debug mode + rpc --debug ./lambda-binary + +The Lambda binary will be started with _LAMBDA_SERVER_PORT=8001 and invoked over RPC. +` +) + +var ( + debugFlag = flag.Bool("debug", false, "Enable debug logging") + goRunFlag = flag.Bool("go-run", false, "Use 'go run' instead of compiled binary") +) + +func main() { + flag.Usage = func() { + fmt.Print(helpMessage) + } + flag.Parse() + + // Determine run mode + mode := cli.ModeBinary + if *goRunFlag { + mode = cli.ModeGoRun + } + + // Create runner + runner := cli.NewRunner(mode, *debugFlag, lambdaServerPort) + + // Parse arguments based on mode (use flag.Args() which contains non-flag arguments) + config, err := runner.ParseArgs(flag.Args()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n\n", err) + flag.Usage() + os.Exit(1) + } + + runner.Debugf("Mode: %v", mode) + runner.Debugf("Lambda path: %s", config.LambdaPath) + runner.Debugf("Lambda args: %v", config.LambdaArgs) + + // Create the command + cmd, err := runner.CreateCommand(config) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to start Lambda process: %v\n", err) + os.Exit(1) + } + + runner.Debugf("Lambda process started with PID: %d", cmd.Process.Pid) + + // Ensure we kill the process on exit + defer func() { + if cmd.Process != nil { + runner.Debugf("Cleaning up Lambda process...") + _ = runner.KillProcessGroup(cmd, syscall.SIGKILL) + _ = cmd.Wait() + } + }() + + if err := waitForServer(lambdaServerPort, 5*time.Second); err != nil { + fmt.Fprintf(os.Stderr, "Lambda server failed to start: %v\n", err) + os.Exit(1) + } + + runner.Debugf("Lambda server is ready on port %s", lambdaServerPort) + + client, err := rpc.Dial("tcp", fmt.Sprintf("localhost:%s", lambdaServerPort)) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to connect to Lambda server: %v\n", err) + os.Exit(1) + } + + defer func() { + if client != nil { + _ = client.Close() + } + }() + + runner.Debugf("Connected to Lambda RPC server") + + argsEvent := wrapper.CobraLambdaEvent{Args: config.LambdaArgs} + payload, err := json.Marshal(argsEvent) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to marshal event: %v\n", err) + os.Exit(1) + } + + args := messages.InvokeRequest{ + Payload: payload, + Deadline: messages.InvokeRequest_Timestamp{ + Seconds: time.Now().Unix() + 10, + }, + } + + runner.Debugf("Invoking Lambda function...") + invokeResponse := &messages.InvokeResponse{} + if err := client.Call("Function.Invoke", args, &invokeResponse); err != nil { + fmt.Fprintf(os.Stderr, "Lambda invocation failed: %v\n", err) + os.Exit(1) + } + + // Check for Lambda execution errors + if invokeResponse.Error != nil { + fmt.Fprintf(os.Stderr, "Lambda execution error: %s\n", invokeResponse.Error.Message) + os.Exit(1) + } + + output := &wrapper.CobraLambdaOutput{} + if err := json.Unmarshal(invokeResponse.Payload, &output); err != nil { + fmt.Fprintf(os.Stderr, "Failed to unmarshal response: %v\n", err) + os.Exit(1) + } + + fmt.Print(output.Stdout) + + runner.Debugf("Sending SIGTERM to Lambda process...") + if err := runner.KillProcessGroup(cmd, syscall.SIGTERM); err != nil { + fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err) + } + + time.Sleep(200 * time.Millisecond) + + runner.Debugf("Attempting to call Function.Ping after SIGTERM...") + pingResponse := &messages.PingResponse{} + + if err := client.Call("Function.Ping", messages.PingRequest{}, pingResponse); err != nil { + runner.Debugf("Function.Ping failed (expected): %v", err) + } else { + runner.Debugf("Function.Ping succeeded unexpectedly") + } +} + +func waitForServer(port string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout waiting for server on port %s", port) +} diff --git a/go.mod b/go.mod index bb9cadb..4f66fb2 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/aws/aws-lambda-go v1.51.0 // indirect github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect diff --git a/go.sum b/go.sum index abf39ef..fc6b2b7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/JayJamieson/go-lambda-invoke v0.0.0-20241203104456-7a8a6587f398 h1:AKAgccffTx3xYMAf6Bdcrsw4vSbxNcb1rYaAZZ7CFms= github.com/JayJamieson/go-lambda-invoke v0.0.0-20241203104456-7a8a6587f398/go.mod h1:kFa+IKA/gJF2uXa7AZhzW9bk1lNmF4TypeGWA+6bm2w= +github.com/aws/aws-lambda-go v1.51.0 h1:/THH60NjiAs3K5TWet3Gx5w8MdR7oPOQH9utaKYY1JQ= +github.com/aws/aws-lambda-go v1.51.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= diff --git a/iac/go-demo/go.mod b/iac/go-demo/go.mod index 5f686b5..12eee4a 100644 --- a/iac/go-demo/go.mod +++ b/iac/go-demo/go.mod @@ -1,14 +1,16 @@ module go-demo -go 1.22.0 +go 1.23 + +toolchain go1.24.4 require ( - github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7 + github.com/JayJamieson/cobra-lambda v0.1.1 github.com/aws/aws-lambda-go v1.47.0 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.10.2 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.10 // indirect ) diff --git a/iac/go-demo/go.sum b/iac/go-demo/go.sum index c70275b..b5a1935 100644 --- a/iac/go-demo/go.sum +++ b/iac/go-demo/go.sum @@ -1,8 +1,8 @@ -github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7 h1:7LH1KZKjHhbou3m4T+4eWoBdERbbh0yKgkwpWTdTEV0= -github.com/JayJamieson/cobra-lambda v0.0.0-20251207075948-7fdd6052f1a7/go.mod h1:QyPjx0bT+o+7oVw3LNTjwrrAka4A+HIIiSF1BOXoNNQ= +github.com/JayJamieson/cobra-lambda v0.1.1 h1:6n1OelK4nMX1/Stvis/JKZ4m8Kq30LgTH9/rye2ScWo= +github.com/JayJamieson/cobra-lambda v0.1.1/go.mod h1:QyPjx0bT+o+7oVw3LNTjwrrAka4A+HIIiSF1BOXoNNQ= github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -10,12 +10,14 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/iac/go-demo/main.go b/iac/go-demo/main.go index 5c20914..b4cbd41 100644 --- a/iac/go-demo/main.go +++ b/iac/go-demo/main.go @@ -22,19 +22,25 @@ var rootCmd = &cobra.Command{ } func Handler(ctx context.Context, event json.RawMessage) (any, error) { - - args := make([]string, 0, 10) - err := json.Unmarshal(event, &args) + args, err := wrapper.UnmarshalEvent(event) if err != nil { return nil, err } - w := wrapper.NewCobraLambda(ctx, rootCmd) - result, err := w.Execute(args) + w := wrapper.NewCobraLambdaCLI(ctx, rootCmd) + result, err := w.Execute(args.Args) + + if err != nil { + return map[string]any{ + "stdout": result.Stdout, + "error": err.Error(), + }, nil + } return map[string]any{ - "stdout": result.Output, + "stdout": result.Stdout, + "error": nil, }, nil } diff --git a/types.go b/types.go deleted file mode 100644 index 4a823e4..0000000 --- a/types.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -type ExecutionOutput struct { - Stdout string `json:"stdout"` -} diff --git a/wrapper/lambda_test.go b/wrapper/lambda_test.go index 42d7b43..bea0058 100644 --- a/wrapper/lambda_test.go +++ b/wrapper/lambda_test.go @@ -44,7 +44,7 @@ func TestNewCobrLambdaHandler_BasicExecution(t *testing.T) { } // Check the result is an OutputCapture - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -115,7 +115,7 @@ func TestNewCobrLambdaHandler_CommandError(t *testing.T) { } // Result should still contain output capture - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture even on error, got %T", result) } @@ -163,7 +163,7 @@ func TestNewCobrLambdaHandler_WithSubcommands(t *testing.T) { t.Fatalf("Handler returned error: %v", err) } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -210,7 +210,7 @@ func TestNewCobrLambdaHandler_EmptyArgs(t *testing.T) { t.Error("Command was not executed") } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } @@ -261,7 +261,7 @@ func TestNewCobrLambdaHandler_ContextPropagation(t *testing.T) { t.Error("Context was not properly propagated to command") } - output, ok := result.(*OutputCapture) + output, ok := result.(*CobraLambdaOutput) if !ok { t.Fatalf("Expected *OutputCapture, got %T", result) } diff --git a/wrapper/wrapper.go b/wrapper/wrapper.go index 2c7f041..d28c7c3 100644 --- a/wrapper/wrapper.go +++ b/wrapper/wrapper.go @@ -9,9 +9,10 @@ import ( "github.com/spf13/cobra" ) -// OutputCapture holds captured output from both Cobra command and os.Stdout/Stderr -type OutputCapture struct { +// CobraLambdaOutput holds captured output from both Cobra command and os.Stdout/Stderr +type CobraLambdaOutput struct { Stdout string `json:"stdout"` + Error string `json:"error"` } type CobraLambda struct { @@ -35,7 +36,7 @@ func NewCobraLambdaCLI(ctx context.Context, cmd *cobra.Command) *CobraLambda { // Execute runs the Cobra command with the given arguments and captures all output // This method is thread-safe and will restore os.Stdout/Stderr even if the command panics // Note: Only one execution can run at a time per wrapper instance to avoid interference -func (w *CobraLambda) Execute(args []string) (*OutputCapture, error) { +func (w *CobraLambda) Execute(args []string) (*CobraLambdaOutput, error) { w.mu.Lock() defer w.mu.Unlock() @@ -100,14 +101,14 @@ func (w *CobraLambda) Execute(args []string) (*OutputCapture, error) { os.Stdout = w.originalStdout os.Stderr = w.originalStderr - return &OutputCapture{ + return &CobraLambdaOutput{ Stdout: sharedBuffer.String(), }, execErr } // ExecuteWithContext is a convenience method that runs Execute with the provided context overriding // context passed in from NewCobraLambda and restoring to original context after execution -func (w *CobraLambda) ExecuteContext(ctx context.Context, args []string) (*OutputCapture, error) { +func (w *CobraLambda) ExecuteContext(ctx context.Context, args []string) (*CobraLambdaOutput, error) { w.cmd.SetContext(ctx) output, err := w.Execute(args) w.cmd.SetContext(w.ctx)