mirror of
https://github.com/4ier/notion-cli.git
synced 2026-09-14 20:17:00 +08:00
443 lines
11 KiB
Go
443 lines
11 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/4ier/notion-cli/internal/client"
|
|
"github.com/4ier/notion-cli/internal/config"
|
|
"github.com/4ier/notion-cli/internal/render"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var authCmd = &cobra.Command{
|
|
Use: "auth",
|
|
Short: "Authenticate with Notion",
|
|
}
|
|
|
|
var authLoginCmd = &cobra.Command{
|
|
Use: "login",
|
|
Short: "Log in to Notion",
|
|
Long: `Authenticate with Notion using an integration token.
|
|
|
|
Use --profile to save credentials under a named profile for multi-workspace support.
|
|
|
|
Examples:
|
|
notion auth login
|
|
notion auth login --with-token
|
|
notion auth login --profile work
|
|
echo "secret_xxx" | notion auth login --with-token --profile personal`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
withToken, _ := cmd.Flags().GetBool("with-token")
|
|
profileName, _ := cmd.Flags().GetString("profile")
|
|
|
|
if profileName == "" {
|
|
profileName = "default"
|
|
}
|
|
|
|
var token string
|
|
if withToken {
|
|
// Read from stdin
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if scanner.Scan() {
|
|
token = strings.TrimSpace(scanner.Text())
|
|
}
|
|
} else {
|
|
// Interactive prompt
|
|
fmt.Print("Paste your integration token: ")
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if scanner.Scan() {
|
|
token = strings.TrimSpace(scanner.Text())
|
|
}
|
|
}
|
|
|
|
if token == "" {
|
|
return fmt.Errorf("no token provided")
|
|
}
|
|
|
|
// Validate token by calling the API
|
|
c := client.New(token)
|
|
me, err := c.GetMe()
|
|
if err != nil {
|
|
return fmt.Errorf("authentication failed: %w", err)
|
|
}
|
|
|
|
// Extract workspace info
|
|
botInfo, _ := me["bot"].(map[string]interface{})
|
|
workspaceName, _ := botInfo["workspace_name"].(string)
|
|
workspaceID, _ := botInfo["workspace_id"].(string)
|
|
botID, _ := me["id"].(string)
|
|
|
|
// Load existing config or create new
|
|
cfg, _ := config.Load()
|
|
if cfg == nil {
|
|
cfg = &config.Config{}
|
|
}
|
|
|
|
// Migrate legacy config if needed
|
|
cfg.MigrateToProfiles()
|
|
|
|
// Set the profile
|
|
cfg.SetProfile(profileName, &config.Profile{
|
|
Token: token,
|
|
WorkspaceName: workspaceName,
|
|
WorkspaceID: workspaceID,
|
|
BotID: botID,
|
|
})
|
|
|
|
// Set as current profile
|
|
cfg.CurrentProfile = profileName
|
|
|
|
if err := config.Save(cfg); err != nil {
|
|
return fmt.Errorf("save config: %w", err)
|
|
}
|
|
|
|
render.Title("✓", fmt.Sprintf("Logged in to %s", workspaceName))
|
|
if profileName != "default" {
|
|
render.Field("Profile", profileName)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var authStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show authentication status",
|
|
Long: `Show current authentication status and profile information.
|
|
|
|
Examples:
|
|
notion auth status`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Resolve token from env or config (same precedence as every
|
|
// other command), so `NOTION_TOKEN` alone counts as authenticated.
|
|
token, err := getToken()
|
|
if err != nil {
|
|
fmt.Println("✗ Not authenticated")
|
|
return nil
|
|
}
|
|
|
|
c := client.New(token)
|
|
me, err := c.GetMe()
|
|
if err != nil {
|
|
return fmt.Errorf("token is invalid: %w", err)
|
|
}
|
|
|
|
botInfo, _ := me["bot"].(map[string]interface{})
|
|
workspaceName, _ := botInfo["workspace_name"].(string)
|
|
name, _ := me["name"].(string)
|
|
integrationType := detectIntegrationType(botInfo)
|
|
|
|
render.Title("✓", "Authenticated")
|
|
|
|
// Config-based profile details are only shown when a config file
|
|
// exists; env-var auth has no profile.
|
|
var profiles []string
|
|
profileName := "default"
|
|
if cfg, err := config.Load(); err == nil {
|
|
if cfg.CurrentProfile != "" {
|
|
profileName = cfg.CurrentProfile
|
|
}
|
|
profiles = cfg.ListProfiles()
|
|
}
|
|
if os.Getenv("NOTION_TOKEN") != "" {
|
|
render.Field("Source", "NOTION_TOKEN env")
|
|
} else if len(profiles) > 1 {
|
|
render.Field("Profile", profileName)
|
|
}
|
|
|
|
render.Field("Workspace", workspaceName)
|
|
render.Field("Bot", name)
|
|
if integrationType != "" {
|
|
render.Field("Integration", integrationType)
|
|
if integrationType == "internal" {
|
|
render.Field("Root page", "not allowed (share a parent page)")
|
|
}
|
|
}
|
|
|
|
// Show other available profiles
|
|
if os.Getenv("NOTION_TOKEN") == "" && len(profiles) > 1 {
|
|
var others []string
|
|
for _, p := range profiles {
|
|
if p != profileName {
|
|
others = append(others, p)
|
|
}
|
|
}
|
|
if len(others) > 0 {
|
|
render.Field("Other profiles", strings.Join(others, ", "))
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var authLogoutCmd = &cobra.Command{
|
|
Use: "logout [profile]",
|
|
Short: "Log out of Notion",
|
|
Long: `Log out of Notion. Optionally specify a profile to remove.
|
|
|
|
Without arguments, clears all profiles. With a profile name, removes only that profile.
|
|
|
|
Examples:
|
|
notion auth logout
|
|
notion auth logout work`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 0 {
|
|
// Clear all profiles
|
|
cfg := &config.Config{}
|
|
if err := config.Save(cfg); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("✓ Logged out (all profiles)")
|
|
return nil
|
|
}
|
|
|
|
// Remove specific profile
|
|
profileName := args[0]
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
if cfg.Profiles == nil {
|
|
return fmt.Errorf("profile %q not found", profileName)
|
|
}
|
|
|
|
if _, ok := cfg.Profiles[profileName]; !ok {
|
|
return fmt.Errorf("profile %q not found", profileName)
|
|
}
|
|
|
|
delete(cfg.Profiles, profileName)
|
|
|
|
// If we removed the current profile, switch to another
|
|
if cfg.CurrentProfile == profileName {
|
|
profiles := cfg.ListProfiles()
|
|
if len(profiles) > 0 {
|
|
cfg.CurrentProfile = profiles[0]
|
|
} else {
|
|
cfg.CurrentProfile = ""
|
|
}
|
|
}
|
|
|
|
if err := config.Save(cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("✓ Logged out from profile %q\n", profileName)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var authSwitchCmd = &cobra.Command{
|
|
Use: "switch [profile]",
|
|
Short: "Switch between workspace profiles",
|
|
Long: `Switch between saved workspace profiles.
|
|
|
|
Without arguments, shows an interactive list of profiles to choose from.
|
|
With a profile name, switches directly to that profile.
|
|
|
|
Examples:
|
|
notion auth switch
|
|
notion auth switch work
|
|
notion auth switch personal`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("not authenticated. Run 'notion auth login' first")
|
|
}
|
|
|
|
// Migrate legacy config if needed
|
|
cfg.MigrateToProfiles()
|
|
|
|
profiles := cfg.ListProfiles()
|
|
if len(profiles) == 0 {
|
|
return fmt.Errorf("no profiles found. Run 'notion auth login' first")
|
|
}
|
|
|
|
var targetProfile string
|
|
|
|
if len(args) == 1 {
|
|
// Direct switch
|
|
targetProfile = args[0]
|
|
if _, ok := cfg.Profiles[targetProfile]; !ok {
|
|
return fmt.Errorf("profile %q not found. Available: %s", targetProfile, strings.Join(profiles, ", "))
|
|
}
|
|
} else {
|
|
// Interactive selection
|
|
if len(profiles) == 1 {
|
|
fmt.Println("Only one profile available.")
|
|
return nil
|
|
}
|
|
|
|
fmt.Println("Available profiles:")
|
|
fmt.Println()
|
|
for i, name := range profiles {
|
|
profile := cfg.Profiles[name]
|
|
marker := " "
|
|
if name == cfg.CurrentProfile {
|
|
marker = "→ "
|
|
}
|
|
workspace := profile.WorkspaceName
|
|
if workspace == "" {
|
|
workspace = "(unknown workspace)"
|
|
}
|
|
fmt.Printf("%s%d. %s (%s)\n", marker, i+1, name, workspace)
|
|
}
|
|
fmt.Println()
|
|
fmt.Print("Select profile (number or name): ")
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
if !scanner.Scan() {
|
|
return nil
|
|
}
|
|
input := strings.TrimSpace(scanner.Text())
|
|
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
// Try as number first
|
|
if num, err := strconv.Atoi(input); err == nil {
|
|
if num >= 1 && num <= len(profiles) {
|
|
targetProfile = profiles[num-1]
|
|
} else {
|
|
return fmt.Errorf("invalid selection: %d", num)
|
|
}
|
|
} else {
|
|
// Try as profile name
|
|
targetProfile = input
|
|
if _, ok := cfg.Profiles[targetProfile]; !ok {
|
|
return fmt.Errorf("profile %q not found", targetProfile)
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetProfile == cfg.CurrentProfile {
|
|
fmt.Printf("Already using profile %q\n", targetProfile)
|
|
return nil
|
|
}
|
|
|
|
cfg.CurrentProfile = targetProfile
|
|
if err := config.Save(cfg); err != nil {
|
|
return fmt.Errorf("save config: %w", err)
|
|
}
|
|
|
|
profile := cfg.Profiles[targetProfile]
|
|
workspace := profile.WorkspaceName
|
|
if workspace == "" {
|
|
workspace = "(unknown)"
|
|
}
|
|
|
|
render.Title("✓", fmt.Sprintf("Switched to profile %q", targetProfile))
|
|
render.Field("Workspace", workspace)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var authDoctorCmd = &cobra.Command{
|
|
Use: "doctor",
|
|
Short: "Check authentication and API connectivity",
|
|
Long: `Run health checks on your Notion CLI setup.
|
|
|
|
Validates:
|
|
- Config file exists and has a token
|
|
- Token is valid (API responds)
|
|
- Workspace is accessible
|
|
- Can list databases
|
|
|
|
Examples:
|
|
notion auth doctor`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fmt.Println("Notion CLI Health Check")
|
|
fmt.Println()
|
|
|
|
// Check 1: Token (env var or config file)
|
|
token, err := getToken()
|
|
if err != nil || token == "" {
|
|
fmt.Println(" ✗ Config: no token found")
|
|
fmt.Println(" Run: notion auth login --with-token, or set NOTION_TOKEN")
|
|
return nil
|
|
}
|
|
if os.Getenv("NOTION_TOKEN") != "" {
|
|
fmt.Println(" ✓ Config: token found (NOTION_TOKEN env)")
|
|
} else {
|
|
fmt.Println(" ✓ Config: token found")
|
|
}
|
|
|
|
// Check 2: Token validity
|
|
c := client.New(token)
|
|
me, err := c.GetMe()
|
|
if err != nil {
|
|
fmt.Printf(" ✗ Auth: token is invalid (%v)\n", err)
|
|
return nil
|
|
}
|
|
|
|
name, _ := me["name"].(string)
|
|
botInfo, _ := me["bot"].(map[string]interface{})
|
|
workspace, _ := botInfo["workspace_name"].(string)
|
|
integrationType := detectIntegrationType(botInfo)
|
|
fmt.Printf(" ✓ Auth: %s\n", name)
|
|
fmt.Printf(" ✓ Workspace: %s\n", workspace)
|
|
if integrationType != "" {
|
|
fmt.Printf(" ✓ Integration: %s\n", integrationType)
|
|
if integrationType == "internal" {
|
|
fmt.Println(" note: internal integrations cannot create pages at the workspace root;")
|
|
fmt.Println(" create a parent page in Notion and share it with this integration first.")
|
|
}
|
|
}
|
|
|
|
// Check 3: Can search
|
|
result, err := c.Search("", "", 1, "")
|
|
if err != nil {
|
|
fmt.Printf(" ✗ API: search failed (%v)\n", err)
|
|
return nil
|
|
}
|
|
results, _ := result["results"].([]interface{})
|
|
fmt.Printf(" ✓ API: search works (%d+ items accessible)\n", len(results))
|
|
|
|
fmt.Println()
|
|
fmt.Println("All checks passed ✓")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
authLoginCmd.Flags().Bool("with-token", false, "Read token from standard input")
|
|
authLoginCmd.Flags().StringP("profile", "p", "", "Profile name to save credentials under (default: \"default\")")
|
|
|
|
authCmd.AddCommand(authLoginCmd)
|
|
authCmd.AddCommand(authStatusCmd)
|
|
authCmd.AddCommand(authLogoutCmd)
|
|
authCmd.AddCommand(authDoctorCmd)
|
|
authCmd.AddCommand(authSwitchCmd)
|
|
}
|
|
|
|
// detectIntegrationType inspects the bot object returned by
|
|
// GET /v1/users/me and classifies the integration as "internal",
|
|
// "public", or "" when the shape is unrecognizable.
|
|
//
|
|
// Notion's response includes `bot.owner.type`, which is:
|
|
// - "workspace" for internal integrations (owned by the workspace)
|
|
// - "user" for public integrations installed by an individual user
|
|
//
|
|
// Ref: https://developers.notion.com/reference/user#bot
|
|
func detectIntegrationType(botInfo map[string]interface{}) string {
|
|
owner, ok := botInfo["owner"].(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
ownerType, _ := owner["type"].(string)
|
|
switch ownerType {
|
|
case "workspace":
|
|
return "internal"
|
|
case "user":
|
|
return "public"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|