Skip to content

Commit bd12a24

Browse files
Add javabin init command to scaffold new app repos
Interactive wizard that creates a repo from javaBin/app-template, customizes Dockerfile per runtime (java/kotlin/typescript/python/go), generates app.yaml and deploy workflow, and optionally registers with the platform. Repos default to private.
1 parent efb40b2 commit bd12a24

File tree

3 files changed

+322
-0
lines changed

3 files changed

+322
-0
lines changed

cmd/init.go

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package cmd
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"regexp"
10+
"strings"
11+
12+
gh "github.com/javaBin/javabin-cli/internal/github"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
const templateRepo = "app-template"
17+
18+
var initCmd = &cobra.Command{
19+
Use: "init",
20+
Short: "Scaffold a new app repo from the Javabin app template",
21+
Long: "Interactive wizard that creates a new repo under javaBin/ from the app-template, customizes it for your runtime, and optionally registers it with the platform.",
22+
RunE: runInit,
23+
}
24+
25+
type runtimeConfig struct {
26+
defaultPort string
27+
dockerfile string
28+
}
29+
30+
var runtimes = map[string]runtimeConfig{
31+
"java": {
32+
defaultPort: "8080",
33+
dockerfile: `FROM eclipse-temurin:21-jdk-alpine AS build
34+
WORKDIR /app
35+
COPY pom.xml .
36+
COPY src ./src
37+
RUN apk add --no-cache maven && mvn package -DskipTests
38+
39+
FROM eclipse-temurin:21-jre-alpine
40+
COPY --from=build /app/target/*.jar /app/app.jar
41+
EXPOSE 8080
42+
CMD ["java", "-jar", "/app/app.jar"]
43+
`,
44+
},
45+
"kotlin": {
46+
defaultPort: "8080",
47+
dockerfile: `FROM eclipse-temurin:21-jdk-alpine AS build
48+
WORKDIR /app
49+
COPY pom.xml .
50+
COPY src ./src
51+
RUN apk add --no-cache maven && mvn package -DskipTests
52+
53+
FROM eclipse-temurin:21-jre-alpine
54+
COPY --from=build /app/target/*.jar /app/app.jar
55+
EXPOSE 8080
56+
CMD ["java", "-jar", "/app/app.jar"]
57+
`,
58+
},
59+
"typescript": {
60+
defaultPort: "3000",
61+
dockerfile: `FROM node:22-alpine AS build
62+
WORKDIR /app
63+
COPY package.json pnpm-lock.yaml ./
64+
RUN corepack enable && pnpm install --frozen-lockfile
65+
COPY . .
66+
RUN pnpm build
67+
68+
FROM node:22-alpine
69+
WORKDIR /app
70+
COPY --from=build /app/dist ./dist
71+
COPY --from=build /app/node_modules ./node_modules
72+
COPY --from=build /app/package.json .
73+
EXPOSE 3000
74+
CMD ["node", "dist/index.js"]
75+
`,
76+
},
77+
"python": {
78+
defaultPort: "8000",
79+
dockerfile: `FROM python:3.12-slim
80+
WORKDIR /app
81+
COPY requirements.txt .
82+
RUN pip install --no-cache-dir -r requirements.txt
83+
COPY . .
84+
EXPOSE 8000
85+
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
86+
`,
87+
},
88+
"go": {
89+
defaultPort: "8080",
90+
dockerfile: `FROM golang:1.22-alpine AS build
91+
WORKDIR /app
92+
COPY go.mod go.sum ./
93+
RUN go mod download
94+
COPY . .
95+
RUN CGO_ENABLED=0 go build -o /app/server .
96+
97+
FROM alpine:3.19
98+
COPY --from=build /app/server /server
99+
EXPOSE 8080
100+
CMD ["/server"]
101+
`,
102+
},
103+
}
104+
105+
func runInit(cmd *cobra.Command, args []string) error {
106+
reader := bufio.NewReader(os.Stdin)
107+
prompt := func(label, defaultVal string) string {
108+
if defaultVal != "" {
109+
fmt.Printf("%s [%s]: ", label, defaultVal)
110+
} else {
111+
fmt.Printf("%s: ", label)
112+
}
113+
input, _ := reader.ReadString('\n')
114+
input = strings.TrimSpace(input)
115+
if input == "" {
116+
return defaultVal
117+
}
118+
return input
119+
}
120+
121+
token, err := gh.GetToken()
122+
if err != nil {
123+
return fmt.Errorf("GitHub auth required: %w", err)
124+
}
125+
126+
// Service name
127+
name := prompt("Service name (lowercase, e.g. moresleep)", "")
128+
if name == "" {
129+
return fmt.Errorf("service name is required")
130+
}
131+
name = strings.ToLower(name)
132+
if !regexp.MustCompile(`^[a-z][a-z0-9-]{0,19}$`).MatchString(name) {
133+
return fmt.Errorf("service name must be lowercase alphanumeric with hyphens, start with a letter, max 20 chars")
134+
}
135+
136+
// Check repo doesn't already exist
137+
if gh.RepoExists(token, name) {
138+
return fmt.Errorf("repository javaBin/%s already exists", name)
139+
}
140+
141+
// Team
142+
fmt.Println("\nAvailable teams:")
143+
teams, err := listTeams(token)
144+
if err != nil {
145+
fmt.Printf(" (could not fetch teams: %v)\n", err)
146+
} else {
147+
for _, t := range teams {
148+
fmt.Printf(" - %s\n", t)
149+
}
150+
}
151+
team := prompt("\nTeam", "")
152+
if team == "" {
153+
return fmt.Errorf("team is required")
154+
}
155+
156+
// Runtime
157+
runtimeNames := []string{"java", "kotlin", "typescript", "python", "go"}
158+
fmt.Printf("\nRuntime options: %s\n", strings.Join(runtimeNames, ", "))
159+
runtime := strings.ToLower(prompt("Runtime", "java"))
160+
rc, ok := runtimes[runtime]
161+
if !ok {
162+
return fmt.Errorf("unsupported runtime: %s (choose from: %s)", runtime, strings.Join(runtimeNames, ", "))
163+
}
164+
165+
// Port
166+
port := prompt("Port", rc.defaultPort)
167+
168+
// Visibility
169+
visibility := prompt("Public repo? (y/n)", "n")
170+
private := strings.ToLower(visibility) != "y"
171+
172+
// Confirm
173+
fmt.Println("\n--- New App Summary ---")
174+
fmt.Printf(" Name: %s\n", name)
175+
fmt.Printf(" Team: %s\n", team)
176+
fmt.Printf(" Runtime: %s\n", runtime)
177+
fmt.Printf(" Port: %s\n", port)
178+
if private {
179+
fmt.Println(" Repo: private")
180+
} else {
181+
fmt.Println(" Repo: public")
182+
}
183+
fmt.Println()
184+
185+
confirm := prompt("Create repo and scaffold? (y/n)", "y")
186+
if strings.ToLower(confirm) != "y" {
187+
fmt.Println("Cancelled.")
188+
return nil
189+
}
190+
191+
// Create repo from template
192+
fmt.Printf("\nCreating javaBin/%s from template... ", name)
193+
cloneURL, err := gh.CreateRepoFromTemplate(token, templateRepo, name, fmt.Sprintf("%s service (%s)", name, team), private)
194+
if err != nil {
195+
fmt.Println("failed")
196+
return fmt.Errorf("create repo from template: %w", err)
197+
}
198+
fmt.Println("done")
199+
200+
// Clone locally
201+
fmt.Printf("Cloning into ./%s... ", name)
202+
cloneCmd := exec.Command("git", "clone", cloneURL)
203+
cloneCmd.Stdout = nil
204+
cloneCmd.Stderr = nil
205+
if err := cloneCmd.Run(); err != nil {
206+
fmt.Println("failed")
207+
return fmt.Errorf("git clone: %w", err)
208+
}
209+
fmt.Println("done")
210+
211+
repoDir := filepath.Join(".", name)
212+
213+
// Write app.yaml
214+
appYaml := fmt.Sprintf("name: %s\nteam: %s\ncompute:\n port: %s\n", name, team, port)
215+
if err := os.WriteFile(filepath.Join(repoDir, "app.yaml"), []byte(appYaml), 0644); err != nil {
216+
return fmt.Errorf("write app.yaml: %w", err)
217+
}
218+
fmt.Println(" wrote app.yaml")
219+
220+
// Write Dockerfile
221+
if err := os.WriteFile(filepath.Join(repoDir, "Dockerfile"), []byte(rc.dockerfile), 0644); err != nil {
222+
return fmt.Errorf("write Dockerfile: %w", err)
223+
}
224+
fmt.Println(" wrote Dockerfile")
225+
226+
// Write deploy workflow
227+
workflowDir := filepath.Join(repoDir, ".github", "workflows")
228+
if err := os.MkdirAll(workflowDir, 0755); err != nil {
229+
return fmt.Errorf("create workflow dir: %w", err)
230+
}
231+
deployYaml := `name: Deploy
232+
on:
233+
push:
234+
branches: [main]
235+
pull_request:
236+
237+
jobs:
238+
javabin:
239+
uses: javaBin/platform/.github/workflows/javabin.yml@main
240+
permissions:
241+
id-token: write
242+
contents: read
243+
pull-requests: write
244+
secrets: inherit
245+
`
246+
if err := os.WriteFile(filepath.Join(workflowDir, "deploy.yml"), []byte(deployYaml), 0644); err != nil {
247+
return fmt.Errorf("write deploy.yml: %w", err)
248+
}
249+
fmt.Println(" wrote .github/workflows/deploy.yml")
250+
251+
// Commit and push
252+
fmt.Print("Committing and pushing... ")
253+
gitCmds := [][]string{
254+
{"add", "app.yaml", "Dockerfile", ".github/workflows/deploy.yml"},
255+
{"commit", "-m", fmt.Sprintf("Configure %s for Javabin platform", name)},
256+
{"push"},
257+
}
258+
for _, gitArgs := range gitCmds {
259+
c := exec.Command("git", gitArgs...)
260+
c.Dir = repoDir
261+
if out, err := c.CombinedOutput(); err != nil {
262+
fmt.Println("failed")
263+
return fmt.Errorf("git %s: %w\n%s", gitArgs[0], err, string(out))
264+
}
265+
}
266+
fmt.Println("done")
267+
268+
// Optionally register
269+
doRegister := prompt("\nRegister with platform now? (y/n)", "y")
270+
if strings.ToLower(doRegister) == "y" {
271+
filePath := fmt.Sprintf("apps/%s.yaml", name)
272+
branchName := fmt.Sprintf("register-%s", name)
273+
prTitle := fmt.Sprintf("Register %s", name)
274+
prBody := fmt.Sprintf("Register `javaBin/%s` with team `%s`.\n\nCreated by `javabin init`.", name, team)
275+
regYaml := fmt.Sprintf("name: %s\nteam: %s\nrepo: javaBin/%s\n", name, team, name)
276+
277+
prURL, err := gh.CreateRegistrationPR(token, branchName, filePath, regYaml, prTitle, prBody)
278+
if err != nil {
279+
fmt.Printf(" Could not create registration PR: %v\n", err)
280+
fmt.Println(" You can register later with: javabin register")
281+
} else {
282+
fmt.Printf(" Registration PR: %s\n", prURL)
283+
}
284+
}
285+
286+
fmt.Printf("\nRepo ready: https://github.com/javaBin/%s\n", name)
287+
fmt.Printf("Next: cd %s && start coding!\n", name)
288+
289+
return nil
290+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ func Execute() error {
1515
}
1616

1717
func init() {
18+
rootCmd.AddCommand(initCmd)
1819
rootCmd.AddCommand(registerCmd)
1920
rootCmd.AddCommand(statusCmd)
2021
rootCmd.AddCommand(whoamiCmd)

internal/github/github.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
)
1414

1515
const (
16+
orgName = "javaBin"
1617
registryOwner = "javaBin"
1718
registryRepo = "registry"
1819
apiBase = "https://api.github.com"
@@ -160,6 +161,36 @@ func doRequest(req *http.Request) ([]byte, error) {
160161
return body, nil
161162
}
162163

164+
// CreateRepoFromTemplate creates a new repo under javaBin/ from a template repo.
165+
// Returns the clone URL of the new repo.
166+
func CreateRepoFromTemplate(token, templateRepo, name, description string, private bool) (string, error) {
167+
url := fmt.Sprintf("%s/repos/%s/%s/generate", apiBase, orgName, templateRepo)
168+
payload := map[string]interface{}{
169+
"owner": orgName,
170+
"name": name,
171+
"description": description,
172+
"private": private,
173+
}
174+
respBody, err := ghPost(token, url, payload)
175+
if err != nil {
176+
return "", err
177+
}
178+
var result struct {
179+
CloneURL string `json:"clone_url"`
180+
}
181+
if err := json.Unmarshal(respBody, &result); err != nil {
182+
return "", err
183+
}
184+
return result.CloneURL, nil
185+
}
186+
187+
// RepoExists checks if a repo exists under javaBin/.
188+
func RepoExists(token, name string) bool {
189+
url := fmt.Sprintf("%s/repos/%s/%s", apiBase, orgName, name)
190+
_, err := ghGet(token, url)
191+
return err == nil
192+
}
193+
163194
func getenv(key string) string {
164195
return os.Getenv(key)
165196
}

0 commit comments

Comments
 (0)