diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6800e51..192338d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b14db5d..6c40762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write # npm trusted publishing and provenance jobs: release: @@ -18,7 +19,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - uses: goreleaser/goreleaser-action@v6 with: @@ -30,3 +31,18 @@ jobs: WEBEX_CLIENT_SECRET: ${{ secrets.WEBEX_CLIENT_SECRET }} WEBEX_SCOPES: ${{ secrets.WEBEX_SCOPES }} WEBEX_READ_ONLY_SCOPES: ${{ secrets.WEBEX_READ_ONLY_SCOPES }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + # npm uses trusted publishing (OIDC), configured per package on npmjs.com + # for this workflow, and falls back to NPM_TOKEN only when OIDC is not set up. + - name: Publish npm packages + run: | + npm install -g npm@11.6.2 + node npm/stage.mjs --dist dist --out npm/build + sh npm/publish.sh npm/build + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 2ebf4c1..f920cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ # Build artifacts -webex -webex-cli +/webex +/webex-cli *.exe dist/ webex-mcp.dxt webex.skill +npm/build/ # Codegen intermediates codegen/api_outline.json diff --git a/AGENTS.md b/AGENTS.md index 50e4f20..8d47636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,17 @@ Releases are handled by a GitHub Actions workflow triggered by pushing a tag. Do **not** run `goreleaser` locally — it requires secrets (e.g. `WEBEX_CLIENT_ID`) that are only available in CI. Version and OAuth defaults are injected via ldflags. +The same workflow publishes the npm packages: `npm/stage.mjs` turns goreleaser's +`dist/` into `@cloverhound/webex-cli` plus one `@cloverhound/webex-cli--` +package per binary, and `npm/publish.sh` publishes them with provenance. It +authenticates with npm trusted publishing (configured per package on npmjs.com +for `release.yml`), or with the `NPM_TOKEN` secret when that is not set up. +Tags with a prerelease suffix (`v0.X.0-rc.1`) publish under the `next` dist-tag. + +To check the packaging without secrets, run `goreleaser build --snapshot --clean` +with dummy `WEBEX_CLIENT_*` values, then `node npm/stage.mjs` and `npm pack` the +folders in `npm/build/`. + ```bash git tag v0.X.0 git push origin v0.X.0 @@ -68,10 +79,11 @@ gh release edit v0.X.0 --title "v0.X.0" --notes "## Improvements - `cmd/root.go` — Root cobra command, global flags (token, org, output, debug, dry-run, paginate, 429 retries, region) - `cmd/auth.go` — Auth subcommand (status, list, switch, set-org, clear-org) -- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE flow +- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE and device login - `cmd/mcp.go` / `cmd/mcp/` — MCP serve/stop, tools, resources, usage log - `cmd/update.go` / `cmd/postinstall.go` / `cmd/skillupdate.go` — self-update and skill install -- `internal/auth/` — Token storage (OS keyring), OAuth, org resolution +- `internal/auth/` — Token storage (OS keyring, credentials file fallback), OAuth, device login, org resolution +- `npm/` — npm wrapper package, staging, and publish scripts - `internal/client/` — HTTP client, request builder, pagination, 429 Retry-After - `internal/appconfig/` — Config file (`~/.webex-cli/config.json`) - `internal/config/` — Process-local flags (token, org, debug, dry-run, retry) diff --git a/README.md b/README.md index c66147c..1c106d7 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,21 @@ curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install. irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex ``` +**npm (any platform with Node.js 18+):** +```bash +npm install -g @cloverhound/webex-cli # or: npx -y @cloverhound/webex-cli +``` + Or download from [Releases](https://github.com/Cloverhound/webex-cli/releases). +The npm package installs only the prebuilt binary for your platform, with no install script and no GitHub download, so it works in sandboxes that allow only package registries. Update it with npm rather than `webex update`. + +The installers verify the download against the release's `checksums.txt` and find the latest version without the GitHub API. For CI or sandboxed agents: + +- Pin a version with `WEBEX_CLI_VERSION=0.16.0` (or `sh -s -- -v 0.16.0`); `webex update` honors it too. +- Set `INSTALL_DIR` to install somewhere other than `~/.local/bin` (Windows: `%LOCALAPPDATA%\webex-cli`). +- Without a terminal, the installer does not prompt: it prints the PATH line to add and installs the agent skill for detected agents. Run `webex post-install --yes` to accept the defaults (including editing your shell profile), `--no-skills` to skip skills, or set `WEBEX_CLI_NONINTERACTIVE=1` to turn prompts off. + ## Quick Start ```bash @@ -138,30 +151,46 @@ See the [full refresh inventory](docs/command-inventory.md), [every renamed comm ## Authentication - **OAuth PKCE flow** — `webex login` opens a browser, no client secret needed on the user side -- **OS keyring storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager +- **Device login** — `webex login --device` prints a URL and code to approve from any browser; used automatically over SSH, in CI, on Linux without a display, or when no browser opens +- **Token storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager, with a plain-text file fallback when no keyring is available - **Auto-refresh** — expired tokens are refreshed automatically - **Multi-user** — log in with multiple Webex accounts and switch between them ```bash -webex login # Login (opens browser) +webex login # Login (opens browser, or device login when headless) +webex login --device # Login by approving a code from any browser +webex login --browser # Force the browser flow webex login --read-only # Login with read scopes only and turn on read-only mode webex logout # Remove stored tokens -webex auth status # Show current user and token status +webex auth status # Show current user, token status, and token store webex auth list # List all authenticated users webex auth switch # Switch default user webex auth set-org # Set a persistent org override webex auth clear-org # Clear the org override +webex auth export # Print the refresh token for $WEBEX_REFRESH_TOKEN ``` -Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. +Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > `$WEBEX_REFRESH_TOKEN` env var > stored login. + +### Headless and Unattended Use + +Device login needs no local browser: `webex login` shows `Open and enter ` (and a QR code on a terminal), and you approve from a browser on any device. With `--output json`, the URL, code, and expiry are printed as JSON on stdout so an agent can relay them. `WEBEX_LOGIN_MODE=device|browser` picks the flow without a flag. A custom integration needs the redirect URIs `https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback` for device login. + +Without an OS keyring (containers, SSH sessions, CI), tokens are saved in plain text to `$XDG_CONFIG_HOME/webex-cli/credentials.json` (`~/.config/webex-cli/` by default, `%APPDATA%\webex-cli\` on Windows) with mode `0600`. `WEBEX_TOKEN_STORE=keyring|file` forces a store; `webex auth status` shows which one holds the token. + +For CI and unattended agents where nobody can approve a code: + +- `WEBEX_TOKEN` takes a personal access token (12 hours) for short jobs. +- `WEBEX_REFRESH_TOKEN` takes a refresh token; the CLI mints access tokens from it as needed. Create one by logging in on your laptop and running `webex auth export`. Set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET` if the token came from your own integration. Webex refresh tokens last about 90 days from last use. The CLI caches the access token and any rotated refresh token in the credentials file when it is writable; otherwise each process mints a new access token. +- For organization-level automation, prefer a [service app](https://developer.webex.com/docs/service-apps) or a bot token over a person's login. ### Read-only Mode `webex login --read-only` requests only read scopes, so Webex itself rejects writes made with the token. Use it when an AI agent should read but not change your Webex data. While read-only mode is on: -- Stored logins with write access are deleted from the keyring, because any program running as you can read it. +- Stored logins with write access are deleted from the keyring and credentials file, because any program running as you can read them. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. -- `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. +- `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`. @@ -307,7 +336,7 @@ A set of skill files in `skill/` enables AI coding agents (Claude Code, Claude C Each sub-skill also contains an auto-generated **Command Reference** section (updated by `make codegen`) that lists every command and its flags, keeping documentation in sync with the API as Postman collections change. -`webex post-install` offers to install all skill files automatically. They are also updated by `webex update`. +The skill files are embedded in the binary, so the installed skill always matches the installed version. `webex post-install` offers to install them, and `webex update` updates installed copies to match the new binary. See the [docs](https://cloverhound.github.io/webex-cli/agent-skill/) for manual setup instructions. diff --git a/cmd/auth.go b/cmd/auth.go index e4aa9b5..afe14e2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "io" "net/http" @@ -46,16 +47,39 @@ var authStatusCmd = &cobra.Command{ } email := cfg.DefaultUser + envSet := false + for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} { + if os.Getenv(env) == "" { + continue + } + envSet = true + switch { + case readOnlyMode(cfg): + fmt.Printf("Env: $%s is set; read-only mode refuses it, so commands fail until it is unset\n", env) + case email == "": + fmt.Printf("Env: $%s is set; commands authenticate with it\n", env) + default: + fmt.Printf("Env: $%s is set and overrides the stored login below\n", env) + } + break + } + if email == "" { - fmt.Println("No authenticated user. Run: webex login") + if !envSet { + fmt.Println("No authenticated user. Run: webex login") + } return nil } userInfo := cfg.Users[email] - tok, err := auth.LoadToken(email) + tok, store, err := auth.LoadTokenWithStore(email) if err != nil { - fmt.Printf("User: %s\n", email) - fmt.Println("Status: token not found in keyring") + fmt.Printf("User: %s\n", email) + if errors.Is(err, auth.ErrTokenNotFound) { + fmt.Println("Status: token not found in the keyring or credentials file") + } else { + fmt.Printf("Status: %v\n", err) + } return nil } @@ -77,7 +101,11 @@ var authStatusCmd = &cobra.Command{ fmt.Printf("Org override: %s\n", cfg.DefaultOrgID) } - fmt.Printf("Source: keyring\n") + if store == auth.StoreFile { + fmt.Printf("Store: file, plain text (%s)\n", auth.CredentialsPath()) + } else { + fmt.Println("Store: OS keyring") + } switch { case cfg.ReadOnly: @@ -121,6 +149,80 @@ var authStatusCmd = &cobra.Command{ }, } +var authExportCmd = &cobra.Command{ + Use: "export [email]", + Short: "Print a stored refresh token for use as $WEBEX_REFRESH_TOKEN", + Long: `Prints the refresh token of a stored login so a login made on one machine can +seed a CI secret or an unattended agent through $WEBEX_REFRESH_TOKEN. The user +is the email given, else the one --user, $WEBEX_USER, the folder default, or the +default user selects. Anyone holding the token can act as that user until it +expires (about 90 days after its last use), so treat it as a password. + +In read-only mode only read-only logins can be exported, and the consumer must +not be in read-only mode, which refuses $WEBEX_REFRESH_TOKEN. + +The token is printed alone on stdout. Confirm at the prompt, or pass --yes +when there is no terminal.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + + cfg, err := appconfig.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + flagUser, envUser := requestedUser(cmd) + email := cfg.DefaultUser + switch { + case len(args) > 0: + email = args[0] + case flagUser != "": + email = flagUser + case envUser != "": + email = envUser + } + if email == "" { + return fmt.Errorf("no authenticated user — run: webex login") + } + tok, err := auth.LoadToken(email) + if err != nil { + return fmt.Errorf("no stored login for %s — run: webex login", email) + } + // The command skips token resolution, which is where read-only mode + // normally rejects write-capable logins. + if readOnlyMode(cfg) && !tok.ReadOnly { + return fmt.Errorf("read-only mode: %s has no read-only login; only read-only logins can be exported", email) + } + if tok.RefreshToken == "" || tok.IsRefreshExpired() { + return fmt.Errorf("the stored login for %s has no usable refresh token — run: webex login", email) + } + + if !yes { + if !canPrompt() { + return fmt.Errorf("refusing to print a refresh token without confirmation; pass --yes") + } + ok, err := confirm(fmt.Sprintf("Print the refresh token for %s?", email), + "Anyone with this token can act as you until it expires.", + "Print it", "Cancel") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("cancelled") + } + } + + if tok.ClientID != "" && tok.ClientID != appconfig.DefaultClientID { + fmt.Fprintf(os.Stderr, "This login uses its own OAuth integration; also set $%s and $%s.\n", + auth.ClientIDEnv, auth.ClientSecretEnv) + } + fmt.Fprintf(os.Stderr, "Webex may rotate refresh tokens on use. If this machine and the consumer of the export\n"+ + "both refresh it, one of them can be signed out; prefer a separate login for each.\n") + fmt.Println(tok.RefreshToken) + return nil + }, +} + var authListCmd = &cobra.Command{ Use: "list", Short: "List all authenticated users", @@ -358,6 +460,8 @@ func init() { authCmd.AddCommand(authTokenCmd) authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authListCmd) + authExportCmd.Flags().Bool("yes", false, "Print without asking for confirmation") + authCmd.AddCommand(authExportCmd) authCmd.AddCommand(authSwitchCmd) authCmd.AddCommand(authSetOrgCmd) authCmd.AddCommand(authClearOrgCmd) diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..6b8c6c0 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/Cloverhound/webex-cli/internal/auth" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + os.Stdout = old + w.Close() + out, _ := io.ReadAll(r) + return string(out) +} + +func TestAuthStatusReportsEnvTokenWithoutStoredLogin(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv(readOnlyEnv, "") + t.Setenv("WEBEX_TOKEN", "") + t.Setenv(auth.RefreshTokenEnv, "rt") + + out := captureStdout(t, func() { + if err := authStatusCmd.RunE(authStatusCmd, nil); err != nil { + t.Error(err) + } + }) + if !strings.Contains(out, "$"+auth.RefreshTokenEnv+" is set") { + t.Errorf("status output does not mention the env token:\n%s", out) + } + if strings.Contains(out, "No authenticated user") { + t.Errorf("status claims no user although commands authenticate from the env:\n%s", out) + } +} diff --git a/cmd/login.go b/cmd/login.go index a16ae6a..4f35fed 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -1,29 +1,65 @@ package cmd import ( + "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" + "runtime" + "strings" + "time" "github.com/Cloverhound/webex-cli/internal/appconfig" "github.com/Cloverhound/webex-cli/internal/auth" "github.com/Cloverhound/webex-cli/internal/localconfig" "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/charmbracelet/huh" + "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" ) +const loginModeEnv = "WEBEX_LOGIN_MODE" + var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Webex via OAuth", - Long: `Opens a browser for Webex OAuth login. Stores tokens in the OS keyring for the authenticated user. + Long: `Logs in to Webex with OAuth and stores the tokens for the authenticated user. + +On a desktop, login opens a browser. With --device, or automatically over SSH, +in CI, on Linux without a display, or when no browser can be opened, login +prints a URL and a code to approve from any browser instead. Set +$` + loginModeEnv + `=device or =browser to choose the flow without a flag. +With --output json, the device code is printed as JSON on stdout. + +Tokens are stored in the OS keyring. When no keyring is available they are +stored in plain text in ` + "`~/.config/webex-cli/credentials.json`" + ` (mode 0600); +set $` + auth.TokenStoreEnv + `=keyring or =file to choose the store. With --read-only, the login requests only read scopes and turns on read-only -mode: stored logins with write access are deleted from the keyring, write -requests are refused, and only read-only logins can be used. Leaving read-only -mode requires a plain 'webex login' from an interactive terminal.`, +mode: stored logins with write access are deleted, write requests are refused, +and only read-only logins can be used. Leaving read-only mode requires a plain +'webex login' from an interactive terminal.`, RunE: func(cmd *cobra.Command, args []string) error { readOnlyLogin, _ := cmd.Flags().GetBool("read-only") + deviceFlag, _ := cmd.Flags().GetBool("device") + browserFlag, _ := cmd.Flags().GetBool("browser") + format, _ := cmd.Flags().GetString("output") + jsonOut := cmd.Flags().Changed("output") && format == "json" + + envHeadless := auth.HeadlessReason(os.Getenv, runtime.GOOS) + mode, headless, err := chooseLoginMode(deviceFlag, browserFlag, os.Getenv(loginModeEnv), envHeadless) + if err != nil { + return err + } + + // Machine-readable output owns stdout; everything for people goes to stderr. + var msg io.Writer = os.Stdout + if jsonOut { + msg = os.Stderr + } + interactive := !jsonOut && canPrompt() cfg, err := appconfig.Load() if err != nil { @@ -38,14 +74,35 @@ mode requires a plain 'webex login' from an interactive terminal.`, if err := readonly.ValidateScopes(scopes); err != nil { return fmt.Errorf("read-only-scopes config: %w", err) } - fmt.Println("Read-only login: stored logins with write access will be removed.") + fmt.Fprintln(msg, "Read-only login: stored logins with write access will be removed.") } else if readOnlyMode(cfg) { if err := confirmLeaveReadOnly(); err != nil { return err } } - result, err := auth.Login(clientID, clientSecret, scopes) + deviceLogin := func() (*auth.LoginResult, error) { + return auth.DeviceLogin(clientID, clientSecret, scopes, func(dc *auth.DeviceCode) { + showDeviceCode(dc, jsonOut) + }) + } + + var result *auth.LoginResult + switch mode { + case "device": + if headless != "" { + fmt.Fprintf(os.Stderr, "Using device login (%s). Use --browser to force the browser flow.\n", headless) + } + result, err = deviceLogin() + case "browser": + result, err = auth.Login(clientID, clientSecret, scopes, false) + default: + result, err = auth.Login(clientID, clientSecret, scopes, true) + if errors.Is(err, auth.ErrNoBrowser) { + fmt.Fprintf(os.Stderr, "Browser login unavailable (%v); using device login.\n", err) + result, err = deviceLogin() + } + } if err != nil { return err } @@ -58,12 +115,12 @@ mode requires a plain 'webex login' from an interactive terminal.`, // Offer to store OAuth credentials with this user account before saving the token. // This allows each user to have independent client credentials for token refresh. - if clientID != "" { + if clientID != "" && interactive { promptStoreCredentials(&result.Token, clientID, clientSecret) } - // Store token in keyring - if err := auth.SaveToken(result.Email, &result.Token); err != nil { + store, err := auth.SaveTokenWithStore(result.Email, &result.Token) + if err != nil { return fmt.Errorf("saving token: %w", err) } @@ -84,27 +141,103 @@ mode requires a plain 'webex login' from an interactive terminal.`, if result.OrgName != "" { orgInfo = fmt.Sprintf(" — %s", result.OrgName) } - fmt.Printf("Logged in as %s (%s)%s\n", result.DisplayName, result.Email, orgInfo) + fmt.Fprintf(msg, "Logged in as %s (%s)%s\n", result.DisplayName, result.Email, orgInfo) if readOnlyLogin { - fmt.Println("Read-only mode is on.") + fmt.Fprintln(msg, "Read-only mode is on.") for _, email := range purged { - fmt.Printf("Removed write-capable login: %s\n", email) + fmt.Fprintf(msg, "Removed write-capable login: %s\n", email) } - if purgeErr != nil { - return purgeErr + } + if jsonOut { + out := map[string]any{ + "status": "logged_in", + "email": result.Email, + "display_name": result.DisplayName, + "org_id": result.OrgID, + "org_name": result.OrgName, + "read_only": readOnlyLogin, + "token_store": store, } + if readOnlyLogin { + out["removed_logins"] = purged + if purgeErr != nil { + out["purge_error"] = purgeErr.Error() + } + } + _ = json.NewEncoder(os.Stdout).Encode(out) + } + if purgeErr != nil { + return purgeErr } - fmt.Println() // Offer to associate this user with the current folder - if cwd, err := os.Getwd(); err == nil { - promptFolderAssociation(result.Email, cwd) + if interactive { + fmt.Println() + if cwd, err := os.Getwd(); err == nil { + promptFolderAssociation(result.Email, cwd) + } } return nil }, } +// chooseLoginMode returns "device", "browser" or "auto" (browser, falling back +// to device when no browser can be used), plus the reason when the environment +// looks headless. +func chooseLoginMode(device, browser bool, envMode, headless string) (mode, reason string, err error) { + if device && browser { + return "", "", fmt.Errorf("--device and --browser cannot be used together") + } + switch { + case device: + return "device", "", nil + case browser: + return "browser", "", nil + } + switch strings.ToLower(strings.TrimSpace(envMode)) { + case "": + case "device": + return "device", "", nil + case "browser": + return "browser", "", nil + default: + return "", "", fmt.Errorf("$%s must be \"device\" or \"browser\", not %q", loginModeEnv, envMode) + } + if headless != "" { + return "device", headless, nil + } + return "auto", "", nil +} + +// showDeviceCode tells the user how to approve the login. The first line is +// meant to be relayed word for word by an agent running the CLI on the user's +// behalf; the user approves in their own browser, not where the CLI runs, so +// no browser is opened here. +func showDeviceCode(dc *auth.DeviceCode, jsonOut bool) { + link := dc.VerificationURIComplete + if link == "" { + link = dc.VerificationURI + } + if jsonOut { + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "status": "pending", + "verification_uri": dc.VerificationURI, + "verification_uri_complete": dc.VerificationURIComplete, + "user_code": dc.UserCode, + "expires_in": dc.ExpiresIn, + "expires_at": time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second).UTC().Format(time.RFC3339), + }) + } + + fmt.Fprintf(os.Stderr, "Open %s and enter %s\n", dc.VerificationURI, dc.UserCode) + if isTerminal(os.Stderr) { + fmt.Fprintln(os.Stderr, "Or scan this code with a phone:") + qrterminal.GenerateHalfBlock(link, qrterminal.L, os.Stderr) + } + fmt.Fprintf(os.Stderr, "Waiting for approval (the code expires in %d minutes)...\n", (dc.ExpiresIn+59)/60) +} + func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string) { var choice string form := huh.NewForm( @@ -112,7 +245,7 @@ func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string huh.NewSelect[string](). Title("Save OAuth credentials with this account?"). Description( - "Storing credentials per-account lets each user refresh tokens independently,\n" + + "Storing credentials per-account lets each user refresh tokens independently,\n"+ "even when multiple accounts are configured with different OAuth apps.", ). Options( @@ -121,7 +254,7 @@ func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string ). Value(&choice), ), - ) + ).WithOutput(os.Stderr) if err := form.Run(); err != nil { return @@ -155,7 +288,7 @@ func promptFolderAssociation(email, dir string) { ). Value(&choice), ), - ) + ).WithOutput(os.Stderr) if err := form.Run(); err != nil { return @@ -172,5 +305,7 @@ func promptFolderAssociation(email, dir string) { func init() { loginCmd.Flags().Bool("read-only", false, "Log in with read-only scopes and turn on read-only mode") + loginCmd.Flags().Bool("device", false, "Log in with a code approved from any browser (no local browser needed)") + loginCmd.Flags().Bool("browser", false, "Force the local browser login even when no browser is detected") rootCmd.AddCommand(loginCmd) } diff --git a/cmd/login_test.go b/cmd/login_test.go new file mode 100644 index 0000000..20c9786 --- /dev/null +++ b/cmd/login_test.go @@ -0,0 +1,33 @@ +package cmd + +import "testing" + +func TestChooseLoginMode(t *testing.T) { + cases := []struct { + name string + device, browser bool + env, headless string + want string + wantErr bool + }{ + {name: "desktop default", want: "auto"}, + {name: "headless default", headless: "SSH session", want: "device"}, + {name: "device flag", device: true, want: "device"}, + {name: "browser flag beats headless", browser: true, headless: "SSH session", want: "browser"}, + {name: "env device", env: "device", want: "device"}, + {name: "env browser beats headless", env: "Browser", headless: "CI environment", want: "browser"}, + {name: "flag beats env", device: true, env: "browser", want: "device"}, + {name: "both flags", device: true, browser: true, wantErr: true}, + {name: "bad env", env: "qr", wantErr: true}, + } + for _, c := range cases { + got, _, err := chooseLoginMode(c.device, c.browser, c.env, c.headless) + if (err != nil) != c.wantErr { + t.Errorf("%s: err = %v", c.name, err) + continue + } + if got != c.want { + t.Errorf("%s: mode = %q, want %q", c.name, got, c.want) + } + } +} diff --git a/cmd/logout.go b/cmd/logout.go index 33d67cf..c4f7fe1 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -11,7 +11,7 @@ import ( var logoutCmd = &cobra.Command{ Use: "logout [email]", Short: "Log out and remove stored credentials", - Long: "Removes stored tokens from the OS keyring. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users.", + Long: "Removes stored tokens from the OS keyring and the credentials file. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users and any tokens cached from $WEBEX_REFRESH_TOKEN.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { allFlag, _ := cmd.Flags().GetBool("all") @@ -27,6 +27,9 @@ var logoutCmd = &cobra.Command{ fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove token for %s: %v\n", email, err) } } + if err := auth.DeleteEnvRefreshCache(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove cached $%s tokens: %v\n", auth.RefreshTokenEnv, err) + } cfg.Users = make(map[string]appconfig.UserInfo) cfg.DefaultUser = "" if err := cfg.Save(); err != nil { diff --git a/cmd/mcp/dispatcher.go b/cmd/mcp/dispatcher.go index d150877..c60096c 100644 --- a/cmd/mcp/dispatcher.go +++ b/cmd/mcp/dispatcher.go @@ -18,6 +18,7 @@ import ( var blockedPrefixes = []string{ "login", "logout", + "auth export", "auth switch", "auth set-org", "auth clear-org", @@ -121,14 +122,10 @@ func makeDispatchHandler(lg *usageLogger, readOnly bool) func(context.Context, m args := strings.Fields(command) - // Blocklist check — join up to 2 tokens to cover two-word prefixes like "auth switch". - prefix := strings.Join(args[:min(2, len(args))], " ") - for _, blocked := range blockedPrefixes { - if strings.HasPrefix(prefix, blocked) { - return mcplib.NewToolResultText(fmt.Sprintf( - "Error: command '%s' is not permitted via the MCP server.", blocked, - )), nil - } + if blocked := blockedCommand(args); blocked != "" { + return mcplib.NewToolResultText(fmt.Sprintf( + "Error: command '%s' is not permitted via the MCP server.", blocked, + )), nil } // Injection guard: reject tokens containing shell-special characters. @@ -219,6 +216,18 @@ func makeDispatchHandler(lg *usageLogger, readOnly bool) func(context.Context, m } } +// blockedCommand returns the blocked prefix that args start with, or "". Up to +// two tokens are joined to cover two-word prefixes like "auth switch". +func blockedCommand(args []string) string { + prefix := strings.Join(args[:min(2, len(args))], " ") + for _, blocked := range blockedPrefixes { + if strings.HasPrefix(prefix, blocked) { + return blocked + } + } + return "" +} + // actionToken returns the action verb from the command args. // For "calling people list", it returns "list" (positional index 2). // For two-token commands like "auth status", it returns "status" (positional index 1). diff --git a/cmd/mcp/dispatcher_test.go b/cmd/mcp/dispatcher_test.go index b882dd8..92fa8bb 100644 --- a/cmd/mcp/dispatcher_test.go +++ b/cmd/mcp/dispatcher_test.go @@ -1,11 +1,28 @@ package mcp import ( + "strings" "testing" "github.com/mark3labs/mcp-go/server" ) +func TestBlockedCommand(t *testing.T) { + cases := map[string]string{ + "auth export": "auth export", + "auth export --yes": "auth export", + "login": "login", + "auth status": "", + "calling people list": "", + "cc recordings export x": "", + } + for command, want := range cases { + if got := blockedCommand(strings.Fields(command)); got != want { + t.Errorf("blockedCommand(%q) = %q, want %q", command, got, want) + } + } +} + func TestReadOnlyServerOmitsWriteTool(t *testing.T) { for _, readOnly := range []bool{false, true} { s := server.NewMCPServer("test", "0", server.WithToolCapabilities(false)) diff --git a/cmd/postinstall.go b/cmd/postinstall.go index cd2d58d..451c7cc 100644 --- a/cmd/postinstall.go +++ b/cmd/postinstall.go @@ -4,31 +4,32 @@ import ( "archive/zip" "bytes" "fmt" - "io" - "net/http" "os" "path/filepath" "runtime" "strings" + "github.com/Cloverhound/webex-cli/skill" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) -const skillBaseURL = "https://raw.githubusercontent.com/Cloverhound/webex-cli/main/skill/" +const coworkName = "Claude Cowork" -var skillSubPaths = []string{ - "SKILL.md", - "admin/SKILL.md", - "calling/SKILL.md", - "cc/SKILL.md", - "device/SKILL.md", - "meetings/SKILL.md", - "messaging/SKILL.md", +// setupOptions controls how post-install and skill updates answer questions. +type setupOptions struct { + prompt bool // ask with interactive forms + assumeYes bool // without prompts, take the recommended action + skills bool // install or update agent skills } -const coworkName = "Claude Cowork" +// newSetupOptions prompts only when a person can answer. These forms are drawn +// on stdout, so it must be a terminal too. +func newSetupOptions(assumeYes, noSkills bool) setupOptions { + prompt := !assumeYes && canPrompt() && isTerminal(os.Stdout) + return setupOptions{prompt: prompt, assumeYes: assumeYes, skills: !noSkills} +} type agentPlatform struct { Name string @@ -45,7 +46,21 @@ var agentPlatforms = []agentPlatform{ var postInstallCmd = &cobra.Command{ Use: "post-install", Short: "Run post-installation setup (PATH, agent skills)", + Long: `Adds the install directory to PATH and installs the Webex agent skill. + +Without a terminal, or with --yes or $WEBEX_CLI_NONINTERACTIVE=1, nothing is +asked: the skill is installed for detected agents (Claude Code, Codex, Cursor). +Without --yes, the PATH line to add is printed instead of editing shell files.`, RunE: func(cmd *cobra.Command, args []string) error { + assumeYes, _ := cmd.Flags().GetBool("yes") + noSkills, _ := cmd.Flags().GetBool("no-skills") + skillsOnly, _ := cmd.Flags().GetBool("skills-only") + opts := newSetupOptions(assumeYes, noSkills) + + if skillsOnly { + return checkSkillUpdates(opts) + } + execPath, err := os.Executable() if err != nil { return fmt.Errorf("detecting executable path: %w", err) @@ -59,7 +74,7 @@ var postInstallCmd = &cobra.Command{ return err } } else { - if err := setupUnixPath(installDir); err != nil { + if err := setupUnixPath(installDir, opts); err != nil { return err } } @@ -67,7 +82,7 @@ var postInstallCmd = &cobra.Command{ } // Step 2: Agent skill installation - if err := setupAgentSkills(); err != nil { + if err := setupAgentSkills(opts); err != nil { return err } @@ -84,7 +99,7 @@ func dirInPath(dir string) bool { return false } -func setupUnixPath(installDir string) error { +func setupUnixPath(installDir string, opts setupOptions) error { shell := filepath.Base(os.Getenv("SHELL")) var rcFile string switch shell { @@ -105,29 +120,35 @@ func setupUnixPath(installDir string) error { } } - var choice string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(fmt.Sprintf("%s is not in your PATH", installDir)). - Description( - fmt.Sprintf( - "The webex binary was installed to %s, but your shell\n"+ - "can't find it yet. Adding it to %s will make the\n"+ - "\"webex\" command available in all new terminal sessions.", - installDir, filepath.Base(rcFile), - ), - ). - Options( - huh.NewOption(fmt.Sprintf("Yes — add to %s", filepath.Base(rcFile)), "yes"), - huh.NewOption("No — I'll do it myself", "no"), - ). - Value(&choice), - ), - ) - - if err := form.Run(); err != nil { - return nil + choice := "no" + if opts.assumeYes { + choice = "yes" + } else if opts.prompt { + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("%s is not in your PATH", installDir)). + Description( + fmt.Sprintf( + "The webex binary was installed to %s, but your shell\n"+ + "can't find it yet. Adding it to %s will make the\n"+ + "\"webex\" command available in all new terminal sessions.", + installDir, filepath.Base(rcFile), + ), + ). + Options( + huh.NewOption(fmt.Sprintf("Yes — add to %s", filepath.Base(rcFile)), "yes"), + huh.NewOption("No — I'll do it myself", "no"), + ). + Value(&choice), + ), + ) + + if err := form.Run(); err != nil { + return nil + } + } else { + fmt.Printf("%s is not in your PATH.\n", installDir) } if choice == "yes" { @@ -159,55 +180,59 @@ func setupWindowsPath(installDir string) error { return nil } -func setupAgentSkills() error { +func setupAgentSkills(opts setupOptions) error { + if !opts.skills { + return nil + } home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("detecting home directory: %w", err) } - var options []huh.Option[string] - for _, p := range agentPlatforms { - label := p.Name - if p.Name == coworkName { - label += " (saves ZIP to ~/Downloads for manual upload)" - } - opt := huh.NewOption(label, p.Name) - if agentDetected(home, p) { - opt = opt.Selected(true) + var selected []string + if opts.prompt { + var options []huh.Option[string] + for _, p := range agentPlatforms { + label := p.Name + if p.Name == coworkName { + label += " (saves ZIP to ~/Downloads for manual upload)" + } + opt := huh.NewOption(label, p.Name) + if agentDetected(home, p) { + opt = opt.Selected(true) + } + options = append(options, opt) } - options = append(options, opt) - } - var selected []string - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Install Webex skill for AI coding agents?"). - Description( - "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+ - "query and manage your Webex environment using natural language.\n"+ - "Detected agents are pre-selected.", - ). - Options(options...). - Value(&selected), - ), - ) - - if err := form.Run(); err != nil { - return nil + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Install Webex skill for AI coding agents?"). + Description( + "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n" + + "query and manage your Webex environment using natural language.\n" + + "Detected agents are pre-selected.", + ). + Options(options...). + Value(&selected), + ), + ) + + if err := form.Run(); err != nil { + return nil + } + } else { + selected = detectedSkillAgents(home) } if len(selected) == 0 { return nil } - fmt.Print("Downloading Webex skill...") - skillFiles, err := downloadSkillFiles() + skillFiles, err := skill.Files() if err != nil { - fmt.Println(" failed") - return fmt.Errorf("downloading skill: %w", err) + return fmt.Errorf("reading embedded skill: %w", err) } - fmt.Println(" ok") for _, name := range selected { for _, p := range agentPlatforms { @@ -260,24 +285,16 @@ func agentDetected(home string, p agentPlatform) bool { return err == nil && info.IsDir() } -func downloadSkillFiles() (map[string][]byte, error) { - files := make(map[string][]byte) - for _, subPath := range skillSubPaths { - resp, err := http.Get(skillBaseURL + subPath) - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", subPath, err) - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("reading %s: %w", subPath, err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: HTTP %d", subPath, resp.StatusCode) +// detectedSkillAgents lists the detected agents that install from a skill +// directory. Cowork is left out because it needs a manual ZIP upload. +func detectedSkillAgents(home string) []string { + var names []string + for _, p := range agentPlatforms { + if p.SkillDir != "" && agentDetected(home, p) { + names = append(names, p.Name) } - files[subPath] = body } - return files, nil + return names } func printCoworkInstructions(zipPath string) { @@ -341,5 +358,9 @@ func installSkill(dest string, content []byte) error { } func init() { + postInstallCmd.Flags().BoolP("yes", "y", false, "Do not prompt; add the PATH line and install skills for detected agents") + postInstallCmd.Flags().Bool("no-skills", false, "Skip agent skill installation") + postInstallCmd.Flags().Bool("skills-only", false, "Only check installed agent skills against this binary's skill") + postInstallCmd.Flags().MarkHidden("skills-only") rootCmd.AddCommand(postInstallCmd) } diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..7659659 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "os" + "strconv" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +const nonInteractiveEnv = "WEBEX_CLI_NONINTERACTIVE" + +// isTerminal is a variable so tests can simulate a terminal. +var isTerminal = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } + +// canPrompt reports whether a person can answer a form. Forms are drawn on +// stderr, so stdout can be piped or carry JSON. Installers and agents usually +// run without a terminal, where a form would fail or hang. +func canPrompt() bool { + if off, _ := strconv.ParseBool(os.Getenv(nonInteractiveEnv)); off { + return false + } + return isTerminal(os.Stdin) && isTerminal(os.Stderr) +} + +// confirm asks a yes/no question on stderr. +func confirm(title, description, yes, no string) (bool, error) { + var ok bool + err := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title(title). + Description(description). + Affirmative(yes). + Negative(no). + Value(&ok), + )).WithOutput(os.Stderr).Run() + return ok, err +} diff --git a/cmd/readonly.go b/cmd/readonly.go index 9ea9896..f458858 100644 --- a/cmd/readonly.go +++ b/cmd/readonly.go @@ -6,8 +6,6 @@ import ( "strconv" "github.com/Cloverhound/webex-cli/internal/appconfig" - "github.com/charmbracelet/huh" - "golang.org/x/term" ) const readOnlyEnv = "WEBEX_READ_ONLY" @@ -31,21 +29,13 @@ func confirmLeaveReadOnly() error { if readOnlyEnvSet() { return fmt.Errorf("$%s is set; unset it to log in with write access", readOnlyEnv) } - if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + if !canPrompt() { return fmt.Errorf("read-only mode is on; leaving it requires 'webex login' from an interactive terminal") } - var leave bool - err := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Leave read-only mode?"). - Description("This logs in with write access. Only continue if you started this login yourself."). - Affirmative("Leave read-only mode"). - Negative("Cancel"). - Value(&leave), - ), - ).Run() + leave, err := confirm("Leave read-only mode?", + "This logs in with write access. Only continue if you started this login yourself.", + "Leave read-only mode", "Cancel") if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index cb1b568..703f7fa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,9 +17,9 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "webex", - Short: "Webex CLI — manage Webex APIs", - Long: `A command-line interface for Webex APIs — Admin, Calling, Contact Center, Devices, Meetings, and Messaging.`, + Use: "webex", + Short: "Webex CLI — manage Webex APIs", + Long: `A command-line interface for Webex APIs — Admin, Calling, Contact Center, Devices, Meetings, and Messaging.`, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Debug mode (set early so auth debug works) @@ -74,17 +74,7 @@ var rootCmd = &cobra.Command{ // Resolve token flagToken, _ := cmd.Flags().GetString("token") envToken := os.Getenv("WEBEX_TOKEN") - userFlag, _ := cmd.Flags().GetString("user") - envUser := os.Getenv("WEBEX_USER") - - // Check local folder config before falling back to global default - if userFlag == "" && envUser == "" { - if cwd, err := os.Getwd(); err == nil { - if lcfg, err := localconfig.Load(cwd); err == nil && lcfg != nil && lcfg.User != "" { - envUser = lcfg.User - } - } - } + userFlag, envUser := requestedUser(cmd) result, err := auth.ResolveToken(flagToken, envToken, userFlag, envUser, cfg, readOnly) if err != nil { @@ -93,9 +83,12 @@ var rootCmd = &cobra.Command{ config.SetToken(result.Token) - // Wire up token refresher for keyring-based auth - if result.Source == auth.SourceKeyring && result.UserEmail != "" { - config.TokenRefresher = auth.MakeRefresher(result.UserEmail, cfg) + // Wire up token refresher for refreshable sources + switch { + case result.Source == auth.SourceStored && result.UserEmail != "": + config.TokenRefresher = auth.MakeRefresher(result.UserEmail, result.Token, cfg) + case result.Source == auth.SourceEnvRefresh: + config.TokenRefresher = auth.MakeEnvRefresher(result.Token, cfg) } // Organization: --organization flag > config default org > resolved user's org > default user's org. @@ -144,6 +137,21 @@ var rootCmd = &cobra.Command{ }, } +// requestedUser returns the --user flag, and otherwise $WEBEX_USER or the +// folder default. Both empty means the configured default user. +func requestedUser(cmd *cobra.Command) (flagUser, envUser string) { + flagUser, _ = cmd.Flags().GetString("user") + envUser = os.Getenv("WEBEX_USER") + if flagUser == "" && envUser == "" { + if cwd, err := os.Getwd(); err == nil { + if lcfg, err := localconfig.Load(cwd); err == nil && lcfg != nil { + envUser = lcfg.User + } + } + } + return flagUser, envUser +} + // setRegion validates a region value and stores it for region-specific hosts. func setRegion(region string) error { if region == "" { @@ -203,7 +211,7 @@ func hideOrgFlags(cmd *cobra.Command) { } func init() { - rootCmd.PersistentFlags().String("token", "", "Webex API token (overrides keyring)") + rootCmd.PersistentFlags().String("token", "", "Webex API token (overrides stored login)") rootCmd.PersistentFlags().String("output", "json", "Output format: json, table, csv, raw") rootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging of HTTP requests") rootCmd.PersistentFlags().Bool("paginate", false, "Auto-paginate list results") @@ -244,6 +252,10 @@ func skipAuth(cmd *cobra.Command) bool { switch top.Name() { case "login", "logout", "config", "version", "update", "post-install", "help", "completion": return true + case "mcp": + // Each tool call runs the CLI in a child process that resolves its own + // token, so the server can start before anyone has logged in. + return true case "auth": // set-org validates the org against the API; token prints it. return cmd.Name() != "set-org" && cmd.Name() != "token" diff --git a/cmd/skillupdate.go b/cmd/skillupdate.go index e212afd..52ac9cf 100644 --- a/cmd/skillupdate.go +++ b/cmd/skillupdate.go @@ -6,10 +6,17 @@ import ( "os" "path/filepath" + "github.com/Cloverhound/webex-cli/skill" "github.com/charmbracelet/huh" ) -func checkSkillUpdates() error { +// checkSkillUpdates compares installed agent skills with the skill embedded in +// this binary. Without prompts, outdated skills are updated and agents without +// the skill are left alone, unless opts.assumeYes is set. +func checkSkillUpdates(opts setupOptions) error { + if !opts.skills { + return nil + } home, err := os.UserHomeDir() if err != nil { return err @@ -21,13 +28,10 @@ func checkSkillUpdates() error { Status string // "outdated", "new" } - fmt.Print("Checking for skill updates...") - latest, err := downloadSkillFiles() + latest, err := skill.Files() if err != nil { - fmt.Println(" failed") - return err + return fmt.Errorf("reading embedded skill: %w", err) } - fmt.Println(" ok") var actions []skillAction for _, p := range agentPlatforms { @@ -35,19 +39,13 @@ func checkSkillUpdates() error { continue // skip Cowork (no filesystem path) } skillDir := filepath.Join(home, p.SkillDir) - rootPath := filepath.Join(skillDir, "SKILL.md") - current, err := os.ReadFile(rootPath) - if err == nil { - outdated := !bytes.Equal(current, latest["SKILL.md"]) - if !outdated { - for subPath := range latest { - if subPath == "SKILL.md" { - continue - } - if _, serr := os.Stat(filepath.Join(skillDir, filepath.FromSlash(subPath))); serr != nil { - outdated = true - break - } + if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err == nil { + outdated := false + for subPath, content := range latest { + current, rerr := os.ReadFile(filepath.Join(skillDir, filepath.FromSlash(subPath))) + if rerr != nil || !bytes.Equal(current, content) { + outdated = true + break } } if outdated { @@ -63,48 +61,58 @@ func checkSkillUpdates() error { return nil } - // Step 1: Ask if user wants to review skill updates - var proceed bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Agent skill updates available"). - Description(fmt.Sprintf("%d agent skill(s) can be updated or installed.", len(actions))). - Affirmative("Review changes"). - Negative("Skip"). - Value(&proceed), - ), - ) + var selected []string + if !opts.prompt { + for _, a := range actions { + if a.Status == "outdated" || opts.assumeYes { + selected = append(selected, a.Platform.Name) + } else { + fmt.Printf(" %s: skill not installed (run: webex post-install --yes)\n", a.Platform.Name) + } + } + } else { + // Step 1: Ask if user wants to review skill updates + var proceed bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Agent skill updates available"). + Description(fmt.Sprintf("%d agent skill(s) can be updated or installed.", len(actions))). + Affirmative("Review changes"). + Negative("Skip"). + Value(&proceed), + ), + ) - if err := confirmForm.Run(); err != nil || !proceed { - return nil - } + if err := confirmForm.Run(); err != nil || !proceed { + return nil + } - // Step 2: Show multiselect with specific skills - var options []huh.Option[string] - for _, a := range actions { - label := a.Platform.Name - if a.Status == "outdated" { - label += " (update available)" - } else { - label += " (not yet installed)" + // Step 2: Show multiselect with specific skills + var options []huh.Option[string] + for _, a := range actions { + label := a.Platform.Name + if a.Status == "outdated" { + label += " (update available)" + } else { + label += " (not yet installed)" + } + options = append(options, huh.NewOption(label, a.Platform.Name).Selected(true)) } - options = append(options, huh.NewOption(label, a.Platform.Name).Selected(true)) - } - var selected []string - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Skill updates available"). - Description("The following agents can be updated or have the Webex skill\ninstalled. Deselect any you want to skip."). - Options(options...). - Value(&selected), - ), - ) + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Skill updates available"). + Description("The following agents can be updated or have the Webex skill\ninstalled. Deselect any you want to skip."). + Options(options...). + Value(&selected), + ), + ) - if err := form.Run(); err != nil { - return nil + if err := form.Run(); err != nil { + return nil + } } if len(selected) == 0 { diff --git a/cmd/skillupdate_test.go b/cmd/skillupdate_test.go new file mode 100644 index 0000000..2a01887 --- /dev/null +++ b/cmd/skillupdate_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Cloverhound/webex-cli/skill" +) + +func TestCheckSkillUpdatesNonInteractive(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + claudeDir := filepath.Join(home, ".claude", "skills", "webex-cli") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "SKILL.md"), []byte("old"), 0644); err != nil { + t.Fatal(err) + } + // Detected but never installed: left alone without --yes. + if err := os.MkdirAll(filepath.Join(home, ".codex"), 0755); err != nil { + t.Fatal(err) + } + + if err := checkSkillUpdates(setupOptions{skills: true}); err != nil { + t.Fatal(err) + } + + want, _ := skill.Files() + got, err := os.ReadFile(filepath.Join(claudeDir, "admin", "SKILL.md")) + if err != nil || string(got) != string(want["admin/SKILL.md"]) { + t.Errorf("outdated Claude Code skill not updated: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "webex-cli", "SKILL.md")); !os.IsNotExist(err) { + t.Errorf("Codex skill installed without --yes: %v", err) + } +} diff --git a/cmd/update.go b/cmd/update.go index 457d625..85be759 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -5,15 +5,19 @@ import ( "archive/zip" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" "strings" + "time" "github.com/spf13/cobra" ) @@ -30,16 +34,43 @@ func init() { rootCmd.AddCommand(updateCmd) } +// versionPinEnv pins the version that install and update use, skipping lookup. +const versionPinEnv = "WEBEX_CLI_VERSION" + +// Release locations, variables so tests can point them at a local server. The +// lookup avoids api.github.com where possible: sandboxes and shared CI hosts +// often block it or exhaust its unauthenticated rate limit, while release +// downloads and the Go module proxy stay reachable. +var ( + releaseBaseURL = "https://github.com/Cloverhound/webex-cli/releases" + goProxyLatestURL = "https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" + githubAPILatestURL = "https://api.github.com/repos/Cloverhound/webex-cli/releases/latest" +) + +var lookupClient = &http.Client{Timeout: 30 * time.Second} + func runUpdate() error { - latest, err := fetchLatestVersion() - if err != nil { - return fmt.Errorf("checking latest version: %w", err) + if binPath, err := executablePath(); err == nil && installedByNPM(binPath) { + return fmt.Errorf("this webex was installed with npm; update it with: npm install -g @cloverhound/webex-cli@latest (npx fetches the latest version on its own)") } current := Version - if !isNewer(latest, current) { - fmt.Printf("Already up to date (v%s)\n", current) - return nil + latest := strings.TrimPrefix(os.Getenv(versionPinEnv), "v") + if latest != "" { + if latest == current { + fmt.Printf("Already at pinned version v%s ($%s)\n", current, versionPinEnv) + return nil + } + } else { + var err error + latest, err = fetchLatestVersion() + if err != nil { + return fmt.Errorf("checking latest version: %w", err) + } + if !isNewer(latest, current) { + fmt.Printf("Already up to date (v%s)\n", current) + return nil + } } fmt.Printf("Updating v%s -> v%s\n", current, latest) @@ -49,44 +80,161 @@ func runUpdate() error { return fmt.Errorf("locating binary: %w", err) } - url := archiveURL(latest) - if err := downloadAndReplace(url, binPath); err != nil { + if err := downloadAndReplace(latest, binPath); err != nil { return err } fmt.Printf("Updated to v%s\n", latest) - // Check for skill updates - if err := checkSkillUpdates(); err != nil { + // Skills are embedded in the binary, so the new binary must run the check + // to install the skill that matches it. + check := exec.Command(binPath, "post-install", "--skills-only") + check.Stdin, check.Stdout, check.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := check.Run(); err != nil { fmt.Printf("Warning: skill update check failed: %v\n", err) } return nil } -// fetchLatestVersion queries the GitHub releases API and returns the latest -// version string (without "v" prefix). +// fetchLatestVersion returns the latest release version (without "v"), trying +// the release checksums file, then the Go module proxy, then the GitHub API. func fetchLatestVersion() (string, error) { - resp, err := http.Get("https://api.github.com/repos/Cloverhound/webex-cli/releases/latest") + type source struct { + url string + lookup func(string) (string, error) + } + sources := []source{ + {releaseBaseURL + "/latest/download/checksums.txt", latestFromChecksums}, + {goProxyLatestURL, latestFromGoProxy}, + {githubAPILatestURL, latestFromGitHubAPI}, + } + + var failures []string + for _, s := range sources { + v, err := s.lookup(s.url) + if err == nil && v != "" { + return v, nil + } + if err == nil { + err = fmt.Errorf("no version found") + } + failures = append(failures, fmt.Sprintf(" %s: %v", s.url, err)) + } + return "", fmt.Errorf("could not determine the latest version; tried:\n%s\nPin a version instead: %s=x.y.z webex update", + strings.Join(failures, "\n"), versionPinEnv) +} + +func latestFromChecksums(url string) (string, error) { + body, err := httpGetBytes(url, nil) if err != nil { return "", err } - defer resp.Body.Close() + return versionFromChecksums(body, runtime.GOOS, runtime.GOARCH) +} - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %s", resp.Status) +func latestFromGoProxy(url string) (string, error) { + body, err := httpGetBytes(url, nil) + if err != nil { + return "", err + } + var info struct { + Version string `json:"Version"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", err } + return strings.TrimPrefix(info.Version, "v"), nil +} +func latestFromGitHubAPI(url string) (string, error) { + header := http.Header{} + if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { + header.Set("Authorization", "Bearer "+tok) + } + body, err := httpGetBytes(url, header) + if err != nil { + return "", err + } var release struct { TagName string `json:"tag_name"` } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.Unmarshal(body, &release); err != nil { return "", err } - return strings.TrimPrefix(release.TagName, "v"), nil } +func httpGetBytes(url string, header http.Header) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + for k, v := range header { + req.Header[k] = v + } + resp, err := lookupClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +// archiveName matches the GoReleaser name template: +// webex-cli_{VERSION}_{OS}_{ARCH}.tar.gz (.zip on Windows). +func archiveName(version, goos, goarch string) string { + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + return fmt.Sprintf("webex-cli_%s_%s_%s.%s", version, goos, goarch, ext) +} + +// versionFromChecksums finds the archive for goos/goarch in a checksums.txt +// and returns the version embedded in its file name. +func versionFromChecksums(data []byte, goos, goarch string) (string, error) { + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + suffix := "_" + goos + "_" + goarch + ext + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + name := strings.TrimPrefix(fields[1], "*") + if strings.HasPrefix(name, "webex-cli_") && strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(strings.TrimPrefix(name, "webex-cli_"), suffix), nil + } + } + return "", fmt.Errorf("no %s archive listed", strings.TrimPrefix(suffix, "_")) +} + +// checksumFor returns the lowercase hex SHA-256 listed for name. +func checksumFor(data []byte, name string) (string, error) { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + return strings.ToLower(fields[0]), nil + } + } + return "", fmt.Errorf("%s is not listed in checksums.txt", name) +} + +func verifyChecksum(data []byte, want string) error { + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != strings.ToLower(want) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", want, got) + } + return nil +} + // isNewer returns true if latest is a higher semver than current. func isNewer(latest, current string) bool { parse := func(v string) [3]int { @@ -108,6 +256,12 @@ func isNewer(latest, current string) bool { return false } +// installedByNPM reports whether binPath lives in an npm package. Replacing it +// in place would leave npm's records out of step with the file on disk. +func installedByNPM(binPath string) bool { + return strings.Contains(strings.ReplaceAll(binPath, `\`, "/"), "/node_modules/@cloverhound/webex-cli-") +} + // executablePath returns the resolved path to the running binary. func executablePath() (string, error) { exe, err := os.Executable() @@ -117,35 +271,44 @@ func executablePath() (string, error) { return filepath.EvalSymlinks(exe) } -// archiveURL builds the download URL for a given version, matching the -// GoReleaser naming template: webex-cli_{VERSION}_{OS}_{ARCH}.tar.gz (.zip on Windows). -func archiveURL(version string) string { - ext := "tar.gz" - if runtime.GOOS == "windows" { - ext = "zip" +// downloadRelease downloads the archive for version and this platform and +// verifies it against the release's checksums.txt. +func downloadRelease(version string) ([]byte, error) { + base := fmt.Sprintf("%s/download/v%s/", releaseBaseURL, version) + sums, err := httpGetBytes(base+"checksums.txt", nil) + if err != nil { + return nil, fmt.Errorf("downloading checksums.txt: %w", err) + } + name := archiveName(version, runtime.GOOS, runtime.GOARCH) + want, err := checksumFor(sums, name) + if err != nil { + return nil, err } - return fmt.Sprintf( - "https://github.com/Cloverhound/webex-cli/releases/download/v%s/webex-cli_%s_%s_%s.%s", - version, version, runtime.GOOS, runtime.GOARCH, ext, - ) -} -// downloadAndReplace downloads the archive from url, extracts the binary, and -// atomically replaces the binary at binaryPath. -func downloadAndReplace(url, binaryPath string) error { - resp, err := http.Get(url) + resp, err := http.Get(base + name) if err != nil { - return fmt.Errorf("downloading release: %w", err) + return nil, fmt.Errorf("downloading release: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed: %s", resp.Status) + return nil, fmt.Errorf("download failed: %s", resp.Status) } - body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading download: %w", err) + return nil, fmt.Errorf("reading download: %w", err) + } + if err := verifyChecksum(body, want); err != nil { + return nil, fmt.Errorf("%s: %w; refusing to install", name, err) + } + return body, nil +} + +// downloadAndReplace downloads and verifies the release archive, extracts the +// binary, and atomically replaces the binary at binaryPath. +func downloadAndReplace(version, binaryPath string) error { + body, err := downloadRelease(version) + if err != nil { + return err } var bin []byte diff --git a/cmd/update_npm_test.go b/cmd/update_npm_test.go new file mode 100644 index 0000000..125de4c --- /dev/null +++ b/cmd/update_npm_test.go @@ -0,0 +1,18 @@ +package cmd + +import "testing" + +func TestInstalledByNPM(t *testing.T) { + cases := map[string]bool{ + "/usr/local/lib/node_modules/@cloverhound/webex-cli-darwin-arm64/bin/webex": true, + `C:\Users\a\AppData\Roaming\npm\node_modules\@cloverhound\webex-cli-win32-x64\bin\webex.exe`: true, + "/home/a/.npm/_npx/1a2b/node_modules/@cloverhound/webex-cli-linux-x64/bin/webex": true, + "/home/a/.local/bin/webex": false, + "/opt/node_modules/other/webex": false, + } + for path, want := range cases { + if got := installedByNPM(path); got != want { + t.Errorf("installedByNPM(%q) = %v, want %v", path, got, want) + } + } +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..3e905c2 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "testing" +) + +const sampleChecksums = `0123abcd webex-cli_0.16.0_darwin_amd64.tar.gz +4567ef01 webex-cli_0.16.0_darwin_arm64.tar.gz +89ab2345 webex-cli_0.16.0_linux_amd64.tar.gz +cdef6789 webex-cli_0.16.0_linux_arm64.tar.gz +0a1b2c3d webex-cli_0.16.0_windows_amd64.zip +` + +func TestVersionFromChecksums(t *testing.T) { + for _, tc := range []struct{ goos, goarch, want string }{ + {"linux", "amd64", "0.16.0"}, + {"darwin", "arm64", "0.16.0"}, + {"windows", "amd64", "0.16.0"}, + } { + got, err := versionFromChecksums([]byte(sampleChecksums), tc.goos, tc.goarch) + if err != nil || got != tc.want { + t.Errorf("%s/%s: got %q, %v; want %q", tc.goos, tc.goarch, got, err, tc.want) + } + } + if _, err := versionFromChecksums([]byte(sampleChecksums), "windows", "arm64"); err == nil { + t.Error("windows/arm64: expected an error for a missing archive") + } +} + +func TestChecksumFor(t *testing.T) { + got, err := checksumFor([]byte(sampleChecksums), "webex-cli_0.16.0_linux_amd64.tar.gz") + if err != nil || got != "89ab2345" { + t.Errorf("got %q, %v", got, err) + } + if _, err := checksumFor([]byte(sampleChecksums), "webex-cli_0.16.0_linux_amd64.tar"); err == nil { + t.Error("expected an error for an unlisted name") + } +} + +func TestVerifyChecksum(t *testing.T) { + data := []byte("archive bytes") + sum := sha256.Sum256(data) + good := hex.EncodeToString(sum[:]) + if err := verifyChecksum(data, strings.ToUpper(good)); err != nil { + t.Errorf("valid checksum rejected: %v", err) + } + if err := verifyChecksum([]byte("tampered"), good); err == nil { + t.Error("tampered data accepted") + } +} + +// setReleaseURLs points the release lookups at srv for the duration of a test. +func setReleaseURLs(t *testing.T, srv *httptest.Server) { + t.Helper() + oldRelease, oldProxy, oldAPI := releaseBaseURL, goProxyLatestURL, githubAPILatestURL + releaseBaseURL = srv.URL + "/releases" + goProxyLatestURL = srv.URL + "/proxy/@latest" + githubAPILatestURL = srv.URL + "/api/latest" + t.Cleanup(func() { + releaseBaseURL, goProxyLatestURL, githubAPILatestURL = oldRelease, oldProxy, oldAPI + }) +} + +func TestFetchLatestVersionFallbackChain(t *testing.T) { + archive := archiveName("0.17.0", runtime.GOOS, runtime.GOARCH) + tests := []struct { + name string + handlers map[string]http.HandlerFunc + want string + }{ + { + name: "checksums", + handlers: map[string]http.HandlerFunc{ + "/releases/latest/download/checksums.txt": func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "abc %s\n", archive) + }, + }, + want: "0.17.0", + }, + { + name: "go proxy", + handlers: map[string]http.HandlerFunc{ + "/proxy/@latest": func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Version":"v0.17.1"}`) + }, + }, + want: "0.17.1", + }, + { + name: "github api with token", + handlers: map[string]http.HandlerFunc{ + "/api/latest": func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer gh-test" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + fmt.Fprint(w, `{"tag_name":"v0.17.2"}`) + }, + }, + want: "0.17.2", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + for p, h := range tc.handlers { + mux.HandleFunc(p, h) + } + srv := httptest.NewServer(mux) + defer srv.Close() + setReleaseURLs(t, srv) + t.Setenv("GITHUB_TOKEN", "gh-test") + + got, err := fetchLatestVersion() + if err != nil || got != tc.want { + t.Fatalf("got %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestFetchLatestVersionReportsEveryURL(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + setReleaseURLs(t, srv) + + _, err := fetchLatestVersion() + if err == nil { + t.Fatal("expected an error") + } + for _, want := range []string{"checksums.txt", "/proxy/@latest", "/api/latest", versionPinEnv + "="} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q:\n%v", want, err) + } + } +} + +func TestDownloadReleaseVerifiesChecksum(t *testing.T) { + archive := archiveName("0.17.0", runtime.GOOS, runtime.GOARCH) + payload := []byte("real archive") + sum := sha256.Sum256(payload) + + for _, tc := range []struct { + name string + served []byte + listed bool + wantErr string + }{ + {"match", payload, true, ""}, + {"tampered", []byte("evil archive"), true, "checksum mismatch"}, + {"unlisted", payload, false, "not listed"}, + } { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/releases/download/v0.17.0/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + if tc.listed { + fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(sum[:]), archive) + } + }) + mux.HandleFunc("/releases/download/v0.17.0/"+archive, func(w http.ResponseWriter, r *http.Request) { + w.Write(tc.served) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + setReleaseURLs(t, srv) + + got, err := downloadRelease("0.17.0") + if tc.wantErr == "" { + if err != nil || string(got) != string(payload) { + t.Fatalf("got %q, %v", got, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got err %v, want %q", err, tc.wantErr) + } + }) + } +} + +func TestNewSetupOptions(t *testing.T) { + old := isTerminal + t.Cleanup(func() { isTerminal = old }) + + isTerminal = func(*os.File) bool { return true } + t.Setenv(nonInteractiveEnv, "") + if o := newSetupOptions(false, false); !o.prompt || !o.skills { + t.Errorf("terminal: %+v", o) + } + if o := newSetupOptions(true, true); o.prompt || !o.assumeYes || o.skills { + t.Errorf("--yes --no-skills: %+v", o) + } + t.Setenv(nonInteractiveEnv, "1") + if o := newSetupOptions(false, false); o.prompt { + t.Errorf("%s=1 still prompts", nonInteractiveEnv) + } + + t.Setenv(nonInteractiveEnv, "") + isTerminal = func(*os.File) bool { return false } + if o := newSetupOptions(false, false); o.prompt || o.assumeYes { + t.Errorf("no terminal: %+v", o) + } +} diff --git a/cmd/version.go b/cmd/version.go index c50571c..0fc01bb 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -17,5 +17,7 @@ var versionCmd = &cobra.Command{ } func init() { + rootCmd.Version = Version + rootCmd.SetVersionTemplate("webex-cli {{.Version}}\n") rootCmd.AddCommand(versionCmd) } diff --git a/go.mod b/go.mod index d08bfa1..bac1903 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,18 @@ module github.com/Cloverhound/webex-cli -go 1.26 +go 1.26.0 require ( github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mark3labs/mcp-go v0.50.0 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc + golang.org/x/sys v0.33.0 golang.org/x/term v0.25.0 ) @@ -19,7 +24,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -32,7 +36,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mark3labs/mcp-go v0.50.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -47,6 +50,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.23.0 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 266c02c..4a4c9e9 100644 --- a/go.sum +++ b/go.sum @@ -45,12 +45,18 @@ github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -59,6 +65,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.50.0 h1:kfxh8ejrBCABy5pez+rUYpsSLUKV2Yg38xBtrGRuo4U= @@ -70,6 +80,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -87,6 +99,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -107,6 +121,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc h1:3XmhiDY3RbxvcWtMLw6gtuiFSrjmhqRHiXP/hWNgQ04= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= @@ -123,3 +139,5 @@ golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/install.ps1 b/install.ps1 index b891f02..e938ea0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,11 +1,15 @@ # Webex CLI installer for Windows # Usage: irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex +# Pin a version: $env:WEBEX_CLI_VERSION = "0.16.0" before running $ErrorActionPreference = "Stop" $Repo = "Cloverhound/webex-cli" $Binary = "webex.exe" -$InstallDir = "$env:LOCALAPPDATA\webex-cli" +$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:LOCALAPPDATA\webex-cli" } +$Releases = "https://github.com/$Repo/releases" +$GoProxyLatest = "https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" +$ApiLatest = "https://api.github.com/repos/$Repo/releases/latest" # Detect architecture $Arch = if ([Environment]::Is64BitOperatingSystem) { @@ -15,28 +19,88 @@ $Arch = if ([Environment]::Is64BitOperatingSystem) { return } -# Get latest version -Write-Host "Fetching latest release..." -$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" -$Version = $Release.tag_name -replace '^v', '' -if (-not $Version) { - Write-Error "Could not determine latest version" - return +function Get-Text($Url, $Headers = @{}) { + $resp = Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing + if ($resp.Content -is [byte[]]) { + return [System.Text.Encoding]::UTF8.GetString($resp.Content) + } + return [string]$resp.Content +} + +# The lookup avoids api.github.com where possible: sandboxes and shared CI +# hosts often block it or exhaust its unauthenticated rate limit. +function Get-LatestVersion { + try { + $sums = Get-Text "$Releases/latest/download/checksums.txt" + $m = [regex]::Match($sums, "webex-cli_(\S+)_windows_${Arch}\.zip") + if ($m.Success) { return $m.Groups[1].Value } + } catch {} + try { + $info = (Get-Text $GoProxyLatest) | ConvertFrom-Json + if ($info.Version) { return $info.Version -replace '^v', '' } + } catch {} + try { + $headers = @{} + if ($env:GITHUB_TOKEN) { $headers["Authorization"] = "Bearer $env:GITHUB_TOKEN" } + $release = (Get-Text $ApiLatest $headers) | ConvertFrom-Json + if ($release.tag_name) { return $release.tag_name -replace '^v', '' } + } catch {} + return $null +} + +if ($env:WEBEX_CLI_VERSION) { + $Version = $env:WEBEX_CLI_VERSION -replace '^v', '' + Write-Host "Using pinned version: v$Version" +} else { + Write-Host "Fetching latest release..." + $Version = Get-LatestVersion + if (-not $Version) { + Write-Host "Could not determine the latest version. Tried:" + Write-Host " $Releases/latest/download/checksums.txt" + Write-Host " $GoProxyLatest" + Write-Host " $ApiLatest" + Write-Host "Pin a version instead:" + Write-Host " `$env:WEBEX_CLI_VERSION = `"x.y.z`"; irm https://raw.githubusercontent.com/$Repo/main/install.ps1 | iex" + Write-Error "Could not determine latest version" + return + } + Write-Host "Latest version: v$Version" } -Write-Host "Latest version: v$Version" # Download $ZipName = "webex-cli_${Version}_windows_${Arch}.zip" -$Url = "https://github.com/$Repo/releases/download/v$Version/$ZipName" +$Url = "$Releases/download/v$Version/$ZipName" $TmpDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "webex-cli-install-$(Get-Random)") try { Write-Host "Downloading $Url..." - Invoke-WebRequest -Uri $Url -OutFile (Join-Path $TmpDir $ZipName) + $ZipPath = Join-Path $TmpDir $ZipName + Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing + + # Verify + $Sums = Get-Text "$Releases/download/v$Version/checksums.txt" + $Expected = $null + foreach ($line in $Sums -split "`n") { + $fields = $line.Trim() -split '\s+' + if ($fields.Count -eq 2 -and $fields[1].TrimStart('*') -eq $ZipName) { + $Expected = $fields[0].ToLower() + break + } + } + if (-not $Expected) { + Write-Error "$ZipName is not listed in checksums.txt; refusing to install" + return + } + $Actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLower() + if ($Actual -ne $Expected) { + Write-Error "Checksum mismatch for ${ZipName} (expected $Expected, got $Actual); refusing to install" + return + } + Write-Host "Checksum verified." # Extract - Expand-Archive -Path (Join-Path $TmpDir $ZipName) -DestinationPath $TmpDir -Force + Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force # Install New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null diff --git a/install.sh b/install.sh index 780ef67..e6ab848 100755 --- a/install.sh +++ b/install.sh @@ -3,10 +3,26 @@ set -e # Webex CLI installer # Usage: curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh | sh +# Pin a version: ... | sh -s -- -v 0.16.0 (or WEBEX_CLI_VERSION=0.16.0) +# Install dir: ... | INSTALL_DIR=/usr/local/bin sh REPO="Cloverhound/webex-cli" BINARY="webex" -INSTALL_DIR="${HOME}/.local/bin" +INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/bin}" +RELEASES="https://github.com/${REPO}/releases" +GOPROXY_LATEST="https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" +API_LATEST="https://api.github.com/repos/${REPO}/releases/latest" + +VERSION="${WEBEX_CLI_VERSION:-}" +while [ $# -gt 0 ]; do + case "$1" in + -v|--version) + [ $# -ge 2 ] || { echo "Error: $1 needs a version, e.g. $1 0.16.0"; exit 1; } + VERSION="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done +VERSION="${VERSION#v}" # Detect OS OS=$(uname -s | tr '[:upper:]' '[:lower:]') @@ -24,24 +40,77 @@ case "$ARCH" in *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac -# Get latest version -echo "Fetching latest release..." -VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') -if [ -z "$VERSION" ]; then - echo "Error: could not determine latest version" - exit 1 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The lookup avoids api.github.com where possible: sandboxes and shared CI +# hosts often block it or exhaust its unauthenticated rate limit. +version_from_checksums() { + curl -fsSL "${RELEASES}/latest/download/checksums.txt" 2>/dev/null | + sed -n "s/.*webex-cli_\([^ ]*\)_${OS}_${ARCH}\.tar\.gz\$/\1/p" | head -n 1 +} + +version_from_goproxy() { + curl -fsSL "$GOPROXY_LATEST" 2>/dev/null | + sed -n 's/.*"Version":"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1 +} + +version_from_api() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$API_LATEST" 2>/dev/null + else + curl -fsSL "$API_LATEST" 2>/dev/null + fi | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' | head -n 1 +} + +if [ -n "$VERSION" ]; then + echo "Using pinned version: v${VERSION}" +else + echo "Fetching latest release..." + VERSION=$(version_from_checksums || true) + [ -n "$VERSION" ] || VERSION=$(version_from_goproxy || true) + [ -n "$VERSION" ] || VERSION=$(version_from_api || true) + if [ -z "$VERSION" ]; then + echo "Error: could not determine the latest version. Tried:" + echo " ${RELEASES}/latest/download/checksums.txt" + echo " ${GOPROXY_LATEST}" + echo " ${API_LATEST}" + echo "Pin a version instead:" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | WEBEX_CLI_VERSION=x.y.z sh" + exit 1 + fi + echo "Latest version: v${VERSION}" fi -echo "Latest version: v${VERSION}" # Download TARBALL="${BINARY}-cli_${VERSION}_${OS}_${ARCH}.tar.gz" -URL="https://github.com/${REPO}/releases/download/v${VERSION}/${TARBALL}" - -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +URL="${RELEASES}/download/v${VERSION}/${TARBALL}" echo "Downloading ${URL}..." curl -fsSL "$URL" -o "${TMPDIR}/${TARBALL}" +curl -fsSL "${RELEASES}/download/v${VERSION}/checksums.txt" -o "${TMPDIR}/checksums.txt" + +# Verify +EXPECTED=$(awk -v f="$TARBALL" '$2 == f || $2 == "*" f { print $1; exit }' "${TMPDIR}/checksums.txt") +if [ -z "$EXPECTED" ]; then + echo "Error: ${TARBALL} is not listed in checksums.txt; refusing to install" + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "${TMPDIR}/${TARBALL}" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "${TMPDIR}/${TARBALL}" | awk '{ print $1 }') +else + echo "Error: neither sha256sum nor shasum is available to verify the download" + exit 1 +fi +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "Error: checksum mismatch for ${TARBALL}; refusing to install" + echo " expected: ${EXPECTED}" + echo " actual: ${ACTUAL}" + exit 1 +fi +echo "Checksum verified." # Extract tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR" @@ -54,8 +123,14 @@ chmod +x "${INSTALL_DIR}/${BINARY}" echo "Installed ${BINARY} v${VERSION} to ${INSTALL_DIR}/${BINARY}" echo "" -# Run post-install setup (PATH configuration, agent skills) -"${INSTALL_DIR}/${BINARY}" post-install < /dev/tty +# Post-install prompts need a terminal; agents and CI run this without one, +# where post-install prints the PATH line and installs skills for detected +# agents. A post-install failure must not fail an install that succeeded. +if [ -z "${WEBEX_CLI_NONINTERACTIVE:-}" ] && [ -t 1 ] && [ -r /dev/tty ]; then + "${INSTALL_DIR}/${BINARY}" post-install < /dev/tty || true +else + WEBEX_CLI_NONINTERACTIVE=1 "${INSTALL_DIR}/${BINARY}" post-install < /dev/null || true +fi echo "" echo "Get started:" diff --git a/internal/auth/device.go b/internal/auth/device.go new file mode 100644 index 0000000..b8fdb62 --- /dev/null +++ b/internal/auth/device.go @@ -0,0 +1,201 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Vars rather than consts so tests can point them at a fake server. +var ( + DeviceAuthorizeURL = "https://webexapis.com/v1/device/authorize" + DeviceTokenURL = "https://webexapis.com/v1/device/token" + + pollUnit = time.Second +) + +const deviceRequestTimeout = 30 * time.Second + +var errDeviceCodeExpired = errors.New("the login code expired before it was approved — run webex login again") + +// DeviceHelperRedirectURIs must be registered on the integration: Webex's device +// approval page redirects to one of them, and which one depends on the region. +const DeviceHelperRedirectURIs = "https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback" + +// DeviceCode is the response to a device authorization request. +type DeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// RequestDeviceCode starts the OAuth device grant. +func RequestDeviceCode(clientID, scopes string) (*DeviceCode, error) { + if clientID == "" { + return nil, fmt.Errorf("client ID not configured — run: webex config set client-id ") + } + ctx, cancel := context.WithTimeout(context.Background(), deviceRequestTimeout) + defer cancel() + form := url.Values{"client_id": {clientID}, "scope": {scopes}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DeviceAuthorizeURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("device authorization request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + if strings.Contains(string(body), "redirect_uri") { + return nil, fmt.Errorf("device login failed (%d): the integration is missing a device-grant redirect URI; add %s to it.\n%s", + resp.StatusCode, DeviceHelperRedirectURIs, string(body)) + } + if resp.StatusCode == http.StatusTooManyRequests { + return nil, fmt.Errorf("device login is rate limited (retry after %s seconds)", resp.Header.Get("Retry-After")) + } + return nil, fmt.Errorf("device authorization failed (%d): %s", resp.StatusCode, string(body)) + } + + var dc DeviceCode + if err := json.Unmarshal(body, &dc); err != nil { + return nil, fmt.Errorf("parsing device authorization response: %w", err) + } + if dc.DeviceCode == "" || dc.UserCode == "" { + return nil, fmt.Errorf("device authorization response is missing the device or user code: %s", string(body)) + } + if dc.ExpiresIn <= 0 { + dc.ExpiresIn = 300 + } + if dc.Interval <= 0 { + dc.Interval = 5 + } + return &dc, nil +} + +// PollDeviceToken polls the token endpoint until the user approves the code, +// denies it, or the code expires. It follows RFC 8628: authorization_pending +// keeps polling and slow_down adds 5 seconds to the interval. +func PollDeviceToken(ctx context.Context, clientID, clientSecret string, dc *DeviceCode, scopes string) (*StoredToken, error) { + interval := time.Duration(dc.Interval) * pollUnit + parent := ctx + // Bounding every request by the code's expiry keeps a stalled connection from + // outliving the code. + ctx, cancel := context.WithTimeout(ctx, time.Duration(dc.ExpiresIn)*pollUnit) + defer cancel() + expired := func() error { + if parent.Err() != nil { + return parent.Err() + } + return errDeviceCodeExpired + } + + for { + select { + case <-ctx.Done(): + return nil, expired() + case <-time.After(interval): + } + + tok, status, err := requestDeviceToken(ctx, clientID, clientSecret, dc.DeviceCode, scopes) + if err != nil { + if ctx.Err() != nil { + return nil, expired() + } + return nil, err + } + switch status { + case "": + return tok, nil + case "authorization_pending": + case "slow_down": + interval += 5 * pollUnit + case "access_denied": + return nil, fmt.Errorf("the login request was denied") + case "expired_token", "invalid_grant": + return nil, fmt.Errorf("the login code expired or is no longer valid — run webex login again") + default: + return nil, fmt.Errorf("device login failed: %s", status) + } + } +} + +// requestDeviceToken makes one token request. It returns the token on success, +// or the OAuth error code for a response the caller can act on. +func requestDeviceToken(ctx context.Context, clientID, clientSecret, deviceCode, scopes string) (*StoredToken, string, error) { + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DeviceTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("device token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusOK { + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, "", fmt.Errorf("parsing device token response: %w", err) + } + return tr.storedToken(tr.grantedScopes(scopes)), "", nil + } + + var oauthErr struct { + Error string `json:"error"` + Description string `json:"error_description"` + Message string `json:"message"` + } + _ = json.Unmarshal(body, &oauthErr) + + switch { + case resp.StatusCode == http.StatusPreconditionRequired: + // Webex answers 428 while the user has not approved yet. + return nil, "authorization_pending", nil + case resp.StatusCode == http.StatusTooManyRequests: + return nil, "slow_down", nil + case oauthErr.Error != "": + return nil, oauthErr.Error, nil + } + return nil, "", fmt.Errorf("device token request failed (%d): %s", resp.StatusCode, string(body)) +} + +// DeviceLogin runs the device grant. show is called once with the code for the +// user to approve; the call then blocks until approval, denial or expiry. +func DeviceLogin(clientID, clientSecret, scopes string, show func(*DeviceCode)) (*LoginResult, error) { + if clientSecret == "" { + return nil, fmt.Errorf("client secret not configured — run: webex config set client-secret ") + } + dc, err := RequestDeviceCode(clientID, scopes) + if err != nil { + return nil, err + } + show(dc) + + tok, err := PollDeviceToken(context.Background(), clientID, clientSecret, dc, scopes) + if err != nil { + return nil, err + } + return completeLogin(tok) +} diff --git a/internal/auth/device_test.go b/internal/auth/device_test.go new file mode 100644 index 0000000..bc500d2 --- /dev/null +++ b/internal/auth/device_test.go @@ -0,0 +1,212 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fakeDeviceToken serves the device token endpoint, answering with responses +// in order and repeating the last one. +func fakeDeviceToken(t *testing.T, responses []func(http.ResponseWriter)) (*int32, func()) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "cid" || pass != "secret" { + t.Errorf("basic auth = %q/%q/%v, want cid/secret", user, pass, ok) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" || r.Form.Get("device_code") != "dev" { + t.Errorf("unexpected form %v", r.Form) + } + n := atomic.AddInt32(&calls, 1) + i := int(n) - 1 + if i >= len(responses) { + i = len(responses) - 1 + } + responses[i](w) + })) + old := DeviceTokenURL + DeviceTokenURL = srv.URL + return &calls, func() { DeviceTokenURL = old; srv.Close() } +} + +func respond(status int, body string) func(http.ResponseWriter) { + return func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(body)) + } +} + +var granted = respond(200, `{"access_token":"at","refresh_token":"rt","expires_in":3600,"token_type":"Bearer","scope":"spark:all"}`) + +func testCode(expiresIn int) *DeviceCode { + return &DeviceCode{DeviceCode: "dev", UserCode: "123456", ExpiresIn: expiresIn, Interval: 1} +} + +func TestPollDeviceTokenStopsStalledRequestAtExpiry(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-time.After(5 * time.Second): + } + })) + defer srv.Close() + defer close(release) + old := DeviceTokenURL + DeviceTokenURL = srv.URL + defer func() { DeviceTokenURL = old }() + + start := time.Now() + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(100), "spark:all") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Errorf("err = %v, want the code to expire", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("polling took %s; the stalled request was not cut off at expiry", elapsed) + } +} + +func TestPollDeviceTokenPendingThenGranted(t *testing.T) { + calls, done := fakeDeviceToken(t, []func(http.ResponseWriter){ + respond(428, `{"message":"authorization pending"}`), + respond(400, `{"error":"authorization_pending"}`), + granted, + }) + defer done() + + tok, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "requested") + if err != nil { + t.Fatal(err) + } + if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.Scopes != "spark:all" { + t.Errorf("token = %+v", tok) + } + if *calls != 3 { + t.Errorf("calls = %d, want 3", *calls) + } +} + +func TestPollDeviceTokenSlowDownAddsFiveIntervals(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){ + respond(400, `{"error":"slow_down"}`), + granted, + }) + defer done() + + // With a 1-unit interval and a 5-unit expiry, the poll after slow_down + // (at 1+6 units) lands past the deadline. + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(5), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expiry after slow_down", err) + } +} + +func TestPollDeviceTokenDenied(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(400, `{"error":"access_denied"}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "") + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("err = %v, want denied", err) + } +} + +func TestPollDeviceTokenExpiredGrant(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(400, `{"error":"expired_token"}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expired", err) + } +} + +func TestPollDeviceTokenTimesOut(t *testing.T) { + calls, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(428, `{}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(20), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expiry", err) + } + if *calls == 0 { + t.Error("expected at least one poll before expiry") + } +} + +func TestRequestDeviceCodeRedirectMismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Write([]byte(`{"message":"redirect_uri_mismatch"}`)) + })) + defer srv.Close() + old := DeviceAuthorizeURL + DeviceAuthorizeURL = srv.URL + defer func() { DeviceAuthorizeURL = old }() + + _, err := RequestDeviceCode("cid", "spark:all") + if err == nil || !strings.Contains(err.Error(), "oauth-helper-") { + t.Fatalf("err = %v, want a hint naming the helper redirect URIs", err) + } +} + +func TestRequestDeviceCodeDefaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + if r.Form.Get("client_id") != "cid" || r.Form.Get("scope") != "spark:all" { + t.Errorf("form = %v", r.Form) + } + w.Write([]byte(`{"device_code":"dev","user_code":"123456","verification_uri":"https://v"}`)) + })) + defer srv.Close() + old := DeviceAuthorizeURL + DeviceAuthorizeURL = srv.URL + defer func() { DeviceAuthorizeURL = old }() + + dc, err := RequestDeviceCode("cid", "spark:all") + if err != nil { + t.Fatal(err) + } + if dc.ExpiresIn != 300 || dc.Interval != 5 { + t.Errorf("defaults = %d/%d, want 300/5", dc.ExpiresIn, dc.Interval) + } +} + +func TestHeadlessReason(t *testing.T) { + env := func(kv ...string) func(string) string { + m := map[string]string{} + for i := 0; i < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return func(k string) string { return m[k] } + } + cases := []struct { + name string + getenv func(string) string + goos string + want bool + }{ + {"mac desktop", env(), "darwin", false}, + {"ssh", env("SSH_CONNECTION", "1 2 3 4"), "darwin", true}, + {"ssh tty", env("SSH_TTY", "/dev/pts/0"), "darwin", true}, + {"ci", env("CI", "true"), "windows", true}, + {"linux no display", env(), "linux", true}, + {"linux x11", env("DISPLAY", ":0"), "linux", false}, + {"linux wayland", env("WAYLAND_DISPLAY", "wayland-0"), "linux", false}, + } + for _, c := range cases { + if got := HeadlessReason(c.getenv, c.goos) != ""; got != c.want { + t.Errorf("%s: headless = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/auth/envrefresh.go b/internal/auth/envrefresh.go new file mode 100644 index 0000000..41f472f --- /dev/null +++ b/internal/auth/envrefresh.go @@ -0,0 +1,129 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +// Environment variables for non-interactive auth from a refresh token. +const ( + RefreshTokenEnv = "WEBEX_REFRESH_TOKEN" + ClientIDEnv = "WEBEX_CLIENT_ID" + ClientSecretEnv = "WEBEX_CLIENT_SECRET" +) + +// envRefreshEntry caches the access token minted from $WEBEX_REFRESH_TOKEN, and +// the rotated refresh token, so each CLI call does not mint a new one. +type envRefreshEntry struct { + Token StoredToken `json:"token"` + Email string `json:"email,omitempty"` + OrgID string `json:"org_id,omitempty"` +} + +const envRefreshKeyPrefix = "env-refresh:" + +// envRefreshMemo holds this process's latest entry per key, so a rotated refresh +// token outlives a failed write to the credentials file until the process exits. +var ( + envRefreshMu sync.Mutex + envRefreshMemo = map[string]*envRefreshEntry{} +) + +// The cache is keyed by a hash of the env value so a changed secret starts fresh +// and the original value never lands on disk. +func envRefreshKey(refreshToken string) string { + sum := sha256.Sum256([]byte(refreshToken)) + return envRefreshKeyPrefix + hex.EncodeToString(sum[:8]) +} + +// DeleteEnvRefreshCache removes every cached token minted from $WEBEX_REFRESH_TOKEN. +func DeleteEnvRefreshCache() error { + envRefreshMu.Lock() + clear(envRefreshMemo) + envRefreshMu.Unlock() + return fileDeletePrefix(envRefreshKeyPrefix) +} + +// EnvRefreshAccessToken returns an access token for the refresh token in +// $WEBEX_REFRESH_TOKEN. A cached access token is reused until it expires or +// equals stale, the token the API just rejected. The cache lives in the +// credentials file whatever $WEBEX_TOKEN_STORE says, since CI and containers +// rarely have a keyring. +func EnvRefreshAccessToken(envRefresh, clientID, clientSecret, stale string) (token, email, orgID string, err error) { + key := envRefreshKey(envRefresh) + var entry *envRefreshEntry + + lockErr := withFileLock("refresh.lock", func() error { + cached := loadEnvRefreshEntry(key) + if cached != nil && !cached.Token.IsExpired() && cached.Token.AccessToken != stale { + entry = cached + return nil + } + + refreshToken := envRefresh + if cached != nil && cached.Token.RefreshToken != "" { + refreshToken = cached.Token.RefreshToken + } + tok, err := RefreshAccessToken(clientID, clientSecret, &StoredToken{RefreshToken: refreshToken, IssuedAt: time.Now()}) + if err != nil && refreshToken != envRefresh { + // The cached refresh token may have been revoked while the secret was + // updated in place; the env value is the source of truth. + tok, err = RefreshAccessToken(clientID, clientSecret, &StoredToken{RefreshToken: envRefresh, IssuedAt: time.Now()}) + } + if err != nil { + return fmt.Errorf("$%s refresh failed: %w", RefreshTokenEnv, err) + } + if tok.RefreshToken == "" { + tok.RefreshToken = refreshToken + } + + entry = &envRefreshEntry{Token: *tok} + if cached != nil { + entry.Email, entry.OrgID = cached.Email, cached.OrgID + } + if entry.OrgID == "" { + if id, err := fetchIdentity(tok.AccessToken); err == nil { + entry.Email, entry.OrgID = id.email, id.orgID + } + } + envRefreshMu.Lock() + envRefreshMemo[key] = entry + envRefreshMu.Unlock() + if data, err := json.Marshal(entry); err == nil { + if err := fileSet(key, string(data)); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not cache the token from $%s (%v); the rotated refresh token is lost when this process exits\n", RefreshTokenEnv, err) + } + } + return nil + }) + if lockErr != nil { + return "", "", "", lockErr + } + return entry.Token.AccessToken, entry.Email, entry.OrgID, nil +} + +// loadEnvRefreshEntry returns the newer of the cached entry in the credentials +// file, which other processes may have updated, and this process's own. +func loadEnvRefreshEntry(key string) *envRefreshEntry { + envRefreshMu.Lock() + memo := envRefreshMemo[key] + envRefreshMu.Unlock() + + data, err := fileGet(key) + if err != nil { + return memo + } + var e envRefreshEntry + if json.Unmarshal([]byte(data), &e) != nil { + return memo + } + if memo != nil && memo.Token.IssuedAt.After(e.Token.IssuedAt) { + return memo + } + return &e +} diff --git a/internal/auth/headless.go b/internal/auth/headless.go new file mode 100644 index 0000000..5895845 --- /dev/null +++ b/internal/auth/headless.go @@ -0,0 +1,15 @@ +package auth + +// HeadlessReason returns why this environment probably has no browser for the +// OAuth redirect to reach, or "" when the browser flow should work. +func HeadlessReason(getenv func(string) string, goos string) string { + switch { + case getenv("SSH_CONNECTION") != "" || getenv("SSH_TTY") != "": + return "SSH session" + case getenv("CI") != "": + return "CI environment" + case goos == "linux" && getenv("DISPLAY") == "" && getenv("WAYLAND_DISPLAY") == "": + return "no graphical display" + } + return "" +} diff --git a/internal/auth/keyring.go b/internal/auth/keyring.go deleted file mode 100644 index b4b7b2f..0000000 --- a/internal/auth/keyring.go +++ /dev/null @@ -1,65 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/zalando/go-keyring" -) - -const serviceName = "webex-cli" - -// StoredToken represents OAuth tokens persisted in the OS keyring. -type StoredToken struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresAt time.Time `json:"expires_at"` - TokenType string `json:"token_type"` - IssuedAt time.Time `json:"issued_at"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` - Scopes string `json:"scopes,omitempty"` - ReadOnly bool `json:"read_only,omitempty"` -} - -// IsExpired returns true if the access token is expired or within 60s of expiry. -func (t *StoredToken) IsExpired() bool { - return time.Now().After(t.ExpiresAt.Add(-60 * time.Second)) -} - -// IsRefreshExpired returns true if the refresh token is likely expired (>90 days since issue). -func (t *StoredToken) IsRefreshExpired() bool { - return time.Now().After(t.IssuedAt.Add(90 * 24 * time.Hour)) -} - -// SaveToken stores a token in the OS keyring keyed by email. -func SaveToken(email string, tok *StoredToken) error { - data, err := json.Marshal(tok) - if err != nil { - return fmt.Errorf("marshaling token: %w", err) - } - return keyring.Set(serviceName, email, string(data)) -} - -// LoadToken retrieves a token from the OS keyring for the given email. -func LoadToken(email string) (*StoredToken, error) { - data, err := keyring.Get(serviceName, email) - if err != nil { - return nil, fmt.Errorf("loading token for %s: %w", email, err) - } - var tok StoredToken - if err := json.Unmarshal([]byte(data), &tok); err != nil { - return nil, fmt.Errorf("parsing stored token: %w", err) - } - return &tok, nil -} - -// DeleteToken removes a token from the OS keyring. -func DeleteToken(email string) error { - err := keyring.Delete(serviceName, email) - if err == keyring.ErrNotFound { - return nil - } - return err -} diff --git a/internal/auth/lock.go b/internal/auth/lock.go new file mode 100644 index 0000000..c6319d7 --- /dev/null +++ b/internal/auth/lock.go @@ -0,0 +1,45 @@ +package auth + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +var lockTimeout = 30 * time.Second + +// withFileLock runs fn while holding an exclusive lock on the named file in +// CredentialsDir, so parallel CLI processes serialize token writes and refreshes. +// When locking is impossible (read-only home, no lock support), fn runs anyway, +// since no other process can hold the lock either. When another process holds +// it past the timeout, fn does not run: it could reuse a single-use refresh +// token or overwrite that process's write. +func withFileLock(name string, fn func() error) error { + dir := CredentialsDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return fn() + } + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fn() + } + defer f.Close() + + deadline := time.Now().Add(lockTimeout) + for { + locked, err := tryLock(f) + if err != nil { + return fn() + } + if locked { + break + } + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s waiting for %s; another webex process is still using it", lockTimeout, f.Name()) + } + time.Sleep(50 * time.Millisecond) + } + defer unlock(f) + return fn() +} diff --git a/internal/auth/lock_test.go b/internal/auth/lock_test.go new file mode 100644 index 0000000..d9a6235 --- /dev/null +++ b/internal/auth/lock_test.go @@ -0,0 +1,38 @@ +package auth + +import ( + "testing" + "time" +) + +func TestWithFileLockTimesOutWithoutRunning(t *testing.T) { + old := lockTimeout + lockTimeout = 100 * time.Millisecond + t.Cleanup(func() { lockTimeout = old }) + + held, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + _ = withFileLock("test.lock", func() error { + close(held) + <-release + return nil + }) + }() + <-held + + ran := false + err := withFileLock("test.lock", func() error { + ran = true + return nil + }) + close(release) + <-done + + if err == nil { + t.Error("expected a timeout error while another holder has the lock") + } + if ran { + t.Error("callback ran without the lock") + } +} diff --git a/internal/auth/lock_unix.go b/internal/auth/lock_unix.go new file mode 100644 index 0000000..235c6e5 --- /dev/null +++ b/internal/auth/lock_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package auth + +import ( + "errors" + "os" + "syscall" +) + +func tryLock(f *os.File) (bool, error) { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return true, nil + } + if errors.Is(err, syscall.EWOULDBLOCK) { + return false, nil + } + return false, err +} + +func unlock(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/auth/lock_windows.go b/internal/auth/lock_windows.go new file mode 100644 index 0000000..f85d137 --- /dev/null +++ b/internal/auth/lock_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package auth + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLock(f *os.File) (bool, error) { + ol := new(windows.Overlapped) + err := windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, ol) + if err == nil { + return true, nil + } + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return false, err +} + +func unlock(f *os.File) { + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, new(windows.Overlapped)) +} diff --git a/internal/auth/main_test.go b/internal/auth/main_test.go new file mode 100644 index 0000000..7875e5a --- /dev/null +++ b/internal/auth/main_test.go @@ -0,0 +1,22 @@ +package auth + +import ( + "os" + "testing" + "time" +) + +// Every test gets a scratch credentials directory so none touches the real one. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "webex-auth-test-") + if err != nil { + panic(err) + } + os.Setenv("XDG_CONFIG_HOME", dir) + os.Unsetenv(TokenStoreEnv) + os.Unsetenv(RefreshTokenEnv) + pollUnit = time.Millisecond + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 232a87c..c288814 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -7,22 +7,38 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/url" + "os" "time" "github.com/pkg/browser" ) +// openBrowser is a var so tests can avoid launching a real browser. +var openBrowser = func(u string) error { + // pkg/browser copies the launcher's output to ours, which would corrupt + // JSON on stdout. + browser.Stdout = io.Discard + browser.Stderr = io.Discard + return browser.OpenURL(u) +} + const ( AuthorizeURL = "https://webexapis.com/v1/authorize" - TokenURL = "https://webexapis.com/v1/access_token" RedirectURI = "http://localhost:8085/callback" ) +// Vars rather than consts so tests can point them at a fake server. +var ( + TokenURL = "https://webexapis.com/v1/access_token" + peopleMeURL = "https://webexapis.com/v1/people/me" +) + // LoginResult contains the token and identity info from a successful login. type LoginResult struct { Token StoredToken @@ -32,8 +48,14 @@ type LoginResult struct { OrgName string } +// ErrNoBrowser reports that the browser flow cannot run here: the callback +// port is unavailable or no browser could be opened. +var ErrNoBrowser = errors.New("browser login unavailable") + // Login runs the OAuth PKCE flow: opens browser, waits for callback, exchanges code, fetches identity. -func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { +// With failFast, it returns an error wrapping ErrNoBrowser instead of waiting +// when the browser cannot be opened, so the caller can switch to device login. +func Login(clientID, clientSecret, scopes string, failFast bool) (*LoginResult, error) { if clientID == "" { return nil, fmt.Errorf("client ID not configured — run: webex config set client-id ") } @@ -51,7 +73,7 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { listener, err := net.Listen("tcp", "127.0.0.1:8085") if err != nil { - return nil, fmt.Errorf("starting callback server: %w", err) + return nil, fmt.Errorf("%w: starting callback server: %v", ErrNoBrowser, err) } mux := http.NewServeMux() @@ -94,10 +116,18 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { url.QueryEscape(challenge), ) - fmt.Println("Opening browser for Webex login...") - fmt.Println("If browser doesn't open, visit:") - fmt.Println(authURL) - _ = browser.OpenURL(authURL) + if failFast { + if err := openBrowser(authURL); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNoBrowser, err) + } + fmt.Fprintln(os.Stderr, "Opened browser for Webex login. If it didn't open, visit:") + fmt.Fprintln(os.Stderr, authURL) + } else { + fmt.Fprintln(os.Stderr, "Opening browser for Webex login...") + fmt.Fprintln(os.Stderr, "If browser doesn't open, visit:") + fmt.Fprintln(os.Stderr, authURL) + _ = openBrowser(authURL) + } // Wait for callback var code string @@ -114,8 +144,11 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { if err != nil { return nil, err } + return completeLogin(tok) +} - // Fetch identity +// completeLogin looks up the identity and org behind a newly issued token. +func completeLogin(tok *StoredToken) (*LoginResult, error) { identity, err := fetchIdentity(tok.AccessToken) if err != nil { return nil, fmt.Errorf("fetching identity: %w", err) @@ -167,18 +200,11 @@ func RefreshAccessToken(clientID, clientSecret string, tok *StoredToken) (*Store // A refresh keeps the original grant, so the scopes and the per-account // credentials carry over unchanged. - now := time.Now() - return &StoredToken{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - TokenType: tokenResp.TokenType, - IssuedAt: now, - ClientID: tok.ClientID, - ClientSecret: tok.ClientSecret, - Scopes: tok.Scopes, - ReadOnly: tok.ReadOnly, - }, nil + refreshed := tokenResp.storedToken(tok.Scopes) + refreshed.ClientID = tok.ClientID + refreshed.ClientSecret = tok.ClientSecret + refreshed.ReadOnly = tok.ReadOnly + return refreshed, nil } type tokenResponse struct { @@ -189,6 +215,28 @@ type tokenResponse struct { Scope string `json:"scope"` } +// grantedScopes prefers the scopes Webex reports granting and otherwise +// records the requested ones. +func (tr tokenResponse) grantedScopes(requested string) string { + if tr.Scope != "" { + return tr.Scope + } + return requested +} + +// storedToken converts a token response issued now. +func (tr tokenResponse) storedToken(scopes string) *StoredToken { + now := time.Now() + return &StoredToken{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresAt: now.Add(time.Duration(tr.ExpiresIn) * time.Second), + TokenType: tr.TokenType, + IssuedAt: now, + Scopes: scopes, + } +} + func exchangeCode(clientID, clientSecret, code, verifier, scopes string) (*StoredToken, error) { form := url.Values{ "grant_type": {"authorization_code"}, @@ -214,20 +262,7 @@ func exchangeCode(clientID, clientSecret, code, verifier, scopes string) (*Store if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } - // Prefer the granted scopes when Webex reports them; otherwise record the request. - if tokenResp.Scope != "" { - scopes = tokenResp.Scope - } - - now := time.Now() - return &StoredToken{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - TokenType: tokenResp.TokenType, - IssuedAt: now, - Scopes: scopes, - }, nil + return tokenResp.storedToken(tokenResp.grantedScopes(scopes)), nil } type identityInfo struct { @@ -237,7 +272,7 @@ type identityInfo struct { } func fetchIdentity(accessToken string) (*identityInfo, error) { - req, _ := http.NewRequest("GET", "https://webexapis.com/v1/people/me", nil) + req, _ := http.NewRequest("GET", peopleMeURL, nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := http.DefaultClient.Do(req) @@ -311,4 +346,3 @@ func generateState() string { rand.Read(buf) return hex.EncodeToString(buf) } - diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index 583bee7..afb1849 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -6,38 +6,74 @@ import ( "sort" "github.com/Cloverhound/webex-cli/internal/appconfig" - "github.com/zalando/go-keyring" ) -// HasReadOnlyToken reports whether the keyring holds a read-only token for email. +// HasReadOnlyToken reports whether the token store holds a read-only token for email. func HasReadOnlyToken(email string) bool { tok, err := LoadToken(email) return err == nil && tok.ReadOnly } // PurgeWriteTokens deletes every stored token that is not read-only and removes -// its user from cfg. It returns the removed emails, sorted. Read-only mode -// depends on this: the keyring is readable by any process running as the user, -// so a write-capable token left there would bypass the CLI's checks. An entry -// that cannot be read or decoded is deleted too, since it may hold write access. +// users left without a login from cfg. It returns the removed emails, sorted. +// Read-only mode depends on this: the keyring and credentials file are readable +// by any process running as the user, so a write-capable token left in either +// would bypass the CLI's checks. Both stores are checked whatever +// $WEBEX_TOKEN_STORE selects, and a keyring that cannot be read is a failure +// unless that setting is "file". An entry that cannot be decoded is deleted too, +// since it may hold write access, and so is the $WEBEX_REFRESH_TOKEN cache, +// which read-only mode never uses. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string + var firstErr error for _, email := range cfg.UserEmails() { - tok, err := LoadToken(email) - if errors.Is(err, keyring.ErrNotFound) || (err == nil && tok.ReadOnly) { + copies, err := readCopies(email, []string{StoreKeyring, StoreFile}) + if errors.Is(err, errKeyringUnreadable) && keyringOptional() { + err = nil + } + if err != nil && !errors.Is(err, ErrTokenNotFound) { + failed = append(failed, email) + if firstErr == nil { + firstErr = err + } + continue + } + if len(copies) == 0 { continue } - if err := DeleteToken(email); err != nil { + kept, ok := 0, true + for _, c := range copies { + if tok, err := decodeToken(c.data); err == nil && tok.ReadOnly { + kept++ + continue + } + if err := deleteFrom(c.store, email); err != nil { + ok = false + if firstErr == nil { + firstErr = err + } + } + } + if !ok { failed = append(failed, email) continue } - cfg.RemoveUser(email) - removed = append(removed, email) + if kept == 0 { + cfg.RemoveUser(email) + removed = append(removed, email) + } } sort.Strings(removed) if len(failed) > 0 { sort.Strings(failed) - return removed, fmt.Errorf("could not remove write-capable tokens for %v from the keyring; remove them manually", failed) + hint := "remove them manually" + if errors.Is(firstErr, errKeyringUnreadable) { + hint += fmt.Sprintf(", or set $%s=file if this system has no keyring", TokenStoreEnv) + } + return removed, fmt.Errorf("could not remove write-capable tokens for %v from the token store (%v); %s", failed, firstErr, hint) + } + if err := DeleteEnvRefreshCache(); err != nil { + return removed, fmt.Errorf("could not remove cached $%s tokens from %s: %w", RefreshTokenEnv, CredentialsPath(), err) } return removed, nil } diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go index 102d0e7..fed121b 100644 --- a/internal/auth/readonly_test.go +++ b/internal/auth/readonly_test.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "os" "strings" "testing" "time" @@ -84,6 +85,52 @@ func TestPurgeWriteTokensDeletesUnreadableEntries(t *testing.T) { } } +func TestPurgeWriteTokensChecksEveryStore(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + t.Setenv(TokenStoreEnv, StoreFile) + if err := keyring.Set(serviceName, "ro@example.com", `{"access_token":"write"}`); err != nil { + t.Fatal(err) + } + saveTestToken(t, "ro@example.com", true) + cfg := testConfig("ro@example.com") + + if _, err := PurgeWriteTokens(cfg); err != nil { + t.Fatal(err) + } + if _, err := keyring.Get(serviceName, "ro@example.com"); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("write-capable keyring copy survived: %v", err) + } + if !HasReadOnlyToken("ro@example.com") { + t.Error("read-only file copy was removed") + } + if _, ok := cfg.Users["ro@example.com"]; !ok { + t.Error("user with a read-only login was removed from config") + } +} + +func TestPurgeWriteTokensDeletesEnvRefreshCache(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + cached := envRefreshKey("env-rt") + if err := fileSet(cached, `{"token":{"refresh_token":"rotated"}}`); err != nil { + t.Fatal(err) + } + if err := fileSet("file@example.com", `{"read_only":true}`); err != nil { + t.Fatal(err) + } + + if _, err := PurgeWriteTokens(testConfig("file@example.com")); err != nil { + t.Fatal(err) + } + if _, err := fileGet(cached); !errors.Is(err, ErrTokenNotFound) { + t.Errorf("env refresh cache still in the credentials file: %v", err) + } + if !HasReadOnlyToken("file@example.com") { + t.Error("read-only login in the credentials file was removed") + } +} + func TestPurgeWriteTokensReportsKeyringErrors(t *testing.T) { keyring.MockInitWithError(errors.New("keychain locked")) defer keyring.MockInit() @@ -104,6 +151,31 @@ func TestPurgeWriteTokensReportsKeyringErrors(t *testing.T) { } } +func TestPurgeWriteTokensReportsKeyringErrorsBesideFileCopy(t *testing.T) { + keyring.MockInitWithError(errors.New("keychain locked")) + defer keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + if err := fileSet("ro@example.com", `{"read_only":true}`); err != nil { + t.Fatal(err) + } + + _, err := PurgeWriteTokens(testConfig("ro@example.com")) + if err == nil || !strings.Contains(err.Error(), "keychain locked") { + t.Errorf("err = %v, want the keyring error although the file holds a copy", err) + } +} + +func TestPurgeWriteTokensIgnoresMissingKeyringWithFileStore(t *testing.T) { + noKeyring(t) + defer keyring.MockInit() + t.Setenv(TokenStoreEnv, StoreFile) + saveTestToken(t, "ro@example.com", true) + + if _, err := PurgeWriteTokens(testConfig("ro@example.com", "gone@example.com")); err != nil { + t.Errorf("purge failed with $%s=file and no keyring: %v", TokenStoreEnv, err) + } +} + func TestResolveTokenReadOnly(t *testing.T) { keyring.MockInit() cfg := testConfig("ro@example.com", "rw@example.com") diff --git a/internal/auth/resolve.go b/internal/auth/resolve.go index 5b066ce..99f1366 100644 --- a/internal/auth/resolve.go +++ b/internal/auth/resolve.go @@ -2,6 +2,8 @@ package auth import ( "fmt" + "os" + "sync" "github.com/Cloverhound/webex-cli/internal/appconfig" ) @@ -10,10 +12,11 @@ import ( type TokenSource int const ( - SourceNone TokenSource = iota - SourceFlag // --token flag - SourceEnv // $WEBEX_TOKEN - SourceKeyring // OS keyring + SourceNone TokenSource = iota + SourceFlag // --token flag + SourceEnv // $WEBEX_TOKEN + SourceStored // stored login (OS keyring or credentials file) + SourceEnvRefresh // $WEBEX_REFRESH_TOKEN ) func (s TokenSource) String() string { @@ -22,13 +25,25 @@ func (s TokenSource) String() string { return "flag" case SourceEnv: return "environment" - case SourceKeyring: - return "keyring" + case SourceStored: + return "stored login" + case SourceEnvRefresh: + return "environment refresh token" default: return "none" } } +// EnvCredentials returns the client credentials for $WEBEX_REFRESH_TOKEN: +// $WEBEX_CLIENT_ID and $WEBEX_CLIENT_SECRET when set, else the configured ones. +func EnvCredentials(cfg *appconfig.Config) (clientID, clientSecret string) { + clientID, clientSecret = os.Getenv(ClientIDEnv), os.Getenv(ClientSecretEnv) + if clientID == "" { + return cfg.EffectiveClientID(), cfg.EffectiveClientSecret() + } + return clientID, clientSecret +} + // ResolveResult contains the resolved token and metadata. type ResolveResult struct { Token string @@ -40,14 +55,17 @@ type ResolveResult struct { // ResolveToken determines the access token using the priority chain: // 1. --token flag // 2. $WEBEX_TOKEN env -// 3. Keyring (resolve user from --user flag → $WEBEX_USER → config default) +// 3. $WEBEX_REFRESH_TOKEN env +// 4. Stored login (resolve user from --user flag → $WEBEX_USER → config default) // -// In read-only mode only keyring tokens from `webex login --read-only` are accepted. +// In read-only mode only stored tokens from `webex login --read-only` are accepted. func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig.Config, readOnly bool) (*ResolveResult, error) { + envRefresh := os.Getenv(RefreshTokenEnv) + // Webex has no public token introspection, so the scopes of a token the CLI // did not obtain itself cannot be checked. - if readOnly && (flagToken != "" || envToken != "") { - return nil, fmt.Errorf("read-only mode does not accept --token or $WEBEX_TOKEN; use a stored read-only login") + if readOnly && (flagToken != "" || envToken != "" || envRefresh != "") { + return nil, fmt.Errorf("read-only mode does not accept --token, $WEBEX_TOKEN or $%s; use a stored read-only login", RefreshTokenEnv) } // 1. Explicit --token flag @@ -60,7 +78,17 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. return &ResolveResult{Token: envToken, Source: SourceEnv}, nil } - // 3. Keyring lookup + // 3. Refresh token from the environment + if envRefresh != "" { + clientID, clientSecret := EnvCredentials(cfg) + token, email, orgID, err := EnvRefreshAccessToken(envRefresh, clientID, clientSecret, "") + if err != nil { + return nil, err + } + return &ResolveResult{Token: token, Source: SourceEnvRefresh, UserEmail: email, OrgID: orgID}, nil + } + + // 4. Stored login email := userFlag if email == "" { email = envUser @@ -86,16 +114,11 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. // Auto-refresh if expired if tok.IsExpired() { - clientID, clientSecret := effectiveCredentials(tok, cfg) - refreshed, err := RefreshAccessToken(clientID, clientSecret, tok) + refreshed, err := refreshStored(email, cfg, tok.AccessToken) if err != nil { return nil, fmt.Errorf("token expired for %s and refresh failed: %w\nRun: webex login", email, err) } tok = refreshed - // Persist the refreshed token - if saveErr := SaveToken(email, tok); saveErr != nil { - fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr) - } } // Determine org ID from config @@ -106,29 +129,71 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. return &ResolveResult{ Token: tok.AccessToken, - Source: SourceKeyring, + Source: SourceStored, UserEmail: email, OrgID: orgID, }, nil } // MakeRefresher returns a callback that refreshes the token for the given user. -func MakeRefresher(email string, cfg *appconfig.Config) func() (string, error) { +// current is the access token in use; the callback is called after the API rejects it. +func MakeRefresher(email, current string, cfg *appconfig.Config) func() (string, error) { + var mu sync.Mutex return func() (string, error) { - tok, err := LoadToken(email) + mu.Lock() + defer mu.Unlock() + refreshed, err := refreshStored(email, cfg, current) + if err != nil { + return "", err + } + current = refreshed.AccessToken + return current, nil + } +} + +// MakeEnvRefresher is MakeRefresher for a token minted from $WEBEX_REFRESH_TOKEN. +func MakeEnvRefresher(current string, cfg *appconfig.Config) func() (string, error) { + var mu sync.Mutex + return func() (string, error) { + mu.Lock() + defer mu.Unlock() + clientID, clientSecret := EnvCredentials(cfg) + token, _, _, err := EnvRefreshAccessToken(os.Getenv(RefreshTokenEnv), clientID, clientSecret, current) if err != nil { return "", err } + current = token + return current, nil + } +} + +// refreshStored refreshes the stored token for email under a lock shared by all +// CLI processes. Webex refresh tokens may be single-use, so when another process +// has already refreshed (the stored access token is no longer stale), its +// result is reused instead of refreshing again. +func refreshStored(email string, cfg *appconfig.Config, stale string) (*StoredToken, error) { + var out *StoredToken + err := withFileLock("refresh.lock", func() error { + tok, err := LoadToken(email) + if err != nil { + return err + } + if !tok.IsExpired() && tok.AccessToken != stale { + out = tok + return nil + } clientID, clientSecret := effectiveCredentials(tok, cfg) refreshed, err := RefreshAccessToken(clientID, clientSecret, tok) if err != nil { - return "", err + return err } if saveErr := SaveToken(email, refreshed); saveErr != nil { - fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr) + fmt.Fprintf(os.Stderr, "Warning: could not save refreshed token: %v\n", saveErr) } - return refreshed.AccessToken, nil - } + out = refreshed + return nil + }) + return out, err } // effectiveCredentials returns the client ID and secret to use for token refresh. diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..3dfa1b7 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,323 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/zalando/go-keyring" +) + +// TokenStoreEnv forces a token store: "keyring" or "file". Unset, the keyring +// is used and the credentials file is the fallback when the keyring fails. +const TokenStoreEnv = "WEBEX_TOKEN_STORE" + +const ( + StoreKeyring = "keyring" + StoreFile = "file" +) + +// ErrTokenNotFound means no store holds a token for the key. +var ErrTokenNotFound = errors.New("token not found") + +// forcedStore returns the store named by $WEBEX_TOKEN_STORE, or "" for automatic selection. +func forcedStore() (string, error) { + switch v := strings.ToLower(strings.TrimSpace(os.Getenv(TokenStoreEnv))); v { + case "", "auto": + return "", nil + case StoreKeyring, StoreFile: + return v, nil + default: + return "", fmt.Errorf("$%s must be %q or %q, not %q", TokenStoreEnv, StoreKeyring, StoreFile, v) + } +} + +// selectedStores returns the stores $WEBEX_TOKEN_STORE allows, keyring first. +func selectedStores() ([]string, error) { + forced, err := forcedStore() + if err != nil { + return nil, err + } + if forced != "" { + return []string{forced}, nil + } + return []string{StoreKeyring, StoreFile}, nil +} + +// storedCopy is the value one store holds for a key. +type storedCopy struct { + store string + data string +} + +// errKeyringUnreadable means the keyring failed for a reason other than a +// missing entry, so it may still hold the key. +var errKeyringUnreadable = errors.New("the keyring could not be read") + +// readCopies returns the copies of key held by each of stores. A missing entry +// is not an error, and no copies at all is ErrTokenNotFound. A keyring failure +// is returned as errKeyringUnreadable together with any copies the file holds: +// loading can use those, but read-only purging must not assume the keyring is empty. +func readCopies(key string, stores []string) ([]storedCopy, error) { + var copies []storedCopy + var kerr error + for _, store := range stores { + var data string + var err error + if store == StoreKeyring { + data, err = keyring.Get(serviceName, key) + if errors.Is(err, keyring.ErrNotFound) { + continue + } + if err != nil { + kerr = fmt.Errorf("%w: %w", errKeyringUnreadable, err) + continue + } + } else { + data, err = fileGet(key) + if errors.Is(err, ErrTokenNotFound) { + continue + } + if err != nil { + return nil, err + } + } + copies = append(copies, storedCopy{store: store, data: data}) + } + if kerr != nil { + return copies, kerr + } + if len(copies) == 0 { + return nil, ErrTokenNotFound + } + return copies, nil +} + +// keyringOptional reports whether keyring failures can be ignored when cleaning +// up: with $WEBEX_TOKEN_STORE=file the user has said this host's keyring is not +// in use, and on a host without one every keyring call fails. +func keyringOptional() bool { + forced, err := forcedStore() + return err == nil && forced == StoreFile +} + +// deleteFrom removes key from one store. +func deleteFrom(store, key string) error { + if store == StoreFile { + return fileDelete(key) + } + if err := keyring.Delete(serviceName, key); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err + } + return nil +} + +// storeSet saves data and returns the store that now holds it. +func storeSet(key, data string) (string, error) { + forced, err := forcedStore() + if err != nil { + return "", err + } + if forced == StoreFile { + return StoreFile, fileSetUser(key, data) + } + kerr := keyring.Set(serviceName, key, data) + if kerr == nil { + // A copy left in the file from an earlier fallback would shadow nothing + // but would keep a stale secret on disk. + _ = fileDelete(key) + return StoreKeyring, nil + } + if forced == StoreKeyring { + return "", kerr + } + if err := fileSetUser(key, data); err != nil { + return "", fmt.Errorf("keyring unavailable (%v) and file store failed: %w", kerr, err) + } + // An older keyring entry would be a stale secret. Loading prefers the newest + // copy, so this matters only for cleanup and may fail while the keyring is down. + _ = keyring.Delete(serviceName, key) + return StoreFile, nil +} + +// storeDelete removes key from both stores whatever $WEBEX_TOKEN_STORE says, +// since a copy saved under an earlier setting stays loadable. +func storeDelete(key string) error { + forced, err := forcedStore() + if err != nil { + return err + } + kerr := deleteFrom(StoreKeyring, key) + hadFile := false + if _, err := fileGet(key); err == nil { + hadFile = true + } + if err := fileDelete(key); err != nil { + return err + } + // Without a keyring, every delete fails there; that only matters when the + // token was not in the file. + if forced == StoreFile || (hadFile && forced != StoreKeyring) { + return nil + } + return kerr +} + +// CredentialsDir returns the directory for the credentials file and lock files: +// $XDG_CONFIG_HOME/webex-cli, %APPDATA%\webex-cli on Windows, or ~/.config/webex-cli. +func CredentialsDir() string { + if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { + return filepath.Join(d, "webex-cli") + } + if runtime.GOOS == "windows" { + if d := os.Getenv("APPDATA"); d != "" { + return filepath.Join(d, "webex-cli") + } + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "webex-cli") +} + +// CredentialsPath returns the path of the plain-text token file. +func CredentialsPath() string { + return filepath.Join(CredentialsDir(), "credentials.json") +} + +type credentialsFile struct { + Tokens map[string]string `json:"tokens"` +} + +func readCredentials() (*credentialsFile, error) { + cf := &credentialsFile{Tokens: map[string]string{}} + data, err := os.ReadFile(CredentialsPath()) + if err != nil { + if os.IsNotExist(err) { + return cf, nil + } + return nil, err + } + if err := json.Unmarshal(data, cf); err != nil { + return nil, fmt.Errorf("parsing %s: %w", CredentialsPath(), err) + } + if cf.Tokens == nil { + cf.Tokens = map[string]string{} + } + return cf, nil +} + +func fileGet(key string) (string, error) { + cf, err := readCredentials() + if err != nil { + return "", err + } + data, ok := cf.Tokens[key] + if !ok { + return "", ErrTokenNotFound + } + return data, nil +} + +// fileDeletePrefix removes every file entry whose key starts with prefix. +func fileDeletePrefix(prefix string) error { + if _, err := os.Stat(CredentialsPath()); os.IsNotExist(err) { + return nil + } + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + n := len(cf.Tokens) + for key := range cf.Tokens { + if strings.HasPrefix(key, prefix) { + delete(cf.Tokens, key) + } + } + if len(cf.Tokens) == n { + return nil + } + return writeCredentials(cf) + }) +} + +func fileSet(key, data string) error { + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + cf.Tokens[key] = data + return writeCredentials(cf) + }) +} + +// fileSetUser saves a login to the file and warns the first time the file is +// created, since it holds tokens in plain text. +func fileSetUser(key, data string) error { + _, statErr := os.Stat(CredentialsPath()) + if err := fileSet(key, data); err != nil { + return err + } + if os.IsNotExist(statErr) { + fmt.Fprintf(os.Stderr, "Warning: tokens are stored in plain text at %s (mode 0600)\n", CredentialsPath()) + } + return nil +} + +func fileDelete(key string) error { + if _, err := os.Stat(CredentialsPath()); os.IsNotExist(err) { + return nil + } + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + if _, ok := cf.Tokens[key]; !ok { + return nil + } + delete(cf.Tokens, key) + return writeCredentials(cf) + }) +} + +// writeCredentials replaces the file through a rename so a reader never sees a +// partial write. +func writeCredentials(cf *credentialsFile) error { + dir := CredentialsDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + data, err := json.MarshalIndent(cf, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "credentials-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if err := tmp.Chmod(0600); err != nil && runtime.GOOS != "windows" { + tmp.Close() + os.Remove(tmpPath) + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, CredentialsPath()); err != nil { + os.Remove(tmpPath) + return err + } + return nil +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 0000000..d0050f8 --- /dev/null +++ b/internal/auth/store_test.go @@ -0,0 +1,291 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/zalando/go-keyring" +) + +// noKeyring simulates a host without a keyring service, such as Linux without D-Bus. +func noKeyring(t *testing.T) { + t.Helper() + keyring.MockInitWithError(errors.New("dbus: no session bus")) + t.Cleanup(func() { os.Remove(CredentialsPath()) }) +} + +func TestStoreFallsBackToFile(t *testing.T) { + noKeyring(t) + + tok := &StoredToken{AccessToken: "at", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)} + if err := SaveToken("a@example.com", tok); err != nil { + t.Fatal(err) + } + got, store, err := LoadTokenWithStore("a@example.com") + if err != nil { + t.Fatal(err) + } + if store != StoreFile || got.AccessToken != "at" { + t.Errorf("store=%s token=%+v", store, got) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(CredentialsPath()) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("credentials mode = %o, want 600", perm) + } + dirInfo, _ := os.Stat(CredentialsDir()) + if perm := dirInfo.Mode().Perm(); perm != 0700 { + t.Errorf("credentials dir mode = %o, want 700", perm) + } + } + + if err := DeleteToken("a@example.com"); err != nil { + t.Fatalf("delete with no keyring: %v", err) + } + if _, err := LoadToken("a@example.com"); err == nil { + t.Error("token still loadable after delete") + } +} + +func TestStoreForcedKeyringDoesNotFallBack(t *testing.T) { + noKeyring(t) + t.Setenv(TokenStoreEnv, "keyring") + + if err := SaveToken("a@example.com", &StoredToken{AccessToken: "at"}); err == nil { + t.Fatal("expected keyring error with WEBEX_TOKEN_STORE=keyring") + } + if _, err := os.Stat(CredentialsPath()); !os.IsNotExist(err) { + t.Error("credentials file written despite WEBEX_TOKEN_STORE=keyring") + } +} + +func TestStoreForcedFileSkipsKeyring(t *testing.T) { + keyring.MockInit() + t.Setenv(TokenStoreEnv, "file") + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + + if err := SaveToken("a@example.com", &StoredToken{AccessToken: "at"}); err != nil { + t.Fatal(err) + } + if _, err := keyring.Get(serviceName, "a@example.com"); err == nil { + t.Error("token written to keyring despite WEBEX_TOKEN_STORE=file") + } + if _, store, err := LoadTokenWithStore("a@example.com"); err != nil || store != StoreFile { + t.Errorf("store=%s err=%v", store, err) + } +} + +func TestDeleteTokenRemovesEveryCopy(t *testing.T) { + for _, forced := range []string{"", StoreKeyring, StoreFile} { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + t.Setenv(TokenStoreEnv, forced) + if err := keyring.Set(serviceName, "a@example.com", `{"access_token":"k"}`); err != nil { + t.Fatal(err) + } + if err := fileSet("a@example.com", `{"access_token":"f"}`); err != nil { + t.Fatal(err) + } + + if err := DeleteToken("a@example.com"); err != nil { + t.Fatalf("store %q: %v", forced, err) + } + if _, err := keyring.Get(serviceName, "a@example.com"); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("store %q: keyring copy survived: %v", forced, err) + } + if _, err := fileGet("a@example.com"); !errors.Is(err, ErrTokenNotFound) { + t.Errorf("store %q: file copy survived: %v", forced, err) + } + } +} + +func TestStoreRejectsUnknownStore(t *testing.T) { + t.Setenv(TokenStoreEnv, "vault") + if err := SaveToken("a@example.com", &StoredToken{}); err == nil { + t.Error("expected error for unknown WEBEX_TOKEN_STORE") + } +} + +// fakeTokenEndpoint counts refresh requests and answers each with a fresh token. +func fakeTokenEndpoint(t *testing.T) *int32 { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + n := atomic.AddInt32(&calls, 1) + r.ParseForm() + if r.Form.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %q", r.Form.Get("grant_type")) + } + // Hold the response so parallel callers overlap. + time.Sleep(50 * time.Millisecond) + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-" + string(rune('0'+n)), + "refresh_token": "rt-" + string(rune('0'+n)), + "expires_in": 3600, + }) + case "/me": + w.Write([]byte(`{"emails":["svc@example.com"],"orgId":"org-1"}`)) + } + })) + oldToken, oldMe := TokenURL, peopleMeURL + TokenURL, peopleMeURL = srv.URL+"/token", srv.URL+"/me" + t.Cleanup(func() { TokenURL, peopleMeURL = oldToken, oldMe; srv.Close() }) + return &calls +} + +func TestParallelRefreshRefreshesOnce(t *testing.T) { + noKeyring(t) + calls := fakeTokenEndpoint(t) + + expired := &StoredToken{AccessToken: "old", RefreshToken: "rt-0", ExpiresAt: time.Now().Add(-time.Hour), IssuedAt: time.Now()} + if err := SaveToken("a@example.com", expired); err != nil { + t.Fatal(err) + } + cfg := testConfig("a@example.com") + + var wg sync.WaitGroup + results := make([]string, 4) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + tok, err := refreshStored("a@example.com", cfg, "old") + if err != nil { + t.Error(err) + return + } + results[i] = tok.AccessToken + }(i) + } + wg.Wait() + + if *calls != 1 { + t.Errorf("refresh requests = %d, want 1", *calls) + } + for _, r := range results { + if r != "at-1" { + t.Errorf("results = %v, want all at-1", results) + break + } + } + stored, _ := LoadToken("a@example.com") + if stored.RefreshToken != "rt-1" { + t.Errorf("stored refresh token = %q, want rt-1", stored.RefreshToken) + } +} + +func TestLoadTokenPrefersNewestCopy(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + encode := func(at string, issued time.Time) string { + data, _ := json.Marshal(StoredToken{AccessToken: at, IssuedAt: issued}) + return string(data) + } + if err := keyring.Set(serviceName, "a@example.com", encode("stale", time.Now().Add(-time.Hour))); err != nil { + t.Fatal(err) + } + if err := fileSet("a@example.com", encode("fresh", time.Now())); err != nil { + t.Fatal(err) + } + + got, store, err := LoadTokenWithStore("a@example.com") + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "fresh" || store != StoreFile { + t.Errorf("loaded %q from %s, want fresh from file", got.AccessToken, store) + } +} + +func TestEnvRefreshKeepsRotatedTokenWithoutCache(t *testing.T) { + keyring.MockInit() + // A regular file where the credentials directory belongs makes every write fail. + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, nil, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", blocker) + + var mu sync.Mutex + valid := map[string]bool{"env-rt-nocache": true} + issued := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/me" { + w.Write([]byte(`{"emails":["svc@example.com"],"orgId":"org-1"}`)) + return + } + r.ParseForm() + mu.Lock() + defer mu.Unlock() + rt := r.Form.Get("refresh_token") + if !valid[rt] { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + // Webex rotates the refresh token, and the old one stops working. + delete(valid, rt) + issued++ + next := fmt.Sprintf("rt-%d", issued) + valid[next] = true + json.NewEncoder(w).Encode(map[string]any{ + "access_token": fmt.Sprintf("at-%d", issued), "refresh_token": next, "expires_in": 3600, + }) + })) + oldToken, oldMe := TokenURL, peopleMeURL + TokenURL, peopleMeURL = srv.URL+"/token", srv.URL+"/me" + t.Cleanup(func() { TokenURL, peopleMeURL = oldToken, oldMe; srv.Close() }) + + tok, _, _, err := EnvRefreshAccessToken("env-rt-nocache", "cid", "secret", "") + if err != nil || tok != "at-1" { + t.Fatalf("first refresh: tok=%s err=%v", tok, err) + } + tok, _, _, err = EnvRefreshAccessToken("env-rt-nocache", "cid", "secret", "at-1") + if err != nil || tok != "at-2" { + t.Fatalf("refresh after a 401: tok=%s err=%v; want at-2 from the rotated refresh token", tok, err) + } +} + +func TestEnvRefreshCachesAndRotates(t *testing.T) { + noKeyring(t) + calls := fakeTokenEndpoint(t) + + tok, email, org, err := EnvRefreshAccessToken("env-rt", "cid", "secret", "") + if err != nil { + t.Fatal(err) + } + if tok != "at-1" || email != "svc@example.com" || org != "org-1" { + t.Errorf("got %s %s %s", tok, email, org) + } + + // A second process reuses the cached access token. + tok, _, _, err = EnvRefreshAccessToken("env-rt", "cid", "secret", "") + if err != nil || tok != "at-1" || *calls != 1 { + t.Fatalf("second call: tok=%s calls=%d err=%v", tok, *calls, err) + } + + // After a 401, it refreshes with the rotated token, not the env value. + tok, _, org, err = EnvRefreshAccessToken("env-rt", "cid", "secret", "at-1") + if err != nil || tok != "at-2" || org != "org-1" { + t.Fatalf("forced refresh: tok=%s org=%s err=%v", tok, org, err) + } + entry := loadEnvRefreshEntry(envRefreshKey("env-rt")) + if entry == nil || entry.Token.RefreshToken != "rt-2" { + t.Errorf("cached entry = %+v, want rotated refresh token rt-2", entry) + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..365a7e7 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,99 @@ +package auth + +import ( + "encoding/json" + "fmt" + "time" +) + +const serviceName = "webex-cli" + +// StoredToken represents OAuth tokens persisted in the token store. +type StoredToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + TokenType string `json:"token_type"` + IssuedAt time.Time `json:"issued_at"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + Scopes string `json:"scopes,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` +} + +// IsExpired returns true if the access token is expired or within 60s of expiry. +func (t *StoredToken) IsExpired() bool { + return time.Now().After(t.ExpiresAt.Add(-60 * time.Second)) +} + +// IsRefreshExpired returns true if the refresh token is likely expired (>90 days since issue). +func (t *StoredToken) IsRefreshExpired() bool { + return time.Now().After(t.IssuedAt.Add(90 * 24 * time.Hour)) +} + +// SaveToken stores a token keyed by email, in the OS keyring when one is +// available and in the credentials file otherwise. +func SaveToken(email string, tok *StoredToken) error { + _, err := SaveTokenWithStore(email, tok) + return err +} + +// SaveTokenWithStore stores a token like SaveToken and names the store that +// now holds it ("keyring" or "file"). +func SaveTokenWithStore(email string, tok *StoredToken) (string, error) { + data, err := json.Marshal(tok) + if err != nil { + return "", fmt.Errorf("marshaling token: %w", err) + } + return storeSet(email, string(data)) +} + +// LoadToken retrieves the token stored for email. +func LoadToken(email string) (*StoredToken, error) { + tok, _, err := LoadTokenWithStore(email) + return tok, err +} + +// LoadTokenWithStore retrieves the token stored for email and names the store +// that held it ("keyring" or "file"). When both stores hold a copy, as after a +// save fell back to the file while the keyring was down, the newest one wins. +func LoadTokenWithStore(email string) (*StoredToken, string, error) { + stores, err := selectedStores() + if err != nil { + return nil, "", err + } + copies, err := readCopies(email, stores) + if len(copies) == 0 { + return nil, "", fmt.Errorf("loading token for %s: %w", email, err) + } + var best *StoredToken + var bestStore string + var parseErr error + for _, c := range copies { + tok, err := decodeToken(c.data) + if err != nil { + parseErr = err + continue + } + if best == nil || tok.IssuedAt.After(best.IssuedAt) { + best, bestStore = tok, c.store + } + } + if best == nil { + return nil, "", parseErr + } + return best, bestStore, nil +} + +func decodeToken(data string) (*StoredToken, error) { + var tok StoredToken + if err := json.Unmarshal([]byte(data), &tok); err != nil { + return nil, fmt.Errorf("parsing stored token: %w", err) + } + return &tok, nil +} + +// DeleteToken removes the token for email from every store that holds it. +func DeleteToken(email string) error { + return storeDelete(email) +} diff --git a/internal/config/config.go b/internal/config/config.go index 702b5c2..557b4b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,10 @@ var ( ) func SetToken(t string) { token = t } -func Token() string { return token } +func Token() string { return token } -func SetMaxRetry(n int) { maxRetry = n } -func MaxRetry() int { return maxRetry } +func SetMaxRetry(n int) { maxRetry = n } +func MaxRetry() int { return maxRetry } func SetMaxRetryTimer(secs int) { maxRetryTimer = secs } func MaxRetryTimer() int { return maxRetryTimer } @@ -55,7 +55,7 @@ func SetOrgID(id string) { orgIDBase64 = EncodeOrgID(uuid) } -func OrgID() string { return orgID } +func OrgID() string { return orgID } func OrgIDBase64() string { return orgIDBase64 } const ( diff --git a/internal/search/query.go b/internal/search/query.go index 6256d57..e94e4f1 100644 --- a/internal/search/query.go +++ b/internal/search/query.go @@ -42,9 +42,9 @@ type Params struct { TimeComparator string Filter string // pre-built GraphQL filter string (from BuildFilter) Fields string // raw GraphQL field selection (overrides default) - Cursor string - PageSize int - CurrentPage int + Cursor string + PageSize int + CurrentPage int Aggregations []Aggregation Interval string Timezone string diff --git a/main.go b/main.go index 5621912..095efc9 100644 --- a/main.go +++ b/main.go @@ -8,9 +8,12 @@ import ( _ "github.com/Cloverhound/webex-cli/cmd/calling" _ "github.com/Cloverhound/webex-cli/cmd/cc" _ "github.com/Cloverhound/webex-cli/cmd/device" - _ "github.com/Cloverhound/webex-cli/cmd/meetings" _ "github.com/Cloverhound/webex-cli/cmd/mcp" + _ "github.com/Cloverhound/webex-cli/cmd/meetings" _ "github.com/Cloverhound/webex-cli/cmd/messaging" + + // Used only when the OS has no CA bundle, as in slim container images. + _ "golang.org/x/crypto/x509roots/fallback" ) func main() { diff --git a/npm/publish.sh b/npm/publish.sh new file mode 100755 index 0000000..ed38100 --- /dev/null +++ b/npm/publish.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Publishes the packages staged by npm/stage.mjs: platform packages first, so +# the wrapper never points at versions that are missing from the registry. +# Versions already on the registry are skipped, so a failed release can be rerun. +set -eu + +OUT="${1:-npm/build}" +VERSION=$(node -p "require('./${OUT}/webex-cli/package.json').version") +TAG=latest +case "$VERSION" in *-*) TAG=next ;; esac + +for dir in "$OUT"/webex-cli-* "$OUT/webex-cli"; do + name=$(node -p "require('./${dir}/package.json').name") + if npm view "${name}@${VERSION}" version >/dev/null 2>&1; then + echo "Skipping ${name}@${VERSION}: already published" + continue + fi + npm publish "$dir" --access public --provenance --tag "$TAG" +done diff --git a/npm/stage.mjs b/npm/stage.mjs new file mode 100755 index 0000000..0d8d6b1 --- /dev/null +++ b/npm/stage.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Builds the npm packages from a goreleaser dist directory: +// one @cloverhound/webex-cli-- package per binary, and the +// @cloverhound/webex-cli wrapper that depends on all of them. +// +// Usage: node npm/stage.mjs [--dist dist] [--out npm/build] [--version X.Y.Z] + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const { values: args } = parseArgs({ + options: { + dist: { type: "string", default: "dist" }, + out: { type: "string", default: path.join(here, "build") }, + version: { type: "string" }, + }, +}); + +const OS = { darwin: "darwin", linux: "linux", windows: "win32" }; +const CPU = { amd64: "x64", arm64: "arm64" }; +const EXPECTED = Object.keys(OS).length * Object.keys(CPU).length; + +const readJSON = (p) => JSON.parse(fs.readFileSync(p, "utf8")); +const writeJSON = (p, v) => fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); + +const version = (args.version ?? readJSON(path.join(args.dist, "metadata.json")).version).replace(/^v/, ""); +const template = readJSON(path.join(here, "webex-cli", "package.json")); + +const binaries = readJSON(path.join(args.dist, "artifacts.json")).filter( + (a) => a.type === "Binary" && OS[a.goos] && CPU[a.goarch], +); +if (binaries.length !== EXPECTED) { + throw new Error(`expected ${EXPECTED} binaries in ${args.dist}/artifacts.json, found ${binaries.length}`); +} + +fs.rmSync(args.out, { recursive: true, force: true }); + +const platformPackages = {}; +for (const a of binaries) { + const os = OS[a.goos]; + const cpu = CPU[a.goarch]; + const name = `${template.name}-${os}-${cpu}`; + const dir = path.join(args.out, `webex-cli-${os}-${cpu}`); + const exe = a.goos === "windows" ? "webex.exe" : "webex"; + + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.copyFileSync(a.path, path.join(dir, "bin", exe)); + fs.chmodSync(path.join(dir, "bin", exe), 0o755); + writeJSON(path.join(dir, "package.json"), { + name, + version, + description: `The ${os}-${cpu} binary for ${template.name}`, + license: template.license, + homepage: template.homepage, + repository: template.repository, + os: [os], + cpu: [cpu], + files: ["bin"], + preferUnplugged: true, + }); + fs.writeFileSync( + path.join(dir, "README.md"), + `# ${name}\n\nThe ${os}-${cpu} binary for [${template.name}](https://www.npmjs.com/package/${template.name}). Install that package instead.\n`, + ); + platformPackages[name] = version; +} + +const mainDir = path.join(args.out, "webex-cli"); +fs.cpSync(path.join(here, "webex-cli"), mainDir, { recursive: true }); +fs.chmodSync(path.join(mainDir, "bin", "webex.js"), 0o755); +writeJSON(path.join(mainDir, "package.json"), { + ...template, + version, + optionalDependencies: Object.fromEntries(Object.entries(platformPackages).sort()), +}); + +console.log(`Staged ${template.name}@${version} and ${binaries.length} platform packages in ${args.out}`); diff --git a/npm/webex-cli/README.md b/npm/webex-cli/README.md new file mode 100644 index 0000000..464826a --- /dev/null +++ b/npm/webex-cli/README.md @@ -0,0 +1,21 @@ +# @cloverhound/webex-cli + +The [Webex CLI](https://github.com/Cloverhound/webex-cli) packaged for npm. It manages Webex Admin, Calling, Contact Center, Devices, Meetings, and Messaging APIs from the command line, and runs as an MCP server for AI agents. + +```bash +npx -y @cloverhound/webex-cli login --device +npx -y @cloverhound/webex-cli calling people list +``` + +Or install it globally: + +```bash +npm install -g @cloverhound/webex-cli +webex --version +``` + +npm installs only the prebuilt binary for your platform (macOS, Linux, or Windows on x64 or arm64) through an optional dependency. No install script runs and nothing is downloaded from GitHub, so the package works in sandboxes that allow the npm registry but block other hosts. + +To run it as an MCP server, use `npx -y @cloverhound/webex-cli@ mcp serve` as the server command. + +See the [documentation](https://cloverhound.github.io/webex-cli/) for login options, including device login for machines without a browser. diff --git a/npm/webex-cli/bin/webex.js b/npm/webex-cli/bin/webex.js new file mode 100755 index 0000000..ad07c7b --- /dev/null +++ b/npm/webex-cli/bin/webex.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +"use strict"; + +// Runs the webex binary from the platform package npm installed alongside this +// one (an optionalDependency selected by its os/cpu fields). + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); + +const SUPPORTED = new Set([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +]); + +function fail(message) { + process.stderr.write(`webex-cli: ${message}\n`); + process.exit(1); +} + +function binaryPath() { + const key = `${process.platform}-${process.arch}`; + if (!SUPPORTED.has(key)) { + fail(`no build for ${key}. See https://github.com/Cloverhound/webex-cli#install for other install methods.`); + } + const pkg = `@cloverhound/webex-cli-${key}`; + const exe = process.platform === "win32" ? "webex.exe" : "webex"; + let bin; + try { + bin = require.resolve(`${pkg}/bin/${exe}`); + } catch { + fail(`${pkg} is not installed. Reinstall without --omit=optional or --no-optional.`); + } + if (process.platform !== "win32") { + try { + fs.accessSync(bin, fs.constants.X_OK); + } catch { + // Some package managers drop the executable bit when unpacking. + try { + fs.chmodSync(bin, 0o755); + } catch {} + } + } + return bin; +} + +const child = spawn(binaryPath(), process.argv.slice(2), { stdio: "inherit" }); + +// MCP hosts stop servers with a signal; pass it on so the binary is not orphaned. +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); +} + +child.on("error", (err) => fail(err.message)); +child.on("exit", (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/npm/webex-cli/package.json b/npm/webex-cli/package.json new file mode 100644 index 0000000..5145e2b --- /dev/null +++ b/npm/webex-cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "@cloverhound/webex-cli", + "version": "0.0.0", + "description": "CLI and MCP server for Webex Admin, Calling, Contact Center, Devices, Meetings, and Messaging APIs", + "license": "MIT", + "homepage": "https://github.com/Cloverhound/webex-cli", + "repository": { + "type": "git", + "url": "git+https://github.com/Cloverhound/webex-cli.git" + }, + "keywords": ["webex", "cli", "mcp", "cisco", "contact-center"], + "bin": { + "webex": "bin/webex.js" + }, + "files": ["bin"], + "engines": { + "node": ">=18" + }, + "optionalDependencies": {} +} diff --git a/site/src/content/docs/agent-skill.mdx b/site/src/content/docs/agent-skill.mdx index 0b71d7a..e78944d 100644 --- a/site/src/content/docs/agent-skill.mdx +++ b/site/src/content/docs/agent-skill.mdx @@ -7,11 +7,13 @@ The Webex CLI includes a [skill](https://agentskills.io) that enables AI coding ## Automatic Setup -The installer and `webex post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Skills are also kept up to date when you run `webex update`. +The installer and `webex post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Without a terminal, or with `webex post-install --yes`, the skill is installed for detected agents without prompts (Cowork is skipped because it needs a manual upload). + +The skill files are embedded in the `webex` binary, so installing them needs no network access and the skill always matches the installed version. `webex update` updates installed copies to match the new binary. ## Manual Setup -If you prefer to install manually, download the skill into the correct folder for your coding agent: +If you prefer to install manually, download the skill into the correct folder for your coding agent. Replace `main` in the URLs with your installed version's tag (for example `v0.16.0`) to match your binary: ### Claude Code diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 3b73777..f8829e5 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -5,12 +5,15 @@ description: Get up and running with the Webex CLI. ## Authentication -The CLI uses OAuth with tokens stored in your OS keyring. +The CLI uses OAuth with tokens stored in your OS keyring, or in a credentials file when no keyring is available. ```bash -# Login (opens browser) +# Login (opens browser, or device login when there is no browser) webex login +# Login by approving a code from any browser +webex login --device + # Check auth status webex auth status @@ -27,6 +30,22 @@ You can also use a token directly: webex --token "$WEBEX_TOKEN" calling people list ``` +### Headless login and unattended use + +Over SSH, in CI, in containers, on Linux without a display, or when no browser can be opened, `webex login` switches to device login. It prints a line such as `Open https://… and enter 123456`, plus a QR code on a terminal. Approve the code from a browser on any device. Use `--browser` to force the browser flow, or set `WEBEX_LOGIN_MODE=device` or `browser`. With `--output json`, the URL, code, and expiry are printed as JSON on stdout, so an AI agent can show them to you. + +If you use your own OAuth integration, register these redirect URIs on it for device login: `https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback` (four URIs, one per letter). + +When no OS keyring is available, tokens are saved in plain text to `~/.config/webex-cli/credentials.json` (or `$XDG_CONFIG_HOME/webex-cli/`, or `%APPDATA%\webex-cli\` on Windows) with mode `0600`. Set `WEBEX_TOKEN_STORE=keyring` or `file` to force a store. `webex auth status` shows the store in use. + +For CI and unattended agents, where nobody can approve a code: + +- `WEBEX_TOKEN` takes a personal access token, which lasts 12 hours. +- `WEBEX_REFRESH_TOKEN` takes a refresh token, from which the CLI mints access tokens as needed. Log in once on your own machine and run `webex auth export` to print one. If it came from your own integration, also set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET`. Refresh tokens last about 90 days from their last use. The CLI caches the access token and any rotated refresh token in the credentials file when it can write there. +- For organization-level automation, use a service app or a bot token instead of a person's login. + +Token resolution order: `--token` > `WEBEX_TOKEN` > `WEBEX_REFRESH_TOKEN` > stored login. + ### Read-only mode Use read-only mode when an AI agent or script should be able to read your Webex data but not change it. @@ -37,9 +56,9 @@ webex login --read-only This login requests only read scopes (`webex config get read-only-scopes` lists them) and turns on read-only mode: -- Stored logins with write access are deleted from the OS keyring. Other programs running as you can read the keyring, so leaving those logins there would let them bypass read-only mode. +- Stored logins with write access are deleted from the OS keyring and the credentials file. Other programs running as you can read both, so leaving those logins there would let them bypass read-only mode. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. Webex also rejects writes because the token has no write scopes. -- `--token` and `$WEBEX_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. +- `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. - `auth switch`, `--user`, and folder defaults work only for users who logged in with `--read-only`. - `webex mcp serve` does not register the `webex_write` tool. diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 030b628..471872c 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -36,7 +36,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Manage rooms, messages, memberships, teams, webhooks, Hybrid Data Security, and attachment actions via the Messaging API. - Browser-based OAuth login with tokens stored securely in your OS keyring. Multi-user support. + Browser or device-code OAuth login with tokens stored securely in your OS keyring. Multi-user support. Use the included skill to let Claude Code, Codex, Cursor, or any coding agent query and manage your Webex environment. diff --git a/site/src/content/docs/installation.mdx b/site/src/content/docs/installation.mdx index 6686971..80071ff 100644 --- a/site/src/content/docs/installation.mdx +++ b/site/src/content/docs/installation.mdx @@ -10,14 +10,52 @@ description: How to install the Webex CLI. curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh | sh ``` -This downloads the latest release and installs it to `/usr/local/bin/webex`. +This downloads the latest release, verifies it, and installs it to `~/.local/bin/webex`. **Windows (PowerShell):** ```powershell irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex ``` -This downloads the latest release and installs it to `%LOCALAPPDATA%\webex-cli\webex.exe`, and adds it to your user PATH. +This downloads the latest release, verifies it, installs it to `%LOCALAPPDATA%\webex-cli\webex.exe`, and adds it to your user PATH. + +**npm (any platform with Node.js 18+):** +```bash +npm install -g @cloverhound/webex-cli +# or run without installing +npx -y @cloverhound/webex-cli --version +``` + +npm installs only the prebuilt binary for your platform (macOS, Linux, or Windows on x64 or arm64), through an optional dependency. No install script runs and nothing is downloaded from GitHub, so this works in sandboxes that allow the npm registry but block other hosts. Update an npm install with npm, not `webex update`. + +## How the installer works + +The installers and `webex update` find the latest version in this order, so they work where `api.github.com` is blocked or rate-limited: + +1. A pinned version: `WEBEX_CLI_VERSION`, or `install.sh -v `. +2. The latest release's `checksums.txt` (`github.com/Cloverhound/webex-cli/releases/latest/download/checksums.txt`). +3. The Go module proxy (`proxy.golang.org`). +4. The GitHub API, using `GITHUB_TOKEN` when it is set. + +Every download is checked against the release's SHA-256 `checksums.txt`. On a mismatch, or if the archive is not listed, nothing is installed. + +## CI, containers, and AI agents + +```bash +# Pin a version and install to a custom directory +curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh \ + | WEBEX_CLI_VERSION=0.16.0 INSTALL_DIR=/usr/local/bin sh +``` + +Where only package registries are reachable, use npm instead. To run the MCP server from an agent's MCP configuration without installing anything first, use `npx -y @cloverhound/webex-cli@ mcp serve` as the command. Pin the version so the server matches the skill you installed. + +When there is no terminal, the installer does not prompt. `webex post-install` prints the PATH line to add and installs the agent skill for detected agents (Claude Code, Codex, Cursor). You can also run it yourself: + +| Option | Effect | +|--------|--------| +| `webex post-install --yes` | No prompts; adds the PATH line to your shell profile and installs skills for detected agents | +| `webex post-install --no-skills` | Skips agent skill installation | +| `WEBEX_CLI_NONINTERACTIVE=1` | Turns off prompts even in a terminal | ## Manual Download @@ -40,10 +78,10 @@ cd webex-cli go build -o webex . ``` -Requires Go 1.23+. +Requires Go 1.26+. ## Verify Installation ```bash -webex version +webex version # or: webex --version ``` diff --git a/skill/SKILL.md b/skill/SKILL.md index 831850d..c9465bf 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -19,7 +19,7 @@ webex ## Authentication -The CLI supports OAuth login with tokens stored in the OS keyring. +The CLI supports OAuth login with tokens stored in the OS keyring, or in a credentials file when no keyring is available. ```bash webex auth status # Show current user, org, token expiry @@ -29,19 +29,22 @@ webex auth set-folder-default # Set default user for current folder webex auth clear-folder-default # Remove folder default webex auth set-org # Set a persistent org override (partner admins) webex auth clear-org # Clear the org override -webex login # OAuth login (opens browser) +webex login # OAuth login (opens browser, or device login when headless) +webex login --device --output json # Device login; prints the URL and code as JSON webex logout [email] # Remove stored credentials ``` -If not logged in, use `--token ` or set `$WEBEX_TOKEN`. +If not logged in, use `--token ` or set `$WEBEX_TOKEN` or `$WEBEX_REFRESH_TOKEN`. + +**Logging in from an agent:** In a sandbox, over SSH, or wherever no browser can reach the CLI, run `webex login --device --output json` in the background. Its first JSON line has `verification_uri` and `user_code`, and the same text appears on stderr as `Open and enter `. Relay that line to the user word for word, and tell them the code expires in about 5 minutes. The user approves in their own browser. The command exits after approval and prints a second JSON line with `"status":"logged_in"`. **Per-folder defaults:** Different folders can be associated with different Webex users via `auth set-folder-default`. When a folder default is set, that user's credentials are used automatically when running commands from that directory. This is useful when different project folders connect to different Webex orgs. -Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > OS keyring. +Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `WEBEX_REFRESH_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > stored login. Org resolution order: `--organization` flag > `auth set-org` override > login user's home org. -**Read-only mode:** When `webex auth status` shows `Mode: read-only`, the stored login has read scopes only. Write requests fail with `read-only mode`, `--token`/`$WEBEX_TOKEN` are refused, and only users marked `(read-only)` in `webex auth list` can be used. Do not try to leave read-only mode or work around it; tell the user which change you need and let them decide. Leaving it requires the user to run `webex login` in an interactive terminal. +**Read-only mode:** When `webex auth status` shows `Mode: read-only`, the stored login has read scopes only. Write requests fail with `read-only mode`, `--token`/`$WEBEX_TOKEN`/`$WEBEX_REFRESH_TOKEN` are refused, and only users marked `(read-only)` in `webex auth list` can be used. Do not try to leave read-only mode or work around it; tell the user which change you need and let them decide. Leaving it requires the user to run `webex login` in an interactive terminal. ## Command Structure diff --git a/skill/embed.go b/skill/embed.go new file mode 100644 index 0000000..761bc63 --- /dev/null +++ b/skill/embed.go @@ -0,0 +1,29 @@ +// Package skill embeds the agent skill files so an installed binary always +// carries the skill that matches its own commands. +package skill + +import ( + "embed" + "io/fs" +) + +//go:embed SKILL.md */SKILL.md +var embedded embed.FS + +// Files returns every embedded skill file keyed by its slash-separated path +// relative to the skill directory, such as "SKILL.md" or "admin/SKILL.md". +func Files() (map[string][]byte, error) { + files := make(map[string][]byte) + err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := embedded.ReadFile(path) + if err != nil { + return err + } + files[path] = data + return nil + }) + return files, err +} diff --git a/skill/embed_test.go b/skill/embed_test.go new file mode 100644 index 0000000..3ad08cb --- /dev/null +++ b/skill/embed_test.go @@ -0,0 +1,15 @@ +package skill + +import "testing" + +func TestFilesIncludesEveryArea(t *testing.T) { + files, err := Files() + if err != nil { + t.Fatal(err) + } + for _, p := range []string{"SKILL.md", "admin/SKILL.md", "calling/SKILL.md", "cc/SKILL.md", "device/SKILL.md", "meetings/SKILL.md", "messaging/SKILL.md"} { + if len(files[p]) == 0 { + t.Errorf("missing or empty %s", p) + } + } +}