package app import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "time" "github.com/joho/godotenv" ) type cliState struct { APIURL string `json:"api_url"` OIDCIssuerURL string `json:"oidc_issuer_url"` OIDCClientID string `json:"oidc_client_id"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenExpiryUTC string `json:"token_expiry_utc"` Workspace string `json:"workspace"` UnixUser string `json:"unix_user"` } type cliClient struct { baseURL string state *cliState } func RunCLI(args []string) error { _ = godotenv.Load() if len(args) == 0 { return cliUsage() } switch args[0] { case "provision": return runCLIProvision(args[1:]) case "login": return runCLILogin(args[1:]) case "help", "--help", "-h": return cliUsage() default: return fmt.Errorf("unknown command %q\n\n%s", args[0], cliUsageText()) } } func runCLIProvision(args []string) error { state, _ := loadCLIState() fs := flag.NewFlagSet("provision", flag.ContinueOnError) fs.SetOutput(os.Stderr) var apiURL, workspace, unixUser, name, description, repoURL string var repoPrivate bool fs.StringVar(&apiURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL") fs.StringVar(&workspace, "workspace", state.Workspace, "workspace name (required)") fs.StringVar(&unixUser, "unix-user", state.UnixUser, "unix user for workspace (required)") fs.StringVar(&name, "name", "", "project name (required)") fs.StringVar(&description, "description", "", "project description") fs.StringVar(&repoURL, "repo-url", "", "existing repository URL (optional)") fs.BoolVar(&repoPrivate, "private", true, "create Forgejo repo as private when repo-url is empty") if err := fs.Parse(args); err != nil { return err } apiURL = strings.TrimRight(strings.TrimSpace(apiURL), "/") workspace = strings.TrimSpace(workspace) unixUser = strings.TrimSpace(unixUser) name = strings.TrimSpace(name) description = strings.TrimSpace(description) repoURL = strings.TrimSpace(repoURL) if apiURL == "" || workspace == "" || unixUser == "" || name == "" { return errors.New("required flags: --api-url, --workspace, --unix-user, --name") } if repoURL != "" && !isValidRepoURL(repoURL) { return errors.New("repo-url must be a valid repo URL (https://..., ssh://..., or git@...)") } state.APIURL = apiURL state.Workspace = workspace state.UnixUser = unixUser c := cliClient{baseURL: apiURL, state: &state} if err := c.ensureAccessToken(); err != nil { return err } if err := saveCLIState(state); err != nil { return err } wsID, err := c.ensureWorkspace(workspace, unixUser) if err != nil { return err } slug := slugify(name) existing, err := c.findProjectBySlug(slug) if err != nil { return err } if existing != nil { p, err := c.reprovisionProject(existing.ID) if err != nil { return err } printCLIProject("reprovisioned", p) return nil } p, err := c.createProject(wsID, name, description, repoPrivate, repoURL) if err != nil { return err } printCLIProject("provisioned", p) return nil } func runCLILogin(args []string) error { state, _ := loadCLIState() fs := flag.NewFlagSet("login", flag.ContinueOnError) fs.SetOutput(os.Stderr) fs.StringVar(&state.APIURL, "api-url", firstNonEmpty(state.APIURL, getenv("BOX_API_URL", "http://127.0.0.1:8080")), "box server API base URL") fs.StringVar(&state.OIDCIssuerURL, "issuer", firstNonEmpty(state.OIDCIssuerURL, getenv("OIDC_ISSUER_URL", "")), "OIDC issuer URL (authentik)") fs.StringVar(&state.OIDCClientID, "client-id", firstNonEmpty(state.OIDCClientID, getenv("OIDC_CLIENT_ID", "")), "OIDC client ID") fs.StringVar(&state.Workspace, "workspace", state.Workspace, "default workspace name") fs.StringVar(&state.UnixUser, "unix-user", state.UnixUser, "default workspace unix user") if err := fs.Parse(args); err != nil { return err } state.APIURL = strings.TrimRight(strings.TrimSpace(state.APIURL), "/") state.OIDCIssuerURL = strings.TrimRight(strings.TrimSpace(state.OIDCIssuerURL), "/") state.OIDCClientID = strings.TrimSpace(state.OIDCClientID) state.Workspace = strings.TrimSpace(state.Workspace) state.UnixUser = strings.TrimSpace(state.UnixUser) if state.APIURL == "" || state.OIDCIssuerURL == "" || state.OIDCClientID == "" { return errors.New("login requires --api-url, --issuer, and --client-id") } d, err := fetchOIDCDiscoveryForCLI(state.OIDCIssuerURL) if err != nil { return err } token, expiry, refresh, err := runPKCEBrowserLogin(d, state.OIDCClientID) if err != nil { return err } state.AccessToken = token state.RefreshToken = refresh state.TokenExpiryUTC = expiry.UTC().Format(time.RFC3339) c := cliClient{baseURL: state.APIURL, state: &state} me, err := c.me() if err != nil { return fmt.Errorf("login token verification failed: %w", err) } if err := saveCLIState(state); err != nil { return err } fmt.Printf("saved login config at %s\n", cliStatePath()) fmt.Printf("authenticated as username=%s email=%s\n", me.Username, me.Email) return nil } func (c *cliClient) ensureAccessToken() error { if strings.TrimSpace(c.state.AccessToken) == "" { return errors.New("not logged in: run `boxctl login`") } exp, err := time.Parse(time.RFC3339, strings.TrimSpace(c.state.TokenExpiryUTC)) if err == nil && time.Now().UTC().Before(exp.Add(-1*time.Minute)) { return nil } if strings.TrimSpace(c.state.RefreshToken) == "" { return errors.New("access token expired and refresh token missing; run `boxctl login`") } d, err := fetchOIDCDiscoveryForCLI(c.state.OIDCIssuerURL) if err != nil { return err } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("client_id", c.state.OIDCClientID) form.Set("refresh_token", c.state.RefreshToken) body, status, err := postForm(d.TokenURL, form) if err != nil { return err } if status >= 300 { return fmt.Errorf("token refresh failed status=%d body=%s", status, strings.TrimSpace(string(body))) } var tr tokenResponse if err := json.Unmarshal(body, &tr); err != nil { return err } if strings.TrimSpace(tr.AccessToken) == "" { return errors.New("token refresh returned empty access_token") } c.state.AccessToken = tr.AccessToken if strings.TrimSpace(tr.RefreshToken) != "" { c.state.RefreshToken = tr.RefreshToken } c.state.TokenExpiryUTC = time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second).Format(time.RFC3339) return nil } type tokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } func runPKCEBrowserLogin(d oidcDiscovery, clientID string) (accessToken string, expiry time.Time, refreshToken string, err error) { verifier, err := randomURLSafe(64) if err != nil { return "", time.Time{}, "", err } state, err := randomURLSafe(24) if err != nil { return "", time.Time{}, "", err } challenge := pkceS256(verifier) ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", time.Time{}, "", err } defer ln.Close() redirectURI := "http://" + ln.Addr().String() + "/callback" codeCh := make(chan string, 1) errCh := make(chan error, 1) mux := http.NewServeMux() mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("state") != state { http.Error(w, "state mismatch", http.StatusBadRequest) errCh <- errors.New("state mismatch") return } code := strings.TrimSpace(r.URL.Query().Get("code")) if code == "" { http.Error(w, "missing code", http.StatusBadRequest) errCh <- errors.New("missing authorization code") return } _, _ = w.Write([]byte("Login successful. You can close this window.")) codeCh <- code }) server := &http.Server{Handler: mux} go func() { _ = server.Serve(ln) }() defer server.Close() authURL, err := url.Parse(d.AuthURL) if err != nil { return "", time.Time{}, "", err } q := authURL.Query() q.Set("response_type", "code") q.Set("client_id", clientID) q.Set("redirect_uri", redirectURI) q.Set("scope", "openid profile email offline_access") q.Set("code_challenge", challenge) q.Set("code_challenge_method", "S256") q.Set("state", state) authURL.RawQuery = q.Encode() fmt.Printf("Open this URL to log in:\n%s\n", authURL.String()) _ = openBrowser(authURL.String()) var code string select { case code = <-codeCh: case err = <-errCh: return "", time.Time{}, "", err case <-time.After(180 * time.Second): return "", time.Time{}, "", errors.New("login timeout waiting for callback") } form := url.Values{} form.Set("grant_type", "authorization_code") form.Set("client_id", clientID) form.Set("code", code) form.Set("redirect_uri", redirectURI) form.Set("code_verifier", verifier) body, status, err := postForm(d.TokenURL, form) if err != nil { return "", time.Time{}, "", err } if status >= 300 { return "", time.Time{}, "", fmt.Errorf("token exchange failed status=%d body=%s", status, strings.TrimSpace(string(body))) } var tr tokenResponse if err := json.Unmarshal(body, &tr); err != nil { return "", time.Time{}, "", err } if strings.TrimSpace(tr.AccessToken) == "" { return "", time.Time{}, "", errors.New("token exchange returned empty access_token") } return tr.AccessToken, time.Now().UTC().Add(time.Duration(tr.ExpiresIn) * time.Second), tr.RefreshToken, nil } func openBrowser(u string) error { for _, cmd := range [][]string{ {"xdg-open", u}, {"open", u}, } { if err := exec.Command(cmd[0], cmd[1:]...).Start(); err == nil { return nil } } return nil } func randomURLSafe(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func pkceS256(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } func fetchOIDCDiscoveryForCLI(issuer string) (oidcDiscovery, error) { resp, err := http.Get(strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration") if err != nil { return oidcDiscovery{}, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return oidcDiscovery{}, fmt.Errorf("oidc discovery failed status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(body))) } var d oidcDiscovery if err := json.Unmarshal(body, &d); err != nil { return oidcDiscovery{}, err } if d.AuthURL == "" || d.TokenURL == "" { return oidcDiscovery{}, errors.New("oidc discovery missing authorization_endpoint/token_endpoint") } return d, nil } func (c *cliClient) ensureWorkspace(name, unixUser string) (int64, error) { var workspaces []Workspace if err := c.getJSON("/api/workspaces", &workspaces); err != nil { return 0, err } for _, w := range workspaces { if w.Name == name { if w.UnixUser != unixUser { return 0, fmt.Errorf("workspace %q already exists with unix_user=%q (requested=%q)", name, w.UnixUser, unixUser) } return w.ID, nil } } var created Workspace if err := c.postJSON("/api/workspaces", map[string]any{"name": name, "unix_user": unixUser}, &created); err != nil { return 0, err } return created.ID, nil } func (c *cliClient) findProjectBySlug(slug string) (*Project, error) { var projects []Project if err := c.getJSON("/api/projects", &projects); err != nil { return nil, err } for i := range projects { if projects[i].Slug == slug { return &projects[i], nil } } return nil, nil } func (c *cliClient) reprovisionProject(projectID int64) (Project, error) { var p Project err := c.postJSON(fmt.Sprintf("/api/projects/%d/reprovision", projectID), map[string]any{}, &p) return p, err } func (c *cliClient) createProject(workspaceID int64, name, description string, repoPrivate bool, repoURL string) (Project, error) { var p Project err := c.postJSON("/api/projects", map[string]any{ "workspace_id": workspaceID, "name": name, "description": description, "repo_private": repoPrivate, "repo_url": repoURL, }, &p) return p, err } func (c *cliClient) me() (User, error) { var u User err := c.getJSON("/api/me", &u) return u, err } func (c *cliClient) getJSON(path string, out any) error { if err := c.ensureAccessToken(); err != nil { return err } req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.state.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body))) } return json.Unmarshal(body, out) } func (c *cliClient) postJSON(path string, payload any, out any) error { if err := c.ensureAccessToken(); err != nil { return err } b, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.state.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return fmt.Errorf("api %s failed status=%d body=%s", path, resp.StatusCode, strings.TrimSpace(string(body))) } return json.Unmarshal(body, out) } func printCLIProject(action string, p Project) { fmt.Printf("project %s id=%d name=%s slug=%s\n", action, p.ID, p.Name, p.Slug) fmt.Printf("workspace=%s unix_user=%s\n", p.Workspace, p.UnixUser) fmt.Printf("route_host=%s\n", p.RouteHost) if p.AppURL != "" { fmt.Printf("app_url=%s\n", p.AppURL) } if p.RepoURL != "" { fmt.Printf("repo_url=%s\n", p.RepoURL) fmt.Printf("clone_url=%s\n", normalizeCloneURL(p.RepoURL)) } if p.WebhookURL != "" { fmt.Printf("webhook_url=%s\n", p.WebhookURL) } } func normalizeCloneURL(repoURL string) string { u := strings.TrimSpace(repoURL) if u == "" { return "" } if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { if !strings.HasSuffix(strings.ToLower(u), ".git") { return u + ".git" } } return u } func cliStatePath() string { home, err := os.UserHomeDir() if err != nil || home == "" { return ".boxctl.json" } return filepath.Join(home, ".config", "boxctl", "config.json") } func loadCLIState() (cliState, error) { path := cliStatePath() b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return cliState{}, nil } return cliState{}, err } var s cliState if err := json.Unmarshal(b, &s); err != nil { return cliState{}, err } return s, nil } func saveCLIState(s cliState) error { path := cliStatePath() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } b, _ := json.MarshalIndent(s, "", " ") tmp := path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, path) } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } func cliUsage() error { fmt.Fprint(os.Stderr, cliUsageText()) return nil } func cliUsageText() string { return `Usage: go run . # server go run ./cmd/boxctl login --api-url https://box.example.com --issuer https://auth.example.com/application/o/box --client-id box-cli [--workspace ] [--unix-user ] go run ./cmd/boxctl provision --name [--repo-url ] [--description ] [--private=true|false] Commands: login OAuth browser login (PKCE) and save tokens/defaults in ~/.config/boxctl/config.json provision Upsert project via API using bearer token ` }