Files
Gittea-plugin/cmd/newproject.go
Spencer e88f33071e Add newproject command and fix template bugs
- Add newproject, ssh, templates commands for full project scaffolding
- Add homelab SSH, Caddy, port, and proxy_host fields to Config
- Fix dotEnvExample: use container-internal port (3000/8000/8080) not host port
- Fix caddySnippet: use configurable proxy_host instead of localhost
- Fix dockerComposeTemplate: add build: path so image-less deploys work
- Commit gtea.exe binary
2026-05-29 12:37:14 -04:00

218 lines
6.1 KiB
Go

package cmd
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
gogitea "code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
)
var (
npType string
npPrivate bool
)
var newprojectCmd = &cobra.Command{
Use: "newproject <name>",
Short: "Full project scaffold: local + Gitea + beepc clone + homelab service + Caddy subdomain",
Args: cobra.ExactArgs(1),
RunE: runNewProject,
}
func init() {
newprojectCmd.Flags().StringVarP(&npType, "type", "t", "", "Stack type: node, python, go, static (required)")
newprojectCmd.Flags().BoolVarP(&npPrivate, "private", "p", true, "Make the Gitea repo private")
newprojectCmd.Flags().StringVarP(&repoDesc, "desc", "d", "", "Repo description")
rootCmd.AddCommand(newprojectCmd)
}
func runNewProject(cmd *cobra.Command, args []string) error {
name := args[0]
if npType == "" {
return fmt.Errorf("--type/-t is required: node, python, go, static")
}
switch npType {
case "node", "python", "go", "static":
default:
return fmt.Errorf("unknown type %q — valid: node, python, go, static", npType)
}
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.SSHHost == "" || cfg.SSHUser == "" {
return fmt.Errorf("homelab SSH not configured — run 'gtea config' to set ssh_host and ssh_user")
}
step := func(n int, msg string) { fmt.Printf("[%d/6] %s\n", n, msg) }
fmt.Printf("==> Scaffolding %q (%s)\n\n", name, npType)
// 1. Local directory + git init
step(1, "Creating local project directory...")
if err := os.MkdirAll(name, 0755); err != nil {
return fmt.Errorf("creating folder: %w", err)
}
if err := runGit(name, "init", "-b", "main"); err != nil {
if err2 := runGit(name, "init"); err2 != nil {
return err2
}
}
// 2. Starter files
step(2, "Writing starter files...")
if err := writeStarterFiles(name, npType); err != nil {
return fmt.Errorf("writing starter files: %w", err)
}
// 3. Gitea repo
step(3, "Creating Gitea repo...")
client, err := gogitea.NewClient(cfg.URL, gogitea.SetToken(cfg.Token))
if err != nil {
return fmt.Errorf("connecting to Gitea: %w", err)
}
repo, _, err := client.CreateRepo(gogitea.CreateRepoOption{
Name: name,
Description: repoDesc,
Private: npPrivate,
AutoInit: false,
})
if err != nil {
return fmt.Errorf("creating Gitea repo: %w", err)
}
fmt.Println(" →", repo.HTMLURL)
// 4. Initial commit and push
step(4, "Initial commit and push...")
remote := authCloneURL(cfg, repo.CloneURL)
if err := runGit(name, "remote", "add", "origin", remote); err != nil {
return err
}
if err := runGit(name, "add", "."); err != nil {
return err
}
if err := runGit(name, "commit", "-m", "Initial commit"); err != nil {
return err
}
if err := runGit(name, "push", "-u", "origin", "main"); err != nil {
return err
}
// Resolve config defaults
projectsDir := orDefault(cfg.ProjectsDir, "~/projects")
homelabDir := orDefault(cfg.HomelabDir, "~/homelab")
domain := orDefault(cfg.Domain, "sketchferret.com")
caddyDir := orDefault(cfg.CaddyConfDir, "~/caddy/conf.d")
caddyReload := orDefault(cfg.CaddyReloadCmd, "sudo caddy reload")
proxyHost := orDefault(cfg.ProxyHost, cfg.SSHHost)
portStart := cfg.PortStart
if portStart == 0 {
portStart = 8200
}
// 5. beepc: clone + homelab service
step(5, fmt.Sprintf("Setting up beepc (%s@%s)...", cfg.SSHUser, cfg.SSHHost))
// Auto-assign port by counting existing service dirs
out, _ := sshRun(cfg.SSHUser, cfg.SSHHost,
fmt.Sprintf("ls %s/services/ 2>/dev/null | wc -l", homelabDir))
count, _ := strconv.Atoi(strings.TrimSpace(out))
port := portStart + count
// Clone to ~/projects/<name>
remotePath := projectsDir + "/" + name
cloneURL := authCloneURL(cfg, repo.CloneURL)
if _, err := sshRun(cfg.SSHUser, cfg.SSHHost,
fmt.Sprintf("mkdir -p %s && git clone %s %s", projectsDir, cloneURL, remotePath)); err != nil {
return fmt.Errorf("cloning on beepc: %w", err)
}
fmt.Printf(" Cloned → %s\n", remotePath)
// Homelab service
serviceDir := homelabDir + "/services/" + name
if err := sshWriteFile(cfg.SSHUser, cfg.SSHHost,
serviceDir+"/docker-compose.yml",
dockerComposeTemplate(name, npType, port)); err != nil {
return fmt.Errorf("writing docker-compose.yml: %w", err)
}
if err := sshWriteFile(cfg.SSHUser, cfg.SSHHost,
serviceDir+"/.env.example",
dotEnvExample(name, npType, port)); err != nil {
return fmt.Errorf("writing .env.example: %w", err)
}
fmt.Printf(" Homelab service → %s\n", serviceDir)
// 6. Caddy
step(6, "Adding Caddy config...")
subdomain := name + "." + domain
if err := sshWriteFile(cfg.SSHUser, cfg.SSHHost,
caddyDir+"/"+name+".caddy",
caddySnippet(subdomain, proxyHost, port)); err != nil {
return fmt.Errorf("writing Caddy snippet: %w", err)
}
if _, err := sshRun(cfg.SSHUser, cfg.SSHHost, caddyReload); err != nil {
fmt.Printf(" Warning: Caddy reload failed — reload manually when ready\n")
} else {
fmt.Printf(" https://%s → localhost:%d\n", subdomain, port)
}
fmt.Printf(`
Done!
Local: ./%s
Gitea: %s
beepc: %s
Service: %s
URL: https://%s
Port: %d
`, name, repo.HTMLURL, remotePath, serviceDir, subdomain, port)
return nil
}
func writeStarterFiles(dir, stackType string) error {
files := map[string]string{
".gitignore": gitignoreFor(stackType),
"README.md": "# " + dir + "\n",
}
switch stackType {
case "node":
files["package.json"] = nodePackageJSON(dir)
files["tsconfig.json"] = tsconfigJSON()
files["src/index.ts"] = nodeIndexTS()
files["Dockerfile"] = nodeDockerfile()
case "python":
files["requirements.txt"] = "# Add dependencies here\n"
files["app.py"] = pythonApp()
files["Dockerfile"] = pythonDockerfile()
case "go":
files["go.mod"] = goMod(dir)
files["main.go"] = goMain()
files["Dockerfile"] = goDockerfile()
case "static":
files["index.html"] = staticHTML(dir)
files["Dockerfile"] = staticDockerfile()
}
for relPath, content := range files {
absPath := filepath.Join(dir, relPath)
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
return err
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return err
}
}
return nil
}
func orDefault(s, def string) string {
if s != "" {
return s
}
return def
}