diff --git a/cmd/config.go b/cmd/config.go index ef2fa57..4db71f7 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -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 "****" diff --git a/cmd/newproject.go b/cmd/newproject.go new file mode 100644 index 0000000..da39d56 --- /dev/null +++ b/cmd/newproject.go @@ -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 ", + 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 +} diff --git a/cmd/root.go b/cmd/root.go index b64c0b3..a14285a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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{ diff --git a/cmd/ssh.go b/cmd/ssh.go new file mode 100644 index 0000000..17a873b --- /dev/null +++ b/cmd/ssh.go @@ -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 +} diff --git a/cmd/templates.go b/cmd/templates.go new file mode 100644 index 0000000..e2d08e3 --- /dev/null +++ b/cmd/templates.go @@ -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(` + + + + + %s + + +

%s

+ + +`, name, name) +} + +func staticDockerfile() string { + return `FROM nginx:alpine +COPY . /usr/share/nginx/html +EXPOSE 80 +` +} diff --git a/gtea.exe b/gtea.exe new file mode 100644 index 0000000..c9b93f7 Binary files /dev/null and b/gtea.exe differ