Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
e257777058 fix(cli): preserve multica login --token (no value) prompt path and tighten regression test
Addresses review feedback on #2017:

1. Restore the legacy no-value form. After the prior commit, `multica
   login --token` (no value) errored with `flag needs an argument:
   --token`, which broke the CLI_INSTALL.md / CLI_AND_DAEMON.md flow for
   headless users. Set `NoOptDefVal` on the `--token` flag to a sentinel
   that runAuthLoginToken treats as "prompt me," so:
     - `--token mul_xxx` and `--token=mul_xxx` consume the value (the
       #1994 fix is preserved),
     - `--token` alone falls through to the interactive prompt,
     - `--token=""` (explicit empty) also prompts.
   pflag with `NoOptDefVal` won't bind the next positional as the flag's
   value, so runAuthLogin recovers `--token mul_xxx` (the form from
   #1994) by promoting a single positional arg into the token. loginCmd
   gains `Args: cobra.MaximumNArgs(1)` so multi-positional typos still
   error fast.

2. Tighten regression coverage. Split into TestLoginTokenFlagWiring
   (asserts the production loginCmd.Flags().Lookup("token") is a String
   flag with the prompt-mode NoOptDefVal — would fail if anyone reverts
   the flag to Bool) and TestLoginTokenFlagParsing (drives all five
   documented invocation forms through the same flag wiring + the
   runAuthLogin space-form recovery). The synthetic-only test that the
   reviewer flagged is gone.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:49:03 +08:00
Jiang Bohan
9299d5db5f fix(cli): make multica login --token accept the PAT as a value
The flag was registered as a Bool, so `multica login --token <PAT>` parsed
`--token` as `true` and dropped the supplied value as an unused positional
argument, then unconditionally prompted "Enter your personal access token:".
This contradicted the user-facing docs (`cli.mdx`, `CLI_AND_DAEMON.md`,
the in-app `connect-remote-dialog`) which show `--token <mul_...>`.

Switch `--token` to a String flag. Both `--token mul_...` and
`--token=mul_...` now bind the value and skip the prompt. Passing
`--token=` with an empty value (or `multica login --token=""`) still
falls through to the interactive prompt for users who don't want the
token in shell history. Updates the few internal docs that showed the
no-value form.

Fixes #1994

Co-authored-by: multica-agent <github@multica.ai>
2026-05-03 10:38:58 +08:00
6 changed files with 151 additions and 17 deletions

View File

@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status

View File

@@ -140,7 +140,7 @@ multica auth status
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---

View File

@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token
multica login --token <mul_...>
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
### Check Status

View File

@@ -91,10 +91,17 @@ func openBrowser(url string) error {
return exec.Command(cmd, args...).Start()
}
func runAuthLogin(cmd *cobra.Command, _ []string) error {
useToken, _ := cmd.Flags().GetBool("token")
if useToken {
return runAuthLoginToken(cmd)
func runAuthLogin(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("token") {
tokenFlag, _ := cmd.Flags().GetString("token")
// `--token mul_xxx` (space form) is what users actually type — that's
// the form from the docs and from #1994. NoOptDefVal prevents pflag
// from consuming the next arg as the flag value, so it lands here as
// a positional. Promote it to the token value.
if tokenFlag == tokenPromptSentinel && len(args) == 1 {
tokenFlag = args[0]
}
return runAuthLoginToken(cmd, tokenFlag)
}
return runAuthLoginBrowser(cmd)
}
@@ -320,13 +327,22 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
return nil
}
func runAuthLoginToken(cmd *cobra.Command) error {
fmt.Print("Enter your personal access token: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("no input")
func runAuthLoginToken(cmd *cobra.Command, providedToken string) error {
// The prompt sentinel is what pflag substitutes for `--token` with no
// value (see loginCmd init); treat it the same as an empty string so we
// fall through to the interactive prompt.
if providedToken == tokenPromptSentinel {
providedToken = ""
}
token := strings.TrimSpace(providedToken)
if token == "" {
fmt.Print("Enter your personal access token: ")
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return fmt.Errorf("no input")
}
token = strings.TrimSpace(scanner.Text())
}
token := strings.TrimSpace(scanner.Text())
if token == "" {
return fmt.Errorf("token is required")
}

View File

@@ -119,6 +119,112 @@ func TestResolveCallbackBinding(t *testing.T) {
}
}
// TestLoginTokenFlagWiring asserts the production loginCmd flag is registered
// the way #1994 needs it to be: a String flag (not Bool) with a NoOptDefVal
// so `--token` (no value) keeps its legacy prompt-mode behavior. This is the
// load-bearing regression guard — without these asserts a future change that
// reverts the flag to Bool could pass while a synthetic stand-in test happily
// keeps testing string-flag parsing.
func TestLoginTokenFlagWiring(t *testing.T) {
tokenFlag := loginCmd.Flags().Lookup("token")
if tokenFlag == nil {
t.Fatal("loginCmd is missing the --token flag")
}
if got := tokenFlag.Value.Type(); got != "string" {
t.Fatalf("loginCmd --token type = %q, want %q (regressed to bool?)", got, "string")
}
if tokenFlag.NoOptDefVal != tokenPromptSentinel {
t.Fatalf("loginCmd --token NoOptDefVal = %q, want %q (legacy `multica login --token` prompt mode would break)", tokenFlag.NoOptDefVal, tokenPromptSentinel)
}
}
// TestLoginTokenFlagParsing exercises every documented invocation form
// against a cobra command wired up exactly the same way as the production
// loginCmd, then runs runAuthLogin's flag-resolution logic to confirm the
// right downstream branch is taken: `--token mul_xxx` and `--token=mul_xxx`
// both consume the value (the bug from #1994), `--token` alone falls
// through to the prompt sentinel (preserves the legacy headless form), and
// no flag at all leaves the browser flow untouched.
func TestLoginTokenFlagParsing(t *testing.T) {
type want struct {
changed bool
resolvedToken string // empty == "fall through to prompt"
expectsPrompted bool
}
cases := []struct {
name string
argv []string
want want
}{
{
name: "space-separated value (the form from #1994)",
argv: []string{"--token", "mul_xxx"},
want: want{changed: true, resolvedToken: "mul_xxx"},
},
{
name: "equals-separated value",
argv: []string{"--token=mul_yyy"},
want: want{changed: true, resolvedToken: "mul_yyy"},
},
{
name: "no value falls through to prompt (legacy CLI_INSTALL.md form)",
argv: []string{"--token"},
want: want{changed: true, expectsPrompted: true},
},
{
name: "explicit empty value also falls through to prompt",
argv: []string{"--token="},
want: want{changed: true, expectsPrompted: true},
},
{
name: "no flag at all → browser flow",
argv: []string{},
want: want{changed: false},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "login"}
// Mirror loginCmd's exact flag wiring. If init() in cmd_login.go
// regresses, TestLoginTokenFlagWiring catches that; here we test
// the parsing behavior given the documented wiring.
cmd.Flags().String("token", "", "")
cmd.Flags().Lookup("token").NoOptDefVal = tokenPromptSentinel
if err := cmd.ParseFlags(tc.argv); err != nil {
t.Fatalf("ParseFlags(%v) error: %v", tc.argv, err)
}
if cmd.Flags().Changed("token") != tc.want.changed {
t.Fatalf("Changed(token) = %v, want %v for argv=%v", cmd.Flags().Changed("token"), tc.want.changed, tc.argv)
}
if !tc.want.changed {
return
}
// Replay runAuthLogin's resolution logic so the test fails if
// either the flag wiring OR the space-form recovery breaks.
tokenFlag, _ := cmd.Flags().GetString("token")
positional := cmd.Flags().Args()
if tokenFlag == tokenPromptSentinel && len(positional) == 1 {
tokenFlag = positional[0]
}
if tc.want.expectsPrompted {
if tokenFlag != tokenPromptSentinel && tokenFlag != "" {
t.Fatalf("expected prompt fall-through, got resolved token %q", tokenFlag)
}
} else {
if tokenFlag != tc.want.resolvedToken {
t.Fatalf("resolved token = %q, want %q", tokenFlag, tc.want.resolvedToken)
}
}
})
}
}
func TestNormalizeAPIBaseURL(t *testing.T) {
t.Run("converts websocket base URL", func(t *testing.T) {
if got := normalizeAPIBaseURL("ws://localhost:18106/ws"); got != "http://localhost:18106" {

View File

@@ -32,11 +32,23 @@ var loginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate and set up workspaces",
Long: "Log in to Multica, then automatically discover and watch all your workspaces.",
RunE: runLogin,
// Up to one positional is accepted so `--token mul_...` (space form) can
// recover the token in runAuthLogin even though pflag won't bind it.
Args: cobra.MaximumNArgs(1),
RunE: runLogin,
}
// tokenPromptSentinel is the value pflag assigns to `--token` when the flag
// is supplied without an explicit value. runAuthLoginToken treats it as
// "prompt me interactively", preserving the legacy `multica login --token`
// no-value form alongside the documented `--token mul_...` value form.
const tokenPromptSentinel = "\x00prompt"
func init() {
loginCmd.Flags().Bool("token", false, "Authenticate by pasting a personal access token")
loginCmd.Flags().String("token", "", "Authenticate using a personal access token. Pass `--token mul_...` to supply it inline, or `--token` alone to be prompted interactively.")
// NoOptDefVal lets `--token` (no value) keep its old prompt-mode behavior
// while `--token mul_...` and `--token=mul_...` consume the value normally.
loginCmd.Flags().Lookup("token").NoOptDefVal = tokenPromptSentinel
loginCmd.Flags().String(callbackHostFlag, "", "Host the OAuth callback URL points at (auto-detected from the server's route when empty). Use this for reverse-proxy / FQDN setups where auto-detection picks the wrong interface.")
}