Skip to content
This repository was archived by the owner on Feb 15, 2026. It is now read-only.

Commit efd43a2

Browse files
feat: add 'run' command for runtime secret injection
1 parent ba68859 commit efd43a2

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,5 @@ func init() {
179179
rootCmd.AddCommand(readmeCmd)
180180
rootCmd.AddCommand(diffCmd)
181181
rootCmd.AddCommand(scanCmd)
182+
rootCmd.AddCommand(runCmd)
182183
}

internal/cmd/run.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/keywaysh/cli/internal/api"
8+
"github.com/keywaysh/cli/internal/git"
9+
"github.com/keywaysh/cli/internal/injector"
10+
"github.com/keywaysh/cli/internal/ui"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var runCmd = &cobra.Command{
15+
Use: "run [command]",
16+
Short: "Inject secrets into a command",
17+
Long: `Run a command with secrets injected into the environment.
18+
Secrets are fetched from the vault and injected directly into the process memory.
19+
They are never written to disk.`,
20+
Example: ` keyway run npm run dev
21+
keyway run -- python script.py
22+
keyway run --env prod -- ./deploy.sh`,
23+
RunE: runRun,
24+
}
25+
26+
func init() {
27+
runCmd.Flags().StringP("env", "e", "development", "Environment name")
28+
}
29+
30+
func runRun(cmd *cobra.Command, args []string) error {
31+
if len(args) == 0 {
32+
return fmt.Errorf("command required")
33+
}
34+
35+
commandName := args[0]
36+
commandArgs := args[1:]
37+
38+
// 1. Detect Repo
39+
repo, err := git.DetectRepo()
40+
if err != nil {
41+
ui.Error("Not in a git repository with GitHub remote")
42+
return err
43+
}
44+
45+
// 2. Ensure Login
46+
token, err := EnsureLogin()
47+
if err != nil {
48+
ui.Error(err.Error())
49+
return err
50+
}
51+
52+
// 3. Setup Client
53+
client := api.NewClient(token)
54+
ctx := context.Background()
55+
56+
// 4. Determine Environment
57+
env, _ := cmd.Flags().GetString("env")
58+
envFlagSet := cmd.Flags().Changed("env")
59+
60+
if !envFlagSet && ui.IsInteractive() {
61+
// Fetch available environments
62+
vaultEnvs, err := client.GetVaultEnvironments(ctx, repo)
63+
if err != nil || len(vaultEnvs) == 0 {
64+
vaultEnvs = []string{"development", "staging", "production"}
65+
}
66+
67+
// Find default index (development)
68+
defaultIdx := 0
69+
for i, e := range vaultEnvs {
70+
if e == "development" {
71+
defaultIdx = i
72+
break
73+
}
74+
}
75+
76+
// Reorder to put default first
77+
if defaultIdx > 0 {
78+
vaultEnvs[0], vaultEnvs[defaultIdx] = vaultEnvs[defaultIdx], vaultEnvs[0]
79+
}
80+
81+
selected, err := ui.Select("Environment:", vaultEnvs)
82+
if err != nil {
83+
return err
84+
}
85+
env = selected
86+
}
87+
88+
ui.Step(fmt.Sprintf("Environment: %s", ui.Value(env)))
89+
90+
// 5. Fetch Secrets
91+
var vaultContent string
92+
err = ui.Spin("Fetching secrets...", func() error {
93+
resp, err := client.PullSecrets(ctx, repo, env)
94+
if err != nil {
95+
return err
96+
}
97+
vaultContent = resp.Content
98+
return nil
99+
})
100+
101+
if err != nil {
102+
if apiErr, ok := err.(*api.APIError); ok {
103+
ui.Error(apiErr.Error())
104+
} else {
105+
ui.Error(err.Error())
106+
}
107+
return err
108+
}
109+
110+
// 6. Parse Secrets
111+
secrets := parseEnvContent(vaultContent)
112+
ui.Success(fmt.Sprintf("Injected %d secrets", len(secrets)))
113+
114+
// 7. Execute Command
115+
return injector.RunCommand(commandName, commandArgs, secrets)
116+
}

internal/injector/injector.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package injector
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"os/signal"
8+
"syscall"
9+
)
10+
11+
// RunCommand executes a command with the provided secrets injected into the environment.
12+
// It handles signal forwarding and exit code propagation.
13+
func RunCommand(command string, args []string, secrets map[string]string) error {
14+
// Prepare the command
15+
cmd := exec.Command(command, args...)
16+
17+
// Connect standard input/output
18+
cmd.Stdin = os.Stdin
19+
cmd.Stdout = os.Stdout
20+
cmd.Stderr = os.Stderr
21+
22+
// Build the environment
23+
// Start with current environment
24+
currentEnv := os.Environ()
25+
newEnv := make([]string, 0, len(currentEnv)+len(secrets))
26+
newEnv = append(newEnv, currentEnv...)
27+
28+
// Append secrets
29+
for k, v := range secrets {
30+
newEnv = append(newEnv, fmt.Sprintf("%s=%s", k, v))
31+
}
32+
cmd.Env = newEnv
33+
34+
// Handle signals
35+
sigs := make(chan os.Signal, 1)
36+
// Notify on all common signals
37+
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
38+
39+
// Start the command
40+
if err := cmd.Start(); err != nil {
41+
return fmt.Errorf("failed to start command: %w", err)
42+
}
43+
44+
// Forward signals to the child process
45+
go func() {
46+
for sig := range sigs {
47+
if cmd.Process != nil {
48+
_ = cmd.Process.Signal(sig)
49+
}
50+
}
51+
}()
52+
53+
// Wait for the command to finish
54+
err := cmd.Wait()
55+
56+
// Handle exit code
57+
if exitError, ok := err.(*exec.ExitError); ok {
58+
// The process exited with a non-zero status
59+
if status, ok := exitError.Sys().(syscall.WaitStatus); ok {
60+
os.Exit(status.ExitStatus())
61+
}
62+
os.Exit(1)
63+
}
64+
65+
return err
66+
}

internal/injector/injector_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package injector
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// Helper to capture output of the RunCommand function is hard because it wires to os.Stdout.
13+
// Let's modify RunCommand to accept stdin/out/err as arguments?
14+
// No, keep it simple. The requirement was "Keep it simple".
15+
// I will write a test that invokes a subprocess that calls RunCommand.
16+
17+
func TestRunCommand(t *testing.T) {
18+
if os.Getenv("GO_TEST_PROCESS") != "1" {
19+
return
20+
}
21+
22+
// This code runs INSIDE the test process when invoked recursively
23+
secrets := map[string]string{
24+
"TEST_SECRET": "secret_value",
25+
}
26+
27+
// We use "env" command to print environment variables
28+
err := RunCommand("env", []string{}, secrets)
29+
if err != nil {
30+
fmt.Fprintf(os.Stderr, "RunCommand failed: %v\n", err)
31+
os.Exit(1)
32+
}
33+
os.Exit(0)
34+
}
35+
36+
// Real test runner
37+
func TestRunCommand_Integration(t *testing.T) {
38+
// We re-run the test binary, setting GO_TEST_PROCESS=1
39+
exe, err := os.Executable()
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
44+
cmd := exec.Command(exe, "-test.run=TestRunCommand")
45+
cmd.Env = append(os.Environ(), "GO_TEST_PROCESS=1")
46+
47+
var stdout, stderr bytes.Buffer
48+
cmd.Stdout = &stdout
49+
cmd.Stderr = &stderr
50+
51+
err = cmd.Run()
52+
53+
if err != nil {
54+
t.Fatalf("Process failed: %v\nStderr: %s", err, stderr.String())
55+
}
56+
57+
output := stdout.String()
58+
if !strings.Contains(output, "TEST_SECRET=secret_value") {
59+
t.Errorf("Expected environment variable TEST_SECRET not found in output.\nOutput:\n%s", output)
60+
}
61+
}

0 commit comments

Comments
 (0)