44package cmd
55
66import (
7+ "bufio"
78 "encoding/json"
89 "fmt"
910 "os"
1011 "sort"
12+ "strings"
1113
1214 "github.com/carabiner-dev/policy"
15+ api "github.com/carabiner-dev/policy/api/v1"
1316 "github.com/spf13/cobra"
1417 "google.golang.org/protobuf/encoding/protojson"
15-
16- api "github.com/carabiner-dev/policy/api/v1"
1718)
1819
1920func addUpdate (parentCmd * cobra.Command ) {
20- var fromJSON string
21+ var (
22+ fromJSON string
23+ force bool
24+ )
2125 updateCmd := & cobra.Command {
2226 Short : "update policy references to their latest versions" ,
2327 Use : "update [flags] <location> [<location>...]" ,
@@ -28,6 +32,10 @@ policy source files in place.
2832Only filesystem locations are supported: remote (VCS locator) locations
2933will be skipped.
3034
35+ By default, the available updates are displayed as a table and the user
36+ is asked to confirm before anything is written. Use --force to skip the
37+ prompt and apply updates directly.
38+
3139When --from-json is provided, updates are read from the given plan file
3240instead of being computed from scratch. The plan file is the JSON
3341document produced by 'policyctl check-update --format=json'; no remote
@@ -42,7 +50,6 @@ the strings that actually changed.`,
4250 PersistentPreRunE : initLogging ,
4351 RunE : func (cmd * cobra.Command , args []string ) error {
4452 cmd .SilenceUsage = true
45-
4653 u := policy .NewUpdater ()
4754
4855 var (
@@ -54,48 +61,109 @@ the strings that actually changed.`,
5461 if len (args ) > 0 {
5562 return fmt .Errorf ("--from-json cannot be combined with positional locations" )
5663 }
57- updates , lerr := loadPlanFile (fromJSON )
58- if lerr != nil {
59- return lerr
60- }
61- applied , err = u .ApplyUpdates (updates )
64+ applied , err = runFromJSON (u , fromJSON , force )
6265 default :
6366 if len (args ) == 0 {
6467 return fmt .Errorf ("at least one location is required (or use --from-json)" )
6568 }
66- applied , err = u . Update ( args ... )
69+ applied , err = runFromLocations ( u , args , force )
6770 }
6871 if err != nil {
6972 return err
7073 }
71-
72- if len (applied ) == 0 {
73- fmt .Fprintln (os .Stderr , "no policy references needed updating" )
74- return nil
75- }
76-
77- files := make ([]string , 0 , len (applied ))
78- for f := range applied {
79- files = append (files , f )
80- }
81- sort .Strings (files )
82- for _ , f := range files {
83- fmt .Fprintf (os .Stdout , "updated %s (%d reference(s))\n " , f , len (applied [f ]))
84- }
74+ reportApplied (applied )
8575 return nil
8676 },
8777 }
8878 updateCmd .Flags ().StringVar (
8979 & fromJSON , "from-json" , "" ,
9080 "apply updates from a plan file produced by 'check-update --format=json'" ,
9181 )
82+ updateCmd .Flags ().BoolVar (
83+ & force , "force" , false ,
84+ "skip the confirmation prompt and apply updates directly" ,
85+ )
9286 parentCmd .AddCommand (updateCmd )
9387}
9488
89+ // runFromLocations handles the default path: check for updates, show
90+ // them, ask for confirmation, then apply. When force is set, skips both
91+ // the table and the prompt and delegates to Updater.Update.
92+ func runFromLocations (u * policy.Updater , args []string , force bool ) (map [string ][]* policy.RefUpdate , error ) {
93+ if force {
94+ return u .Update (args ... )
95+ }
96+
97+ updates , err := u .CheckUpdates (args ... )
98+ if err != nil {
99+ return nil , err
100+ }
101+ if len (updates ) == 0 {
102+ fmt .Fprintln (os .Stderr , "no policy references have updates available" )
103+ return nil , nil
104+ }
105+ printUpdatesTable (os .Stdout , updates )
106+ if ! confirm ("Apply these updates?" ) {
107+ fmt .Fprintln (os .Stderr , "aborted; no files were modified" )
108+ return nil , nil
109+ }
110+ return u .ApplyUpdates (updates )
111+ }
112+
113+ // runFromJSON handles the --from-json path: decode the plan, show it,
114+ // optionally prompt, then apply.
115+ func runFromJSON (u * policy.Updater , path string , force bool ) (map [string ][]* policy.RefUpdate , error ) {
116+ updates , err := loadPlanFile (path )
117+ if err != nil {
118+ return nil , err
119+ }
120+ if len (updates ) == 0 {
121+ fmt .Fprintln (os .Stderr , "plan file contains no updates to apply" )
122+ return nil , nil
123+ }
124+ if ! force {
125+ printUpdatesTable (os .Stdout , updates )
126+ if ! confirm ("Apply these updates?" ) {
127+ fmt .Fprintln (os .Stderr , "aborted; no files were modified" )
128+ return nil , nil
129+ }
130+ }
131+ return u .ApplyUpdates (updates )
132+ }
133+
134+ func reportApplied (applied map [string ][]* policy.RefUpdate ) {
135+ if len (applied ) == 0 {
136+ fmt .Fprintln (os .Stderr , "no policy references needed updating" )
137+ return
138+ }
139+ files := make ([]string , 0 , len (applied ))
140+ for f := range applied {
141+ files = append (files , f )
142+ }
143+ sort .Strings (files )
144+ for _ , f := range files {
145+ fmt .Fprintf (os .Stdout , "updated %s (%d reference(s))\n " , f , len (applied [f ])) //nolint:errcheck // stdout write errors are not actionable here
146+ for _ , r := range applied [f ] {
147+ fmt .Fprintf (os .Stdout , " - %s\n " , r .Old .GetLocation ().GetUri ()) //nolint:errcheck // stdout write errors are not actionable here
148+ }
149+ }
150+ }
151+
152+ func confirm (prompt string ) bool {
153+ fmt .Fprintf (os .Stderr , "%s [y/N]: " , prompt )
154+ r := bufio .NewReader (os .Stdin )
155+ line , err := r .ReadString ('\n' )
156+ if err != nil {
157+ return false
158+ }
159+ line = strings .TrimSpace (strings .ToLower (line ))
160+ return line == "y" || line == "yes"
161+ }
162+
95163// loadPlanFile decodes the JSON plan produced by check-update into the
96164// map shape accepted by Updater.ApplyUpdates.
97165func loadPlanFile (path string ) (map [string ][]* policy.RefUpdate , error ) {
98- data , err := os .ReadFile (path ) //nolint:gosec // path provided by the user on the command line
166+ data , err := os .ReadFile (path )
99167 if err != nil {
100168 return nil , fmt .Errorf ("reading plan file: %w" , err )
101169 }
0 commit comments