Skip to content

Commit e702514

Browse files
committed
feat: local variable overrides
- Add local_overrides table to vault schema - enveil set KEY=VALUE --local saves override to local vault only - enveil delete KEY --local removes only the local override - Local overrides take precedence over server variables in run, get, and list - list marks overrides with [local override] and shows count - Deleting a server variable also cleans up its local override if one exists - Fix server connect to validate API key before saving (was accepting any key) - Fix get and list to derive master key once instead of twice in server mode
1 parent 7853cff commit e702514

7 files changed

Lines changed: 270 additions & 40 deletions

File tree

cli/cmd/enveil/delete.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ var deleteCmd = &cobra.Command{
1818
RunE: runDelete,
1919
}
2020

21+
var deleteLocalFlag bool
22+
2123
func init() {
24+
deleteCmd.Flags().BoolVar(&deleteLocalFlag, "local", false, "Delete only the local override, leave the server variable intact")
2225
rootCmd.AddCommand(deleteCmd)
2326
}
2427

@@ -34,6 +37,11 @@ func runDelete(cmd *cobra.Command, args []string) error {
3437
return fmt.Errorf("enveil is not initialized, run 'enveil init' first")
3538
}
3639

40+
// --local: only remove the local override
41+
if deleteLocalFlag {
42+
return deleteLocalOverride(cfg, key)
43+
}
44+
3745
if cfg.HasServer() {
3846
fmt.Printf("\n Delete '%s' from %s? [y/N]: ", key, ui.EnvBadge(cfg.ActiveProject, cfg.ActiveEnv))
3947
var response string
@@ -47,6 +55,12 @@ func runDelete(cmd *cobra.Command, args []string) error {
4755
if err := client.DeleteVariable(cfg.ActiveProject, cfg.ActiveEnv, key); err != nil {
4856
return err
4957
}
58+
59+
// Also remove local override if one exists, to avoid a ghost value
60+
if err := removeLocalOverrideIfExists(cfg, key); err != nil {
61+
return err
62+
}
63+
5064
ui.Success("'%s' deleted from %s", key, ui.EnvBadge(cfg.ActiveProject, cfg.ActiveEnv))
5165
return nil
5266
}
@@ -100,4 +114,39 @@ func runDelete(cmd *cobra.Command, args []string) error {
100114

101115
ui.Success("'%s' deleted from %s", key, ui.EnvBadge(cfg.ActiveProject, cfg.ActiveEnv))
102116
return nil
117+
}
118+
119+
func deleteLocalOverride(cfg *config.Config, key string) error {
120+
masterKeyHex, err := promptAndDeriveKey(cfg)
121+
if err != nil {
122+
return err
123+
}
124+
125+
v, err := vault.Open(cfg.VaultPath, masterKeyHex)
126+
if err != nil {
127+
return err
128+
}
129+
defer v.Close()
130+
131+
if err := v.DeleteLocalOverride(cfg.ActiveProject, cfg.ActiveEnv, key); err != nil {
132+
return err
133+
}
134+
135+
ui.Success("local override for '%s' removed — server value will be used", key)
136+
return nil
137+
}
138+
139+
func removeLocalOverrideIfExists(cfg *config.Config, key string) error {
140+
masterKeyHex, err := promptAndDeriveKey(cfg)
141+
if err != nil {
142+
return err
143+
}
144+
145+
v, err := vault.Open(cfg.VaultPath, masterKeyHex)
146+
if err != nil {
147+
return err
148+
}
149+
defer v.Close()
150+
151+
return v.DeleteLocalOverride(cfg.ActiveProject, cfg.ActiveEnv, key)
103152
}

cli/cmd/enveil/get.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ func init() {
2121
rootCmd.AddCommand(getCmd)
2222
}
2323

24-
2524
func runGet(cmd *cobra.Command, args []string) error {
2625
key := strings.TrimSpace(args[0])
2726

@@ -34,12 +33,33 @@ func runGet(cmd *cobra.Command, args []string) error {
3433
return fmt.Errorf("enveil is not initialized, run 'enveil init' first")
3534
}
3635

36+
// Derive key once — used for local vault in both modes
37+
masterKeyHex, err := promptAndDeriveKey(cfg)
38+
if err != nil {
39+
return err
40+
}
41+
42+
localVault, err := vault.Open(cfg.VaultPath, masterKeyHex)
43+
if err != nil {
44+
return err
45+
}
46+
defer localVault.Close()
47+
3748
if cfg.HasServer() {
49+
// Check local override first
50+
if overrideVal, exists, err := localVault.GetLocalOverride(cfg.ActiveProject, cfg.ActiveEnv, key); err != nil {
51+
return fmt.Errorf("error reading local overrides: %w", err)
52+
} else if exists {
53+
fmt.Println(overrideVal)
54+
return nil
55+
}
56+
3857
client := serverclient.New(cfg.ServerURL, cfg.ServerAPIKey)
3958
vars, err := client.GetVariables(cfg.ActiveProject, cfg.ActiveEnv)
4059
if err != nil {
4160
return fmt.Errorf("error fetching variables from server: %w", err)
4261
}
62+
4363
value, ok := vars[key]
4464
if !ok {
4565
return fmt.Errorf("variable '%s' not found", key)
@@ -48,34 +68,23 @@ func runGet(cmd *cobra.Command, args []string) error {
4868
return nil
4969
}
5070

51-
masterKeyHex, err := promptAndDeriveKey(cfg)
52-
if err != nil {
53-
return err
54-
}
55-
56-
v, err := vault.Open(cfg.VaultPath, masterKeyHex)
57-
if err != nil {
58-
return err
59-
}
60-
defer v.Close()
61-
62-
projectID, _, err := v.GetProjectByPath(getCurrentDir())
71+
projectID, _, err := localVault.GetProjectByPath(getCurrentDir())
6372
if err != nil {
6473
return err
6574
}
6675
if projectID == 0 {
6776
return fmt.Errorf("project not found in vault, run 'enveil init'")
6877
}
6978

70-
envID, err := v.GetEnvironment(projectID, cfg.ActiveEnv)
79+
envID, err := localVault.GetEnvironment(projectID, cfg.ActiveEnv)
7180
if err != nil {
7281
return err
7382
}
7483
if envID == 0 {
7584
return fmt.Errorf("environment '%s' not found", cfg.ActiveEnv)
7685
}
7786

78-
value, err := v.GetVariable(envID, key)
87+
value, err := localVault.GetVariable(envID, key)
7988
if err != nil {
8089
return err
8190
}

cli/cmd/enveil/list.go

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,76 +36,98 @@ func runList(cmd *cobra.Command, args []string) error {
3636
}
3737

3838
var vars map[string]string
39+
var overrides map[string]string
40+
41+
// Derive key once — used for local vault regardless of mode
42+
masterKeyHex, err := promptAndDeriveKey(cfg)
43+
if err != nil {
44+
return err
45+
}
46+
47+
localVault, err := vault.Open(cfg.VaultPath, masterKeyHex)
48+
if err != nil {
49+
return err
50+
}
51+
defer localVault.Close()
3952

4053
if cfg.HasServer() {
4154
client := serverclient.New(cfg.ServerURL, cfg.ServerAPIKey)
4255
vars, err = client.GetVariables(cfg.ActiveProject, cfg.ActiveEnv)
4356
if err != nil {
4457
return fmt.Errorf("error fetching variables from server: %w", err)
4558
}
46-
} else {
47-
masterKeyHex, err := promptAndDeriveKey(cfg)
48-
if err != nil {
49-
return err
50-
}
5159

52-
v, err := vault.Open(cfg.VaultPath, masterKeyHex)
60+
overrides, err = localVault.GetLocalOverrides(cfg.ActiveProject, cfg.ActiveEnv)
5361
if err != nil {
54-
return err
62+
return fmt.Errorf("error reading local overrides: %w", err)
5563
}
56-
defer v.Close()
57-
58-
projectID, _, err := v.GetProjectByPath(getCurrentDir())
64+
} else {
65+
projectID, _, err := localVault.GetProjectByPath(getCurrentDir())
5966
if err != nil {
6067
return err
6168
}
6269
if projectID == 0 {
6370
return fmt.Errorf("project not found in vault, run 'enveil init'")
6471
}
6572

66-
envID, err := v.GetEnvironment(projectID, cfg.ActiveEnv)
73+
envID, err := localVault.GetEnvironment(projectID, cfg.ActiveEnv)
6774
if err != nil {
6875
return err
6976
}
7077
if envID == 0 {
7178
return fmt.Errorf("environment '%s' not found", cfg.ActiveEnv)
7279
}
7380

74-
vars, err = v.GetVariables(envID)
81+
vars, err = localVault.GetVariables(envID)
7582
if err != nil {
7683
return err
7784
}
7885
}
7986

8087
ui.Header(fmt.Sprintf("%s %s", cfg.ActiveProject, ui.EnvBadge(cfg.ActiveProject, cfg.ActiveEnv)))
8188

82-
if len(vars) == 0 {
89+
if len(vars) == 0 && len(overrides) == 0 {
8390
ui.Muted(" No variables in this environment.")
8491
fmt.Println()
8592
return nil
8693
}
8794

88-
// Find longest key for alignment
89-
maxLen := 0
95+
allKeys := make(map[string]struct{})
9096
for k := range vars {
97+
allKeys[k] = struct{}{}
98+
}
99+
for k := range overrides {
100+
allKeys[k] = struct{}{}
101+
}
102+
103+
maxLen := 0
104+
for k := range allKeys {
91105
if len(k) > maxLen {
92106
maxLen = len(k)
93107
}
94108
}
95109

96-
// Sort keys
97-
keys := make([]string, 0, len(vars))
98-
for k := range vars {
110+
keys := make([]string, 0, len(allKeys))
111+
for k := range allKeys {
99112
keys = append(keys, k)
100113
}
101114
sort.Strings(keys)
102115

116+
localCount := 0
103117
for _, k := range keys {
104-
fmt.Printf(" %-*s = ***\n", maxLen, k)
118+
if _, isOverride := overrides[k]; isOverride {
119+
fmt.Printf(" %-*s = *** [local override]\n", maxLen, k)
120+
localCount++
121+
} else {
122+
fmt.Printf(" %-*s = ***\n", maxLen, k)
123+
}
105124
}
106125

107126
fmt.Println()
108-
ui.Muted(" %d variable(s) total", len(vars))
127+
ui.Muted(" %d variable(s) total", len(allKeys))
128+
if localCount > 0 {
129+
ui.Muted(" %d local override(s) — visible only on this machine", localCount)
130+
}
109131
fmt.Println()
110132
return nil
111133
}

cli/cmd/enveil/run.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import (
1515
var runCmd = &cobra.Command{
1616
Use: "run <comando>",
1717
Short: "Run a command with the active environment variables injected",
18-
// DisableFlagParsing permite pasar flags al comando hijo sin que cobra las intercepte
19-
// por ejemplo: enveil run npm run dev --watch
2018
DisableFlagParsing: true,
2119
Args: cobra.MinimumNArgs(1),
2220
RunE: runRun,
@@ -38,13 +36,18 @@ func runRun(cmd *cobra.Command, args []string) error {
3836

3937
var vars map[string]string
4038

41-
// Use server if configured
4239
if cfg.HasServer() {
4340
client := serverclient.New(cfg.ServerURL, cfg.ServerAPIKey)
4441
vars, err = client.GetVariables(cfg.ActiveProject, cfg.ActiveEnv)
4542
if err != nil {
4643
return fmt.Errorf("error fetching variables from server: %w", err)
4744
}
45+
46+
// Apply local overrides on top of server variables
47+
vars, err = applyLocalOverrides(cfg, vars)
48+
if err != nil {
49+
return err
50+
}
4851
} else {
4952
masterKeyHex, err := promptAndDeriveKey(cfg)
5053
if err != nil {
@@ -79,7 +82,6 @@ func runRun(cmd *cobra.Command, args []string) error {
7982
}
8083
}
8184

82-
// Build environment and exec
8385
env := os.Environ()
8486
for k, v := range vars {
8587
env = append(env, k+"="+v)
@@ -91,4 +93,39 @@ func runRun(cmd *cobra.Command, args []string) error {
9193
}
9294

9395
return syscall.Exec(binary, args, env)
96+
}
97+
98+
// applyLocalOverrides opens the local vault and merges any overrides
99+
// on top of the provided vars map. Local overrides always win.
100+
// Returns a new map — does not mutate the original.
101+
func applyLocalOverrides(cfg *config.Config, vars map[string]string) (map[string]string, error) {
102+
masterKeyHex, err := promptAndDeriveKey(cfg)
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
v, err := vault.Open(cfg.VaultPath, masterKeyHex)
108+
if err != nil {
109+
return nil, err
110+
}
111+
defer v.Close()
112+
113+
overrides, err := v.GetLocalOverrides(cfg.ActiveProject, cfg.ActiveEnv)
114+
if err != nil {
115+
return nil, fmt.Errorf("error reading local overrides: %w", err)
116+
}
117+
118+
if len(overrides) == 0 {
119+
return vars, nil
120+
}
121+
122+
merged := make(map[string]string, len(vars))
123+
for k, v := range vars {
124+
merged[k] = v
125+
}
126+
for k, v := range overrides {
127+
merged[k] = v
128+
}
129+
130+
return merged, nil
94131
}

cli/cmd/enveil/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ func runServerConnect(cmd *cobra.Command, args []string) error {
7171
return fmt.Errorf("could not connect to server: %w", err)
7272
}
7373

74+
// Then validate the API key with an authenticated request
75+
if _, err := client.ListProjects(); err != nil {
76+
return fmt.Errorf("invalid API key: %w", err)
77+
}
78+
7479
cfg, err := config.Load()
7580
if err != nil {
7681
return err

0 commit comments

Comments
 (0)