cmd/tailscale: add shell tab-completion

The approach is lifted from cobra: `tailscale completion bash` emits a bash
script for configuring the shell's autocomplete:

    . <( tailscale completion bash )

so that typing:

    tailscale st<TAB>

invokes:

    tailscale completion __complete -- st

RELNOTE=tailscale CLI now supports shell tab-completion

Fixes #3793

Signed-off-by: Paul Scott <paul@tailscale.com>
This commit is contained in:
Paul Scott
2024-02-29 22:56:25 +00:00
committed by Paul Scott
parent 21a0fe1b9b
commit 82394debb7
32 changed files with 2393 additions and 18 deletions
+11 -10
View File
@@ -22,6 +22,7 @@ import (
"github.com/mattn/go-isatty"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob"
"tailscale.com/paths"
"tailscale.com/version/distro"
@@ -197,6 +198,7 @@ change in the future.
whoisCmd,
debugCmd,
driveCmd,
idTokenCmd,
},
FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error {
@@ -206,11 +208,6 @@ change in the future.
return flag.ErrHelp
},
}
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
)
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
@@ -222,6 +219,8 @@ change in the future.
}
return true
})
ffcomplete.Inject(rootCmd, func(c *ffcli.Command) { c.LongHelp = hidden + c.LongHelp }, usageFunc)
return rootCmd
}
@@ -303,9 +302,12 @@ func usageFunc(c *ffcli.Command) string {
return usageFuncOpt(c, true)
}
// hidden is the prefix that hides subcommands and flags from --help output when
// found at the start of the subcommand's LongHelp or flag's Usage.
const hidden = "HIDDEN: "
func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
var b strings.Builder
const hiddenPrefix = "HIDDEN: "
if c.ShortHelp != "" {
fmt.Fprintf(&b, "%s\n\n", c.ShortHelp)
@@ -319,8 +321,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
}
fmt.Fprintf(&b, "\n")
if c.LongHelp != "" {
help, _ := strings.CutPrefix(c.LongHelp, hiddenPrefix)
if help := strings.TrimPrefix(c.LongHelp, hidden); help != "" {
fmt.Fprintf(&b, "%s\n\n", help)
}
@@ -328,7 +329,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
fmt.Fprintf(&b, "SUBCOMMANDS\n")
tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
for _, subcommand := range c.Subcommands {
if strings.HasPrefix(subcommand.LongHelp, hiddenPrefix) {
if strings.HasPrefix(subcommand.LongHelp, hidden) {
continue
}
fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp)
@@ -343,7 +344,7 @@ func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
c.FlagSet.VisitAll(func(f *flag.Flag) {
var s string
name, usage := flag.UnquoteUsage(f)
if strings.HasPrefix(usage, hiddenPrefix) {
if strings.HasPrefix(usage, hidden) {
return
}
if isBoolFlag(f) {
+2 -2
View File
@@ -24,9 +24,9 @@ import (
var configureHostCmd = &ffcli.Command{
Name: "configure-host",
Exec: runConfigureSynology,
ShortUsage: "tailscale configure-host",
ShortUsage: "tailscale configure-host\n" + synologyConfigureCmd.ShortUsage,
ShortHelp: synologyConfigureCmd.ShortHelp,
LongHelp: synologyConfigureCmd.LongHelp,
LongHelp: hidden + synologyConfigureCmd.LongHelp,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("configure-host")
return fs
+2 -1
View File
@@ -48,7 +48,8 @@ var debugCmd = &ffcli.Command{
Name: "debug",
Exec: runDebug,
ShortUsage: "tailscale debug <debug-flags | subcommand>",
LongHelp: `HIDDEN: "tailscale debug" contains misc debug facilities; it is not a stable interface.`,
ShortHelp: "Debug commands",
LongHelp: hidden + `"tailscale debug" contains misc debug facilities; it is not a stable interface.`,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("debug")
fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion
// Package ffcomplete provides shell tab-completion of subcommands, flags and
// arguments for Go programs written with [ffcli].
//
// The shell integration scripts have been extracted from Cobra
// (https://cobra.dev/), whose authors deserve most of the credit for this work.
// These shell completion functions invoke `$0 completion __complete -- ...`
// which is wired up to [Complete].
package ffcomplete
import (
"context"
"flag"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
"tailscale.com/tempfork/spf13/cobra"
)
type compOpts struct {
showFlags bool
showDescs bool
}
func newFS(name string, opts *compOpts) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.BoolVar(&opts.showFlags, "flags", true, "Suggest flag completions with subcommands")
fs.BoolVar(&opts.showDescs, "descs", true, "Include flag, subcommand, and other descriptions in completions")
return fs
}
// Inject adds the 'completion' subcommand to the root command which provide the
// user with shell scripts for calling `completion __command` to provide
// tab-completion suggestions.
//
// root.Name needs to match the command that the user is tab-completing for the
// shell script to work as expected by default.
//
// The hide function is called with the __complete Command instance to provide a
// hook to omit it from the help output, if desired.
func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) {
var opts compOpts
compFS := newFS("completion", &opts)
completeCmd := &ffcli.Command{
Name: "__complete",
ShortUsage: root.Name + " completion __complete -- <args to complete...>",
ShortHelp: "Tab-completion suggestions for interactive shells",
UsageFunc: usageFunc,
FlagSet: compFS,
Exec: func(ctx context.Context, args []string) error {
// Set up debug logging for the rest of this function call.
if t := os.Getenv("BASH_COMP_DEBUG_FILE"); t != "" {
tf, err := os.OpenFile(t, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("opening debug file: %w", err)
}
defer func(origW io.Writer, origPrefix string, origFlags int) {
log.SetOutput(origW)
log.SetFlags(origFlags)
log.SetPrefix(origPrefix)
tf.Close()
}(log.Writer(), log.Prefix(), log.Flags())
log.SetOutput(tf)
log.SetFlags(log.Lshortfile)
log.SetPrefix("debug: ")
}
// Send back the results to the shell.
words, dir, err := internal.Complete(root, args, opts.showFlags, opts.showDescs)
if err != nil {
dir = ShellCompDirectiveError
}
for _, word := range words {
fmt.Println(word)
}
fmt.Println(":" + strconv.Itoa(int(dir)))
return err
},
}
if hide != nil {
hide(completeCmd)
}
root.Subcommands = append(
root.Subcommands,
&ffcli.Command{
Name: "completion",
ShortUsage: root.Name + " completion <shell> [--flags] [--descs]",
ShortHelp: "Shell tab-completion scripts.",
LongHelp: fmt.Sprintf(cobra.UsageTemplate, root.Name),
// Print help if run without args.
Exec: func(ctx context.Context, args []string) error { return flag.ErrHelp },
// Omit the '__complete' subcommand from the 'completion' help.
UsageFunc: func(c *ffcli.Command) string {
// Filter the subcommands to omit '__complete'.
s := make([]*ffcli.Command, 0, len(c.Subcommands))
for _, sub := range c.Subcommands {
if !strings.HasPrefix(sub.Name, "__") {
s = append(s, sub)
}
}
// Swap in the filtered subcommands list for the rest of the call.
defer func(r []*ffcli.Command) { c.Subcommands = r }(c.Subcommands)
c.Subcommands = s
// Render the usage.
if usageFunc == nil {
return ffcli.DefaultUsageFunc(c)
}
return usageFunc(c)
},
Subcommands: append(
scriptCmds(root, usageFunc),
completeCmd,
),
},
)
}
// Flag registers a completion function for the flag in fs with given name.
// comp will always called with a 1-element slice.
//
// comp will be called to return suggestions when the user tries to tab-complete
// '--name=<TAB>' or '--name <TAB>' for the commands using fs.
func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) {
f := fs.Lookup(name)
if f == nil {
panic(fmt.Errorf("ffcomplete.Flag: flag %s not found", name))
}
if internal.CompleteFlags == nil {
internal.CompleteFlags = make(map[*flag.Flag]CompleteFunc)
}
internal.CompleteFlags[f] = comp
}
// Args registers a completion function for the args of cmd.
//
// comp will be called to return suggestions when the user tries to tab-complete
// `prog <TAB>` or `prog subcmd arg1 <TAB>`, for example.
func Args(cmd *ffcli.Command, comp CompleteFunc) {
if internal.CompleteCmds == nil {
internal.CompleteCmds = make(map[*ffcli.Command]CompleteFunc)
}
internal.CompleteCmds[cmd] = comp
}
@@ -0,0 +1,17 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && ts_omit_completion
package ffcomplete
import (
"flag"
"github.com/peterbourgon/ff/v3/ffcli"
)
func Inject(root *ffcli.Command, hide func(*ffcli.Command), usageFunc func(*ffcli.Command) string) {}
func Flag(fs *flag.FlagSet, name string, comp CompleteFunc) {}
func Args(cmd *ffcli.Command, comp CompleteFunc) *ffcli.Command { return cmd }
@@ -0,0 +1,60 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ffcomplete
import (
"strings"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
"tailscale.com/tempfork/spf13/cobra"
)
type ShellCompDirective = cobra.ShellCompDirective
const (
ShellCompDirectiveError = cobra.ShellCompDirectiveError
ShellCompDirectiveNoSpace = cobra.ShellCompDirectiveNoSpace
ShellCompDirectiveNoFileComp = cobra.ShellCompDirectiveNoFileComp
ShellCompDirectiveFilterFileExt = cobra.ShellCompDirectiveFilterFileExt
ShellCompDirectiveFilterDirs = cobra.ShellCompDirectiveFilterDirs
ShellCompDirectiveKeepOrder = cobra.ShellCompDirectiveKeepOrder
ShellCompDirectiveDefault = cobra.ShellCompDirectiveDefault
)
// CompleteFunc is used to return tab-completion suggestions to the user as they
// are typing command-line instructions. It returns the list of things to
// suggest and an additional directive to the shell about what extra
// functionality to enable.
type CompleteFunc = internal.CompleteFunc
// LastArg returns the last element of args, or the empty string if args is
// empty.
func LastArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[len(args)-1]
}
// Fixed returns a CompleteFunc which suggests the given words.
func Fixed(words ...string) CompleteFunc {
return func(args []string) ([]string, cobra.ShellCompDirective, error) {
match := LastArg(args)
matches := make([]string, 0, len(words))
for _, word := range words {
if strings.HasPrefix(word, match) {
matches = append(matches, word)
}
}
return matches, cobra.ShellCompDirectiveNoFileComp, nil
}
}
// FilesWithExtensions returns a CompleteFunc that tells the shell to limit file
// suggestions to those with the given extensions.
func FilesWithExtensions(exts ...string) CompleteFunc {
return func(args []string) ([]string, cobra.ShellCompDirective, error) {
return exts, cobra.ShellCompDirectiveFilterFileExt, nil
}
}
@@ -0,0 +1,256 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package internal
import (
"flag"
"fmt"
"strings"
"github.com/peterbourgon/ff/v3"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/tempfork/spf13/cobra"
)
var (
CompleteCmds map[*ffcli.Command]CompleteFunc
CompleteFlags map[*flag.Flag]CompleteFunc
)
type CompleteFunc func([]string) ([]string, cobra.ShellCompDirective, error)
// Complete returns the autocomplete suggestions for the root program and args.
//
// The returned words do not necessarily need to be prefixed with the last arg
// which is being completed. For example, '--bool-flag=' will have completions
// 'true' and 'false'.
//
// "HIDDEN: " is trimmed from the start of Flag Usage's.
func Complete(root *ffcli.Command, args []string, startFlags, descs bool) (words []string, dir cobra.ShellCompDirective, err error) {
// Explicitly log panics.
defer func() {
if r := recover(); r != nil {
if rerr, ok := err.(error); ok {
err = fmt.Errorf("panic: %w", rerr)
} else {
err = fmt.Errorf("panic: %v", r)
}
}
}()
// Set up the arguments.
if len(args) == 0 {
args = []string{""}
}
// Completion criteria.
completeArg := args[len(args)-1]
args = args[:len(args)-1]
emitFlag := startFlags || strings.HasPrefix(completeArg, "-")
emitArgs := true
// Traverse the command-tree to find the cmd command whose
// subcommand, flags, or arguments are being completed.
cmd := root
walk:
for {
// Ensure there's a flagset with ContinueOnError set.
if cmd.FlagSet == nil {
cmd.FlagSet = flag.NewFlagSet(cmd.Name, flag.ContinueOnError)
}
cmd.FlagSet.Init(cmd.FlagSet.Name(), flag.ContinueOnError)
// Manually split the args so we know when we're completing flags/args.
flagArgs, argArgs, flagNeedingValue := splitFlagArgs(cmd.FlagSet, args)
if flagNeedingValue != "" {
completeArg = flagNeedingValue + "=" + completeArg
emitFlag = true
}
args = argArgs
// Parse the flags.
err := ff.Parse(cmd.FlagSet, flagArgs, cmd.Options...)
if err != nil {
return nil, 0, fmt.Errorf("%s flag parsing: %w", cmd.Name, err)
}
if cmd.FlagSet.NArg() > 0 {
// This shouldn't happen if splitFlagArgs is accurately finding the
// split between flags and args.
_ = false
}
if len(args) == 0 {
break
}
// Check if the first argument is actually a subcommand.
for _, sub := range cmd.Subcommands {
if strings.EqualFold(sub.Name, args[0]) {
args = args[1:]
cmd = sub
continue walk
}
}
break
}
if len(args) > 0 {
emitFlag = false
}
// Complete '-flag=...'. If the args ended with '-flag ...' we will have
// rewritten to '-flag=...' by now.
if emitFlag && strings.HasPrefix(completeArg, "-") && strings.Contains(completeArg, "=") {
// Don't complete '-flag' later on as the
// flag name is terminated by a '='.
emitFlag = false
emitArgs = false
dashFlag, completeVal, _ := strings.Cut(completeArg, "=")
_, f := cutDash(dashFlag)
flag := cmd.FlagSet.Lookup(f)
if flag != nil {
if comp := CompleteFlags[flag]; comp != nil {
// Complete custom flag values.
var err error
words, dir, err = comp([]string{completeVal})
if err != nil {
return nil, 0, fmt.Errorf("completing %s flag %s: %w", cmd.Name, flag.Name, err)
}
} else if isBoolFlag(flag) {
// Complete true/false.
for _, vals := range [][]string{
{"true", "TRUE", "True", "1"},
{"false", "FALSE", "False", "0"},
} {
for _, val := range vals {
if strings.HasPrefix(val, completeVal) {
words = append(words, val)
break
}
}
}
}
}
}
// Complete '-flag...'.
if emitFlag {
used := make(map[string]struct{})
cmd.FlagSet.Visit(func(f *flag.Flag) {
used[f.Name] = struct{}{}
})
cd, cf := cutDash(completeArg)
cmd.FlagSet.VisitAll(func(f *flag.Flag) {
if !strings.HasPrefix(f.Name, cf) {
return
}
// Skip flags already set by the user.
if _, seen := used[f.Name]; seen {
return
}
// Suggest single-dash '-v' for single-char flags and
// double-dash '--verbose' for longer.
d := cd
if (d == "" || d == "-") && cf == "" && len(f.Name) > 1 {
d = "--"
}
if descs {
_, usage := flag.UnquoteUsage(f)
usage = strings.TrimPrefix(usage, "HIDDEN: ")
if usage != "" {
words = append(words, d+f.Name+"\t"+usage)
return
}
}
words = append(words, d+f.Name)
})
}
if emitArgs {
// Complete 'sub...'.
for _, sub := range cmd.Subcommands {
if strings.HasPrefix(sub.Name, completeArg) {
if descs {
if sub.ShortHelp != "" {
words = append(words, sub.Name+"\t"+sub.ShortHelp)
continue
}
}
words = append(words, sub.Name)
}
}
// Complete custom args.
if comp := CompleteCmds[cmd]; comp != nil {
w, d, err := comp(append(args, completeArg))
if err != nil {
return nil, 0, fmt.Errorf("completing %s args: %w", cmd.Name, err)
}
dir = d
words = append(words, w...)
}
}
// Strip any descriptions if they were suppressed.
if !descs {
for i := range words {
words[i], _, _ = strings.Cut(words[i], "\t")
}
}
return words, dir, nil
}
// splitFlagArgs separates a list of command-line arguments into arguments
// comprising flags and their values, preceding arguments to be passed to the
// command. This follows the stdlib 'flag' parsing conventions. If the final
// argument is a flag name which takes a value but has no value specified, it is
// omitted from flagArgs and argArgs and instead returned in needValue.
func splitFlagArgs(fs *flag.FlagSet, args []string) (flagArgs, argArgs []string, flagNeedingValue string) {
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
return args[:i], args[i+1:], ""
}
d, f := cutDash(a)
if d == "" {
return args[:i], args[i:], ""
}
if strings.Contains(f, "=") {
continue
}
flag := fs.Lookup(f)
if flag == nil {
return args[:i], args[i:], ""
}
if isBoolFlag(flag) {
continue
}
// Consume an extra argument for the flag value.
if i == len(args)-1 {
return args[:i], nil, args[i]
}
i++
}
return args, nil, ""
}
func cutDash(s string) (dashes, flag string) {
if strings.HasPrefix(s, "-") {
if strings.HasPrefix(s[1:], "-") {
return "--", s[2:]
}
return "-", s[1:]
}
return "", s
}
func isBoolFlag(f *flag.Flag) bool {
bf, ok := f.Value.(interface {
IsBoolFlag() bool
})
return ok && bf.IsBoolFlag()
}
@@ -0,0 +1,219 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package internal_test
import (
_ "embed"
"flag"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/cmd/tailscale/cli/ffcomplete/internal"
)
func newFlagSet(name string, errh flag.ErrorHandling, flags func(fs *flag.FlagSet)) *flag.FlagSet {
fs := flag.NewFlagSet(name, errh)
if flags != nil {
flags(fs)
}
return fs
}
func TestComplete(t *testing.T) {
t.Parallel()
// Build our test program in testdata.
root := &ffcli.Command{
Name: "prog",
FlagSet: newFlagSet("prog", flag.ContinueOnError, func(fs *flag.FlagSet) {
fs.Bool("v", false, "verbose")
fs.Bool("root-bool", false, "root `bool`")
fs.String("root-str", "", "some `text`")
}),
Subcommands: []*ffcli.Command{
{
Name: "debug",
ShortHelp: "Debug data",
FlagSet: newFlagSet("prog debug", flag.ExitOnError, func(fs *flag.FlagSet) {
fs.String("cpu-profile", "", "write cpu profile to `file`")
fs.Bool("debug-bool", false, "debug bool")
fs.Int("level", 0, "a number")
fs.String("enum", "", "a flag that takes several specific values")
ffcomplete.Flag(fs, "enum", ffcomplete.Fixed("alpha", "beta", "charlie"))
}),
},
func() *ffcli.Command {
cmd := &ffcli.Command{
Name: "ping",
FlagSet: newFlagSet("prog ping", flag.ContinueOnError, func(fs *flag.FlagSet) {
fs.String("until", "", "when pinging should end")
ffcomplete.Flag(fs, "until", ffcomplete.Fixed("forever", "direct"))
}),
}
ffcomplete.Args(cmd, ffcomplete.Fixed("jupiter", "neptune", "venus"))
return cmd
}(),
},
}
tests := []struct {
args []string
showFlags bool
showDescs bool
wantComp []string
wantDir ffcomplete.ShellCompDirective
}{
{
args: []string{"deb"},
wantComp: []string{"debug"},
},
{
args: []string{"deb"},
showDescs: true,
wantComp: []string{"debug\tDebug data"},
},
{
args: []string{"-"},
wantComp: []string{"--root-bool", "--root-str", "-v"},
},
{
args: []string{"--"},
wantComp: []string{"--root-bool", "--root-str", "--v"},
},
{
args: []string{"-r"},
wantComp: []string{"-root-bool", "-root-str"},
},
{
args: []string{"--r"},
wantComp: []string{"--root-bool", "--root-str"},
},
{
args: []string{"--root-str=s", "--r"},
wantComp: []string{"--root-bool"}, // omits --root-str which is already set
},
{
// '--' disables flag parsing, so we shouldn't suggest flags.
args: []string{"--", "--root"},
wantComp: nil,
},
{
// '--' is used as the value of '--root-str'.
args: []string{"--root-str", "--", "--r"},
wantComp: []string{"--root-bool"},
},
{
// '--' here is a flag value, so doesn't disable flag parsing.
args: []string{"--root-str", "--", "--root"},
wantComp: []string{"--root-bool"},
},
{
// Equivalent to '--root-str=-- -- --r' meaning '--r' is not
// a flag because it's preceded by a '--' argument:
// https://go.dev/play/p/UCtftQqVhOD.
args: []string{"--root-str", "--", "--", "--r"},
wantComp: nil,
},
{
args: []string{"--root-bool="},
wantComp: []string{"true", "false"},
},
{
args: []string{"--root-bool=t"},
wantComp: []string{"true"},
},
{
args: []string{"--root-bool=T"},
wantComp: []string{"TRUE"},
},
{
args: []string{"debug", "--de"},
wantComp: []string{"--debug-bool"},
},
{
args: []string{"debug", "--enum="},
wantComp: []string{"alpha", "beta", "charlie"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--enum=al"},
wantComp: []string{"alpha"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--level", ""},
wantComp: nil,
},
{
args: []string{"debug", "--enum", "b"},
wantComp: []string{"beta"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"debug", "--enum", "al"},
wantComp: []string{"alpha"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
showFlags: true,
wantComp: []string{"--until", "jupiter", "neptune", "venus"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
showFlags: true,
showDescs: true,
wantComp: []string{
"--until\twhen pinging should end",
"jupiter",
"neptune",
"venus",
},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", ""},
wantComp: []string{"jupiter", "neptune", "venus"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
{
args: []string{"ping", "j"},
wantComp: []string{"jupiter"},
wantDir: ffcomplete.ShellCompDirectiveNoFileComp,
},
}
// Run the tests.
for _, test := range tests {
test := test
name := strings.Join(test.args, "␣")
if test.showFlags {
name += "+flags"
}
if test.showDescs {
name += "+descs"
}
t.Run(name, func(t *testing.T) {
// Capture the binary
complete, dir, err := internal.Complete(root, test.args, test.showFlags, test.showDescs)
if err != nil {
t.Fatalf("completion error: %s", err)
}
// Test the results match our expectation.
if test.wantComp != nil {
if diff := cmp.Diff(test.wantComp, complete); diff != "" {
t.Errorf("unexpected completion directives (-want +got):\n%s", diff)
}
}
if test.wantDir != dir {
t.Errorf("got shell completion directive %[1]d (%[1]s), want %[2]d (%[2]s)", dir, test.wantDir)
}
})
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion && !ts_omit_completion_scripts
package ffcomplete
import (
"context"
"flag"
"os"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/tempfork/spf13/cobra"
)
func compCmd(fs *flag.FlagSet) string {
var s strings.Builder
s.WriteString("completion __complete")
fs.VisitAll(func(f *flag.Flag) {
s.WriteString(" --")
s.WriteString(f.Name)
s.WriteString("=")
s.WriteString(f.Value.String())
})
s.WriteString(" --")
return s.String()
}
func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command {
nameForVar := root.Name
nameForVar = strings.ReplaceAll(nameForVar, "-", "_")
nameForVar = strings.ReplaceAll(nameForVar, ":", "_")
var (
bashFS = newFS("bash", &compOpts{})
zshFS = newFS("zsh", &compOpts{})
fishFS = newFS("fish", &compOpts{})
pwshFS = newFS("powershell", &compOpts{})
)
return []*ffcli.Command{
{
Name: "bash",
ShortHelp: "Generate bash shell completion script",
ShortUsage: ". <( " + root.Name + " completion bash )",
UsageFunc: usageFunc,
FlagSet: bashFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptBash(os.Stdout, root.Name, compCmd(bashFS), nameForVar)
},
},
{
Name: "zsh",
ShortHelp: "Generate zsh shell completion script",
ShortUsage: ". <( " + root.Name + " completion zsh )",
UsageFunc: usageFunc,
FlagSet: zshFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptZsh(os.Stdout, root.Name, compCmd(zshFS), nameForVar)
},
},
{
Name: "fish",
ShortHelp: "Generate fish shell completion script",
ShortUsage: root.Name + " completion fish | source",
UsageFunc: usageFunc,
FlagSet: fishFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptFish(os.Stdout, root.Name, compCmd(fishFS), nameForVar)
},
},
{
Name: "powershell",
ShortHelp: "Generate powershell completion script",
ShortUsage: root.Name + " completion powershell | Out-String | Invoke-Expression",
UsageFunc: usageFunc,
FlagSet: pwshFS,
Exec: func(ctx context.Context, args []string) error {
return cobra.ScriptPowershell(os.Stdout, root.Name, compCmd(pwshFS), nameForVar)
},
},
}
}
@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build go1.19 && !ts_omit_completion && ts_omit_completion_scripts
package ffcomplete
import "github.com/peterbourgon/ff/v3/ffcli"
func scriptCmds(root *ffcli.Command, usageFunc func(*ffcli.Command) string) []*ffcli.Command {
return nil
}
+2
View File
@@ -26,6 +26,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/time/rate"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/envknob"
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
@@ -418,6 +419,7 @@ var fileGetCmd = &ffcli.Command{
skip: skip conflicting files: leave them in the taildrop inbox and print an error. get any non-conflicting files
overwrite: overwrite existing file
rename: write to a new number-suffixed filename`)
ffcomplete.Flag(fs, "conflict", ffcomplete.Fixed("skip", "overwrite", "rename"))
return fs
})(),
}
+5
View File
@@ -8,16 +8,21 @@ import (
"errors"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
)
var idTokenCmd = &ffcli.Command{
Name: "id-token",
ShortUsage: "tailscale id-token <aud>",
ShortHelp: "Fetch an OIDC id-token for the Tailscale machine",
LongHelp: hidden,
Exec: runIDToken,
}
func runIDToken(ctx context.Context, args []string) error {
if !envknob.UseWIPCode() {
return errors.New("tailscale id-token: works-in-progress require TAILSCALE_USE_WIP_CODE=1 envvar")
}
if len(args) != 1 {
return errors.New("usage: tailscale id-token <aud>")
}
+23
View File
@@ -10,8 +10,10 @@ import (
"io"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
)
var ncCmd = &ffcli.Command{
@@ -21,6 +23,27 @@ var ncCmd = &ffcli.Command{
Exec: runNC,
}
func init() {
ffcomplete.Args(ncCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
if len(args) > 1 {
return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
return completeHostOrIP(ffcomplete.LastArg(args))
})
}
func completeHostOrIP(arg string) ([]string, ffcomplete.ShellCompDirective, error) {
st, err := localClient.Status(context.Background())
if err != nil {
return nil, 0, err
}
nodes := make([]string, 0, len(st.Peer))
for _, node := range st.Peer {
nodes = append(nodes, strings.TrimSuffix(node.DNSName, "."))
}
return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
func runNC(ctx context.Context, args []string) error {
st, err := localClient.Status(ctx)
if err != nil {
+10
View File
@@ -17,6 +17,7 @@ import (
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
)
@@ -59,6 +60,15 @@ relay node.
})(),
}
func init() {
ffcomplete.Args(pingCmd, func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
if len(args) > 1 {
return nil, ffcomplete.ShellCompDirectiveNoFileComp, nil
}
return completeHostOrIP(ffcomplete.LastArg(args))
})
}
var pingArgs struct {
num int
size int
+18 -1
View File
@@ -10,10 +10,12 @@ import (
"fmt"
"net/netip"
"os/exec"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/web"
"tailscale.com/clientupdate"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn"
"tailscale.com/net/netutil"
"tailscale.com/net/tsaddr"
@@ -74,9 +76,24 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet {
setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an app connector for domain specific internet traffic for the tailnet")
setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates")
setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version")
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information")
setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, hidden+"allow management plane to gather device posture information")
setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252")
ffcomplete.Flag(setf, "exit-node", func(args []string) ([]string, ffcomplete.ShellCompDirective, error) {
st, err := localClient.Status(context.Background())
if err != nil {
return nil, 0, err
}
nodes := make([]string, 0, len(st.Peer))
for _, node := range st.Peer {
if !node.ExitNodeOption {
continue
}
nodes = append(nodes, strings.TrimSuffix(node.DNSName, "."))
}
return nodes, ffcomplete.ShellCompDirectiveNoFileComp, nil
})
if safesocket.GOOSUsesPeerCreds(goos) {
setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
}
+29
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/cmd/tailscale/cli/ffcomplete"
"tailscale.com/ipn"
)
@@ -35,6 +36,34 @@ This command is currently in alpha and may change in the future.`,
Exec: switchProfile,
}
func init() {
ffcomplete.Args(switchCmd, func(s []string) (words []string, dir ffcomplete.ShellCompDirective, err error) {
_, all, err := localClient.ProfileStatus(context.Background())
if err != nil {
return nil, 0, err
}
seen := make(map[string]bool, 3*len(all))
wordfns := []func(prof ipn.LoginProfile) string{
func(prof ipn.LoginProfile) string { return string(prof.ID) },
func(prof ipn.LoginProfile) string { return prof.NetworkProfile.DomainName },
func(prof ipn.LoginProfile) string { return prof.Name },
}
for _, wordfn := range wordfns {
for _, prof := range all {
word := wordfn(prof)
if seen[word] {
continue
}
seen[word] = true
words = append(words, fmt.Sprintf("%s\tid: %s, tailnet: %s, account: %s", word, prof.ID, prof.NetworkProfile.DomainName, prof.Name))
}
}
return words, ffcomplete.ShellCompDirectiveNoFileComp, nil
})
}
var switchArgs struct {
list bool
}
+1 -1
View File
@@ -105,7 +105,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "HIDDEN: install host routes to other Tailscale nodes")
upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, hidden+"install host routes to other Tailscale nodes")
upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node")
upf.BoolVar(&upArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
+5 -2
View File
@@ -34,8 +34,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L 💣 github.com/mdlayher/socket from github.com/mdlayher/netlink
github.com/miekg/dns from tailscale.com/net/dns/recursive
💣 github.com/mitchellh/go-ps from tailscale.com/cmd/tailscale/cli+
github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli
github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli
github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli+
github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli+
github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3
github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli
github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+
@@ -78,6 +78,8 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/clientupdate from tailscale.com/client/web+
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/cmd/tailscale/cli/ffcomplete from tailscale.com/cmd/tailscale/cli
tailscale.com/cmd/tailscale/cli/ffcomplete/internal from tailscale.com/cmd/tailscale/cli/ffcomplete
tailscale.com/control/controlbase from tailscale.com/control/controlhttp
tailscale.com/control/controlhttp from tailscale.com/cmd/tailscale/cli
tailscale.com/control/controlknobs from tailscale.com/net/portmapper
@@ -119,6 +121,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
💣 tailscale.com/safesocket from tailscale.com/client/tailscale+
tailscale.com/syncs from tailscale.com/cmd/tailscale/cli+
tailscale.com/tailcfg from tailscale.com/client/tailscale+
tailscale.com/tempfork/spf13/cobra from tailscale.com/cmd/tailscale/cli/ffcomplete+
tailscale.com/tka from tailscale.com/client/tailscale+
W tailscale.com/tsconst from tailscale.com/net/interfaces
tailscale.com/tstime from tailscale.com/control/controlhttp+