Skip to content

Commit 3c87801

Browse files
committed
feat: Parse and prompt to re-use previous configuration
1 parent d2992ad commit 3c87801

File tree

1 file changed

+132
-40
lines changed

1 file changed

+132
-40
lines changed

cli/configssh.go

Lines changed: 132 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"bufio"
45
"bytes"
56
"errors"
67
"fmt"
@@ -33,9 +34,9 @@ const (
3334
sshCoderConfigDocsHeader = `
3435
#
3536
# 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+
#
3940
# Last config-ssh options:
4041
`
4142
// Relative paths are assumed to be in ~/.ssh, except when
@@ -53,11 +54,50 @@ var (
5354
sshCoderIncludedRe = regexp.MustCompile(`^\s*((?i)Include) coder(\s|$)`)
5455
)
5556

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+
5697
func configSSH() *cobra.Command {
5798
var (
58-
sshConfigFile string
99+
coderConfig sshCoderConfigOptions
59100
coderConfigFile string
60-
sshOptions []string
61101
showDiff bool
62102
skipProxyCommand bool
63103

@@ -99,30 +139,26 @@ func configSSH() *cobra.Command {
99139
return err
100140
}
101141

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+
102151
dirname, err := os.UserHomeDir()
103152
if err != nil {
104153
return xerrors.Errorf("user home dir failed: %w", err)
105154
}
106155

107-
sshConfigFileOrig := sshConfigFile // Store the pre ~/ replacement name for serializing options.
156+
sshConfigFile := coderConfig.sshConfigFile // Store the pre ~/ replacement name for serializing options.
108157
if strings.HasPrefix(sshConfigFile, "~/") {
109158
sshConfigFile = filepath.Join(dirname, sshConfigFile[2:])
110159
}
111-
coderConfigFileOrig := coderConfigFile
112160
coderConfigFile = filepath.Join(dirname, coderConfigFile[2:]) // Replace ~/ with home dir.
113161

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-
126162
// Only allow not-exist errors to avoid trashing
127163
// the users SSH config.
128164
configRaw, err := os.ReadFile(sshConfigFile)
@@ -139,6 +175,25 @@ func configSSH() *cobra.Command {
139175
return xerrors.Errorf("unexpected content in %s: remove the file and rerun the command to continue", coderConfigFile)
140176
}
141177
}
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+
}
142197

143198
// Keep track of changes we are making.
144199
var changes []string
@@ -147,14 +202,14 @@ func configSSH() *cobra.Command {
147202
// remove if present.
148203
configModified, ok := stripOldConfigBlock(configRaw)
149204
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))
151206
}
152207

153208
// Check for the presence of the coder Include
154209
// statement is present and add if missing.
155210
configModified, ok = sshConfigAddCoderInclude(configModified)
156211
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))
158213
}
159214

160215
root := createConfig(cmd)
@@ -197,19 +252,13 @@ func configSSH() *cobra.Command {
197252
}
198253

199254
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)
211261
}
212-
_, _ = buf.WriteString("#\n")
213262

214263
// Ensure stable sorting of output.
215264
slices.SortFunc(workspaceConfigs, func(a, b workspaceConfig) bool {
@@ -222,7 +271,7 @@ func configSSH() *cobra.Command {
222271
configOptions := []string{
223272
"Host coder." + hostname,
224273
}
225-
for _, option := range sshOptions {
274+
for _, option := range coderConfig.sshOptions {
226275
configOptions = append(configOptions, "\t"+option)
227276
}
228277
configOptions = append(configOptions,
@@ -248,9 +297,9 @@ func configSSH() *cobra.Command {
248297
modifyCoderConfig := !bytes.Equal(coderConfigRaw, buf.Bytes())
249298
if modifyCoderConfig {
250299
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))
252301
} 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))
254303
}
255304
}
256305

@@ -259,7 +308,7 @@ func configSSH() *cobra.Command {
259308
// Write to stderr to avoid dirtying the diff output.
260309
_, _ = fmt.Fprint(out, "Changes:\n\n")
261310
for _, change := range changes {
262-
_, _ = fmt.Fprintf(out, "* %s\n", change)
311+
_, _ = fmt.Fprintf(out, " * %s\n", change)
263312
}
264313
}
265314

@@ -283,8 +332,11 @@ func configSSH() *cobra.Command {
283332
}
284333

285334
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"), " "))
286338
_, 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),
288340
IsConfirm: true,
289341
})
290342
if err != nil {
@@ -315,10 +367,10 @@ func configSSH() *cobra.Command {
315367
return nil
316368
},
317369
}
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.")
319371
cmd.Flags().StringVar(&coderConfigFile, "ssh-coder-config-file", sshDefaultCoderConfigFileName, "Specifies the path to an Coder SSH config file. Useful for testing.")
320372
_ = 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.")
322374
cmd.Flags().BoolVarP(&showDiff, "diff", "D", false, "Show diff of changes that will be made.")
323375
cmd.Flags().BoolVarP(&skipProxyCommand, "skip-proxy-command", "", false, "Specifies whether the ProxyCommand option should be skipped. Useful for testing.")
324376
_ = cmd.Flags().MarkHidden("skip-proxy-command")
@@ -356,6 +408,46 @@ func sshConfigAddCoderInclude(data []byte) (modifiedData []byte, modified bool)
356408
return data, true
357409
}
358410

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+
359451
// writeWithTempFileAndMove writes to a temporary file in the same
360452
// directory as path and renames the temp file to the file provided in
361453
// path. This ensure we avoid trashing the file we are writing due to

0 commit comments

Comments
 (0)