- 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
218 lines
6.1 KiB
Go
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
|
|
}
|