Skip to content

Commit 23ff670

Browse files
Initial javabin CLI with register, status, and whoami commands
Go CLI using cobra for command framework and AWS SDK v2. Commands: - register: interactive wizard, creates registration PR against javaBin/registry - status: project costs (Cost Explorer), ECS services, deployment info - whoami: AWS identity (STS), GitHub user (gh CLI) Auth: GitHub via gh CLI token or GITHUB_TOKEN env var. AWS via standard credential chain. Cognito deferred to Phase 1. Includes GoReleaser config for cross-platform builds and Homebrew tap.
0 parents  commit 23ff670

File tree

12 files changed

+839
-0
lines changed

12 files changed

+839
-0
lines changed

.goreleaser.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
version: 2
2+
3+
builds:
4+
- main: .
5+
binary: javabin
6+
env:
7+
- CGO_ENABLED=0
8+
goos:
9+
- darwin
10+
- linux
11+
goarch:
12+
- amd64
13+
- arm64
14+
ldflags:
15+
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}}
16+
17+
archives:
18+
- format: tar.gz
19+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
20+
21+
brews:
22+
- repository:
23+
owner: javaBin
24+
name: homebrew-tap
25+
homepage: https://github.com/javaBin/javabin-cli
26+
description: Developer CLI for the Javabin platform
27+
install: |
28+
bin.install "javabin"
29+
30+
checksum:
31+
name_template: checksums.txt
32+
33+
changelog:
34+
sort: asc
35+
filters:
36+
exclude:
37+
- "^docs:"
38+
- "^test:"

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# javabin CLI
2+
3+
Developer CLI for the Javabin platform.
4+
5+
## Install
6+
7+
```bash
8+
brew install javaBin/tap/javabin # macOS/Linux via Homebrew
9+
go install github.com/javaBin/javabin-cli@latest # Go toolchain
10+
```
11+
12+
## Commands
13+
14+
### `javabin register`
15+
16+
Interactive wizard to register a new app with the platform. Creates a PR against [javaBin/registry](https://github.com/javaBin/registry).
17+
18+
```bash
19+
javabin register
20+
```
21+
22+
### `javabin status`
23+
24+
Show project status: costs, ECS services, deployments.
25+
26+
```bash
27+
javabin status # infers project from git remote
28+
javabin status --project moresleep
29+
```
30+
31+
### `javabin whoami`
32+
33+
Show current identity (AWS + GitHub).
34+
35+
```bash
36+
javabin whoami
37+
```
38+
39+
## Authentication
40+
41+
- **GitHub:** Uses `gh auth token` if available, or `GITHUB_TOKEN`/`GH_TOKEN` environment variables
42+
- **AWS:** Standard credential chain (environment variables, `~/.aws/credentials`, SSO)
43+
44+
## What This CLI Does NOT Do
45+
46+
- No deploy, plan, apply, or generate commands — those run exclusively in CI
47+
- No infrastructure management — use `app.yaml` and let the platform handle it
48+
49+
## Development
50+
51+
```bash
52+
go build -o javabin .
53+
./javabin --help
54+
```
55+
56+
## Release
57+
58+
Releases are built with [GoReleaser](https://goreleaser.com/) on semver tags. Binaries are published to GitHub Releases and the Homebrew tap.

cmd/register.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"strings"
10+
11+
"github.com/javaBin/javabin-cli/internal/config"
12+
gh "github.com/javaBin/javabin-cli/internal/github"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var registerCmd = &cobra.Command{
17+
Use: "register",
18+
Short: "Register a new app with the Javabin platform",
19+
Long: "Interactive wizard that creates a registration PR against javaBin/registry.",
20+
RunE: runRegister,
21+
}
22+
23+
func runRegister(cmd *cobra.Command, args []string) error {
24+
reader := bufio.NewReader(os.Stdin)
25+
prompt := func(label, defaultVal string) string {
26+
if defaultVal != "" {
27+
fmt.Printf("%s [%s]: ", label, defaultVal)
28+
} else {
29+
fmt.Printf("%s: ", label)
30+
}
31+
input, _ := reader.ReadString('\n')
32+
input = strings.TrimSpace(input)
33+
if input == "" {
34+
return defaultVal
35+
}
36+
return input
37+
}
38+
39+
token, err := gh.GetToken()
40+
if err != nil {
41+
return fmt.Errorf("GitHub auth required: %w", err)
42+
}
43+
44+
// Repo name
45+
repoName := prompt("Repository name (e.g. moresleep)", "")
46+
if repoName == "" {
47+
return fmt.Errorf("repository name is required")
48+
}
49+
50+
// Validate repo exists
51+
fmt.Printf("Checking javaBin/%s exists... ", repoName)
52+
if !repoExists(token, repoName) {
53+
fmt.Println("not found")
54+
return fmt.Errorf("repository javaBin/%s does not exist", repoName)
55+
}
56+
fmt.Println("ok")
57+
58+
// List teams from registry
59+
fmt.Println("\nAvailable teams:")
60+
teams, err := listTeams(token)
61+
if err != nil {
62+
fmt.Printf(" (could not fetch teams: %v)\n", err)
63+
} else {
64+
for _, t := range teams {
65+
fmt.Printf(" - %s\n", t)
66+
}
67+
}
68+
team := prompt("\nTeam", "")
69+
if team == "" {
70+
return fmt.Errorf("team is required")
71+
}
72+
73+
// Auth
74+
fmt.Println("\nAuth options: internal, external, both, none")
75+
auth := prompt("Auth", "none")
76+
77+
// Budget
78+
budget := prompt("Monthly budget (NOK)", "1000")
79+
80+
// Dev environment
81+
devEnv := prompt("Need a dev environment? (y/n)", "n")
82+
83+
// Confirm
84+
fmt.Println("\n--- Registration Summary ---")
85+
fmt.Printf(" Repo: javaBin/%s\n", repoName)
86+
fmt.Printf(" Team: %s\n", team)
87+
fmt.Printf(" Auth: %s\n", auth)
88+
fmt.Printf(" Budget: %s NOK\n", budget)
89+
if strings.ToLower(devEnv) == "y" {
90+
fmt.Println(" Dev: yes")
91+
}
92+
fmt.Println()
93+
94+
confirm := prompt("Create registration PR? (y/n)", "y")
95+
if strings.ToLower(confirm) != "y" {
96+
fmt.Println("Cancelled.")
97+
return nil
98+
}
99+
100+
// Build app YAML content
101+
var yamlLines []string
102+
yamlLines = append(yamlLines, fmt.Sprintf("name: %s", repoName))
103+
yamlLines = append(yamlLines, fmt.Sprintf("team: %s", team))
104+
yamlLines = append(yamlLines, fmt.Sprintf("repo: javaBin/%s", repoName))
105+
if auth != "none" && auth != "" {
106+
yamlLines = append(yamlLines, fmt.Sprintf("auth: %s", auth))
107+
}
108+
if budget != "1000" && budget != "" {
109+
yamlLines = append(yamlLines, fmt.Sprintf("budget_alert_nok: %s", budget))
110+
}
111+
yamlContent := strings.Join(yamlLines, "\n") + "\n"
112+
113+
// Create PR via GitHub API
114+
filePath := fmt.Sprintf("apps/%s.yaml", repoName)
115+
branchName := fmt.Sprintf("register-%s", repoName)
116+
prTitle := fmt.Sprintf("Register %s", repoName)
117+
prBody := fmt.Sprintf("Register `javaBin/%s` with team `%s`.\n\nCreated by `javabin register`.", repoName, team)
118+
119+
prURL, err := gh.CreateRegistrationPR(token, branchName, filePath, yamlContent, prTitle, prBody)
120+
if err != nil {
121+
return fmt.Errorf("failed to create PR: %w", err)
122+
}
123+
124+
fmt.Printf("\nRegistration PR created: %s\n", prURL)
125+
fmt.Println("A platform owner will review and merge it.")
126+
127+
_ = config.EnsureConfigDir()
128+
return nil
129+
}
130+
131+
func repoExists(token, name string) bool {
132+
req, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/javaBin/%s", name), nil)
133+
req.Header.Set("Authorization", "Bearer "+token)
134+
resp, err := http.DefaultClient.Do(req)
135+
if err != nil {
136+
return false
137+
}
138+
defer resp.Body.Close()
139+
return resp.StatusCode == 200
140+
}
141+
142+
func listTeams(token string) ([]string, error) {
143+
url := "https://api.github.com/repos/javaBin/registry/contents/teams"
144+
req, _ := http.NewRequest("GET", url, nil)
145+
req.Header.Set("Authorization", "Bearer "+token)
146+
resp, err := http.DefaultClient.Do(req)
147+
if err != nil {
148+
return nil, err
149+
}
150+
defer resp.Body.Close()
151+
if resp.StatusCode != 200 {
152+
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
153+
}
154+
var items []struct {
155+
Name string `json:"name"`
156+
}
157+
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
158+
return nil, err
159+
}
160+
var teams []string
161+
for _, item := range items {
162+
if strings.HasSuffix(item.Name, ".yaml") {
163+
teams = append(teams, strings.TrimSuffix(item.Name, ".yaml"))
164+
}
165+
}
166+
return teams, nil
167+
}

cmd/root.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
var rootCmd = &cobra.Command{
8+
Use: "javabin",
9+
Short: "Javabin platform CLI",
10+
Long: "Developer CLI for the Javabin platform. Register apps, check status, and manage identity.",
11+
}
12+
13+
func Execute() error {
14+
return rootCmd.Execute()
15+
}
16+
17+
func init() {
18+
rootCmd.AddCommand(registerCmd)
19+
rootCmd.AddCommand(statusCmd)
20+
rootCmd.AddCommand(whoamiCmd)
21+
}

cmd/status.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/javaBin/javabin-cli/internal/aws"
10+
"github.com/javaBin/javabin-cli/internal/config"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var projectFlag string
15+
16+
var statusCmd = &cobra.Command{
17+
Use: "status",
18+
Short: "Show project status (costs, services, deployments)",
19+
RunE: runStatus,
20+
}
21+
22+
func init() {
23+
statusCmd.Flags().StringVar(&projectFlag, "project", "", "Project name (inferred from git remote if not set)")
24+
}
25+
26+
func runStatus(cmd *cobra.Command, args []string) error {
27+
project := projectFlag
28+
if project == "" {
29+
project = inferProject()
30+
}
31+
if project == "" {
32+
return fmt.Errorf("could not infer project name — use --project flag or run from a javaBin repo")
33+
}
34+
35+
fmt.Printf("Project: %s\n\n", project)
36+
ctx := context.Background()
37+
38+
cfg, err := aws.LoadConfig(ctx)
39+
if err != nil {
40+
return fmt.Errorf("AWS credentials not configured: %w", err)
41+
}
42+
43+
// Cost this month
44+
fmt.Println("--- Costs (month-to-date) ---")
45+
cost, err := aws.GetMonthlyCost(ctx, cfg, project)
46+
if err != nil {
47+
fmt.Printf(" Could not fetch costs: %v\n", err)
48+
} else {
49+
fmt.Printf(" Spend: $%.2f\n", cost)
50+
}
51+
52+
// ECS services
53+
fmt.Println("\n--- ECS Services ---")
54+
services, err := aws.ListServices(ctx, cfg, "javabin-platform")
55+
if err != nil {
56+
fmt.Printf(" Could not list services: %v\n", err)
57+
} else if len(services) == 0 {
58+
fmt.Println(" No running services")
59+
} else {
60+
for _, svc := range services {
61+
if strings.Contains(svc.Name, project) {
62+
fmt.Printf(" %s running=%d desired=%d\n", svc.Name, svc.RunningCount, svc.DesiredCount)
63+
}
64+
}
65+
}
66+
67+
// TODO: Last 5 deployments (requires ECS describe-services with deployments)
68+
// TODO: Untagged resources (requires Config or resource group tagging API)
69+
70+
_ = config.EnsureConfigDir()
71+
return nil
72+
}
73+
74+
func inferProject() string {
75+
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
76+
if err != nil {
77+
return ""
78+
}
79+
url := strings.TrimSpace(string(out))
80+
// Handle both HTTPS and SSH URLs
81+
// https://github.com/javaBin/moresleep.git -> moresleep
82+
// git@github.com:javaBin/moresleep.git -> moresleep
83+
for _, prefix := range []string{
84+
"https://github.com/javaBin/",
85+
"git@github.com:javaBin/",
86+
} {
87+
if strings.HasPrefix(url, prefix) {
88+
name := strings.TrimPrefix(url, prefix)
89+
name = strings.TrimSuffix(name, ".git")
90+
return name
91+
}
92+
}
93+
return ""
94+
}

0 commit comments

Comments
 (0)