diff --git a/cmd/pnf/cloud.go b/cmd/pnf/cloud.go new file mode 100644 index 0000000..e354e8c --- /dev/null +++ b/cmd/pnf/cloud.go @@ -0,0 +1,267 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +const defaultCloudURL = "https://p.ninetyfive.gg" + +type cloudCredentials struct { + APIKey string `json:"api_key"` + URL string `json:"url"` + DefaultTeam string `json:"default_team,omitempty"` +} + +func defaultConfigDir() string { + home, _ := os.UserHomeDir() + + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "p95") + case "windows": + appdata := os.Getenv("APPDATA") + if appdata == "" { + appdata = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(appdata, "p95") + default: // Linux and others + xdgConfig := os.Getenv("XDG_CONFIG_HOME") + if xdgConfig == "" { + xdgConfig = filepath.Join(home, ".config") + } + return filepath.Join(xdgConfig, "p95") + } +} + +func credentialsPath() string { + return filepath.Join(defaultConfigDir(), "credentials.json") +} + +func loadCredentials() (*cloudCredentials, error) { + data, err := os.ReadFile(credentialsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var creds cloudCredentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, err + } + return &creds, nil +} + +func saveCredentials(creds cloudCredentials) error { + dir := defaultConfigDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return err + } + return os.WriteFile(credentialsPath(), data, 0600) +} + +func cloudCmd(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: pnf cloud \n\nCommands:\n login Authenticate and store an API key\n logout Remove stored credentials\n status Show current authentication status") + os.Exit(1) + } + switch args[0] { + case "login": + cloudLoginCmd(args[1:]) + case "logout": + cloudLogoutCmd() + case "status": + cloudStatusCmd(args[1:]) + default: + fmt.Fprintf(os.Stderr, "Unknown cloud command: %s\n", args[0]) + os.Exit(1) + } +} + +func cloudLogoutCmd() { + path := credentialsPath() + err := os.Remove(path) + if err != nil { + if os.IsNotExist(err) { + fmt.Println("Not logged in.") + return + } + fmt.Fprintf(os.Stderr, "Error removing credentials: %v\n", err) + os.Exit(1) + } + fmt.Println("Logged out. Credentials removed.") +} + +func cloudLoginCmd(args []string) { + url := os.Getenv("P95_URL") + if url == "" { + url = defaultCloudURL + } + // Allow --url override + for i, a := range args { + if a == "--url" && i+1 < len(args) { + url = args[i+1] + } else if v, ok := strings.CutPrefix(a, "--url="); ok { + url = v + } + } + url = strings.TrimRight(url, "/") + + // Step 1: Open browser to API keys settings page + apiKeysURL := url + "/settings/api-keys" + fmt.Printf("Opening %s in your browser...\n", apiKeysURL) + fmt.Println("Generate a new API key and paste it below.") + fmt.Println() + openURL(apiKeysURL) + + // Step 2: Read pasted API key + reader := bufio.NewReader(os.Stdin) + fmt.Print("API key: ") + apiKey, _ := reader.ReadString('\n') + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + fmt.Fprintln(os.Stderr, "No API key provided.") + os.Exit(1) + } + + // Step 3: Validate key and get user info + req, _ := http.NewRequest(http.MethodGet, url+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to %s: %v\n", url, err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + fmt.Fprintln(os.Stderr, "Invalid API key. Please generate a valid key and try again.") + os.Exit(1) + } + if resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "Error verifying API key (status %d)\n", resp.StatusCode) + os.Exit(1) + } + + var user struct { + Email string `json:"email"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + fmt.Fprintf(os.Stderr, "Error reading user info: %v\n", err) + os.Exit(1) + } + + // Step 4: Fetch default (personal) team + teamReq, _ := http.NewRequest(http.MethodGet, url+"/api/v1/teams", nil) + teamReq.Header.Set("Authorization", "Bearer "+apiKey) + teamResp, err := http.DefaultClient.Do(teamReq) + defaultTeam := "" + if err == nil && teamResp.StatusCode == http.StatusOK { + defer teamResp.Body.Close() + var teams []struct { + Slug string `json:"slug"` + IsPersonal bool `json:"is_personal"` + } + if json.NewDecoder(teamResp.Body).Decode(&teams) == nil { + for _, t := range teams { + if t.IsPersonal { + defaultTeam = t.Slug + break + } + } + // Fall back to first team if no personal team found + if defaultTeam == "" && len(teams) > 0 { + defaultTeam = teams[0].Slug + } + } + } + + // Step 5: Save credentials + if err := saveCredentials(cloudCredentials{APIKey: apiKey, URL: url, DefaultTeam: defaultTeam}); err != nil { + fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Logged in as %s.", user.Email) + if defaultTeam != "" { + fmt.Printf(" Default team: %s.", defaultTeam) + } + fmt.Printf("\nCredentials saved to %s\n", credentialsPath()) +} + +func cloudStatusCmd(_ []string) { + // Env vars take priority + apiKey := os.Getenv("P95_API_KEY") + url := os.Getenv("P95_URL") + source := "environment" + defaultTeam := "" + + // Load credentials file (for default team and as fallback for key/url) + creds, err := loadCredentials() + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading credentials: %v\n", err) + os.Exit(1) + } + if creds != nil { + defaultTeam = creds.DefaultTeam + if apiKey == "" { + apiKey = creds.APIKey + url = creds.URL + source = credentialsPath() + } + } + + if apiKey == "" { + fmt.Println("No credentials found. Run 'pnf cloud login' to authenticate.") + return + } + + if url == "" { + url = defaultCloudURL + } + + // Verify the key with the API + req, _ := http.NewRequest(http.MethodGet, strings.TrimRight(url, "/")+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Linked to %s (key: %s...)\nCould not verify: %v\n", url, apiKey[:min(12, len(apiKey))], err) + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + fmt.Printf("API key invalid or expired. Run 'pnf cloud login' to re-authenticate.\nSource: %s\n", source) + return + } + + var user struct { + Email string `json:"email"` + Name string `json:"name"` + } + json.NewDecoder(resp.Body).Decode(&user) + + prefix := apiKey + if len(prefix) > 12 { + prefix = prefix[:12] + } + fmt.Printf("Linked to %s as %s (key: %s...)\n", url, user.Email, prefix) + if defaultTeam != "" { + fmt.Printf("Default team: %s\n", defaultTeam) + } + fmt.Printf("Credentials from: %s\n", source) +} + + diff --git a/cmd/pnf/main.go b/cmd/pnf/main.go index 944de52..64a3942 100644 --- a/cmd/pnf/main.go +++ b/cmd/pnf/main.go @@ -50,6 +50,8 @@ func main() { fmt.Println("pnf v0.1.0") case "help", "--help", "-h": printUsage() + case "cloud": + cloudCmd(os.Args[2:]) case "runs", "run", "jobs", "workers", "worker": delegateToP95() default: @@ -75,6 +77,7 @@ Commands: ls List projects and runs show Show metrics for a run serve Start the web viewer + cloud Manage cloud authentication runs Manage cloud runs jobs Manage cloud jobs workers Manage cloud workers @@ -86,6 +89,9 @@ Examples: pnf ls --logdir ./logs --project demo-project pnf show --logdir ./logs pnf serve --logdir ./logs + pnf cloud login + pnf cloud login --url https://your-cloud-url + pnf cloud status pnf runs list --project team/app pnf jobs create --project team/app --script train.py diff --git a/sdk/python/src/p95/cloud_cli.py b/sdk/python/src/p95/cloud_cli.py index 190ce38..bfa7c10 100644 --- a/sdk/python/src/p95/cloud_cli.py +++ b/sdk/python/src/p95/cloud_cli.py @@ -16,19 +16,19 @@ from typing import Any, Dict, Optional from p95.client import P95Client -from p95.config import get_config +from p95.config import get_config, _load_credentials def _get_client() -> P95Client: """Get a configured P95 cloud client.""" config = get_config() if not config.api_key: - api_key = os.environ.get("P95_API_KEY") - if not api_key: - _error("P95_API_KEY environment variable is required") - config.api_key = api_key - if not config.base_url: - config.base_url = os.environ.get("P95_URL", "https://p.ninetyfive.gg") + creds = _load_credentials() + config.api_key = os.environ.get("P95_API_KEY") or creds.get("api_key") + if not config.api_key: + _error("No API key found. Run 'pnf cloud login' or set P95_API_KEY.") + if not os.environ.get("P95_URL") and creds.get("url"): + config.base_url = creds["url"] return P95Client(config) diff --git a/sdk/python/src/p95/config.py b/sdk/python/src/p95/config.py index 7d857db..a47eba3 100644 --- a/sdk/python/src/p95/config.py +++ b/sdk/python/src/p95/config.py @@ -1,9 +1,39 @@ """Configuration management for the p95 SDK.""" +import json import os from dataclasses import dataclass, field from pathlib import Path -from typing import Literal, Optional, Set +from typing import Any, Dict, Literal, Optional, Set + + +def _credentials_path() -> Path: + """Get the platform-specific credentials file path (mirrors the Go CLI).""" + import platform + + home = Path.home() + system = platform.system() + + if system == "Darwin": + config_dir = home / "Library" / "Application Support" / "p95" + elif system == "Windows": + appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming")) + config_dir = Path(appdata) / "p95" + else: + xdg_config = os.environ.get("XDG_CONFIG_HOME", str(home / ".config")) + config_dir = Path(xdg_config) / "p95" + + return config_dir / "credentials.json" + + +def _load_credentials() -> Dict[str, Any]: + """Load credentials from the file written by `pnf cloud login`. Returns {} if not found.""" + path = _credentials_path() + try: + with open(path) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} def _default_logdir() -> str: @@ -33,6 +63,7 @@ class SDKConfig: # Remote mode settings base_url: str = "https://p.ninetyfive.gg" api_key: Optional[str] = None + default_team: Optional[str] = None # Local mode settings logdir: str = field(default_factory=_default_logdir) @@ -136,17 +167,20 @@ def configure( def _detect_mode() -> Literal["local", "remote"]: """ - Auto-detect the operating mode based on environment variables. + Auto-detect the operating mode based on environment variables and credentials file. Priority: 1. P95_LOGDIR set -> local mode 2. P95_URL or P95_API_KEY set -> remote mode - 3. Default -> local mode (zero config experience) + 3. Credentials file has api_key -> remote mode + 4. Default -> local mode (zero config experience) """ if os.environ.get("P95_LOGDIR"): return "local" if os.environ.get("P95_API_KEY"): return "remote" + if _load_credentials().get("api_key"): + return "remote" return "local" @@ -167,4 +201,18 @@ def get_config() -> SDKConfig: if "api_key" not in _explicitly_set and os.environ.get("P95_API_KEY"): _config.api_key = os.environ["P95_API_KEY"] + # Fall back to credentials file for any values still unset + if "api_key" not in _explicitly_set and not _config.api_key: + creds = _load_credentials() + if creds.get("api_key"): + _config.api_key = creds["api_key"] + if ( + "base_url" not in _explicitly_set + and not os.environ.get("P95_URL") + and creds.get("url") + ): + _config.base_url = creds["url"] + if creds.get("default_team"): + _config.default_team = creds["default_team"] + return _config