-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaccount.go
More file actions
386 lines (358 loc) · 10.9 KB
/
Copy pathaccount.go
File metadata and controls
386 lines (358 loc) · 10.9 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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"github.com/aviorstudio/termcade/internal/registry"
)
func cmdLogin(args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return cmdLoginContext(ctx, args, os.Stdout)
}
func cmdLoginContext(ctx context.Context, args []string, out io.Writer) error {
if len(args) != 0 {
return fmt.Errorf("usage: termcade login")
}
client := registry.New(registry.URL(nil), "")
session, err := client.DeviceLogin(ctx, "Termcade CLI", func(uri, code string) {
fmt.Fprintf(out, "\nUsing a browser, visit:\n %s\n\nAnd enter this code:\n %s\n\nWaiting for approval (Ctrl+C to cancel)…\n", uri, code)
})
if err != nil {
if context.Cause(ctx) != nil {
return errors.New("login canceled")
}
return err
}
if err := registry.SaveSession(session); err != nil {
return err
}
fmt.Fprintf(out, "logged in as %s (%s)\n", session.Email, session.Registry)
// Signing in on a new machine is the moment your library should arrive.
// Nobody should have to know a second command exists to get the games
// they already own, so this runs here rather than waiting to be asked.
//
// Best-effort: the login itself succeeded, and reporting it as a failure
// because a restore did not finish would be a lie about what happened.
if err := cmdSync(); err != nil {
fmt.Fprintf(os.Stderr, "note: could not restore your library: %v\n", err)
}
return nil
}
func cmdLogout() error {
if err := registry.ClearSession(); err != nil {
return err
}
fmt.Println("logged out")
return nil
}
// cmdKeys manages publish keys: the credential a release workflow holds so
// publishing does not need an interactive account login on a CI runner.
func cmdKeys(args []string) error {
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("managing keys requires an account — run `termcade login`")
}
client := registry.New(registry.URL(session), session.Token)
switch {
case len(args) == 0 || args[0] == "list":
keys, err := client.Keys()
if err != nil {
return err
}
if len(keys) == 0 {
fmt.Println("no publish keys — create one with `termcade keys new <name> <username>`")
return nil
}
for _, k := range keys {
used := "never used"
if k.LastUsed != "" {
used = "last used " + k.LastUsed
}
fmt.Printf("%-24s %-20s %s\n %s\n", k.Name, k.Username, used, k.ID)
}
return nil
case args[0] == "new":
if len(args) != 3 {
return fmt.Errorf("usage: termcade keys new <name> <username>")
}
key, err := client.CreateKey(args[1], args[2])
if err != nil {
return err
}
// Printed once because it exists once. The registry stores a hash and
// cannot produce this again, so anything that loses it needs a new key.
fmt.Printf("created %q, publishing as %s\n\n %s\n\n", key.Name, key.Username, key.Token)
fmt.Fprintln(os.Stderr,
"that token is shown once and cannot be recovered — put it somewhere safe now")
return nil
case args[0] == "revoke":
if len(args) != 2 {
return fmt.Errorf("usage: termcade keys revoke <id>")
}
if err := client.DeleteKey(args[1]); err != nil {
return err
}
fmt.Println("revoked")
return nil
}
return fmt.Errorf("unknown keys subcommand; try: list · new <name> <username> · revoke <id>")
}
// cmdOrg manages studios: a handle more than one person publishes under.
//
// An org exists so `aviorstudio/tetris` can outlive any one account, and so a
// second person can release it. Membership is enough to publish; admin governs
// the studio itself.
func cmdOrg(args []string) error {
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("managing studios requires an account — run `termcade login`")
}
client := registry.New(registry.URL(session), session.Token)
switch {
case len(args) == 0 || args[0] == "list":
me, err := client.Me()
if err != nil {
return err
}
if me.Username != "" {
fmt.Printf("%-24s you\n", me.Username)
}
for _, org := range me.Orgs {
role := "member"
if org.Admin {
role = "admin"
}
fmt.Printf("%-24s studio (%s)\n", org.Username, role)
}
if len(me.Orgs) == 0 {
fmt.Println("\nno studios — create one with `termcade org new <username>`")
}
return nil
case args[0] == "new":
if len(args) < 2 || len(args) > 3 {
return fmt.Errorf("usage: termcade org new <username> [bio]")
}
bio := ""
if len(args) == 3 {
bio = args[2]
}
org, err := client.CreateOrg(args[1], bio, "")
if err != nil {
return err
}
fmt.Printf("created %s — you are its admin\n", org.Username)
fmt.Printf("publish as %s/<game>, and add people with `termcade org add %s <email>`\n",
org.Username, org.Username)
return nil
case args[0] == "show":
if len(args) != 2 {
return fmt.Errorf("usage: termcade org show <username>")
}
org, err := client.Org(args[1])
if err != nil {
return err
}
fmt.Printf("%s\n", org.Username)
if org.Bio != "" {
fmt.Printf(" %s\n", org.Bio)
}
if len(org.Games) == 0 {
fmt.Println(" no published games")
}
for _, game := range org.Games {
fmt.Printf(" %s\n", game)
}
// Only members see addresses, so this is attempted and quietly
// skipped: a studio page you can read without belonging to it should
// not fail because you do not.
if members, err := client.Members(args[1]); err == nil && len(members) > 0 {
fmt.Println()
for _, m := range members {
role := "member"
if m.Admin {
role = "admin"
}
fmt.Printf(" %-32s %s\n", m.Email, role)
}
}
return nil
case args[0] == "add":
// One call for adding and for promoting: they are the same intent at
// different times, and a separate `promote` would only work in one
// order.
if len(args) < 3 || len(args) > 4 {
return fmt.Errorf("usage: termcade org add <org> <email> [admin]")
}
admin := len(args) == 4 && args[3] == "admin"
if err := client.AddMember(args[1], args[2], admin); err != nil {
return err
}
role := "a member"
if admin {
role = "an admin"
}
fmt.Printf("%s is now %s of %s\n", args[2], role, args[1])
return nil
case args[0] == "edit":
if len(args) != 3 {
return fmt.Errorf("usage: termcade org edit <org> <bio>")
}
bio := args[2]
org, err := client.UpdateOrg(args[1], &bio, nil)
if err != nil {
return err
}
fmt.Printf("updated %s\n", org.Username)
return nil
case args[0] == "delete":
// Confirmed by typing the name. Dissolving a studio releases its
// handle for anyone to claim, and cannot be undone.
if len(args) != 3 || args[2] != args[1] {
return fmt.Errorf(
"this dissolves the studio and releases its handle for anyone to claim.\n"+
"it cannot be undone. to confirm:\n\n termcade org delete %s %s",
valueOr(args, 1), valueOr(args, 1))
}
if err := client.DeleteOrg(args[1]); err != nil {
return err
}
fmt.Printf("dissolved %s\n", args[1])
return nil
case args[0] == "remove":
if len(args) != 3 {
return fmt.Errorf("usage: termcade org remove <org> <email>")
}
if err := client.RemoveMember(args[1], args[2]); err != nil {
return err
}
fmt.Printf("removed %s from %s\n", args[2], args[1])
return nil
}
return fmt.Errorf(
"unknown org subcommand; try: list · new <username> [bio] · show <username> · " +
"edit <org> <bio> · add <org> <email> [admin] · remove <org> <email> · " +
"delete <org> <org>")
}
// valueOr keeps a usage message readable when the argument it wants to quote
// was the thing that was missing.
func valueOr(args []string, i int) string {
if i < len(args) {
return args[i]
}
return "<org>"
}
// cmdWhoami reports the account, its handle, and every studio it can publish
// under. The first thing to reach for when a publish is refused and the reason
// is "you are not a member of" something.
func cmdWhoami() error {
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("not signed in — run `termcade login`")
}
me, err := registry.New(registry.URL(session), session.Token).Me()
if err != nil {
return err
}
fmt.Printf("%s (%s)\n", me.Email, session.Registry)
if me.Username == "" {
// A real state: an account whose creation lost a race for its handle.
fmt.Println("\nno username yet — claim one with `termcade username <name>`")
} else {
fmt.Printf("\npublish as:\n %-24s you\n", me.Username)
}
for _, org := range me.Orgs {
role := "member"
if org.Admin {
role = "admin"
}
fmt.Printf(" %-24s studio (%s)\n", org.Username, role)
}
return nil
}
// cmdUsername claims or renames this account's handle, and with no argument
// reports whether one is free.
func cmdUsername(args []string) error {
if len(args) != 1 {
return fmt.Errorf("usage: termcade username <name>")
}
session, err := registry.LoadSession()
if err != nil {
return err
}
// Checking availability needs no account, so it works before signing up —
// which is when somebody is choosing a name.
client := registry.New(registry.URL(session), "")
owner, taken, err := client.HandleTaken(args[0])
if err != nil {
return err
}
if session == nil {
if taken {
kind := "an account"
if owner.IsOrg {
kind = "a studio"
}
return fmt.Errorf("%s is taken (by %s)", args[0], kind)
}
fmt.Printf("%s is available — create an account in the app, then claim it after `termcade login`\n", args[0])
return nil
}
claimed, err := registry.New(registry.URL(session), session.Token).SetUsername(args[0])
if err != nil {
return err
}
// The stored session carries the handle, so it has to follow the rename or
// every later command reports the old one.
session.Username = claimed.Name
if err := registry.SaveSession(*session); err != nil {
return err
}
fmt.Printf("you are %s — publish as %s/<game>\n", claimed.Name, claimed.Name)
return nil
}
// cmdAccountDelete removes the account, after saying what that means and
// waiting for the word.
func cmdAccountDelete(args []string) error {
session, err := registry.LoadSession()
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("not signed in — run `termcade login`")
}
// Confirmed by typing the handle, not by pressing y. This releases a name
// somebody else can then claim, and it cannot be undone.
if len(args) != 1 || args[0] != session.Username {
return fmt.Errorf(
"this deletes %s and releases the handle %q for anyone to claim.\n"+
"it cannot be undone. to confirm:\n\n termcade account delete %s",
session.Email, session.Username, session.Username)
}
if err := registry.New(registry.URL(session), session.Token).DeleteAccount(); err != nil {
return err
}
if err := registry.ClearSession(); err != nil {
return err
}
fmt.Println("account deleted")
return nil
}
func cmdAccount(args []string) error {
if len(args) >= 1 && args[0] == "delete" {
return cmdAccountDelete(args[1:])
}
return fmt.Errorf("unknown account subcommand; try: termcade account delete <username>")
}