-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcmd_init.go
More file actions
287 lines (255 loc) · 7.34 KB
/
cmd_init.go
File metadata and controls
287 lines (255 loc) · 7.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
func init() {
cmd := &cobra.Command{
Use: "init",
Short: "Interactive setup wizard to configure frm",
RunE: func(cmd *cobra.Command, args []string) error {
return runInit(cmd.InOrStdin(), cmd.OutOrStdout())
},
}
rootCmd.AddCommand(cmd)
}
// writeConfig atomically writes the config to path via a temp file + rename.
// This avoids race conditions with file-sync tools (e.g. Dropbox) that can
// observe and sync a partially-written or truncated file.
func writeConfig(path string, cfg Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.json")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("writing config: %w", err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("syncing config: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("closing config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("renaming config: %w", err)
}
return nil
}
// prompt reads a line from the scanner, printing the given message first.
// Returns the trimmed input. If the scanner hits EOF, returns empty string and io.EOF.
func prompt(scanner *bufio.Scanner, w io.Writer, msg string) (string, error) {
fmt.Fprint(w, msg)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return "", err
}
return "", io.EOF
}
return strings.TrimSpace(scanner.Text()), nil
}
func runInit(r io.Reader, w io.Writer) error {
scanner := bufio.NewScanner(r)
path := configPath()
dir := configDir()
var existing *Config
if data, err := os.ReadFile(path); err == nil {
var cfg Config
if err := json.Unmarshal(data, &cfg); err == nil {
existing = &cfg
}
}
var services []ServiceConfig
if existing != nil {
fmt.Fprintf(w, "Config file already exists at %s\n", path)
answer, err := prompt(scanner, w, "Do you want to (o)verwrite or (a)dd a service? [o/a]: ")
if err != nil {
return err
}
switch strings.ToLower(answer) {
case "a", "add":
services = existing.Services
case "o", "overwrite":
// start fresh
default:
return fmt.Errorf("invalid choice %q, expected 'o' or 'a'", answer)
}
}
// Prompt for service type
svcType, err := prompt(scanner, w, "Service type: (c)arddav or (j)map? [c]: ")
if err != nil {
return err
}
svcType = strings.ToLower(svcType)
if svcType == "" {
svcType = "c"
}
switch svcType {
case "c", "carddav":
svc, err := promptCardDAV(scanner, w)
if err != nil {
return err
}
services = append(services, svc)
case "j", "jmap":
svc, err := promptJMAP(scanner, w)
if err != nil {
return err
}
services = append(services, svc)
default:
return fmt.Errorf("unknown service type %q", svcType)
}
cfg := Config{Services: services}
// Write config
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
if err := writeConfig(path, cfg); err != nil {
return err
}
fmt.Fprintf(w, "\nConfig written to %s\n", path)
// Ask about adding JMAP if we just added CardDAV
if services[len(services)-1].Type == "carddav" {
addJMAP, err := prompt(scanner, w, "Add JMAP service for email context? [y/N]: ")
if err == nil && strings.ToLower(addJMAP) == "y" {
svc, err := promptJMAP(scanner, w)
if err != nil {
return err
}
cfg.Services = append(cfg.Services, svc)
if err := writeConfig(path, cfg); err != nil {
return err
}
fmt.Fprintf(w, "Config updated with JMAP service.\n")
}
}
fmt.Fprintf(w, "\nRun 'frm triage' to start categorizing your contacts\n")
return nil
}
func promptCardDAV(scanner *bufio.Scanner, w io.Writer) (ServiceConfig, error) {
fmt.Fprintln(w, "\nCardDAV Setup")
fmt.Fprintln(w, "Choose a provider preset:")
fmt.Fprintln(w, " 1) iCloud (needs app-specific password)")
fmt.Fprintln(w, " 2) Fastmail")
fmt.Fprintln(w, " 3) Custom URL")
choice, err := prompt(scanner, w, "Provider [1/2/3]: ")
if err != nil {
return ServiceConfig{}, err
}
var endpoint string
var isFastmail bool
switch choice {
case "1", "icloud":
endpoint = "https://contacts.icloud.com"
fmt.Fprintln(w, "Using iCloud endpoint. You will need an app-specific password.")
fmt.Fprintln(w, "Generate one at https://appleid.apple.com/account/manage")
case "2", "fastmail":
isFastmail = true
fmt.Fprintln(w, "Using Fastmail endpoint.")
case "3", "custom":
endpoint, err = prompt(scanner, w, "CardDAV endpoint URL: ")
if err != nil {
return ServiceConfig{}, err
}
default:
return ServiceConfig{}, fmt.Errorf("invalid provider choice %q", choice)
}
if !isFastmail && endpoint == "" {
return ServiceConfig{}, fmt.Errorf("endpoint URL is required")
}
username, err := prompt(scanner, w, "Username: ")
if err != nil {
return ServiceConfig{}, err
}
if username == "" {
return ServiceConfig{}, fmt.Errorf("username is required")
}
if isFastmail {
endpoint = "https://carddav.fastmail.com/dav/addressbooks/user/" + username + "/Default"
fmt.Fprintf(w, "Endpoint: %s\n", endpoint)
}
fmt.Fprintln(w, "Note: password will be visible as you type (no terminal raw mode).")
password, err := prompt(scanner, w, "Password: ")
if err != nil {
return ServiceConfig{}, err
}
if password == "" {
return ServiceConfig{}, fmt.Errorf("password is required")
}
svc := ServiceConfig{
Type: "carddav",
Endpoint: endpoint,
Username: username,
Password: password,
}
// Validate connection
fmt.Fprintln(w, "Validating connection...")
client, err := newCardDAVClient(svc)
if err != nil {
fmt.Fprintf(w, "Warning: could not connect to CardDAV server: %v\n", err)
save, promptErr := prompt(scanner, w, "Save config anyway? [y/N]: ")
if promptErr != nil {
return ServiceConfig{}, promptErr
}
if strings.ToLower(save) != "y" {
return ServiceConfig{}, fmt.Errorf("aborted")
}
return svc, nil
}
ctx := context.Background()
_, err = findAddressBook(ctx, client)
if err != nil {
fmt.Fprintf(w, "Warning: connected but could not find address books: %v\n", err)
save, promptErr := prompt(scanner, w, "Save config anyway? [y/N]: ")
if promptErr != nil {
return ServiceConfig{}, promptErr
}
if strings.ToLower(save) != "y" {
return ServiceConfig{}, fmt.Errorf("aborted")
}
return svc, nil
}
fmt.Fprintln(w, "Connection successful! Address book found.")
return svc, nil
}
func promptJMAP(scanner *bufio.Scanner, w io.Writer) (ServiceConfig, error) {
fmt.Fprintln(w, "\nJMAP Setup")
endpoint, err := prompt(scanner, w, "JMAP session endpoint URL: ")
if err != nil {
return ServiceConfig{}, err
}
if endpoint == "" {
return ServiceConfig{}, fmt.Errorf("session endpoint is required")
}
fmt.Fprintln(w, "Note: token will be visible as you type (no terminal raw mode).")
token, err := prompt(scanner, w, "API token: ")
if err != nil {
return ServiceConfig{}, err
}
if token == "" {
return ServiceConfig{}, fmt.Errorf("token is required")
}
return ServiceConfig{
Type: "jmap",
SessionEndpoint: endpoint,
Token: token,
}, nil
}