@@ -12,17 +12,30 @@ import (
1212 "github.com/spf13/cobra"
1313)
1414
15+ // redactedPlaceholder is printed in place of env values in `env get` output
16+ // unless --reveal is passed. Values files commonly contain secret-shaped
17+ // material (API keys, connection strings, tokens) and must not leak to logs
18+ // or terminal scrollback by default. Revealing is always audited.
19+ const redactedPlaceholder = "<redacted>"
20+
1521// newEnvCommand returns the "env" subcommand for managing named environments.
1622func newEnvCommand () * cobra.Command {
1723 cmd := & cobra.Command {
1824 Use : "env" ,
1925 Short : "Manage named environments" ,
20- Long : "Create, list, show, and delete named environments for parameterized workflow execution." ,
26+ Long : `Named environments (e.g., "production", "staging") store reusable input
27+ and env variable sets for parameterized workflow runs. Combine with
28+ ` + "`mantle run --env <name>`" + ` and ` + "`mantle plan --env <name>`" + ` to promote the
29+ same workflow across environments without rewriting CLI flags.
30+
31+ Override precedence (highest wins):
32+ --input flags > --values file > --env named environment > workflow defaults` ,
2133 }
2234
2335 cmd .AddCommand (newEnvCreateCommand ())
36+ cmd .AddCommand (newEnvUpdateCommand ())
2437 cmd .AddCommand (newEnvListCommand ())
25- cmd .AddCommand (newEnvShowCommand ())
38+ cmd .AddCommand (newEnvGetCommand ())
2639 cmd .AddCommand (newEnvDeleteCommand ())
2740
2841 return cmd
@@ -36,31 +49,39 @@ func newEnvCreateCommand() *cobra.Command {
3649 cmd := & cobra.Command {
3750 Use : "create <name>" ,
3851 Short : "Create a named environment" ,
39- Long : "Creates a named environment from a values file." ,
52+ Long : `Creates a named environment from a YAML values file. The file must
53+ contain top-level ` + "`inputs:`" + ` and/or ` + "`env:`" + ` keys. Names must match
54+ [a-z0-9][a-z0-9_-]*. Fails if an environment with the same name already
55+ exists in the team scope; use ` + "`mantle env update`" + ` to replace it.` ,
4056 Example : ` mantle env create production --from prod.values.yaml
4157 mantle env create staging --from staging.values.yaml` ,
4258 Args : cobra .ExactArgs (1 ),
4359 RunE : func (cmd * cobra.Command , args []string ) error {
44- name := args [0 ]
60+ return runEnvWrite (cmd , args [0 ], fromFile , writeModeCreate )
61+ },
62+ }
4563
46- vals , err := workflow .LoadValues (fromFile )
47- if err != nil {
48- return fmt .Errorf ("loading values file: %w" , err )
49- }
64+ cmd .Flags ().StringVar (& fromFile , "from" , "" , "Values file to load inputs and env from (required)" )
65+ _ = cmd .MarkFlagRequired ("from" )
5066
51- store , cleanup , err := newEnvStore (cmd )
52- if err != nil {
53- return err
54- }
55- defer cleanup ()
67+ return cmd
68+ }
5669
57- env , err := store . Create ( cmd . Context (), name , vals . Inputs , vals . Env )
58- if err != nil {
59- return err
60- }
70+ // newEnvUpdateCommand returns the "env update" subcommand, which replaces the
71+ // stored inputs and env on an existing named environment.
72+ func newEnvUpdateCommand () * cobra. Command {
73+ var fromFile string
6174
62- fmt .Fprintf (cmd .OutOrStdout (), "Created environment %q\n " , env .Name )
63- return nil
75+ cmd := & cobra.Command {
76+ Use : "update <name>" ,
77+ Short : "Update an existing named environment" ,
78+ Long : `Replaces the inputs and env of an existing environment with the contents
79+ of a values file. Preserves the environment ID, creation timestamp, and audit
80+ history. Fails if the environment does not exist.` ,
81+ Example : ` mantle env update production --from prod.values.yaml` ,
82+ Args : cobra .ExactArgs (1 ),
83+ RunE : func (cmd * cobra.Command , args []string ) error {
84+ return runEnvWrite (cmd , args [0 ], fromFile , writeModeUpdate )
6485 },
6586 }
6687
@@ -70,13 +91,56 @@ func newEnvCreateCommand() *cobra.Command {
7091 return cmd
7192}
7293
94+ type envWriteMode int
95+
96+ const (
97+ writeModeCreate envWriteMode = iota
98+ writeModeUpdate
99+ )
100+
101+ // runEnvWrite is the shared body for create/update: load a values file,
102+ // dispatch to the matching Store method, and print a single-line confirmation.
103+ func runEnvWrite (cmd * cobra.Command , name , fromFile string , mode envWriteMode ) error {
104+ vals , err := workflow .LoadValues (fromFile )
105+ if err != nil {
106+ return fmt .Errorf ("loading values file: %w" , err )
107+ }
108+
109+ store , cleanup , err := newEnvStore (cmd )
110+ if err != nil {
111+ return err
112+ }
113+ defer cleanup ()
114+
115+ var e * environment.Environment
116+ var verb string
117+ switch mode {
118+ case writeModeCreate :
119+ e , err = store .Create (cmd .Context (), name , vals .Inputs , vals .Env )
120+ verb = "Created"
121+ case writeModeUpdate :
122+ e , err = store .Update (cmd .Context (), name , vals .Inputs , vals .Env )
123+ verb = "Updated"
124+ }
125+ if err != nil {
126+ return err
127+ }
128+
129+ fmt .Fprintf (cmd .OutOrStdout (), "%s environment %q\n " , verb , e .Name )
130+ return nil
131+ }
132+
73133// newEnvListCommand returns the "env list" subcommand, which prints all named
74- // environments for the current team.
134+ // environments for the current team with their creation and update timestamps .
75135func newEnvListCommand () * cobra.Command {
76136 return & cobra.Command {
77137 Use : "list" ,
78- Short : "List all environments" ,
79- Args : cobra .NoArgs ,
138+ Short : "List all named environments" ,
139+ Long : `Lists every environment in the current team scope with creation and
140+ update timestamps. Raw input and env values are never shown here — use
141+ ` + "`mantle env get <name> --reveal`" + ` to view them.` ,
142+ Example : ` mantle env list` ,
143+ Args : cobra .NoArgs ,
80144 RunE : func (cmd * cobra.Command , args []string ) error {
81145 store , cleanup , err := newEnvStore (cmd )
82146 if err != nil {
@@ -95,22 +159,35 @@ func newEnvListCommand() *cobra.Command {
95159 }
96160
97161 w := tabwriter .NewWriter (cmd .OutOrStdout (), 0 , 0 , 2 , ' ' , 0 )
98- fmt .Fprintln (w , "NAME\t CREATED" )
162+ fmt .Fprintln (w , "NAME\t CREATED\t UPDATED " )
99163 for _ , e := range envs {
100- fmt .Fprintf (w , "%s\t %s\n " , e .Name , e .CreatedAt .UTC ().Format ("2006-01-02 15:04:05 UTC" ))
164+ fmt .Fprintf (w , "%s\t %s\t %s\n " ,
165+ e .Name ,
166+ e .CreatedAt .UTC ().Format ("2006-01-02 15:04:05 UTC" ),
167+ e .UpdatedAt .UTC ().Format ("2006-01-02 15:04:05 UTC" ),
168+ )
101169 }
102170 return w .Flush ()
103171 },
104172 }
105173}
106174
107- // newEnvShowCommand returns the "env show" subcommand, which prints the inputs
108- // and env vars stored in a named environment.
109- func newEnvShowCommand () * cobra.Command {
110- return & cobra.Command {
111- Use : "show <name>" ,
175+ // newEnvGetCommand returns the "env get" subcommand, which prints the stored
176+ // inputs and env for a named environment. Env values are redacted unless the
177+ // caller passes --reveal, which emits an environment.revealed audit event.
178+ func newEnvGetCommand () * cobra.Command {
179+ var reveal bool
180+
181+ cmd := & cobra.Command {
182+ Use : "get <name>" ,
112183 Short : "Show environment details" ,
113- Args : cobra .ExactArgs (1 ),
184+ Long : `Shows the stored inputs and env for a named environment along with its
185+ timestamps. Env values are redacted by default because values files often
186+ contain secret-shaped material. Pass --reveal to print raw values; every
187+ reveal emits an ` + "`environment.revealed`" + ` audit event.` ,
188+ Example : ` mantle env get production
189+ mantle env get production --reveal` ,
190+ Args : cobra .ExactArgs (1 ),
114191 RunE : func (cmd * cobra.Command , args []string ) error {
115192 store , cleanup , err := newEnvStore (cmd )
116193 if err != nil {
@@ -123,42 +200,63 @@ func newEnvShowCommand() *cobra.Command {
123200 return err
124201 }
125202
126- fmt .Fprintf (cmd .OutOrStdout (), "Name: %s\n " , env .Name )
127- if len (env .Inputs ) > 0 {
128- fmt .Fprintln (cmd .OutOrStdout (), "\n Inputs:" )
129- keys := make ([]string , 0 , len (env .Inputs ))
130- for k := range env .Inputs {
131- keys = append (keys , k )
203+ if reveal {
204+ if revealErr := store .EmitReveal (cmd .Context (), env ); revealErr != nil {
205+ return fmt .Errorf ("recording reveal audit event: %w" , revealErr )
132206 }
133- sort .Strings (keys )
134- for _ , k := range keys {
135- fmt .Fprintf (cmd .OutOrStdout (), " %s: %v\n " , k , env .Inputs [k ])
207+ }
208+
209+ out := cmd .OutOrStdout ()
210+ fmt .Fprintf (out , "Name: %s\n " , env .Name )
211+ fmt .Fprintf (out , "ID: %s\n " , env .ID )
212+ fmt .Fprintf (out , "Created: %s\n " , env .CreatedAt .UTC ().Format ("2006-01-02 15:04:05 UTC" ))
213+ fmt .Fprintf (out , "Updated: %s\n " , env .UpdatedAt .UTC ().Format ("2006-01-02 15:04:05 UTC" ))
214+
215+ if len (env .Inputs ) > 0 {
216+ fmt .Fprintln (out , "\n Inputs:" )
217+ for _ , k := range sortedKeys (env .Inputs ) {
218+ fmt .Fprintf (out , " %s: %v\n " , k , env .Inputs [k ])
136219 }
137220 }
138221 if len (env .Env ) > 0 {
139- fmt .Fprintln (cmd .OutOrStdout (), "\n Env:" )
140- keys := make ([]string , 0 , len (env .Env ))
141- for k := range env .Env {
142- keys = append (keys , k )
222+ fmt .Fprintln (out , "\n Env:" )
223+ for _ , k := range sortedStringKeys (env .Env ) {
224+ if reveal {
225+ fmt .Fprintf (out , " %s: %s\n " , k , env .Env [k ])
226+ } else {
227+ fmt .Fprintf (out , " %s: %s\n " , k , redactedPlaceholder )
228+ }
143229 }
144- sort .Strings (keys )
145- for _ , k := range keys {
146- fmt .Fprintf (cmd .OutOrStdout (), " %s: %s\n " , k , env .Env [k ])
230+ if ! reveal {
231+ fmt .Fprintln (out , "\n (Env values redacted. Pass --reveal to display; reveals are audited.)" )
147232 }
148233 }
149234 return nil
150235 },
151236 }
237+
238+ cmd .Flags ().BoolVar (& reveal , "reveal" , false , "Print raw env values (emits an environment.revealed audit event)" )
239+ return cmd
152240}
153241
154- // newEnvDeleteCommand returns the "env delete" subcommand, which removes a
155- // named environment from the database .
242+ // newEnvDeleteCommand returns the "env delete" subcommand, which permanently
243+ // removes a named environment. Requires --yes to confirm .
156244func newEnvDeleteCommand () * cobra.Command {
157- return & cobra.Command {
245+ var yes bool
246+
247+ cmd := & cobra.Command {
158248 Use : "delete <name>" ,
159249 Short : "Delete an environment" ,
160- Args : cobra .ExactArgs (1 ),
250+ Long : `Permanently deletes a named environment. Requires --yes to confirm
251+ because deletion cannot be undone from the CLI — referring workflow runs will
252+ fail until the environment is recreated.` ,
253+ Example : ` mantle env delete staging --yes` ,
254+ Args : cobra .ExactArgs (1 ),
161255 RunE : func (cmd * cobra.Command , args []string ) error {
256+ if ! yes {
257+ return fmt .Errorf ("refusing to delete %q without --yes" , args [0 ])
258+ }
259+
162260 store , cleanup , err := newEnvStore (cmd )
163261 if err != nil {
164262 return err
@@ -173,6 +271,9 @@ func newEnvDeleteCommand() *cobra.Command {
173271 return nil
174272 },
175273 }
274+
275+ cmd .Flags ().BoolVarP (& yes , "yes" , "y" , false , "Confirm deletion (required)" )
276+ return cmd
176277}
177278
178279// newEnvStore builds an environment.Store from the current command context.
@@ -187,7 +288,29 @@ func newEnvStore(cmd *cobra.Command) (*environment.Store, func(), error) {
187288 return nil , nil , fmt .Errorf ("failed to connect to database: %w" , err )
188289 }
189290
190- store := & environment.Store {DB : database }
291+ store := & environment.Store {DB : database , Actor : "cli" }
191292 cleanup := func () { database .Close () }
192293 return store , cleanup , nil
193294}
295+
296+ // sortedKeys returns the keys of m in ascending order. Used for deterministic
297+ // output in env get.
298+ func sortedKeys (m map [string ]any ) []string {
299+ keys := make ([]string , 0 , len (m ))
300+ for k := range m {
301+ keys = append (keys , k )
302+ }
303+ sort .Strings (keys )
304+ return keys
305+ }
306+
307+ // sortedStringKeys returns the keys of m in ascending order. Used for
308+ // deterministic output in env get.
309+ func sortedStringKeys (m map [string ]string ) []string {
310+ keys := make ([]string , 0 , len (m ))
311+ for k := range m {
312+ keys = append (keys , k )
313+ }
314+ sort .Strings (keys )
315+ return keys
316+ }
0 commit comments