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
This commit is contained in:
2026-05-29 12:37:14 -04:00
parent b8c6e129e8
commit e88f33071e
6 changed files with 530 additions and 0 deletions

View File

@@ -24,6 +24,32 @@ var configCmd = &cobra.Command{
cfg.Username = prompt(reader, fmt.Sprintf("Username [%s]", cfg.Username), cfg.Username)
cfg.Token = prompt(reader, fmt.Sprintf("API Token [%s]", maskToken(cfg.Token)), cfg.Token)
fmt.Println("\n--- Homelab / beepc ---")
cfg.SSHHost = prompt(reader, fmt.Sprintf("SSH host (e.g. beepc) [%s]", cfg.SSHHost), cfg.SSHHost)
cfg.SSHUser = prompt(reader, fmt.Sprintf("SSH user [%s]", cfg.SSHUser), cfg.SSHUser)
cfg.ProjectsDir = promptDefault(reader, fmt.Sprintf("Projects dir on host [%s]", cfg.ProjectsDir), cfg.ProjectsDir, "~/projects")
cfg.HomelabDir = promptDefault(reader, fmt.Sprintf("Homelab repo dir on host [%s]", cfg.HomelabDir), cfg.HomelabDir, "~/homelab")
proxyDefault := cfg.ProxyHost
if proxyDefault == "" {
proxyDefault = cfg.SSHHost
}
cfg.ProxyHost = promptDefault(reader, fmt.Sprintf("Proxy host for Caddy (IP/hostname VPS uses to reach beepc) [%s]", proxyDefault), cfg.ProxyHost, proxyDefault)
fmt.Println("\n--- Caddy ---")
cfg.CaddyConfDir = promptDefault(reader, fmt.Sprintf("Caddy conf.d dir [%s]", cfg.CaddyConfDir), cfg.CaddyConfDir, "~/caddy/conf.d")
cfg.CaddyReloadCmd = promptDefault(reader, fmt.Sprintf("Caddy reload command [%s]", cfg.CaddyReloadCmd), cfg.CaddyReloadCmd, "sudo caddy reload")
cfg.Domain = promptDefault(reader, fmt.Sprintf("Base domain [%s]", cfg.Domain), cfg.Domain, "sketchferret.com")
fmt.Println("\n--- Port assignment ---")
portStr := ""
if cfg.PortStart != 0 {
portStr = fmt.Sprintf("%d", cfg.PortStart)
}
portInput := promptDefault(reader, fmt.Sprintf("Port range start [%s]", portStr), portStr, "8200")
if p, err := fmt.Sscanf(portInput, "%d", &cfg.PortStart); p == 0 || err != nil {
cfg.PortStart = 8200
}
if err := saveConfig(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}
@@ -42,6 +68,15 @@ func prompt(reader *bufio.Reader, label, fallback string) string {
return input
}
// promptDefault returns fallback if input is empty, otherwise defaultVal if fallback is also empty.
func promptDefault(reader *bufio.Reader, label, fallback, defaultVal string) string {
result := prompt(reader, label, fallback)
if result == "" {
return defaultVal
}
return result
}
func maskToken(t string) string {
if len(t) < 4 {
return "****"

217
cmd/newproject.go Normal file
View File

@@ -0,0 +1,217 @@
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
}

View File

@@ -17,6 +17,21 @@ type Config struct {
URL string `json:"url"`
Token string `json:"token"`
Username string `json:"username"`
// Homelab / beepc
SSHHost string `json:"ssh_host"`
SSHUser string `json:"ssh_user"`
ProjectsDir string `json:"projects_dir"`
HomelabDir string `json:"homelab_dir"`
ProxyHost string `json:"proxy_host"` // IP/hostname Caddy on VPS uses to reach beepc
// Caddy reverse proxy
CaddyConfDir string `json:"caddy_conf_dir"`
CaddyReloadCmd string `json:"caddy_reload_cmd"`
// Project defaults
Domain string `json:"domain"`
PortStart int `json:"port_range_start"`
}
var rootCmd = &cobra.Command{

37
cmd/ssh.go Normal file
View File

@@ -0,0 +1,37 @@
package cmd
import (
"fmt"
"os/exec"
"path"
"strings"
)
// sshRun executes a shell command on the remote host, returning combined output.
// Uses the system ssh binary so existing keys and ~/.ssh/config are honored.
func sshRun(user, host, command string) (string, error) {
c := exec.Command("ssh", "-o", "BatchMode=yes", user+"@"+host, command)
out, err := c.CombinedOutput()
if err != nil {
trimmed := strings.TrimSpace(string(out))
if trimmed != "" {
return string(out), fmt.Errorf("ssh %s@%s: %s: %w", user, host, trimmed, err)
}
return string(out), fmt.Errorf("ssh %s@%s: command failed: %w", user, host, err)
}
return string(out), nil
}
// sshWriteFile writes content to a remote file, creating parent directories.
// Content is streamed via stdin to avoid shell escaping issues.
func sshWriteFile(user, host, remotePath, content string) error {
dir := path.Dir(remotePath)
cmd := fmt.Sprintf("mkdir -p %s && cat > %s", dir, remotePath)
c := exec.Command("ssh", "-o", "BatchMode=yes", user+"@"+host, cmd)
c.Stdin = strings.NewReader(content)
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("writing %s on %s@%s: %s: %w",
remotePath, user, host, strings.TrimSpace(string(out)), err)
}
return nil
}

226
cmd/templates.go Normal file
View File

@@ -0,0 +1,226 @@
package cmd
import "fmt"
func gitignoreFor(stackType string) string {
base := ".env\n.env.*\n!.env.example\n"
switch stackType {
case "node":
return base + "node_modules/\ndist/\n.next/\n*.log\n"
case "python":
return base + "__pycache__/\n*.pyc\n.venv/\nvenv/\n*.egg-info/\ndist/\nbuild/\n"
case "go":
return base + "*.exe\n*.test\n*.out\n/vendor/\n"
case "static":
return base + "dist/\n.cache/\n"
}
return base
}
func dockerComposeTemplate(name, stackType string, port int) string {
projectsDir := "~/projects"
var svc string
switch stackType {
case "node":
svc = fmt.Sprintf(
" app:\n image: ${IMAGE:-ghcr.io/sketchferret/%s:latest}\n"+
" build: %s/%s\n"+
" restart: unless-stopped\n ports:\n - \"%d:3000\"\n env_file: .env\n",
name, projectsDir, name, port)
case "python":
svc = fmt.Sprintf(
" app:\n image: ${IMAGE:-ghcr.io/sketchferret/%s:latest}\n"+
" build: %s/%s\n"+
" restart: unless-stopped\n ports:\n - \"%d:8000\"\n env_file: .env\n",
name, projectsDir, name, port)
case "go":
svc = fmt.Sprintf(
" app:\n image: ${IMAGE:-ghcr.io/sketchferret/%s:latest}\n"+
" build: %s/%s\n"+
" restart: unless-stopped\n ports:\n - \"%d:8080\"\n env_file: .env\n",
name, projectsDir, name, port)
case "static":
svc = fmt.Sprintf(
" web:\n image: nginx:alpine\n restart: unless-stopped\n"+
" ports:\n - \"%d:80\"\n volumes:\n - ./dist:/usr/share/nginx/html:ro\n",
port)
}
return "services:\n" + svc
}
func dotEnvExample(name, stackType string, port int) string {
_ = port // host port is set by compose; container uses its own internal port
header := fmt.Sprintf("# %s environment\n# Copy to .env and fill in values\n\n", name)
switch stackType {
case "node":
return header + "PORT=3000\n# DATABASE_URL=\n# SECRET_KEY=\n"
case "python":
return header + "PORT=8000\n# DATABASE_URL=\n# SECRET_KEY=\n"
case "go":
return header + "PORT=8080\n# DATABASE_URL=\n"
}
return header
}
func caddySnippet(subdomain, proxyHost string, port int) string {
return fmt.Sprintf("%s {\n\treverse_proxy %s:%d\n}\n", subdomain, proxyHost, port)
}
// --- local starter files ---
func nodePackageJSON(name string) string {
return fmt.Sprintf(`{
"name": "%s",
"version": "0.1.0",
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.0.0",
"ts-node": "^10.0.0",
"@types/node": "^20.0.0"
}
}
`, name)
}
func tsconfigJSON() string {
return `{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
}
}
`
}
func nodeIndexTS() string {
// Template literal backtick handled via concatenation
return "import http from 'http';\n\n" +
"const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;\n\n" +
"const server = http.createServer((_req, res) => {\n" +
" res.writeHead(200, { 'Content-Type': 'text/plain' });\n" +
" res.end('Hello World\\n');\n" +
"});\n\n" +
"server.listen(port, () => {\n" +
" console.log(`Server running on port ${port}`);\n" +
"});\n"
}
func nodeDockerfile() string {
return `FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
`
}
func pythonApp() string {
return `from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello World"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
`
}
func pythonDockerfile() string {
return `FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
`
}
func goMod(name string) string {
return fmt.Sprintf("module %s\n\ngo 1.22\n", name)
}
func goMain() string {
return `package main
import (
"fmt"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello World")
})
fmt.Println("Listening on :" + port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
`
}
func goDockerfile() string {
return `FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app .
FROM scratch
COPY --from=builder /app/app /app
EXPOSE 8080
CMD ["/app"]
`
}
func staticHTML(name string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
</head>
<body>
<h1>%s</h1>
</body>
</html>
`, name, name)
}
func staticDockerfile() string {
return `FROM nginx:alpine
COPY . /usr/share/nginx/html
EXPOSE 80
`
}