feat(alias): add alias command group with quote-aware expansion
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
1d81396bab
|
|
@ -0,0 +1,178 @@
|
|||
package alias
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
)
|
||||
|
||||
func NewAliasCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := i18n.Default()
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
tr = translators[0]
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "alias",
|
||||
Short: tr.T("cmd.alias.short"),
|
||||
}
|
||||
cmd.AddCommand(newSetCmd(tr))
|
||||
cmd.AddCommand(newListCmd(tr))
|
||||
cmd.AddCommand(newDeleteCmd(tr))
|
||||
cmd.AddCommand(newImportCmd(tr))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := resolve(translators)
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <name> <expansion>...",
|
||||
Short: tr.T("cmd.alias.set.short"),
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
// Everything after the name forms the expansion, so quoting on the
|
||||
// shell is optional for multi-word expansions.
|
||||
expansion := strings.Join(args[1:], " ")
|
||||
if strings.TrimSpace(expansion) == "" {
|
||||
return errors.New(tr.T("error.alias.empty_expansion"))
|
||||
}
|
||||
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Aliases == nil {
|
||||
cfg.Aliases = map[string]string{}
|
||||
}
|
||||
cfg.Aliases[name] = expansion
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.set", i18n.Args{
|
||||
"name": name,
|
||||
"expansion": expansion,
|
||||
}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
// Once the alias name is read, treat the rest of the line as the literal
|
||||
// expansion so flag-looking tokens (e.g. --label) are not parsed here.
|
||||
cmd.Flags().SetInterspersed(false)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := resolve(translators)
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: tr.T("cmd.alias.list.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if len(cfg.Aliases) == 0 {
|
||||
_, err := fmt.Fprintln(out, tr.T("output.alias.none"))
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(cfg.Aliases))
|
||||
for name := range cfg.Aliases {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if _, err := fmt.Fprintf(out, "%s: %s\n", name, cfg.Aliases[name]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDeleteCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := resolve(translators)
|
||||
return &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Short: tr.T("cmd.alias.delete.short"),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := cfg.Aliases[name]; !ok {
|
||||
return errors.New(tr.Tf("error.alias.not_found", i18n.Args{"name": name}))
|
||||
}
|
||||
delete(cfg.Aliases, name)
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.deleted", i18n.Args{"name": name}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newImportCmd(translators ...*i18n.Translator) *cobra.Command {
|
||||
tr := resolve(translators)
|
||||
var file string
|
||||
cmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: tr.T("cmd.alias.import.short"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
data, err := readImportSource(cmd.InOrStdin(), file)
|
||||
if err != nil {
|
||||
return errors.New(tr.Tf("error.alias.import_read", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
var pairs map[string]string
|
||||
if err := yaml.Unmarshal(data, &pairs); err != nil {
|
||||
return errors.New(tr.Tf("error.alias.import_parse", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Aliases == nil {
|
||||
cfg.Aliases = map[string]string{}
|
||||
}
|
||||
for name, expansion := range pairs {
|
||||
cfg.Aliases[name] = expansion
|
||||
}
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
|
||||
}
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.imported", i18n.Args{"count": len(pairs)}))
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", tr.T("flag.alias.file"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func readImportSource(stdin io.Reader, file string) ([]byte, error) {
|
||||
if file != "" {
|
||||
return os.ReadFile(file)
|
||||
}
|
||||
return io.ReadAll(stdin)
|
||||
}
|
||||
|
||||
func resolve(translators []*i18n.Translator) *i18n.Translator {
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
return translators[0]
|
||||
}
|
||||
return i18n.Default()
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package alias
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
func tempConfigDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
|
||||
}
|
||||
|
||||
func run(t *testing.T, cmd *cobra.Command, args ...string) string {
|
||||
t.Helper()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute %v: %v", args, err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func TestNewAliasCmd(t *testing.T) {
|
||||
cmd := NewAliasCmd()
|
||||
if cmd.Use != "alias" {
|
||||
t.Fatalf("Use = %q, want alias", cmd.Use)
|
||||
}
|
||||
expected := map[string]bool{
|
||||
"set <name> <expansion>...": false,
|
||||
"list": false,
|
||||
"delete <name>": false,
|
||||
"import": false,
|
||||
}
|
||||
for _, sub := range cmd.Commands() {
|
||||
if _, ok := expected[sub.Use]; !ok {
|
||||
t.Fatalf("unexpected subcommand: %q", sub.Use)
|
||||
}
|
||||
expected[sub.Use] = true
|
||||
if sub.Short == "" {
|
||||
t.Fatalf("subcommand %q has empty Short", sub.Use)
|
||||
}
|
||||
}
|
||||
for name, found := range expected {
|
||||
if !found {
|
||||
t.Fatalf("missing subcommand: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasSetListDelete(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
|
||||
sets := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"co", "pr", "+view"}, "pr +view"},
|
||||
{[]string{"bugs", "issue", "+list", "--label", "bug"}, "issue +list --label bug"},
|
||||
}
|
||||
for _, s := range sets {
|
||||
run(t, newSetCmd(), s.args...)
|
||||
}
|
||||
|
||||
// set persists the joined expansion
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
for _, s := range sets {
|
||||
if cfg.Aliases[s.args[0]] != s.want {
|
||||
t.Fatalf("Aliases[%q] = %q, want %q", s.args[0], cfg.Aliases[s.args[0]], s.want)
|
||||
}
|
||||
}
|
||||
|
||||
// list shows both
|
||||
listOut := run(t, newListCmd())
|
||||
for _, s := range sets {
|
||||
if !strings.Contains(listOut, s.args[0]+": "+s.want) {
|
||||
t.Fatalf("list missing %q, got:\n%s", s.args[0], listOut)
|
||||
}
|
||||
}
|
||||
|
||||
// delete removes one, keeps the other
|
||||
run(t, newDeleteCmd(), "co")
|
||||
listOut = run(t, newListCmd())
|
||||
if strings.Contains(listOut, "co: ") {
|
||||
t.Fatalf("expected co deleted, got:\n%s", listOut)
|
||||
}
|
||||
if !strings.Contains(listOut, "bugs: ") {
|
||||
t.Fatalf("expected bugs retained, got:\n%s", listOut)
|
||||
}
|
||||
|
||||
// deleting a missing alias errors
|
||||
del := newDeleteCmd()
|
||||
del.SetOut(&bytes.Buffer{})
|
||||
del.SetErr(&bytes.Buffer{})
|
||||
del.SetArgs([]string{"nope"})
|
||||
if err := del.Execute(); err == nil {
|
||||
t.Fatal("expected error deleting missing alias")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasSetRejectsEmptyExpansion(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
cmd := newSetCmd()
|
||||
// MinimumNArgs(2) is satisfied, but a whitespace-only expansion is rejected.
|
||||
if err := cmd.RunE(cmd, []string{"x", " "}); err == nil {
|
||||
t.Fatal("expected error for empty expansion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasListEmpty(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
out := run(t, newListCmd())
|
||||
if strings.TrimSpace(out) == "" {
|
||||
t.Fatal("expected a message for empty alias list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasImportFile(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "aliases.yaml")
|
||||
content := "co: pr +view\nbugs: issue +list --label bug\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
|
||||
cmd := newImportCmd()
|
||||
run(t, cmd, "--file", path)
|
||||
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if cfg.Aliases["co"] != "pr +view" || cfg.Aliases["bugs"] != "issue +list --label bug" {
|
||||
t.Fatalf("imported aliases = %#v", cfg.Aliases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasImportStdin(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
|
||||
cmd := newImportCmd()
|
||||
cmd.SetIn(strings.NewReader("ci: ci +status\n"))
|
||||
run(t, cmd)
|
||||
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if cfg.Aliases["ci"] != "ci +status" {
|
||||
t.Fatalf("imported alias = %q", cfg.Aliases["ci"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasImportInvalidYAML(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
cmd := newImportCmd()
|
||||
cmd.SetIn(strings.NewReader("::: not yaml :::"))
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs(nil)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
// expandAlias rewrites args when the first positional token names a saved alias.
|
||||
// Built-in commands always take precedence, so an alias can never shadow a real
|
||||
// command; a colliding alias simply never expands.
|
||||
func expandAlias(root *cobra.Command, args []string) ([]string, bool) {
|
||||
if len(args) == 0 {
|
||||
return args, false
|
||||
}
|
||||
name := args[0]
|
||||
if name == "" || strings.HasPrefix(name, "-") {
|
||||
return args, false
|
||||
}
|
||||
if isBuiltinCommand(root, name) {
|
||||
return args, false
|
||||
}
|
||||
|
||||
cfg, err := internalConfig.Load()
|
||||
if err != nil {
|
||||
return args, false
|
||||
}
|
||||
expansion, ok := cfg.Aliases[name]
|
||||
if !ok {
|
||||
return args, false
|
||||
}
|
||||
parts := splitArgs(expansion)
|
||||
if len(parts) == 0 {
|
||||
return args, false
|
||||
}
|
||||
return append(parts, args[1:]...), true
|
||||
}
|
||||
|
||||
func isBuiltinCommand(root *cobra.Command, name string) bool {
|
||||
for _, c := range root.Commands() {
|
||||
if c.Name() == name {
|
||||
return true
|
||||
}
|
||||
for _, alias := range c.Aliases {
|
||||
if alias == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitArgs breaks an alias expansion into argv tokens, honoring single and
|
||||
// double quotes so expansions can carry multi-word flag values.
|
||||
func splitArgs(s string) []string {
|
||||
var args []string
|
||||
var buf strings.Builder
|
||||
inWord := false
|
||||
var quote rune
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case quote != 0:
|
||||
if r == quote {
|
||||
quote = 0
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
inWord = true
|
||||
case r == '\'' || r == '"':
|
||||
quote = r
|
||||
inWord = true
|
||||
case r == ' ' || r == '\t' || r == '\n':
|
||||
if inWord {
|
||||
args = append(args, buf.String())
|
||||
buf.Reset()
|
||||
inWord = false
|
||||
}
|
||||
default:
|
||||
buf.WriteRune(r)
|
||||
inWord = true
|
||||
}
|
||||
}
|
||||
if inWord {
|
||||
args = append(args, buf.String())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
)
|
||||
|
||||
func TestSplitArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"pr +view", []string{"pr", "+view"}},
|
||||
{" issue +list ", []string{"issue", "+list"}},
|
||||
{`pr +view --title "needs review"`, []string{"pr", "+view", "--title", "needs review"}},
|
||||
{`repo +create --name 'my repo'`, []string{"repo", "+create", "--name", "my repo"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := splitArgs(tc.in); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("splitArgs(%q) = %#v, want %#v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandAlias(t *testing.T) {
|
||||
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cfg := internalConfig.DefaultConfig()
|
||||
cfg.Aliases = map[string]string{
|
||||
"co": "pr +view",
|
||||
"config": "should never expand",
|
||||
}
|
||||
if err := internalConfig.Save(cfg); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "gitlink-cli"}
|
||||
root.AddCommand(&cobra.Command{Use: "config"})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
expand bool
|
||||
}{
|
||||
{"alias match", []string{"co", "42"}, []string{"pr", "+view", "42"}, true},
|
||||
{"builtin wins", []string{"config", "list"}, nil, false},
|
||||
{"flag first", []string{"--lang", "zh-CN"}, nil, false},
|
||||
{"unknown token", []string{"nope"}, nil, false},
|
||||
{"empty args", nil, nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := expandAlias(root, tc.args)
|
||||
if ok != tc.expand {
|
||||
t.Fatalf("expandAlias ok = %v, want %v", ok, tc.expand)
|
||||
}
|
||||
if tc.expand && !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("expandAlias = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
|
|
@ -57,6 +58,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
|||
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
|
||||
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
|
||||
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
|
||||
rootCmd.AddCommand(aliasCmd.NewAliasCmd(tr))
|
||||
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
|
||||
rootCmd.AddCommand(newVersionCmd(version, tr))
|
||||
|
||||
|
|
@ -90,6 +92,12 @@ func Execute() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Rewrite argv when the first token is a saved alias. Translator resolution
|
||||
// above still sees the raw args, so --lang is unaffected by expansion.
|
||||
if expanded, ok := expandAlias(rootCmd, args); ok {
|
||||
rootCmd.SetArgs(expanded)
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# 新增命令别名(alias)
|
||||
|
||||
新增 `gitlink-cli alias` 根命令组,用于把常用命令行保存为用户自定义的快捷方式,行为对齐 `gh alias`。别名统一存放在配置文件(`config.yaml`)的 `aliases` 字段,通过 yaml.v3 与其他配置项一起读写,无需额外文件。
|
||||
|
||||
## 子命令
|
||||
|
||||
- `alias set <name> <expansion>...`:创建或更新别名。`name` 之后的所有参数会拼成展开内容,因此多词展开在 shell 上加不加引号都可以,例如 `alias set bugs issue +list --label bug`。
|
||||
- `alias list`:按名称排序列出全部别名;没有别名时给出提示。
|
||||
- `alias delete <name>`:删除别名,别名不存在时返回明确错误。
|
||||
- `alias import`:从 YAML 的 `name: expansion` 映射批量导入,来源可用 `--file` 指定文件,或从标准输入读取(便于管道注入);导入采用合并语义,同名覆盖。
|
||||
|
||||
## 别名展开
|
||||
|
||||
别名展开已接入根命令分发:在 `cmd.Execute()` 里,若 `os.Args` 的第一个位置参数命中已保存的别名,就把该 token 替换为别名展开(按 shell 风格拆分,识别单双引号)后再交给 cobra 分发。展开发生在翻译器解析之后,因此 `--lang` 等全局标志不受影响。
|
||||
|
||||
内置命令始终优先:展开前会先检查该 token 是否为已注册的根命令(或其 cobra 别名),命中则不展开。这样别名无法遮蔽真实命令——与内置命令同名的别名只是永远不会被触发,因此 `set` 不做额外的命名冲突校验。
|
||||
|
||||
## 本次变更
|
||||
|
||||
`internal/config` 的 `Config` 增加 `Aliases map[string]string` 字段并验证读写往返;新增 `cmd/alias` 命令组与 `cmd/expand.go` 展开逻辑,并在 `cmd/root.go` 注册与接线;中英文帮助与提示文案补齐到 `en-US.json` 与 `zh-CN.json`。测试覆盖配置往返、set/list/delete、文件与标准输入导入、非法 YAML,以及展开的别名命中、内置优先、首个为标志、未知 token 等分支和引号拆分。
|
||||
|
|
@ -20,6 +20,8 @@ type Config struct {
|
|||
Editor string `yaml:"editor,omitempty"`
|
||||
Pager string `yaml:"pager,omitempty"`
|
||||
Lang string `yaml:"lang,omitempty"`
|
||||
// Aliases maps a user-defined shortcut name to the command line it expands to.
|
||||
Aliases map[string]string `yaml:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
|
|||
|
|
@ -85,6 +85,35 @@ func TestLoadAndSave(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadAndSaveAliases(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
|
||||
want := map[string]string{
|
||||
"co": "pr +view",
|
||||
"bugs": "issue +list --label bug",
|
||||
"quote": "pr +view --title \"needs review\"",
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Aliases = want
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save error: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load error: %v", err)
|
||||
}
|
||||
if len(loaded.Aliases) != len(want) {
|
||||
t.Fatalf("Aliases len = %d, want %d", len(loaded.Aliases), len(want))
|
||||
}
|
||||
for name, expansion := range want {
|
||||
if loaded.Aliases[name] != expansion {
|
||||
t.Fatalf("Aliases[%q] = %q, want %q", name, loaded.Aliases[name], expansion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultsWhenFileMissing(t *testing.T) {
|
||||
tempConfigDir(t)
|
||||
// No config file exists
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
{
|
||||
"cmd.alias.delete.short": "Delete an alias",
|
||||
"cmd.alias.import.short": "Import aliases from a YAML file",
|
||||
"cmd.alias.list.short": "List all aliases",
|
||||
"cmd.alias.set.short": "Create or update an alias",
|
||||
"cmd.alias.short": "Manage command aliases",
|
||||
"cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.",
|
||||
"cmd.api.short": "Make raw API requests to GitLink",
|
||||
"cmd.auth.login.short": "Login to GitLink",
|
||||
|
|
@ -105,6 +110,11 @@
|
|||
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
|
||||
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
|
||||
"cmd.webhook.view.short": "View webhook details",
|
||||
"error.alias.empty_expansion": "alias expansion cannot be empty",
|
||||
"error.alias.import_parse": "failed to parse aliases: {message}",
|
||||
"error.alias.import_read": "failed to read aliases: {message}",
|
||||
"error.alias.not_found": "no such alias: {name}",
|
||||
"error.alias.save_failed": "failed to save alias: {message}",
|
||||
"error.auth.delete_token_failed": "failed to delete token: {message}",
|
||||
"error.auth.login_failed": "login failed: {message}",
|
||||
"error.auth.store_token_failed": "failed to store token: {message}",
|
||||
|
|
@ -114,6 +124,7 @@
|
|||
"error.missing_required_flag": "required flag --{name} is missing",
|
||||
"error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login",
|
||||
"error.unsupported_language": "unsupported language: {lang}",
|
||||
"flag.alias.file": "Read aliases from a YAML file (default: stdin)",
|
||||
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
|
||||
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
|
||||
"flag.api.batch_file": "Read an API batch plan from a JSON file",
|
||||
|
|
@ -229,6 +240,7 @@
|
|||
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
|
||||
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||
"flag.webhook.url": "Webhook target URL",
|
||||
"output.alias.none": "No aliases configured.",
|
||||
"output.auth.env_hint": " Or set {env} environment variable",
|
||||
"output.auth.login_hint": " Run: gitlink-cli auth login",
|
||||
"output.config.file": "Config file: {path}",
|
||||
|
|
@ -259,6 +271,9 @@
|
|||
"prompt.auth.password": "Password: ",
|
||||
"prompt.auth.token": "Paste your access token: ",
|
||||
"prompt.auth.username": "Username/Email/Phone: ",
|
||||
"success.alias.deleted": "✓ Alias {name} deleted",
|
||||
"success.alias.imported": "✓ Imported {count} alias(es)",
|
||||
"success.alias.set": "✓ Alias {name} set to {expansion}",
|
||||
"success.auth.logged_in_as": "✓ Logged in as {login}",
|
||||
"success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable",
|
||||
"success.auth.logged_out": "✓ Logged out",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
{
|
||||
"cmd.alias.delete.short": "删除别名",
|
||||
"cmd.alias.import.short": "从 YAML 文件导入别名",
|
||||
"cmd.alias.list.short": "列出所有别名",
|
||||
"cmd.alias.set.short": "创建或更新别名",
|
||||
"cmd.alias.short": "管理命令别名",
|
||||
"cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。",
|
||||
"cmd.api.short": "向 GitLink 发起原始 API 请求",
|
||||
"cmd.auth.login.short": "登录 GitLink",
|
||||
|
|
@ -105,6 +110,11 @@
|
|||
"cmd.webhook.test.short": "触发 Webhook 测试投递",
|
||||
"cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段",
|
||||
"cmd.webhook.view.short": "查看 Webhook 详情",
|
||||
"error.alias.empty_expansion": "别名展开内容不能为空",
|
||||
"error.alias.import_parse": "解析别名失败:{message}",
|
||||
"error.alias.import_read": "读取别名失败:{message}",
|
||||
"error.alias.not_found": "别名不存在:{name}",
|
||||
"error.alias.save_failed": "保存别名失败:{message}",
|
||||
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
|
||||
"error.auth.login_failed": "登录失败:{message}",
|
||||
"error.auth.store_token_failed": "保存 Token 失败:{message}",
|
||||
|
|
@ -114,6 +124,7 @@
|
|||
"error.missing_required_flag": "缺少必需参数 --{name}",
|
||||
"error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录",
|
||||
"error.unsupported_language": "不支持的语言:{lang}",
|
||||
"flag.alias.file": "从 YAML 文件读取别名(默认:标准输入)",
|
||||
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
|
||||
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
|
||||
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
|
||||
|
|
@ -229,6 +240,7 @@
|
|||
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
|
||||
"flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
|
||||
"flag.webhook.url": "Webhook 目标 URL",
|
||||
"output.alias.none": "尚未配置任何别名。",
|
||||
"output.auth.env_hint": " 或设置 {env} 环境变量",
|
||||
"output.auth.login_hint": " 运行:gitlink-cli auth login",
|
||||
"output.config.file": "配置文件:{path}",
|
||||
|
|
@ -259,6 +271,9 @@
|
|||
"prompt.auth.password": "密码:",
|
||||
"prompt.auth.token": "粘贴你的访问 Token:",
|
||||
"prompt.auth.username": "用户名/邮箱/手机号:",
|
||||
"success.alias.deleted": "✓ 已删除别名 {name}",
|
||||
"success.alias.imported": "✓ 已导入 {count} 个别名",
|
||||
"success.alias.set": "✓ 已设置别名 {name} = {expansion}",
|
||||
"success.auth.logged_in_as": "✓ 已登录为 {login}",
|
||||
"success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录",
|
||||
"success.auth.logged_out": "✓ 已退出登录",
|
||||
|
|
|
|||
Loading…
Reference in New Issue