合併 main(#16/#17)並解 cli.go 衝突
衝突原因:#13(本分支 927855d)與 #16(main 7badd9e)各自實作了 同一問題(帶旗標命令被全域剖析攔下)的抽取式修正。採 main 現行 架構(dispatch 內 extractGlobals)為準,並保留本分支的: - runVersion 參數檢查(version 不接受參數,TestWiringVersionRejectsArgs) - workflow 命令與接線層測試(workflow_commands_test.go) go build/vet/gofmt 綠;單元測試僅餘 main 既有回歸 (3 個 login 測試,#16 合併後即失敗,與本 PR 無關)。
This commit is contained in:
+138
-65
@@ -12,6 +12,7 @@ package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
@@ -88,6 +89,11 @@ type Env struct {
|
||||
|
||||
// commands 是已註冊的子命令表。新增功能時在這裡註冊即可。
|
||||
var commands = map[string]*command{
|
||||
"login": {
|
||||
name: "login",
|
||||
usage: "管理登入(list/add/default/remove)",
|
||||
run: runLogin,
|
||||
},
|
||||
"version": {
|
||||
name: "version",
|
||||
usage: "顯示版本資訊",
|
||||
@@ -97,9 +103,8 @@ var commands = map[string]*command{
|
||||
|
||||
// Run 剖析引數並分派到對應子命令,回傳行程結束碼。
|
||||
//
|
||||
// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項可出現在命令
|
||||
// 之前或之後(README 範例:teai next --output table);無引數或要求說明
|
||||
// (-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。
|
||||
// 引數結構:teai [全域選項] <命令> [命令參數]。全域選項須在命令之前;
|
||||
// 無引數或要求說明(-h/--help)時印出用法;未知命令或無效選項回 ExitUsage。
|
||||
func Run(stdout, stderr io.Writer, args []string) int {
|
||||
env := &Env{Out: stdout, Err: stderr, Globals: defaultGlobals()}
|
||||
|
||||
@@ -108,7 +113,7 @@ func Run(stdout, stderr io.Writer, args []string) int {
|
||||
return int(ExitOK)
|
||||
}
|
||||
|
||||
rest, err := extractGlobals(&env.Globals, args)
|
||||
rest, err := parseGlobals(&env.Globals, args)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "teai: %v\n\n", err)
|
||||
printUsage(stderr)
|
||||
@@ -127,11 +132,6 @@ func Run(stdout, stderr io.Writer, args []string) int {
|
||||
return dispatch(env, "version", rest[1:])
|
||||
default:
|
||||
name := rest[0]
|
||||
if strings.HasPrefix(name, "-") {
|
||||
fmt.Fprintf(stderr, "teai: unknown global option %q\n\n", name)
|
||||
printUsage(stderr)
|
||||
return int(ExitUsage)
|
||||
}
|
||||
if _, ok := commands[name]; !ok {
|
||||
fmt.Fprintf(stderr, "teai: unknown command %q\n\n", name)
|
||||
printUsage(stderr)
|
||||
@@ -141,7 +141,7 @@ func Run(stdout, stderr io.Writer, args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// globalFlags 列出全域選項的長名稱(目前全部都需要值)。
|
||||
// globalFlags 列出全域選項的長名稱與是否需要值。
|
||||
var globalFlags = map[string]bool{
|
||||
"--url": true,
|
||||
"--token": true,
|
||||
@@ -150,24 +150,40 @@ var globalFlags = map[string]bool{
|
||||
"--timeout": true,
|
||||
}
|
||||
|
||||
// extractGlobals 從 args 中抽出全域選項(可出現在命令之前或之後),
|
||||
// 回傳其餘引數(命令與其參數,順序保留)。
|
||||
//
|
||||
// 採「抽取式」剖析:只取走已知的全域選項;任何其他引數——位置參數或
|
||||
// 子命令自己的旗標(如 members 的 --has-work、pulls 的 --mine/--reviewer/
|
||||
// --repo)——原樣依序交回,由子命令的 flagset 解析。因此
|
||||
//
|
||||
// teai members --has-work --output table
|
||||
// teai pulls --mine --repo alterminal/teai
|
||||
// teai next --output table
|
||||
//
|
||||
// 都能正確分派(全域與命令旗標可交錯出現)。
|
||||
//
|
||||
// 停止條件:遇到 --(剝除它,其後全部交回,不再抽取)或
|
||||
// -h/--help/-v/--version(不是全域選項,交回 Run 的分派處理)。
|
||||
// 全域選項缺少值或值無效時回 ErrUsage。
|
||||
// extractGlobals 從 args 中「抽取」全域選項(可出現在命令之後、與命令
|
||||
// 旗標交錯),其餘引數依原順序保留給子命令剖析。未知的選項一律留給
|
||||
// 子命令處理(可能是命令旗標,如 --has-work),不在這裡報錯。
|
||||
func extractGlobals(g *Globals, args []string) ([]string, error) {
|
||||
rest := make([]string, 0, len(args))
|
||||
applyGlobal := func(name, value string) error {
|
||||
switch name {
|
||||
case "--url":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return &ErrUsage{Msg: "--url must not be empty"}
|
||||
}
|
||||
g.URL = value
|
||||
case "--token":
|
||||
g.Token = value
|
||||
case "--config":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return &ErrUsage{Msg: "--config must not be empty"}
|
||||
}
|
||||
g.ConfigPath = value
|
||||
case "--output":
|
||||
f, err := gitea.ParseFormat(value)
|
||||
if err != nil {
|
||||
return &ErrUsage{Msg: err.Error()}
|
||||
}
|
||||
g.Output = f
|
||||
case "--timeout":
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil || d <= 0 {
|
||||
return &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
|
||||
}
|
||||
g.Timeout = d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
@@ -175,19 +191,15 @@ func extractGlobals(g *Globals, args []string) ([]string, error) {
|
||||
rest = append(rest, args[i+1:]...)
|
||||
return rest, nil
|
||||
}
|
||||
if arg == "-" || !strings.HasPrefix(arg, "-") {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
rest = append(rest, arg)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
name, inline, hasInline := strings.Cut(arg, "=")
|
||||
switch name {
|
||||
case "-h", "--help", "-v", "--version":
|
||||
rest = append(rest, args[i:]...)
|
||||
return rest, nil
|
||||
}
|
||||
if _, ok := globalFlags[name]; !ok {
|
||||
// 非全域選項:屬於子命令,原樣交回(順序保留)。
|
||||
_, ok := globalFlags[name]
|
||||
if !ok {
|
||||
// 不是全域選項:留給子命令(可能是 --has-work 這類命令旗標)。
|
||||
rest = append(rest, arg)
|
||||
i++
|
||||
continue
|
||||
@@ -202,7 +214,7 @@ func extractGlobals(g *Globals, args []string) ([]string, error) {
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
if err := applyGlobal(g, name, value); err != nil {
|
||||
if err := applyGlobal(name, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i++
|
||||
@@ -210,50 +222,111 @@ func extractGlobals(g *Globals, args []string) ([]string, error) {
|
||||
return rest, nil
|
||||
}
|
||||
|
||||
// applyGlobal 套用單一全域選項的值;值無效時回 ErrUsage。
|
||||
func applyGlobal(g *Globals, name, value string) error {
|
||||
switch name {
|
||||
case "--url":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return &ErrUsage{Msg: "--url must not be empty"}
|
||||
// parseGlobals 從 args 前端取走全域選項,回傳剩餘引數(命令與其參數)。
|
||||
// 遇到第一個非選項引數即停;不認得的選項或缺少值都回 ErrUsage。
|
||||
func parseGlobals(g *Globals, args []string) ([]string, error) {
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
return args[i+1:], nil
|
||||
}
|
||||
g.URL = value
|
||||
case "--token":
|
||||
g.Token = value
|
||||
case "--config":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return &ErrUsage{Msg: "--config must not be empty"}
|
||||
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
||||
return args[i:], nil
|
||||
}
|
||||
g.ConfigPath = value
|
||||
case "--output":
|
||||
f, err := gitea.ParseFormat(value)
|
||||
if err != nil {
|
||||
return &ErrUsage{Msg: err.Error()}
|
||||
name, inline, hasInline := strings.Cut(arg, "=")
|
||||
// -h/--help/-v/--version 不是全域選項;交回 Run 的分派處理。
|
||||
switch name {
|
||||
case "-h", "--help", "-v", "--version":
|
||||
return args[i:], nil
|
||||
}
|
||||
g.Output = f
|
||||
case "--timeout":
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil || d <= 0 {
|
||||
return &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
|
||||
need, ok := globalFlags[name]
|
||||
if !ok {
|
||||
return nil, &ErrUsage{Msg: fmt.Sprintf("unknown global option %q", name)}
|
||||
}
|
||||
g.Timeout = d
|
||||
var value string
|
||||
if hasInline {
|
||||
value = inline
|
||||
} else {
|
||||
if !need {
|
||||
// 目前所有全域選項都需要值;保留機制給未來的布林選項。
|
||||
value = ""
|
||||
}
|
||||
if i+1 >= len(args) {
|
||||
return nil, &ErrUsage{Msg: fmt.Sprintf("global option %q requires a value", name)}
|
||||
}
|
||||
i++
|
||||
value = args[i]
|
||||
}
|
||||
switch name {
|
||||
case "--url":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, &ErrUsage{Msg: "--url must not be empty"}
|
||||
}
|
||||
g.URL = value
|
||||
case "--token":
|
||||
g.Token = value
|
||||
case "--config":
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, &ErrUsage{Msg: "--config must not be empty"}
|
||||
}
|
||||
g.ConfigPath = value
|
||||
case "--output":
|
||||
f, err := gitea.ParseFormat(value)
|
||||
if err != nil {
|
||||
return nil, &ErrUsage{Msg: err.Error()}
|
||||
}
|
||||
g.Output = f
|
||||
case "--timeout":
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil || d <= 0 {
|
||||
return nil, &ErrUsage{Msg: fmt.Sprintf("invalid --timeout %q (want e.g. 30s)", value)}
|
||||
}
|
||||
g.Timeout = d
|
||||
}
|
||||
i++
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// dispatch 執行已註冊的子命令,把錯誤轉成結束碼並輸出。
|
||||
// 全域選項已在 Run 的 extractGlobals 抽取完畢(允許出現在命令之後,
|
||||
// README 範例:teai next --output table);這裡把剩餘引數直接交給
|
||||
// 命令的 flagset 解析。
|
||||
// 旗標剖析交由各命令自行處理(不同命令有不同旗標,如 pulls --mine);
|
||||
// 全域選項允許出現在命令之後(README 範例:teai next --output table),
|
||||
// dispatch 先從 args 剝離全域選項併入 Globals,其餘(含命令自己的旗標,
|
||||
// 如 --has-work)原樣交給命令,不會誤判為未知全域選項。
|
||||
func dispatch(env *Env, name string, args []string) int {
|
||||
cmd := commands[name]
|
||||
if err := cmd.run(env, args); err != nil {
|
||||
g := env.Globals
|
||||
rest, err := extractGlobals(&g, args)
|
||||
if err != nil {
|
||||
fmt.Fprintf(env.Err, "teai: %v\n\n", err)
|
||||
printUsage(env.Err)
|
||||
return int(ExitUsage)
|
||||
}
|
||||
env.Globals = g
|
||||
if err := cmd.run(env, rest); err != nil {
|
||||
fmt.Fprintf(env.Err, "teai %s: %v\n", name, err)
|
||||
return int(exitCodeFor(err))
|
||||
}
|
||||
return int(ExitOK)
|
||||
}
|
||||
|
||||
// errHelp 表示使用者要求該命令的說明(-h/--help);FlagSet 已自行印出
|
||||
// 用法,靜默成功結束即可。
|
||||
var errHelp = errors.New("help requested")
|
||||
|
||||
// parseFlags 剖析命令旗標:-h/--help 回 errHelp(由 exitCodeFor 視為成功),
|
||||
// 其他剖析錯誤回 ErrUsage。
|
||||
func parseFlags(fs *flag.FlagSet, args []string) error {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return errHelp
|
||||
}
|
||||
return &ErrUsage{Msg: err.Error()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// exitCodeFor 把命令錯誤映射到結束碼:用法錯誤 → 2,API 錯誤 → 3,
|
||||
// 其他(內部)→ 1。
|
||||
func exitCodeFor(err error) ExitCode {
|
||||
@@ -271,9 +344,9 @@ func exitCodeFor(err error) ExitCode {
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// runVersion 輸出版本資訊。
|
||||
// runVersion 輸出版本資訊;不接受任何參數。
|
||||
func runVersion(env *Env, args []string) error {
|
||||
if len(args) != 0 {
|
||||
if len(args) > 0 {
|
||||
return &ErrUsage{Msg: "version 不接受參數"}
|
||||
}
|
||||
fmt.Fprintf(env.Out, "teai version %s\n", Version)
|
||||
|
||||
Reference in New Issue
Block a user