1
1
package cli
2
2
3
3
import (
4
+ "bufio"
4
5
"bytes"
5
6
"errors"
6
7
"fmt"
@@ -33,9 +34,9 @@ const (
33
34
sshCoderConfigDocsHeader = `
34
35
#
35
36
# You should not hand-edit this file, all changes will be lost upon workspace
36
- # creation, deletion or when running "coder config-ssh".
37
- `
38
- sshCoderConfigOptionsHeader = ` #
37
+ # creation, deletion or when running "coder config-ssh".`
38
+ sshCoderConfigOptionsHeader = `
39
+ #
39
40
# Last config-ssh options:
40
41
`
41
42
// Relative paths are assumed to be in ~/.ssh, except when
@@ -53,11 +54,50 @@ var (
53
54
sshCoderIncludedRe = regexp .MustCompile (`^\s*((?i)Include) coder(\s|$)` )
54
55
)
55
56
57
+ // sshCoderConfigOptions represents options that can be stored and read
58
+ // from the coder config in ~/.ssh/coder.
59
+ type sshCoderConfigOptions struct {
60
+ sshConfigFile string
61
+ sshOptions []string
62
+ }
63
+
64
+ func (o sshCoderConfigOptions )isZero ()bool {
65
+ return o .sshConfigFile == sshDefaultConfigFileName && len (o .sshOptions )== 0
66
+ }
67
+
68
+ func (o sshCoderConfigOptions )equal (other sshCoderConfigOptions )bool {
69
+ // Compare without side-effects or regard to order.
70
+ opt1 := slices .Clone (o .sshOptions )
71
+ sort .Strings (opt1 )
72
+ opt2 := slices .Clone (other .sshOptions )
73
+ sort .Strings (opt2 )
74
+ return o .sshConfigFile == other .sshConfigFile && slices .Equal (opt1 ,opt2 )
75
+ }
76
+
77
+ func (o sshCoderConfigOptions )asArgs () (args []string ) {
78
+ if o .sshConfigFile != sshDefaultConfigFileName {
79
+ args = append (args ,"--ssh-config-file" ,o .sshConfigFile )
80
+ }
81
+ for _ ,opt := range o .sshOptions {
82
+ args = append (args ,"--ssh-option" ,fmt .Sprintf ("%q" ,opt ))
83
+ }
84
+ return args
85
+ }
86
+
87
+ func (o sshCoderConfigOptions )asList () (list []string ) {
88
+ if o .sshConfigFile != sshDefaultConfigFileName {
89
+ list = append (list ,fmt .Sprintf ("ssh-config-file: %s" ,o .sshConfigFile ))
90
+ }
91
+ for _ ,opt := range o .sshOptions {
92
+ list = append (list ,fmt .Sprintf ("ssh-option: %s" ,opt ))
93
+ }
94
+ return list
95
+ }
96
+
56
97
func configSSH ()* cobra.Command {
57
98
var (
58
- sshConfigFile string
99
+ coderConfig sshCoderConfigOptions
59
100
coderConfigFile string
60
- sshOptions []string
61
101
showDiff bool
62
102
skipProxyCommand bool
63
103
@@ -99,30 +139,26 @@ func configSSH() *cobra.Command {
99
139
return err
100
140
}
101
141
142
+ out := cmd .OutOrStdout ()
143
+ if showDiff {
144
+ out = cmd .OutOrStderr ()
145
+ }
146
+ binaryFile ,err := currentBinPath (out )
147
+ if err != nil {
148
+ return err
149
+ }
150
+
102
151
dirname ,err := os .UserHomeDir ()
103
152
if err != nil {
104
153
return xerrors .Errorf ("user home dir failed: %w" ,err )
105
154
}
106
155
107
- sshConfigFileOrig := sshConfigFile // Store the pre ~/ replacement name for serializing options.
156
+ sshConfigFile := coderConfig . sshConfigFile // Store the pre ~/ replacement name for serializing options.
108
157
if strings .HasPrefix (sshConfigFile ,"~/" ) {
109
158
sshConfigFile = filepath .Join (dirname ,sshConfigFile [2 :])
110
159
}
111
- coderConfigFileOrig := coderConfigFile
112
160
coderConfigFile = filepath .Join (dirname ,coderConfigFile [2 :])// Replace ~/ with home dir.
113
161
114
- // TODO(mafredri): Check coderConfigFile for previous options
115
- // coderConfigFile.
116
-
117
- out := cmd .OutOrStdout ()
118
- if showDiff {
119
- out = cmd .OutOrStderr ()
120
- }
121
- binaryFile ,err := currentBinPath (out )
122
- if err != nil {
123
- return err
124
- }
125
-
126
162
// Only allow not-exist errors to avoid trashing
127
163
// the users SSH config.
128
164
configRaw ,err := os .ReadFile (sshConfigFile )
@@ -139,6 +175,25 @@ func configSSH() *cobra.Command {
139
175
return xerrors .Errorf ("unexpected content in %s: remove the file and rerun the command to continue" ,coderConfigFile )
140
176
}
141
177
}
178
+ lastCoderConfig := sshCoderConfigParseLastOptions (bytes .NewReader (coderConfigRaw ))
179
+
180
+ // Only prompt when no arguments are provided and avoid
181
+ // prompting in diff mode (unexpected behavior).
182
+ if ! showDiff && coderConfig .isZero ()&& ! lastCoderConfig .isZero () {
183
+ line ,err := cliui .Prompt (cmd , cliui.PromptOptions {
184
+ Text :fmt .Sprintf ("Found previous configuration option(s):\n \n - %s\n \n Use previous option(s)?" ,strings .Join (lastCoderConfig .asList (),"\n - " )),
185
+ IsConfirm :true ,
186
+ })
187
+ if err != nil {
188
+ // TODO(mafredri): Better way to differ between "no" and Ctrl+C?
189
+ if line == "" && xerrors .Is (err ,cliui .Canceled ) {
190
+ return nil
191
+ }
192
+ }else {
193
+ coderConfig = lastCoderConfig
194
+ }
195
+ _ ,_ = fmt .Fprint (out ,"\n " )
196
+ }
142
197
143
198
// Keep track of changes we are making.
144
199
var changes []string
@@ -147,14 +202,14 @@ func configSSH() *cobra.Command {
147
202
// remove if present.
148
203
configModified ,ok := stripOldConfigBlock (configRaw )
149
204
if ok {
150
- changes = append (changes ,fmt .Sprintf ("Remove old config block (START-CODER/END-CODER) from %s" ,sshConfigFileOrig ))
205
+ changes = append (changes ,fmt .Sprintf ("Remove old config block (START-CODER/END-CODER) from %s" ,sshConfigFile ))
151
206
}
152
207
153
208
// Check for the presence of the coder Include
154
209
// statement is present and add if missing.
155
210
configModified ,ok = sshConfigAddCoderInclude (configModified )
156
211
if ok {
157
- changes = append (changes ,fmt .Sprintf ("Add %q to %s" ,"Include coder" ,sshConfigFileOrig ))
212
+ changes = append (changes ,fmt .Sprintf ("Add %q to %s" ,"Include coder" ,sshConfigFile ))
158
213
}
159
214
160
215
root := createConfig (cmd )
@@ -197,19 +252,13 @@ func configSSH() *cobra.Command {
197
252
}
198
253
199
254
buf := & bytes.Buffer {}
200
- _ ,_ = buf .WriteString (sshCoderConfigHeader )
201
- _ ,_ = buf .WriteString (sshCoderConfigDocsHeader )
202
-
203
- // Store the provided flags as part of the
204
- // config for future (re)use.
205
- _ ,_ = buf .WriteString (sshCoderConfigOptionsHeader )
206
- if sshConfigFileOrig != sshDefaultConfigFileName {
207
- _ ,_ = fmt .Fprintf (buf ,"# :%s=%s\n " ,"ssh-config-file" ,sshConfigFileOrig )
208
- }
209
- for _ ,opt := range sshOptions {
210
- _ ,_ = fmt .Fprintf (buf ,"# :%s=%s\n " ,"ssh-option" ,opt )
255
+
256
+ // Write header and store the provided options as part
257
+ // of the config for future (re)use.
258
+ err = sshCoderConfigWriteHeader (buf ,coderConfig )
259
+ if err != nil {
260
+ return xerrors .Errorf ("write coder config header failed: %w" ,err )
211
261
}
212
- _ ,_ = buf .WriteString ("#\n " )
213
262
214
263
// Ensure stable sorting of output.
215
264
slices .SortFunc (workspaceConfigs ,func (a ,b workspaceConfig )bool {
@@ -222,7 +271,7 @@ func configSSH() *cobra.Command {
222
271
configOptions := []string {
223
272
"Host coder." + hostname ,
224
273
}
225
- for _ ,option := range sshOptions {
274
+ for _ ,option := range coderConfig . sshOptions {
226
275
configOptions = append (configOptions ,"\t " + option )
227
276
}
228
277
configOptions = append (configOptions ,
@@ -248,9 +297,9 @@ func configSSH() *cobra.Command {
248
297
modifyCoderConfig := ! bytes .Equal (coderConfigRaw ,buf .Bytes ())
249
298
if modifyCoderConfig {
250
299
if len (coderConfigRaw )== 0 {
251
- changes = append (changes ,fmt .Sprintf ("Write auto-generated coder config file to %s" ,coderConfigFileOrig ))
300
+ changes = append (changes ,fmt .Sprintf ("Write auto-generated coder config file to %s" ,coderConfigFile ))
252
301
}else {
253
- changes = append (changes ,fmt .Sprintf ("Update auto-generated coder config file in %s" ,coderConfigFileOrig ))
302
+ changes = append (changes ,fmt .Sprintf ("Update auto-generated coder config file in %s" ,coderConfigFile ))
254
303
}
255
304
}
256
305
@@ -259,7 +308,7 @@ func configSSH() *cobra.Command {
259
308
// Write to stderr to avoid dirtying the diff output.
260
309
_ ,_ = fmt .Fprint (out ,"Changes:\n \n " )
261
310
for _ ,change := range changes {
262
- _ ,_ = fmt .Fprintf (out ,"* %s\n " ,change )
311
+ _ ,_ = fmt .Fprintf (out ," * %s\n " ,change )
263
312
}
264
313
}
265
314
@@ -283,8 +332,11 @@ func configSSH() *cobra.Command {
283
332
}
284
333
285
334
if len (changes )> 0 {
335
+ // In diff mode we don't prompt re-using the previous
336
+ // configuration, so we output the entire command.
337
+ diffCommand := fmt .Sprintf ("$ %s %s" ,cmd .CommandPath (),strings .Join (append (coderConfig .asArgs (),"--diff" )," " ))
286
338
_ ,err = cliui .Prompt (cmd , cliui.PromptOptions {
287
- Text :fmt .Sprintf ("The following changes will be made to your SSH configuration (use --diff to see changes) :\n \n * %s\n \n Continue?" ,strings .Join (changes ,"\n * " )),
339
+ Text :fmt .Sprintf ("The following changes will be made to your SSH configuration: \n \n * %s \n \n To see changes, run with --diff :\n \n %s\n \n Continue?" ,strings .Join (changes ,"\n * " ), diffCommand ),
288
340
IsConfirm :true ,
289
341
})
290
342
if err != nil {
@@ -315,10 +367,10 @@ func configSSH() *cobra.Command {
315
367
return nil
316
368
},
317
369
}
318
- cliflag .StringVarP (cmd .Flags (),& sshConfigFile ,"ssh-config-file" ,"" ,"CODER_SSH_CONFIG_FILE" ,sshDefaultConfigFileName ,"Specifies the path to an SSH config." )
370
+ cliflag .StringVarP (cmd .Flags (),& coderConfig . sshConfigFile ,"ssh-config-file" ,"" ,"CODER_SSH_CONFIG_FILE" ,sshDefaultConfigFileName ,"Specifies the path to an SSH config." )
319
371
cmd .Flags ().StringVar (& coderConfigFile ,"ssh-coder-config-file" ,sshDefaultCoderConfigFileName ,"Specifies the path to an Coder SSH config file. Useful for testing." )
320
372
_ = cmd .Flags ().MarkHidden ("ssh-coder-config-file" )
321
- cmd .Flags ().StringArrayVarP (& sshOptions ,"ssh-option" ,"o" , []string {},"Specifies additional SSH options to embed in each host stanza." )
373
+ cmd .Flags ().StringArrayVarP (& coderConfig . sshOptions ,"ssh-option" ,"o" , []string {},"Specifies additional SSH options to embed in each host stanza." )
322
374
cmd .Flags ().BoolVarP (& showDiff ,"diff" ,"D" ,false ,"Show diff of changes that will be made." )
323
375
cmd .Flags ().BoolVarP (& skipProxyCommand ,"skip-proxy-command" ,"" ,false ,"Specifies whether the ProxyCommand option should be skipped. Useful for testing." )
324
376
_ = cmd .Flags ().MarkHidden ("skip-proxy-command" )
@@ -356,6 +408,46 @@ func sshConfigAddCoderInclude(data []byte) (modifiedData []byte, modified bool)
356
408
return data ,true
357
409
}
358
410
411
+ func sshCoderConfigWriteHeader (w io.Writer ,o sshCoderConfigOptions )error {
412
+ _ ,_ = fmt .Fprint (w ,sshCoderConfigHeader )
413
+ _ ,_ = fmt .Fprint (w ,sshCoderConfigDocsHeader )
414
+ _ ,_ = fmt .Fprint (w ,sshCoderConfigOptionsHeader )
415
+ if o .sshConfigFile != sshDefaultConfigFileName {
416
+ _ ,_ = fmt .Fprintf (w ,"# :%s=%s\n " ,"ssh-config-file" ,o .sshConfigFile )
417
+ }
418
+ for _ ,opt := range o .sshOptions {
419
+ _ ,_ = fmt .Fprintf (w ,"# :%s=%s\n " ,"ssh-option" ,opt )
420
+ }
421
+ _ ,_ = fmt .Fprint (w ,"#\n " )
422
+ return nil
423
+ }
424
+
425
+ func sshCoderConfigParseLastOptions (r io.Reader ) (o sshCoderConfigOptions ) {
426
+ o .sshConfigFile = sshDefaultConfigFileName // Default value is not written.
427
+
428
+ s := bufio .NewScanner (r )
429
+ for s .Scan () {
430
+ line := s .Text ()
431
+ if strings .HasPrefix (line ,"# :" ) {
432
+ line = strings .TrimPrefix (line ,"# :" )
433
+ parts := strings .SplitN (line ,"=" ,2 )
434
+ switch parts [0 ] {
435
+ case "ssh-config-file" :
436
+ o .sshConfigFile = parts [1 ]
437
+ case "ssh-option" :
438
+ o .sshOptions = append (o .sshOptions ,parts [1 ])
439
+ default :
440
+ // Unknown option, ignore.
441
+ }
442
+ }
443
+ }
444
+ if err := s .Err ();err != nil {
445
+ panic (err )
446
+ }
447
+
448
+ return o
449
+ }
450
+
359
451
// writeWithTempFileAndMove writes to a temporary file in the same
360
452
// directory as path and renames the temp file to the file provided in
361
453
// path. This ensure we avoid trashing the file we are writing due to