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 ", 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/ 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 }