Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,64 @@ jobs:
go test ./...
- name: Vulnerability scan
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

# Scaffold a real full-stack project, install it, set up the DB and hit the
# /health-check endpoint. Catches runtime breakage that `go test` can't (e.g.
# a bad DATABASE_URL that only fails when the app actually opens the DB).
scaffold-smoke:
if: |
(github.event.action == 'review_requested' ||
(github.event.action == 'labeled' && github.event.label.name == 'ci-testing') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'ci-testing'))) ||
(github.event_name == 'push')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# sqlite needs no Docker service, so both ORMs run on the cheap runner.
- orm: drizzle
setup_db: pnpm run db:generate && pnpm run db:migrate
- orm: prisma
# `prisma migrate dev` is interactive; `db push` syncs the schema
# non-interactively for the smoke test.
setup_db: pnpm run db:generate && pnpm exec prisma db push
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
- uses: actions/setup-node@v4
with:
node-version: "22"
- uses: pnpm/action-setup@v4
with:
version: 9
- name: Build CLI
run: go build -o bungkus-cli .
- name: Scaffold ${{ matrix.orm }} + sqlite
run: ./bungkus-cli create app --base astro-react --backend hono --orm ${{ matrix.orm }} --db sqlite --layout flat --pm pnpm --git=false
- name: Install, set up DB, seed
working-directory: app
run: |
cp .env.example .env
pnpm install
${{ matrix.setup_db }}
pnpm run db:seed
- name: Boot API and verify /health-check
working-directory: app
run: |
pnpm run dev:server > server.log 2>&1 &
for i in $(seq 1 30); do
body=$(curl -s http://localhost:8000/health-check || true)
if echo "$body" | grep -q '"status":"ok"'; then
echo "health-check: $body"
echo "$body" | grep -q '"db":"connected"' || { echo "FAIL: db not connected"; exit 1; }
echo "$body" | grep -q 'Ada Lovelace' || { echo "FAIL: seed rows missing"; exit 1; }
exit 0
fi
sleep 2
done
echo "FAIL: /health-check never came up"; cat server.log; exit 1
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,21 @@ my-app/
domain/ # shared contract: zod schemas (with --validation zod) or plain types
```

- **`--backend hono`** runs on Node via `tsx`; **`--backend elysia`** runs on Bun. `pnpm dev` runs `apps/web` and `apps/api` together.
- **`--orm drizzle` / `--orm prisma`** add the config, a `db/` client, and `.env.example` under `apps/api`. `web` and `api` both depend on `packages/domain` via `workspace:*`.
- **`--backend hono`** runs on Node via `tsx`; **`--backend elysia`** runs on Bun. `pnpm dev` runs `apps/web` (`http://localhost:3000`) and `apps/api` (`http://localhost:8000`) together. Every backend exposes `GET /health-check`; with an ORM selected it also runs a read-only query against the database and returns the rows, so you can confirm the DB wiring end-to-end.
- **`--orm drizzle` / `--orm prisma`** add the config, a `db/` client, `.env.example`, and `db:generate` / `db:migrate` / `db:seed` scripts under `apps/api`. `db:seed` inserts a couple of dummy rows so a fresh DB (and `/health-check`) returns real data. `web` and `api` both depend on `packages/domain` via `workspace:*`.
- **`--db postgres` / `--db mysql`** also generate a root `docker-compose.yml` whose credentials match `.env.example`, so `docker compose up -d` gives you a working database. `sqlite` needs nothing extra; `d1` targets Cloudflare Workers (pair with `--deploy cloudflare-workers`).

The post-scaffold summary prints the get-started steps for your exact combo (install, dev, and — when a server database is selected — `docker compose up -d` plus the `db:generate` / `db:migrate` commands).

### AI-agent-ready

Every scaffolded project ships with files that make it work well with Claude Code and other AI agents out of the box:

- **`AGENTS.md`** (and a `CLAUDE.md` pointing to it) — describes your *exact* stack: real commands, both dev URLs, the monorepo map, the ORM/DB workflow, and the `/health-check` probe.
- **`.claude/settings.json`** — a permission allowlist for routine dev commands (package manager, `docker compose` when a server DB is selected, `wrangler` for Cloudflare, read-only git) so agents don't stall on prompts.
- **`.claude/commands/`** — project slash-commands: `/verify` (typecheck + build + test, and curl the health-check), `/format-fix`, and `/new-component` (follows the repo's naming + JSDoc conventions).
- **`.mcp.json`** — with `--test playwright`, a Playwright MCP server so an agent can drive the running app in a browser.

## Project Structure

```
Expand Down
10 changes: 10 additions & 0 deletions clean.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env sh
# Remove the scaffolded my-app folder (dev cleanup). Pass a name to remove a
# different folder: ./clean.sh some-app
target="${1:-my-app}"
if [ -e "$target" ]; then
rm -rf "$target"
echo "Removed $target"
else
echo "Nothing to remove: $target does not exist"
fi
159 changes: 159 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spencer-osbrjp/bungkus-cli/config"
"github.com/spencer-osbrjp/bungkus-cli/pkg"
"github.com/spf13/cobra"
)

var addCmd = &cobra.Command{
Use: "add <option> [dir]",
Short: "Add a tool to an existing project.",
Long: `Add a tool from the bungkus registry to an existing project without
touching your code.

Renders the option's config templates into the project (existing files are
NEVER overwritten — they are skipped and reported) and additively merges the
option's dependencies and scripts into package.json (existing versions and
scripts are never changed; other package.json fields, key order, and the
file's own indent style are preserved).

GitHub Actions workflow files (.github/**) are always written to the git
repository root, even when adding inside a subdirectory such as apps/web.

The package manager is detected from package.json's packageManager field or
a lockfile (searched upward to the git root), and the base framework from
package.json dependencies; use --pm / --base to override. Run "bungkus-cli
add" with no option to list what can be added.`,
Args: cobra.MaximumNArgs(2),
RunE: runAdd,
}

func init() {
rootCmd.AddCommand(addCmd)
addCmd.Flags().String("pm", "", "Package manager override (pnpm, bun, npm, yarn); detected otherwise")
addCmd.Flags().String("base", "", "Base framework override; detected from package.json otherwise")
addCmd.Flags().String("deploy", "", "Deploy target for CI/CD options (cloudflare-pages, cloudflare-workers)")
}

func printAddable() {
fmt.Println("Addable options:")
for _, c := range pkg.AddableOptions() {
fmt.Printf(" %-8s %s\n", c.Name+":", strings.Join(c.Options, ", "))
}
fmt.Println("\ncicd options require --deploy.")
}

func runAdd(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
printAddable()
return nil
}
opt := args[0]
dir := "."
if len(args) == 2 {
dir = args[1]
}

raw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if err != nil {
return fmt.Errorf("no package.json found in %s — 'add' works on an existing project (use 'create' to scaffold a new one)", dir)
}
cfg, err := pkg.DetectProject(dir, raw)
if err != nil {
return err
}
if cmd.Flags().Changed("pm") {
v, _ := cmd.Flags().GetString("pm")
cfg.PM = pkg.PackageManager(v)
}
if cmd.Flags().Changed("base") {
v, _ := cmd.Flags().GetString("base")
cfg.Base = pkg.BaseFramework(v)
}
if cmd.Flags().Changed("deploy") {
v, _ := cmd.Flags().GetString("deploy")
cfg.Deployment = pkg.DeployTarget(v)
}
if cfg.PM == "" {
return fmt.Errorf("could not detect the package manager (no or ambiguous lockfile) — pass --pm (pnpm, bun, npm, yarn)")
}
if !cfg.PM.IsValid() {
return fmt.Errorf("invalid package manager: %s (pnpm, bun, npm, yarn)", cfg.PM)
}
if cfg.Base == "" {
return fmt.Errorf("could not detect the base framework from package.json — pass --base (astro, astro-react, astro-vue, nuxt, vite, vite-react, vite-vue)")
}
if !cfg.Base.IsValid() {
return fmt.Errorf("invalid base framework: %s", cfg.Base)
}
if cfg.Deployment != "none" && !cfg.Deployment.IsValid() {
return fmt.Errorf("invalid deploy target: %s (cloudflare-pages, cloudflare-workers)", cfg.Deployment)
}

rep, err := pkg.Add(dir, config.Templates, cfg, opt)
if errors.Is(err, pkg.ErrUnknownAddOption) {
fmt.Printf("unknown option %q\n\n", opt)
printAddable()
return errors.New("nothing added")
}
if err != nil {
return err
}
printAddReport(rep, dir, opt, cfg)
return nil
}

func printAddReport(rep *pkg.AddReport, dir, opt string, cfg pkg.ProjectConfig) {
fmt.Printf("Added %s (%s) to %s\n\n", opt, strings.Join(rep.Categories, ", "), dir)
for _, f := range rep.CreatedFiles {
if strings.HasPrefix(f, "..") {
fmt.Printf(" created %s (repo root)\n", f)
} else {
fmt.Printf(" created %s\n", f)
}
}
for _, f := range rep.SkippedFiles {
fmt.Printf(" skipped %s (already exists — kept yours)\n", f)
}
if len(rep.DepsAdded)+len(rep.DepsSkipped)+len(rep.ScriptsAdded)+len(rep.ScriptsSkipped) > 0 {
fmt.Println("\n package.json:")
for _, d := range rep.DepsAdded {
fmt.Printf(" + %s\n", d)
}
for _, s := range rep.ScriptsAdded {
fmt.Printf(" + scripts %s\n", s)
}
for _, d := range rep.DepsSkipped {
fmt.Printf(" = %s (already present — kept yours)\n", d)
}
for _, s := range rep.ScriptsSkipped {
fmt.Printf(" = scripts %s (already defined — kept yours)\n", s)
}
}

if rep.WorkflowRelocated {
fmt.Printf("\n note: workflow(s) written to %s/.github — review job steps if this is a monorepo.\n", rep.GitRoot)
}
if rep.NoGitWarning {
for _, f := range rep.CreatedFiles {
if strings.HasPrefix(filepath.ToSlash(f), ".github/") {
fmt.Printf("\n warning: no git repository found — .github/ written under %s;\n", dir)
fmt.Println(" GitHub Actions only reads .github/ at the repo root.")
break
}
}
}

if rep.PkgJSONChanged {
fmt.Printf("\nRun `%s` to install the new dependencies.\n", cfg.PM.InstallCmd())
} else if len(rep.CreatedFiles) == 0 {
fmt.Printf("\nNothing to do — everything %s provides already exists.\n", opt)
}
}
8 changes: 8 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ to scaffold into the current directory.`,
v, _ := cmd.Flags().GetString("audit")
cfg.Audit = pkg.AuditTool(v)
}
if cmd.Flags().Changed("desktop") {
v, _ := cmd.Flags().GetString("desktop")
cfg.Desktop = pkg.DesktopTarget(v)
}
if cmd.Flags().Changed("backend") {
v, _ := cmd.Flags().GetString("backend")
cfg.Backend = pkg.BackendLib(v)
Expand Down Expand Up @@ -264,6 +268,9 @@ to scaffold into the current directory.`,
if cfg.CICD != "none" && cfg.Deployment == "none" {
return fmt.Errorf("--cicd requires a deploy target (--deploy cloudflare-pages or --deploy cloudflare-workers)")
}
if !cfg.Desktop.IsValid() {
return fmt.Errorf("invalid desktop shell: %s (none, tauri)", cfg.Desktop)
}
if !cfg.Backend.IsValid() {
return fmt.Errorf("invalid backend: %s (none, hono, elysia)", cfg.Backend)
}
Expand Down Expand Up @@ -333,6 +340,7 @@ func init() {
createCmd.Flags().String("cms", "none", "CMS (none, microcms)")
createCmd.Flags().String("test", "none", "Testing library (none, playwright)")
createCmd.Flags().String("audit", "none", "Audit / performance tool (none, lhci)")
createCmd.Flags().String("desktop", "none", "Desktop shell (none, tauri)")
createCmd.Flags().StringP("template", "t", "", "Predefined template (astro, astro-react, astro-vue, nuxt, vite, vite-react, vite-vue)")
createCmd.Flags().String("deploy", "none", "Deployment target (none, cloudflare-pages, cloudflare-workers)")
createCmd.Flags().String("cicd", "none", "CI/CD provider (none, github-actions)")
Expand Down
37 changes: 31 additions & 6 deletions config/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@
},
"devDependencies": {
"eslint": "^10.0.0",
"@eslint/js": "^10.0.0"
"@eslint/js": "^10.0.0",
"globals": "^17.4.0",
"typescript-eslint": "^8.58.0"
}
}
},
Expand Down Expand Up @@ -546,6 +548,25 @@
}
}
],
"desktop": [
{
"value": "none",
"label": "None",
"packages": {}
},
{
"value": "tauri",
"label": "Tauri 2",
"packages": {
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4"
}
}
}
],
"backend": [
{
"value": "none",
Expand All @@ -564,7 +585,7 @@
"tsx": "^4.19.2"
},
"scripts": {
"dev:server": "tsx watch server/index.ts"
"dev:server": "tsx watch --env-file-if-exists=.env server/index.ts"
}
}
},
Expand Down Expand Up @@ -595,12 +616,14 @@
"drizzle-orm": "^0.36.4"
},
"devDependencies": {
"drizzle-kit": "^0.28.1"
"drizzle-kit": "^0.28.1",
"tsx": "^4.19.2"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"db:seed": "tsx --env-file-if-exists=.env db/seed.ts"
}
}
},
Expand All @@ -612,12 +635,14 @@
"@prisma/client": "^5.22.0"
},
"devDependencies": {
"prisma": "^5.22.0"
"prisma": "^5.22.0",
"tsx": "^4.19.2"
},
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"db:seed": "tsx --env-file-if-exists=.env prisma/seed.ts"
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions config/templates/agent/mcp/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
Loading
Loading