keystore.go 13.7 KB
Newer Older
Jeromy's avatar
Jeromy committed
1 2 3
package commands

import (
4
	"bytes"
Jeromy's avatar
Jeromy committed
5 6
	"fmt"
	"io"
7 8
	"io/ioutil"
	"os"
9 10
	"path/filepath"
	"strings"
11
	"text/tabwriter"
Jeromy's avatar
Jeromy committed
12

Jakub Sztandera's avatar
Jakub Sztandera committed
13
	cmds "github.com/ipfs/go-ipfs-cmds"
14 15
	config "github.com/ipfs/go-ipfs-config"
	oldcmds "github.com/ipfs/go-ipfs/commands"
16
	cmdenv "github.com/ipfs/go-ipfs/core/commands/cmdenv"
17
	"github.com/ipfs/go-ipfs/core/commands/e"
Petar Maymounkov's avatar
renames  
Petar Maymounkov committed
18
	ke "github.com/ipfs/go-ipfs/core/commands/keyencode"
rendaw's avatar
rendaw committed
19
	fsrepo "github.com/ipfs/go-ipfs/repo/fsrepo"
Jakub Sztandera's avatar
Jakub Sztandera committed
20
	options "github.com/ipfs/interface-go-ipfs-core/options"
rendaw's avatar
rendaw committed
21
	"github.com/libp2p/go-libp2p-core/crypto"
22
	peer "github.com/libp2p/go-libp2p-core/peer"
Jeromy's avatar
Jeromy committed
23 24 25
)

var KeyCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
26
	Helptext: cmds.HelpText{
27 28
		Tagline: "Create and list IPNS name keypairs",
		ShortDescription: `
29 30
'ipfs key gen' generates a new keypair for usage with IPNS and 'ipfs name
publish'.
31 32 33 34 35 36 37 38 39 40

  > ipfs key gen --type=rsa --size=2048 mykey
  > ipfs name publish --key=mykey QmSomeHash

'ipfs key list' lists the available keys.

  > ipfs key list
  self
  mykey
		`,
Jeromy's avatar
Jeromy committed
41 42
	},
	Subcommands: map[string]*cmds.Command{
rendaw's avatar
rendaw committed
43 44 45 46 47 48
		"gen":    keyGenCmd,
		"export": keyExportCmd,
		"import": keyImportCmd,
		"list":   keyListCmd,
		"rename": keyRenameCmd,
		"rm":     keyRmCmd,
49
		"rotate": keyRotateCmd,
Jeromy's avatar
Jeromy committed
50 51 52 53 54 55 56 57
	},
}

type KeyOutput struct {
	Name string
	Id   string
}

58 59 60 61
type KeyOutputList struct {
	Keys []KeyOutput
}

Michael Muré's avatar
Michael Muré committed
62
// KeyRenameOutput define the output type of keyRenameCmd
Michael Muré's avatar
Michael Muré committed
63 64 65 66 67 68 69
type KeyRenameOutput struct {
	Was       string
	Now       string
	Id        string
	Overwrite bool
}

Kejie Zhang's avatar
Kejie Zhang committed
70
const (
71
	keyStoreAlgorithmDefault = options.Ed25519Key
Petar Maymounkov's avatar
Petar Maymounkov committed
72 73 74
	keyStoreTypeOptionName   = "type"
	keyStoreSizeOptionName   = "size"
	oldKeyOptionName         = "oldkey"
Kejie Zhang's avatar
Kejie Zhang committed
75 76
)

77
var keyGenCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
78
	Helptext: cmds.HelpText{
Jeromy's avatar
Jeromy committed
79 80
		Tagline: "Create a new keypair",
	},
Steven Allen's avatar
Steven Allen committed
81
	Options: []cmds.Option{
82
		cmds.StringOption(keyStoreTypeOptionName, "t", "type of the key to create: rsa, ed25519").WithDefault(keyStoreAlgorithmDefault),
Steven Allen's avatar
Steven Allen committed
83
		cmds.IntOption(keyStoreSizeOptionName, "s", "size of the key to generate"),
84
		ke.OptionIPNSBase,
Jeromy's avatar
Jeromy committed
85
	},
Steven Allen's avatar
Steven Allen committed
86
	Arguments: []cmds.Argument{
rendaw's avatar
rendaw committed
87
		cmds.StringArg("name", true, false, "name of key to create"),
Jeromy's avatar
Jeromy committed
88
	},
keks's avatar
keks committed
89
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
rendaw's avatar
rendaw committed
90 91 92 93 94
		api, err := cmdenv.GetApi(env, req)
		if err != nil {
			return err
		}

Kejie Zhang's avatar
Kejie Zhang committed
95
		typ, f := req.Options[keyStoreTypeOptionName].(string)
Jeromy's avatar
Jeromy committed
96
		if !f {
keks's avatar
keks committed
97
			return fmt.Errorf("please specify a key type with --type")
Jeromy's avatar
Jeromy committed
98 99
		}

rendaw's avatar
rendaw committed
100 101 102
		name := req.Arguments[0]
		if name == "self" {
			return fmt.Errorf("cannot create key with name 'self'")
Jeromy's avatar
Jeromy committed
103 104
		}

105
		opts := []options.KeyGenerateOption{options.Key.Type(typ)}
Jeromy's avatar
Jeromy committed
106

Kejie Zhang's avatar
Kejie Zhang committed
107
		size, sizefound := req.Options[keyStoreSizeOptionName].(int)
108 109
		if sizefound {
			opts = append(opts, options.Key.Size(size))
Jeromy's avatar
Jeromy committed
110
		}
111
		keyEnc, err := ke.KeyEncoderFromString(req.Options[ke.OptionIPNSBase.Name()].(string))
112
		if err != nil {
113 114
			return err
		}
Jeromy's avatar
Jeromy committed
115

rendaw's avatar
rendaw committed
116
		key, err := api.Key().Generate(req.Context, name, opts...)
Jeromy's avatar
Jeromy committed
117 118

		if err != nil {
keks's avatar
keks committed
119
			return err
Jeromy's avatar
Jeromy committed
120 121
		}

rendaw's avatar
rendaw committed
122
		return cmds.EmitOnce(res, &KeyOutput{
Jeromy's avatar
Jeromy committed
123
			Name: name,
124
			Id:   keyEnc.FormatID(key.ID()),
Jeromy's avatar
Jeromy committed
125 126
		})
	},
127
	Encoders: cmds.EncoderMap{
rendaw's avatar
rendaw committed
128
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, ko *KeyOutput) error {
Overbool's avatar
Overbool committed
129
			_, err := w.Write([]byte(ko.Id + "\n"))
130 131
			return err
		}),
Jeromy's avatar
Jeromy committed
132
	},
rendaw's avatar
rendaw committed
133
	Type: KeyOutput{},
Jeromy's avatar
Jeromy committed
134 135
}

rendaw's avatar
rendaw committed
136 137 138
var keyExportCmd = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline: "Export a keypair",
139 140 141
		ShortDescription: `
Exports a named libp2p key to disk.

142
By default, the output will be stored at './<key-name>.key', but an alternate
143 144
path can be specified with '--output=<path>' or '-o=<path>'.
`,
rendaw's avatar
rendaw committed
145 146 147 148
	},
	Arguments: []cmds.Argument{
		cmds.StringArg("name", true, false, "name of key to export").EnableStdin(),
	},
149 150 151
	Options: []cmds.Option{
		cmds.StringOption(outputOptionName, "o", "The path where the output should be stored."),
	},
152
	NoRemote: true,
rendaw's avatar
rendaw committed
153 154 155 156
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		name := req.Arguments[0]

		if name == "self" {
rendaw's avatar
rendaw committed
157
			return fmt.Errorf("cannot export key with name 'self'")
rendaw's avatar
rendaw committed
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
		}

		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		r, err := fsrepo.Open(cfgRoot)
		if err != nil {
			return err
		}
		defer r.Close()

		sk, err := r.Keystore().Get(name)
		if err != nil {
			return fmt.Errorf("key with name '%s' doesn't exist", name)
		}

176
		encoded, err := crypto.MarshalPrivateKey(sk)
rendaw's avatar
rendaw committed
177 178 179 180
		if err != nil {
			return err
		}

181
		return res.Emit(bytes.NewReader(encoded))
rendaw's avatar
rendaw committed
182
	},
183 184 185 186 187 188 189 190 191 192 193 194 195 196
	PostRun: cmds.PostRunMap{
		cmds.CLI: func(res cmds.Response, re cmds.ResponseEmitter) error {
			req := res.Request()

			v, err := res.Next()
			if err != nil {
				return err
			}

			outReader, ok := v.(io.Reader)
			if !ok {
				return e.New(e.TypeErr(outReader, v))
			}

197 198 199 200 201 202
			outPath, _ := req.Options[outputOptionName].(string)
			if outPath == "" {
				trimmed := strings.TrimRight(fmt.Sprintf("%s.key", req.Arguments[0]), "/")
				_, outPath = filepath.Split(trimmed)
				outPath = filepath.Clean(outPath)
			}
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217

			// create file
			file, err := os.Create(outPath)
			if err != nil {
				return err
			}
			defer file.Close()

			_, err = io.Copy(file, outReader)
			if err != nil {
				return err
			}

			return nil
		},
rendaw's avatar
rendaw committed
218 219 220 221 222 223 224 225
	},
}

var keyImportCmd = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline: "Import a key and prints imported key id",
	},
	Options: []cmds.Option{
226
		ke.OptionIPNSBase,
rendaw's avatar
rendaw committed
227 228 229
	},
	Arguments: []cmds.Argument{
		cmds.StringArg("name", true, false, "name to associate with key in keychain"),
230
		cmds.FileArg("key", true, false, "key provided by generate or export"),
rendaw's avatar
rendaw committed
231 232 233
	},
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		name := req.Arguments[0]
rendaw's avatar
rendaw committed
234 235 236 237 238

		if name == "self" {
			return fmt.Errorf("cannot import key with name 'self'")
		}

239
		keyEnc, err := ke.KeyEncoderFromString(req.Options[ke.OptionIPNSBase.Name()].(string))
240 241 242 243
		if err != nil {
			return err
		}

244 245 246 247 248
		file, err := cmdenv.GetFileArg(req.Files.Entries())
		if err != nil {
			return err
		}
		defer file.Close()
rendaw's avatar
rendaw committed
249

250
		data, err := ioutil.ReadAll(file)
rendaw's avatar
rendaw committed
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
		if err != nil {
			return err
		}

		sk, err := crypto.UnmarshalPrivateKey(data)
		if err != nil {
			return err
		}

		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		r, err := fsrepo.Open(cfgRoot)
		if err != nil {
			return err
		}
		defer r.Close()

		_, err = r.Keystore().Get(name)
		if err == nil {
			return fmt.Errorf("key with name '%s' already exists", name)
		}

		err = r.Keystore().Put(name, sk)
		if err != nil {
			return err
		}

		pid, err := peer.IDFromPrivateKey(sk)
		if err != nil {
			return err
		}

		return cmds.EmitOnce(res, &KeyOutput{
			Name: name,
288
			Id:   keyEnc.FormatID(pid),
rendaw's avatar
rendaw committed
289 290 291 292 293 294 295 296 297 298 299
		})
	},
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, ko *KeyOutput) error {
			_, err := w.Write([]byte(ko.Id + "\n"))
			return err
		}),
	},
	Type: KeyOutput{},
}

300
var keyListCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
301
	Helptext: cmds.HelpText{
Jeromy's avatar
Jeromy committed
302 303
		Tagline: "List all local keypairs",
	},
Steven Allen's avatar
Steven Allen committed
304 305
	Options: []cmds.Option{
		cmds.BoolOption("l", "Show extra information about keys."),
306
		ke.OptionIPNSBase,
307
	},
keks's avatar
keks committed
308
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
309
		keyEnc, err := ke.KeyEncoderFromString(req.Options[ke.OptionIPNSBase.Name()].(string))
310
		if err != nil {
311 312 313
			return err
		}

314
		api, err := cmdenv.GetApi(env, req)
Jeromy's avatar
Jeromy committed
315
		if err != nil {
keks's avatar
keks committed
316
			return err
Jeromy's avatar
Jeromy committed
317 318
		}

319
		keys, err := api.Key().List(req.Context)
Jeromy's avatar
Jeromy committed
320
		if err != nil {
keks's avatar
keks committed
321
			return err
Jeromy's avatar
Jeromy committed
322 323
		}

324
		list := make([]KeyOutput, 0, len(keys))
325 326

		for _, key := range keys {
327 328
			list = append(list, KeyOutput{
				Name: key.Name(),
329
				Id:   keyEnc.FormatID(key.ID()),
330
			})
331 332
		}

keks's avatar
keks committed
333
		return cmds.EmitOnce(res, &KeyOutputList{list})
Jeromy's avatar
Jeromy committed
334
	},
335
	Encoders: cmds.EncoderMap{
Overbool's avatar
Overbool committed
336
		cmds.Text: keyOutputListEncoders(),
Jeromy's avatar
Jeromy committed
337
	},
338 339 340
	Type: KeyOutputList{},
}

Kejie Zhang's avatar
Kejie Zhang committed
341 342 343 344
const (
	keyStoreForceOptionName = "force"
)

Michael Muré's avatar
Michael Muré committed
345
var keyRenameCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
346
	Helptext: cmds.HelpText{
Michael Muré's avatar
Michael Muré committed
347 348
		Tagline: "Rename a keypair",
	},
Steven Allen's avatar
Steven Allen committed
349 350 351
	Arguments: []cmds.Argument{
		cmds.StringArg("name", true, false, "name of key to rename"),
		cmds.StringArg("newName", true, false, "new name of the key"),
Michael Muré's avatar
Michael Muré committed
352
	},
Steven Allen's avatar
Steven Allen committed
353 354
	Options: []cmds.Option{
		cmds.BoolOption(keyStoreForceOptionName, "f", "Allow to overwrite an existing key."),
355
		ke.OptionIPNSBase,
Michael Muré's avatar
Michael Muré committed
356
	},
keks's avatar
keks committed
357
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
358
		api, err := cmdenv.GetApi(env, req)
Michael Muré's avatar
Michael Muré committed
359
		if err != nil {
keks's avatar
keks committed
360
			return err
Michael Muré's avatar
Michael Muré committed
361
		}
362
		keyEnc, err := ke.KeyEncoderFromString(req.Options[ke.OptionIPNSBase.Name()].(string))
363
		if err != nil {
364 365
			return err
		}
Michael Muré's avatar
Michael Muré committed
366

367 368
		name := req.Arguments[0]
		newName := req.Arguments[1]
Kejie Zhang's avatar
Kejie Zhang committed
369
		force, _ := req.Options[keyStoreForceOptionName].(bool)
Michael Muré's avatar
Michael Muré committed
370

371
		key, overwritten, err := api.Key().Rename(req.Context, name, newName, options.Key.Force(force))
Michael Muré's avatar
Michael Muré committed
372
		if err != nil {
keks's avatar
keks committed
373
			return err
Michael Muré's avatar
Michael Muré committed
374 375
		}

keks's avatar
keks committed
376
		return cmds.EmitOnce(res, &KeyRenameOutput{
Michael Muré's avatar
Michael Muré committed
377 378
			Was:       name,
			Now:       newName,
379
			Id:        keyEnc.FormatID(key.ID()),
380
			Overwrite: overwritten,
Michael Muré's avatar
Michael Muré committed
381 382
		})
	},
383
	Encoders: cmds.EncoderMap{
Overbool's avatar
Overbool committed
384 385
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, kro *KeyRenameOutput) error {
			if kro.Overwrite {
386
				fmt.Fprintf(w, "Key %s renamed to %s with overwriting\n", kro.Id, cmdenv.EscNonPrint(kro.Now))
Michael Muré's avatar
Michael Muré committed
387
			} else {
388
				fmt.Fprintf(w, "Key %s renamed to %s\n", kro.Id, cmdenv.EscNonPrint(kro.Now))
Michael Muré's avatar
Michael Muré committed
389
			}
390 391
			return nil
		}),
Michael Muré's avatar
Michael Muré committed
392 393 394 395
	},
	Type: KeyRenameOutput{},
}

396
var keyRmCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
397
	Helptext: cmds.HelpText{
Michael Muré's avatar
Michael Muré committed
398 399
		Tagline: "Remove a keypair",
	},
Steven Allen's avatar
Steven Allen committed
400 401
	Arguments: []cmds.Argument{
		cmds.StringArg("name", true, true, "names of keys to remove").EnableStdin(),
Michael Muré's avatar
Michael Muré committed
402
	},
Steven Allen's avatar
Steven Allen committed
403 404
	Options: []cmds.Option{
		cmds.BoolOption("l", "Show extra information about keys."),
405
		ke.OptionIPNSBase,
Michael Muré's avatar
Michael Muré committed
406
	},
keks's avatar
keks committed
407
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
408
		api, err := cmdenv.GetApi(env, req)
Michael Muré's avatar
Michael Muré committed
409
		if err != nil {
keks's avatar
keks committed
410
			return err
Michael Muré's avatar
Michael Muré committed
411
		}
412
		keyEnc, err := ke.KeyEncoderFromString(req.Options[ke.OptionIPNSBase.Name()].(string))
413
		if err != nil {
414 415
			return err
		}
Michael Muré's avatar
Michael Muré committed
416

417
		names := req.Arguments
Michael Muré's avatar
Michael Muré committed
418

Michael Muré's avatar
Michael Muré committed
419 420
		list := make([]KeyOutput, 0, len(names))
		for _, name := range names {
421
			key, err := api.Key().Remove(req.Context, name)
Michael Muré's avatar
Michael Muré committed
422
			if err != nil {
keks's avatar
keks committed
423
				return err
Michael Muré's avatar
Michael Muré committed
424 425
			}

426 427
			list = append(list, KeyOutput{
				Name: name,
428
				Id:   keyEnc.FormatID(key.ID()),
429
			})
Michael Muré's avatar
Michael Muré committed
430 431
		}

keks's avatar
keks committed
432
		return cmds.EmitOnce(res, &KeyOutputList{list})
Michael Muré's avatar
Michael Muré committed
433
	},
434
	Encoders: cmds.EncoderMap{
Overbool's avatar
Overbool committed
435
		cmds.Text: keyOutputListEncoders(),
Michael Muré's avatar
Michael Muré committed
436
	},
Michael Muré's avatar
Michael Muré committed
437
	Type: KeyOutputList{},
Michael Muré's avatar
Michael Muré committed
438 439
}

440 441 442 443 444
var keyRotateCmd = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline: "Rotates the ipfs identity.",
		ShortDescription: `
Generates a new ipfs identity and saves it to the ipfs config file.
445
Your existing identity key will be backed up in the Keystore.
446 447 448 449 450 451 452 453 454 455 456
The daemon must not be running when calling this command.

ipfs uses a repository in the local file system. By default, the repo is
located at ~/.ipfs. To change the repo location, set the $IPFS_PATH
environment variable:

    export IPFS_PATH=/path/to/ipfsrepo
`,
	},
	Arguments: []cmds.Argument{},
	Options: []cmds.Option{
457
		cmds.StringOption(oldKeyOptionName, "o", "Keystore name to use for backing up your existing identity"),
458 459
		cmds.StringOption(keyStoreTypeOptionName, "t", "type of the key to create: rsa, ed25519").WithDefault(keyStoreAlgorithmDefault),
		cmds.IntOption(keyStoreSizeOptionName, "s", "size of the key to generate"),
460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479
	},
	NoRemote: true,
	PreRun: func(req *cmds.Request, env cmds.Environment) error {
		cctx := env.(*oldcmds.Context)
		daemonLocked, err := fsrepo.LockedByOtherProcess(cctx.ConfigRoot)
		if err != nil {
			return err
		}

		log.Info("checking if daemon is running...")
		if daemonLocked {
			log.Debug("ipfs daemon is running")
			e := "ipfs daemon is running. please stop it to run this command"
			return cmds.ClientError(e)
		}

		return nil
	},
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		cctx := env.(*oldcmds.Context)
480 481
		nBitsForKeypair, nBitsGiven := req.Options[keyStoreSizeOptionName].(int)
		algorithm, _ := req.Options[keyStoreTypeOptionName].(string)
482 483 484 485
		oldKey, ok := req.Options[oldKeyOptionName].(string)
		if !ok {
			return fmt.Errorf("keystore name for backing up old key must be provided")
		}
486 487 488
		if oldKey == "self" {
			return fmt.Errorf("keystore name for back up cannot be named 'self'")
		}
489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542
		return doRotate(os.Stdout, cctx.ConfigRoot, oldKey, algorithm, nBitsForKeypair, nBitsGiven)
	},
}

func doRotate(out io.Writer, repoRoot string, oldKey string, algorithm string, nBitsForKeypair int, nBitsGiven bool) error {
	// Open repo
	repo, err := fsrepo.Open(repoRoot)
	if err != nil {
		return fmt.Errorf("opening repo (%v)", err)
	}
	defer repo.Close()

	// Read config file from repo
	cfg, err := repo.Config()
	if err != nil {
		return fmt.Errorf("reading config from repo (%v)", err)
	}

	// Generate new identity
	var identity config.Identity
	if nBitsGiven {
		identity, err = config.CreateIdentity(out, []options.KeyGenerateOption{
			options.Key.Size(nBitsForKeypair),
			options.Key.Type(algorithm),
		})
	} else {
		identity, err = config.CreateIdentity(out, []options.KeyGenerateOption{
			options.Key.Type(algorithm),
		})
	}
	if err != nil {
		return fmt.Errorf("creating identity (%v)", err)
	}

	// Save old identity to keystore
	oldPrivKey, err := cfg.Identity.DecodePrivateKey("")
	if err != nil {
		return fmt.Errorf("decoding old private key (%v)", err)
	}
	keystore := repo.Keystore()
	if err := keystore.Put(oldKey, oldPrivKey); err != nil {
		return fmt.Errorf("saving old key in keystore (%v)", err)
	}

	// Update identity
	cfg.Identity = identity

	// Write config file to repo
	if err = repo.SetConfig(cfg); err != nil {
		return fmt.Errorf("saving new key to config (%v)", err)
	}
	return nil
}

Overbool's avatar
Overbool committed
543 544
func keyOutputListEncoders() cmds.EncoderFunc {
	return cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, list *KeyOutputList) error {
545
		withID, _ := req.Options["l"].(bool)
546 547 548

		tw := tabwriter.NewWriter(w, 1, 2, 1, ' ', 0)
		for _, s := range list.Keys {
549
			if withID {
550
				fmt.Fprintf(tw, "%s\t%s\t\n", s.Id, cmdenv.EscNonPrint(s.Name))
551
			} else {
552
				fmt.Fprintf(tw, "%s\n", cmdenv.EscNonPrint(s.Name))
553
			}
554
		}
555 556 557
		tw.Flush()
		return nil
	})
Jeromy's avatar
Jeromy committed
558
}