@@ -27,14 +27,14 @@ func newDeployApprovalApproveCommand() *cobra.Command {
2727 Short : "Approve a deploy approval request" ,
2828 Long : `Approve a deploy approval request.
2929
30- If no ID is provided, searches for the latest pending approval request matching the current git branch .
31- Shows the request details and prompts for MFA code before approving.
30+ If no ID is provided, searches for pending approval requests matching the current git commit .
31+ Shows all matching requests and prompts once before approving all of them .
3232
3333Examples:
3434 # Approve by ID
3535 cx deploy-approval approve abc123-def456-...
3636
37- # Approve latest for current git branch (prompts for MFA)
37+ # Approve latest for current git commit (prompts for MFA)
3838 cx deploy-approval approve
3939
4040 # Approve latest for a specific branch
@@ -43,7 +43,7 @@ Examples:
4343 # Approve for a specific commit
4444 cx deploy-approval approve --commit abc123def
4545
46- # Approve across multiple racks
46+ # Approve across multiple racks (one PIN entry, one touch per rack)
4747 cx deploy-approval approve --racks staging,us,eu` ,
4848 Args : cobra .MaximumNArgs (1 ),
4949 RunE : SilenceOnError (func (cmd * cobra.Command , args []string ) error {
@@ -53,8 +53,8 @@ Examples:
5353
5454 cmd .Flags ().StringVar (& opts .racks , "racks" , "" , "Comma-separated list of racks to search" )
5555 cmd .Flags ().StringVarP (& opts .app , "app" , "a" , "" , appFlagHelp )
56- cmd .Flags ().StringVar (& opts .branch , "branch" , "" , "Search by git branch (uses current branch if no ID given) " )
57- cmd .Flags ().StringVar (& opts .commit , "commit" , "" , "Search by git commit hash" )
56+ cmd .Flags ().StringVar (& opts .branch , "branch" , "" , "Search by git branch" )
57+ cmd .Flags ().StringVar (& opts .commit , "commit" , "" , "Search by git commit hash (uses current commit by default) " )
5858 cmd .Flags ().StringVar (& opts .notes , "notes" , "" , "Optional notes for approval" )
5959
6060 return cmd
@@ -66,7 +66,6 @@ func executeDeployApprovalApprove(cmd *cobra.Command, args []string, opts deploy
6666 return err
6767 }
6868
69- // Resolve app name (auto-detect from .convox/app or directory)
7069 app , err := ResolveApp (opts .app )
7170 if err != nil {
7271 return err
@@ -111,57 +110,232 @@ func approveByID(cmd *cobra.Command, racks []string, publicID, notes string) err
111110 return fmt .Errorf ("deploy approval request %s not found" , publicID )
112111}
113112
113+ type rackApproval struct {
114+ rack string
115+ req * deployApprovalRequest
116+ }
117+
114118func approveBySearch (cmd * cobra.Command , racks []string , app , branch , commit , notes string ) error {
115- // Search each rack for a pending request
116- for _ , rack := range racks {
117- req := findPendingRequest (cmd , rack , app , branch , commit )
118- if req == nil {
119- continue
119+ // Collect all requests from all racks (pending or approved, like show command)
120+ allRequests := collectAllRequests (cmd , racks , app , branch , commit )
121+
122+ if len (allRequests ) == 0 {
123+ if branch != "" {
124+ return fmt .Errorf ("no deploy approval request found for app %q branch %q" , app , branch )
120125 }
126+ return fmt .Errorf ("no deploy approval request found for app %q commit %q" , app , commit )
127+ }
121128
122- // Found a request - show details and prompt for confirmation
123- showRack := len (racks ) > 1
124- fmt .Println ("\n 📋 Deploy Approval Request Found:" )
125- if err := printDeployApprovalDetails (req , rack , showRack ); err != nil {
129+ // Display all requests
130+ fmt .Println ()
131+ for i , r := range allRequests {
132+ if i > 0 {
133+ fmt .Println ()
134+ }
135+ if err := printDeployApprovalDetails (r .req , r .rack , len (racks ) > 1 ); err != nil {
126136 return err
127137 }
138+ }
139+
140+ // Filter to only pending requests
141+ var pending []rackApproval
142+ for _ , r := range allRequests {
143+ if r .req .Status == "pending" {
144+ pending = append (pending , r )
145+ }
146+ }
147+
148+ // If no pending requests, nothing to approve
149+ if len (pending ) == 0 {
150+ fmt .Println ("\n All requests are already approved." )
151+ return nil
152+ }
153+
154+ // Prompt for confirmation
155+ promptText := "\n Press Enter to approve"
156+ if len (pending ) > 1 {
157+ promptText = fmt .Sprintf ("\n Press Enter to approve %d pending request(s)" , len (pending ))
158+ }
159+ fmt .Print (promptText + " (or Ctrl+C to abort): " )
160+
161+ reader := bufio .NewReader (os .Stdin )
162+ if _ , err := reader .ReadString ('\n' ); err != nil {
163+ return fmt .Errorf ("aborted" )
164+ }
165+
166+ // Approve each pending request, caching PIN after first one
167+ return approveAllRequests (cmd , pending , notes , len (racks ) > 1 )
168+ }
128169
129- fmt .Print ("\n Press Enter to approve (or Ctrl+C to abort): " )
130- reader := bufio .NewReader (os .Stdin )
131- if _ , err := reader .ReadString ('\n' ); err != nil {
132- return fmt .Errorf ("aborted" )
170+ func collectAllRequests (
171+ cmd * cobra.Command , racks []string , app , branch , commit string ,
172+ ) []rackApproval {
173+ var results []rackApproval
174+ for _ , rack := range racks {
175+ // Try pending first, then approved (like show command)
176+ for _ , status := range []string {"pending" , "approved" } {
177+ req , found := searchForRequestInRack (cmd , rack , app , branch , commit , status )
178+ if found {
179+ results = append (results , rackApproval {rack : rack , req : req })
180+ break
181+ }
133182 }
183+ }
184+ return results
185+ }
186+
187+ func approveAllRequests (cmd * cobra.Command , pending []rackApproval , notes string , showRack bool ) error {
188+ var cachedPIN string
189+ var successCount int
190+
191+ for i , p := range pending {
192+ printApprovalContext (cmd , p , i + 1 , len (pending ))
134193
135- approved , err := approveDeployRequest (cmd , rack , req .PublicID , notes )
194+ approved , pin , err := approveDeployRequestWithPIN (cmd , p . rack , p . req .PublicID , notes , cachedPIN )
136195 if err != nil {
137- return err
196+ return fmt .Errorf ("failed to approve request on rack %s: %w" , p .rack , err )
197+ }
198+
199+ // Cache the PIN for subsequent approvals
200+ if cachedPIN == "" && pin != "" {
201+ cachedPIN = pin
138202 }
139203
140- return printApprovalSuccess (cmd , approved , rack , showRack )
204+ if err := printApprovalSuccess (cmd , approved , p .rack , showRack ); err != nil {
205+ return err
206+ }
207+ successCount ++
141208 }
142209
143- if branch != "" {
144- return fmt .Errorf ( "no pending deploy approval request found for app %q branch %q " , app , branch )
210+ if successCount > 1 {
211+ fmt .Printf ( " \n ✅ Successfully approved %d requests \n " , successCount )
145212 }
146- return fmt .Errorf ("no pending deploy approval request found for app %q commit %q" , app , commit )
213+
214+ return nil
147215}
148216
149- func findPendingRequest (cmd * cobra.Command , rack , app , branch , commit string ) * deployApprovalRequest {
150- req , found := searchForRequestInRack (cmd , rack , app , branch , commit , "pending" )
151- if ! found {
152- return nil
217+ func printApprovalContext (cmd * cobra.Command , p rackApproval , current , total int ) {
218+ out := cmd .ErrOrStderr ()
219+ _ , _ = fmt .Fprintln (out )
220+
221+ if total > 1 {
222+ _ , _ = fmt .Fprintf (out , "Approving request %d of %d:\n " , current , total )
223+ } else {
224+ _ , _ = fmt .Fprintln (out , "Approving request:" )
225+ }
226+
227+ _ , _ = fmt .Fprintf (out , " Rack: %s\n " , p .rack )
228+ _ , _ = fmt .Fprintf (out , " ID: %s\n " , p .req .PublicID )
229+ _ , _ = fmt .Fprintf (out , " Message: %s\n " , p .req .Message )
230+ if p .req .App != "" {
231+ _ , _ = fmt .Fprintf (out , " App: %s\n " , p .req .App )
232+ }
233+ if p .req .GitCommitHash != "" {
234+ _ , _ = fmt .Fprintf (out , " Commit: %s\n " , p .req .GitCommitHash )
235+ }
236+ if p .req .GitBranch != "" {
237+ _ , _ = fmt .Fprintf (out , " Branch: %s\n " , p .req .GitBranch )
153238 }
154- return req
155239}
156240
157241func approveDeployRequest (cmd * cobra.Command , rack , requestID , notes string ) (* deployApprovalRequest , error ) {
242+ result , _ , err := approveDeployRequestWithPIN (cmd , rack , requestID , notes , "" )
243+ return result , err
244+ }
245+
246+ func approveDeployRequestWithPIN (
247+ cmd * cobra.Command , rack , requestID , notes , cachedPIN string ,
248+ ) (* deployApprovalRequest , string , error ) {
158249 payload := map [string ]interface {}{}
159250 if notes != "" {
160251 payload ["notes" ] = notes
161252 }
162253
163254 endpoint := fmt .Sprintf ("/deploy-approval-requests/%s/approve" , requestID )
164- return postDeployApprovalRequest (cmd , rack , endpoint , payload )
255+
256+ // Get MFA auth with PIN caching
257+ mfaAuth , pinUsed , err := getMFAAuthWithPIN (cmd , rack , cachedPIN )
258+ if err != nil {
259+ return nil , "" , err
260+ }
261+
262+ result , err := postDeployApprovalRequestWithMFA (cmd , rack , endpoint , payload , mfaAuth )
263+ if err != nil {
264+ return nil , "" , err
265+ }
266+
267+ return result , pinUsed , nil
268+ }
269+
270+ func getMFAAuthWithPIN (cmd * cobra.Command , rack , cachedPIN string ) (string , string , error ) {
271+ if os .Getenv ("RACK_GATEWAY_API_TOKEN" ) != "" {
272+ return "" , "" , nil
273+ }
274+
275+ gatewayURL , bearer , err := gatewayAuthInfo (rack )
276+ if err != nil {
277+ return "" , "" , err
278+ }
279+
280+ status , err := loadMFAStatus (gatewayURL , bearer )
281+ if err != nil {
282+ return "" , "" , err
283+ }
284+
285+ method , err := selectMFAMethod (status , rack )
286+ if err != nil {
287+ return "" , "" , err
288+ }
289+
290+ return collectMFAAuthWithPIN (cmd , gatewayURL , bearer , method , cachedPIN )
291+ }
292+
293+ func collectMFAAuthWithPIN (
294+ cmd * cobra.Command , baseURL , bearer string , method MFAMethodResponse , cachedPIN string ,
295+ ) (string , string , error ) {
296+ out := cmd .ErrOrStderr ()
297+
298+ switch method .Type {
299+ case "webauthn" :
300+ // Only print the message if this is the first call (no cached PIN)
301+ if cachedPIN == "" {
302+ if err := writeLine (out , "Multi-factor authentication required (WebAuthn)." ); err != nil {
303+ return "" , "" , err
304+ }
305+ }
306+
307+ assertionData , pinUsed , err := collectWebAuthnAssertionWithPIN (baseURL , bearer , cachedPIN )
308+ if err != nil {
309+ return "" , "" , fmt .Errorf ("WebAuthn verification failed: %w" , err )
310+ }
311+
312+ return "webauthn." + assertionData , pinUsed , nil
313+
314+ case "totp" :
315+ if err := writeLine (out , "Multi-factor authentication required (TOTP)." ); err != nil {
316+ return "" , "" , err
317+ }
318+
319+ code , err := promptMFACode ()
320+ if err != nil {
321+ return "" , "" , err
322+ }
323+
324+ return "totp." + code , "" , nil
325+
326+ default :
327+ return "" , "" , fmt .Errorf ("unsupported MFA method for inline verification: %s" , method .Type )
328+ }
329+ }
330+
331+ func postDeployApprovalRequestWithMFA (
332+ cmd * cobra.Command , rack , endpoint string , payload map [string ]interface {}, mfaAuth string ,
333+ ) (* deployApprovalRequest , error ) {
334+ var result deployApprovalRequest
335+ if err := gatewayRequestWithMFA (cmd , rack , "POST" , endpoint , payload , & result , mfaAuth ); err != nil {
336+ return nil , err
337+ }
338+ return & result , nil
165339}
166340
167341func printApprovalSuccess (cmd * cobra.Command , approved * deployApprovalRequest , rack string , showRack bool ) error {
0 commit comments