package app import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "gitocean/internal/backup" appconfig "gitocean/internal/config" "gitocean/internal/dbutil" "gitocean/internal/gitutil" "golang.org/x/crypto/bcrypt" "io" "net/http" "net/url" "os/exec" "strings" ) // ---------------- CLI helpers ---------------- func apiRequest(ctx context.Context, server, method, path, token string, body any, out any) error { server = strings.TrimRight(server, "/") var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return err } rdr = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, server+path, rdr) if err != nil { return err } if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { var e struct { Error string `json:"error"` } if json.Unmarshal(b, &e) == nil && e.Error != "" { if resp.StatusCode == http.StatusUnauthorized { return fmt.Errorf("%s\nYour session may have expired. Run:\n gitocean login", e.Error) } return fmt.Errorf("%s", e.Error) } return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } if out != nil && len(b) > 0 { if err := json.Unmarshal(b, out); err != nil { return err } } return nil } func envServer() string { return appconfig.EnvServer() } func configPath() string { return appconfig.ClientConfigPath() } func loadConfig() (Config, error) { return appconfig.LoadClient() } func saveConfig(cfg Config) error { return appconfig.SaveClient(cfg) } func approveGitCredential(cfg Config) { u, err := url.Parse(cfg.Server) if err != nil || u.Host == "" || u.Scheme == "" { return } cmd := exec.Command("git", "credential", "approve") cmd.Stdin = strings.NewReader(fmt.Sprintf("protocol=%s\nhost=%s\nusername=%s\npassword=%s\n\n", u.Scheme, u.Host, cfg.Username, cfg.Token)) _ = cmd.Run() } func prompt(label string) string { fmt.Print(label) var s string fmt.Scanln(&s) return strings.TrimSpace(s) } func printJSON(v any) { b, _ := json.MarshalIndent(v, "", " ") fmt.Println(string(b)) } func splitOwnerRepo(s string) (string, string, error) { parts := strings.Split(s, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", fmt.Errorf("expected OWNER/REPO, got %q", s) } return strings.ToLower(parts[0]), strings.ToLower(parts[1]), nil } func splitRepoBranch(s string) (owner, repo, branch string, err error) { left, branch, ok := strings.Cut(s, ":") if !ok || branch == "" { return "", "", "", fmt.Errorf("expected OWNER/REPO:BRANCH, got %q", s) } owner, repo, err = splitOwnerRepo(left) return owner, repo, branch, err } func parseRepoCreateArgs(args []string) (name, visibility, description string, jsonOut bool, err error) { pub, priv := false, false for i := 0; i < len(args); i++ { arg := args[i] switch { case arg == "--public" || arg == "-public": pub = true case arg == "--private" || arg == "-private": priv = true case arg == "--json": jsonOut = true case arg == "--description": if i+1 >= len(args) { return "", "", "", false, errors.New("--description requires a value") } i++ description = args[i] case strings.HasPrefix(arg, "--description="): description = strings.TrimPrefix(arg, "--description=") default: if strings.HasPrefix(arg, "-") { return "", "", "", false, fmt.Errorf("unknown flag %s", arg) } if name != "" { return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)") } name = arg } } if name == "" || pub == priv { return "", "", "", false, errors.New("usage: gitocean repo create NAME (--public|--private)") } visibility = "private" if pub { visibility = "public" } return name, visibility, description, jsonOut, nil } func parseRepoPublishArgs(args []string) (name, visibility, description, remoteName, branch string, err error) { remoteName = "origin" branch = "main" filtered := []string{} for i := 0; i < len(args); i++ { switch args[i] { case "--remote": if i+1 >= len(args) { return "", "", "", "", "", errors.New("--remote requires a value") } i++ remoteName = args[i] case "--branch": if i+1 >= len(args) { return "", "", "", "", "", errors.New("--branch requires a value") } i++ branch = args[i] default: filtered = append(filtered, args[i]) } } name, visibility, description, _, err = parseRepoCreateArgs(filtered) if err != nil { return "", "", "", "", "", err } return name, visibility, description, remoteName, branch, nil } func removeBoolFlag(args *[]string, flagName string) bool { in := *args out := in[:0] found := false for _, arg := range in { if arg == flagName { found = true continue } out = append(out, arg) } *args = out return found } func repoCloneURL(server, owner, repo string) string { return fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(server, "/"), owner, repo) } func printRepoCreated(server string, repo Repository) { remote := repoCloneURL(server, repo.Owner, repo.Name) fmt.Printf("Created %s repository %s/%s\n\n", repo.Visibility, repo.Owner, repo.Name) fmt.Println("Clone:") fmt.Printf(" git clone %s\n\n", remote) fmt.Println("Add existing repo:") fmt.Printf(" git remote add origin %s\n", remote) fmt.Printf(" git branch -M %s\n", repo.DefaultBranch) fmt.Printf(" git push -u origin %s\n", repo.DefaultBranch) } func printRepoView(server string, repo Repository) { fmt.Printf("%s/%s\n", repo.Owner, repo.Name) fmt.Printf("Visibility: %s\n", repo.Visibility) fmt.Printf("Default branch: %s\n", repo.DefaultBranch) fmt.Printf("Archived: %v\n", repo.Archived) if repo.Description != "" { fmt.Printf("Description: %s\n", repo.Description) } fmt.Println("\nClone:") fmt.Printf(" git clone %s\n", repoCloneURL(server, repo.Owner, repo.Name)) } func printRepoTable(repos []Repository) { fmt.Printf("%-28s %-10s %-8s %s\n", "REPOSITORY", "VISIBILITY", "ARCHIVED", "DESCRIPTION") for _, r := range repos { fmt.Printf("%-28s %-10s %-8v %s\n", r.Owner+"/"+r.Name, r.Visibility, r.Archived, r.Description) } } func printRefs(kind string, refs []RefInfo) { fmt.Printf("%-28s %-10s %-20s %s\n", strings.ToUpper(strings.TrimSuffix(kind, "es")), "COMMIT", "DATE", "MESSAGE") for _, r := range refs { commit := r.Commit if len(commit) > 10 { commit = commit[:10] } fmt.Printf("%-28s %-10s %-20s %s\n", r.Name, commit, r.Date.Format("2006-01-02 15:04"), r.Message) } } func printPR(pr PullRequest) { fmt.Printf("#%d %s: %s\n", pr.Number, pr.Status, pr.Title) fmt.Printf("Author: %s\n", pr.Author) fmt.Printf("From: %s/%s:%s\n", pr.SourceOwner, pr.SourceRepo, pr.SourceBranch) fmt.Printf("To: %s/%s:%s\n", pr.TargetOwner, pr.TargetRepo, pr.TargetBranch) if pr.Description != "" { fmt.Printf("\n%s\n", pr.Description) } } func apiTextRequest(ctx context.Context, server, path, token string) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(server, "/")+path, nil) if err != nil { return "", err } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return string(b), nil } func defaultServerConfigPath() string { return appconfig.DefaultServerConfigPath() } func loadServerConfig(path string) (ServerConfig, error) { return appconfig.LoadServer(path) } func saveServerConfig(path string, cfg ServerConfig) error { return appconfig.SaveServer(path, cfg) } func promptDefault(label, def string) string { fmt.Printf("%s [%s]: ", label, def) var s string fmt.Scanln(&s) if strings.TrimSpace(s) == "" { return def } return strings.TrimSpace(s) } func createUserDirect(db *sql.DB, email, username, password string, isAdmin bool) error { if !strings.Contains(email, "@") || !usernameRE.MatchString(username) || len(password) < 8 { return errors.New("invalid email, username, or password") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } _, err = db.Exec(`INSERT INTO users (email, username, password_hash, is_admin) VALUES (?, ?, ?, ?)`, email, username, string(hash), isAdmin) return err } func gitRefs(repoPath, prefix string) ([]RefInfo, error) { return gitutil.Refs(repoPath, prefix) } func createBackup(cfg ServerConfig, file string) error { return backup.Create(cfg, file) } func restoreBackup(cfg ServerConfig, file string) error { return backup.Restore(cfg, file) } func mysqlCLIArgs(dsn string) ([]string, string, error) { return backup.MySQLCLIArgs(dsn) } func copyDir(src, dst string) error { return backup.CopyDir(src, dst) } func isDuplicateColumnError(err error) bool { return dbutil.IsDuplicateColumnError(err) }