Skip to content

Commit cd409e7

Browse files
committed
add frontend-only workspace kind: scaffold frontend/ apps without Go backend, devops, or backend verify gate
1 parent a435f2b commit cd409e7

10 files changed

Lines changed: 269 additions & 50 deletions

File tree

ronyup/README.MD

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ This copies the embedded workspace skeleton, initializes a `go.work`, and create
4949

5050
Pass `--kind fullstack` to scaffold a `backend/` + `frontend/` split instead of a Go-only repo. The Go workspace (`go.work`, `cmd/service`, `pkg`, `feature`, `Makefile`, `.golangci.yml`) is created under `backend/`, while `devops/`, `docs/`, and the AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) stay at the root. In fullstack mode, run `setup feature` and `make`/`go` commands from the `backend/` directory.
5151

52+
Pass `--kind frontend` to scaffold a frontend-only repo: a `frontend/` application plus shared AI assistant config and `docs/` at the root, with no Go workspace, `devops/`, or backend verify gate.
53+
5254
### Add a feature
5355

5456
```bash
@@ -123,7 +125,7 @@ Workspace flags:
123125

124126
- `--repoDir`, `-r`: destination directory for the workspace.
125127
- `--appName`, `-a`: application name (default `myapp`).
126-
- `--kind`, `-k`: workspace kind, `backend` (default) or `fullstack`.
128+
- `--kind`, `-k`: workspace kind, `backend` (default), `fullstack`, or `frontend`.
127129

128130
Feature flags:
129131

@@ -214,7 +216,7 @@ Flags:
214216
```
215217
-r, --repoDir string destination directory for the setup (default "./my-repo")
216218
-a, --appName string application name (default "myapp")
217-
-k, --kind string workspace kind: backend | fullstack (default "backend")
219+
-k, --kind string workspace kind: backend | fullstack | frontend (default "backend")
218220
```
219221

220222
What it does:
@@ -226,6 +228,7 @@ What it does:
226228
Kinds:
227229
- `backend` (default): a Go-only workspace at `repoDir`.
228230
- `fullstack`: a `backend/` + `frontend/` split. The Go workspace is created under `backend/` (modules are prefixed with `backend/`, e.g. `github.com/you/repo/backend/cmd/service`), while `devops/`, `docs/`, and AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) stay at the root and are shared. Run `setup feature` from the `backend/` directory.
231+
- `frontend`: a frontend-only workspace — a `frontend/` application plus shared AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) and `docs/` at the root. No Go workspace, `devops/`, `Makefile`, or backend verify gate is created; only the frontend verify stop hook is installed.
229232

230233
#### setup feature
231234

ronyup/cmd/mcp/knowledge/resources/architecture/workspace-layout.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
`ronyup setup workspace` supports two layouts via `--kind` (default `backend`):
1+
`ronyup setup workspace` supports three layouts via `--kind` (default `backend`):
22

33
- **`backend`** — a Go-only workspace at the repository root.
44
- **`fullstack`** — a `backend/` + `frontend/` split. The Go workspace is moved into `backend/`, while AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`), `devops/`, and `docs/` stay at the repository root and are shared by both sides.
5+
- **`frontend`** — a frontend-only workspace: a `frontend/` application plus shared AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) and `docs/` at the repository root. There is **no** Go workspace, `devops/`, `Makefile`, or backend verify gate; only the frontend verify stop hook is installed.
56

67
## Backend layout (root of the Go workspace)
78

@@ -31,9 +32,9 @@ Each module under `feature/<name>/` (or `feature/<template>/<name>/` when groupe
3132
- In `backend` kind, modules are `<repoModule>/cmd/service`, `<repoModule>/feature/<name>`, etc.
3233
- In `fullstack` kind, the Go workspace is nested, so modules are `<repoModule>/backend/cmd/service`, `<repoModule>/backend/feature/<name>`, etc. Run `ronyup setup feature` (and `go`/`make` commands) from the `backend/` directory. `docs/design` still lives at the repository root, and the `scaffold_feature` design gate resolves it from the parent of `backend/` automatically.
3334

34-
## Frontend (fullstack only)
35+
## Frontend (`fullstack` and `frontend` kinds)
3536

36-
- `frontend/` — holds the web/mobile application(s). It is framework-agnostic by default (a placeholder `README.MD`); initialize it with the stack of your choice (React/Vite, Next.js, SvelteKit, …) and call the backend via its OpenAPI spec at `/docs`.
37+
- `frontend/` — holds the web/mobile application(s) at the repository root. It is framework-agnostic by default (a placeholder `README.MD`); initialize it with the stack of your choice (React/Vite, Next.js, SvelteKit, …). In `fullstack` workspaces it calls the backend via its OpenAPI spec at `/docs`; in `frontend`-only workspaces it talks to external services over their HTTP/OpenAPI APIs.
3738
- **One app vs. many — always clarify first.** Do not assume a single frontend app. Before creating or editing anything under `frontend/`, ask the user whether there is one app or multiple.
3839
- Single app: code may live directly under `frontend/`.
3940
- Multiple apps: give each app its own directory, `frontend/<app-name>/` (e.g. `frontend/admin/`, `frontend/web/`). Confirm which app a change targets, and the app name/stack when initializing a new one, before proceeding.

ronyup/cmd/mcp/knowledge/resources/tools/scaffold_workspace.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ The tool runs `ronyup setup workspace` with the provided `path` as the working d
1010

1111
- **`backend`** (default) — a Go-only workspace at `path`, containing `go.work`, `cmd/service/`, `pkg/i18n/`, an empty `feature/` tree, `devops/`, `docs/`, and a `.ai/mcp/mcp.json` for IDE integration.
1212
- **`fullstack`** — a `backend/` + `frontend/` split. The Go workspace (`go.work`, `cmd/service/`, `pkg/`, `feature/`, `Makefile`, `.golangci.yml`) is created under `backend/`, while `devops/`, `docs/`, and the AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) stay at the root and are shared. A framework-agnostic `frontend/` placeholder is created for the web/mobile app.
13+
- **`frontend`** — a frontend-only workspace: a framework-agnostic `frontend/` placeholder plus shared AI assistant config (`.ai/`, `.agents/`, `.cursor/`, `AGENTS.md`) and `docs/` at the root. No Go workspace, `devops/`, `Makefile`, or backend verify gate is created; only the frontend verify stop hook is installed.
1314

1415
For `fullstack`, Go module paths are prefixed with `backend/` (e.g. `<repoModule>/backend/cmd/service`). Run `scaffold_feature` (and `go`/`make` commands) against the `backend/` directory; the design gate still finds `docs/design` at the repository root.
1516

ronyup/cmd/mcp/tools/scaffold/scaffold.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ func registerSetupWorkspace(srv *mcpsdk.Server, runner Runner, executable string
3333
},
3434
"kind": map[string]any{
3535
"type": "string",
36-
"description": "Workspace layout: 'backend' (Go-only at the root, default) or " +
36+
"description": "Workspace layout: 'backend' (Go-only at the root, default), " +
3737
"'fullstack' (backend/ + frontend/ split, with the Go workspace under backend/ " +
38-
"and devops/, docs/ and AI config kept at the root).",
38+
"and devops/, docs/ and AI config kept at the root), or 'frontend' (frontend/ app " +
39+
"plus shared AI config and docs/ at the root, with no Go workspace).",
3940
"default": "backend",
40-
"enum": []string{"backend", "fullstack"},
41+
"enum": []string{"backend", "fullstack", "frontend"},
4142
},
4243
"skills": map[string]any{
4344
"type": "array",

ronyup/cmd/setup/interactive.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,11 @@ func runWorkspaceInteractive(cmd *cobra.Command) error {
9393
Value(&opt.ApplicationName),
9494
huh.NewSelect[string]().
9595
Title("Workspace Kind").
96-
Description("Backend only, or backend + frontend split").
96+
Description("Backend only, backend + frontend split, or frontend only").
9797
Options(
9898
huh.NewOption("Backend only", KindBackend),
9999
huh.NewOption("Backend + Frontend", KindFullstack),
100+
huh.NewOption("Frontend only", KindFrontend),
100101
).
101102
Value(&opt.Kind),
102103
),

ronyup/cmd/setup/setup.go

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,17 @@ const (
2222
// KindFullstack scaffolds a backend/ + frontend/ split: the Go workspace
2323
// is moved into backend/ while AI config, devops/ and docs/ stay at root.
2424
KindFullstack = "fullstack"
25+
// KindFrontend scaffolds a frontend-only workspace: a frontend/ application
26+
// plus shared AI config and docs/ at the root, with no Go workspace.
27+
KindFrontend = "frontend"
2528
)
2629

30+
// hasBackend reports whether the workspace kind includes a Go backend.
31+
func hasBackend(kind string) bool { return kind != KindFrontend }
32+
33+
// hasFrontend reports whether the workspace kind includes a frontend app.
34+
func hasFrontend(kind string) bool { return kind != KindBackend }
35+
2736
// backendDir is the subdirectory that holds the Go workspace for fullstack
2837
// scaffolds.
2938
const backendDir = "backend"
@@ -133,7 +142,7 @@ func init() {
133142
"kind",
134143
"k",
135144
KindBackend,
136-
"workspace kind: backend | fullstack",
145+
"workspace kind: backend | fullstack | frontend",
137146
)
138147
workspaceFlagSet.StringSliceVarP(
139148
&opt.Skills,
@@ -146,7 +155,7 @@ func init() {
146155
_ = CmdSetupWorkspace.RegisterFlagCompletionFunc(
147156
"kind",
148157
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
149-
return []string{KindBackend, KindFullstack}, cobra.ShellCompDirectiveNoFileComp
158+
return []string{KindBackend, KindFullstack, KindFrontend}, cobra.ShellCompDirectiveNoFileComp
150159
},
151160
)
152161

@@ -212,8 +221,13 @@ func runWorkspace(cmd *cobra.Command) error {
212221
opt.Kind = KindBackend
213222
}
214223

215-
if opt.Kind != KindBackend && opt.Kind != KindFullstack {
216-
return fmt.Errorf("invalid workspace kind %q: must be %q or %q", opt.Kind, KindBackend, KindFullstack)
224+
switch opt.Kind {
225+
case KindBackend, KindFullstack, KindFrontend:
226+
default:
227+
return fmt.Errorf(
228+
"invalid workspace kind %q: must be %q, %q or %q",
229+
opt.Kind, KindBackend, KindFullstack, KindFrontend,
230+
)
217231
}
218232

219233
resolved, err := resolveSkillSelection(opt.Skills, opt.Kind)
@@ -253,15 +267,25 @@ func goModulePrefix() string {
253267
return base
254268
}
255269

256-
// workspaceDestMapper routes skeleton entries for fullstack scaffolds: AI
257-
// config, devops/ and docs/ stay at the repository root while the Go workspace
258-
// is placed under backend/. For backend scaffolds it returns nil (default
259-
// behaviour copies everything to the repo root).
270+
// workspaceDestMapper routes skeleton entries by workspace kind:
271+
// - fullstack: AI config, devops/ and docs/ stay at the repository root while
272+
// the Go workspace is placed under backend/.
273+
// - frontend: only shared AI config and docs/ are copied to the root; the
274+
// entire Go workspace (cmd/, pkg/, feature/, Makefile, .golangci.yml,
275+
// verify.sh) and the backend stop hook are skipped.
276+
// - backend: returns nil (default behaviour copies everything to the root).
260277
func workspaceDestMapper(repoRoot string) func(string) (string, bool) {
261-
if opt.Kind != KindFullstack {
278+
switch opt.Kind {
279+
case KindFullstack:
280+
return fullstackDestMapper(repoRoot)
281+
case KindFrontend:
282+
return frontendDestMapper(repoRoot)
283+
default:
262284
return nil
263285
}
286+
}
264287

288+
func fullstackDestMapper(repoRoot string) func(string) (string, bool) {
265289
rootLevel := map[string]bool{
266290
".cursor": true,
267291
".agents": true,
@@ -276,22 +300,53 @@ func workspaceDestMapper(repoRoot string) func(string) (string, bool) {
276300
return repoRoot, false
277301
}
278302

279-
top := relPath
280-
if before, _, ok := strings.Cut(relPath, "/"); ok {
281-
top = before
303+
if rootLevel[topSegment(relPath)] {
304+
return filepath.Join(repoRoot, relPath), false
282305
}
283306

284-
// Normalize the template suffix so e.g. "AGENTS.mdtmpl" matches.
285-
top = strings.TrimSuffix(top, "tmpl")
307+
return filepath.Join(repoRoot, backendDir, relPath), false
308+
}
309+
}
286310

287-
if rootLevel[top] {
288-
return filepath.Join(repoRoot, relPath), false
311+
func frontendDestMapper(repoRoot string) func(string) (string, bool) {
312+
rootLevel := map[string]bool{
313+
".cursor": true,
314+
".agents": true,
315+
".ai": true,
316+
"docs": true,
317+
"AGENTS.md": true,
318+
}
319+
320+
return func(relPath string) (string, bool) {
321+
if relPath == "" {
322+
return repoRoot, false
289323
}
290324

291-
return filepath.Join(repoRoot, backendDir, relPath), false
325+
// Skip the entire Go workspace; keep only shared AI config and docs/.
326+
if !rootLevel[topSegment(relPath)] {
327+
return "", true
328+
}
329+
330+
// The backend verify stop hook is irrelevant without a Go workspace.
331+
if strings.HasSuffix(filepath.ToSlash(relPath), "hooks/backend-verify.sh") {
332+
return "", true
333+
}
334+
335+
return filepath.Join(repoRoot, relPath), false
292336
}
293337
}
294338

339+
// topSegment returns the first path segment, with any "tmpl" template suffix
340+
// stripped so e.g. "AGENTS.mdtmpl" matches "AGENTS.md".
341+
func topSegment(relPath string) string {
342+
top := relPath
343+
if before, _, ok := strings.Cut(relPath, "/"); ok {
344+
top = before
345+
}
346+
347+
return strings.TrimSuffix(top, "tmpl")
348+
}
349+
295350
func createWorkspace(_ context.Context) error {
296351
// get the absolute path to the output directory
297352
repoPath, err := filepath.Abs(opt.RepositoryRootDir)
@@ -335,8 +390,11 @@ func copyWorkspaceTemplate(cmd *cobra.Command) {
335390
},
336391
))
337392

338-
if opt.Kind == KindFullstack {
393+
if hasFrontend(opt.Kind) {
339394
copyFrontendTemplate(cmd, repoRoot, templateInput)
395+
}
396+
397+
if opt.Kind == KindFullstack {
340398
fixupBackendDevopsPath(cmd, repoRoot)
341399
}
342400

@@ -347,22 +405,25 @@ func copyWorkspaceTemplate(cmd *cobra.Command) {
347405

348406
cmd.Println("Workspace created successfully")
349407

350-
goRoot := filepath.Join(repoRoot, goRootRel())
351-
modulePrefix := goModulePrefix()
352-
353-
packages := []string{"pkg/i18n", "cmd/service"}
354-
p := z.RunCmdParams{Dir: goRoot}
355-
z.RunCmd(cmd.Context(), p, "go", "work", "init")
356-
357-
for _, pkg := range packages {
358-
p = z.RunCmdParams{Dir: filepath.Join(goRoot, pkg)}
359-
z.RunCmd(cmd.Context(), p, "go", "mod", "init", path.Join(modulePrefix, pkg))
360-
z.RunCmd(cmd.Context(), p, "go", "mod", "edit", "-go=1.25")
361-
z.RunCmd(cmd.Context(), p, "go", "mod", "tidy", "-e")
362-
z.RunCmd(cmd.Context(), p, "go", "work", "use", ".")
408+
// Frontend-only workspaces have no Go workspace to initialize.
409+
if hasBackend(opt.Kind) {
410+
goRoot := filepath.Join(repoRoot, goRootRel())
411+
modulePrefix := goModulePrefix()
412+
413+
packages := []string{"pkg/i18n", "cmd/service"}
414+
p := z.RunCmdParams{Dir: goRoot}
415+
z.RunCmd(cmd.Context(), p, "go", "work", "init")
416+
417+
for _, pkg := range packages {
418+
p = z.RunCmdParams{Dir: filepath.Join(goRoot, pkg)}
419+
z.RunCmd(cmd.Context(), p, "go", "mod", "init", path.Join(modulePrefix, pkg))
420+
z.RunCmd(cmd.Context(), p, "go", "mod", "edit", "-go=1.25")
421+
z.RunCmd(cmd.Context(), p, "go", "mod", "tidy", "-e")
422+
z.RunCmd(cmd.Context(), p, "go", "work", "use", ".")
423+
}
363424
}
364425

365-
p = z.RunCmdParams{Dir: repoRoot}
426+
p := z.RunCmdParams{Dir: repoRoot}
366427

367428
isGitRepo, err := isGitRepository(repoRoot)
368429
if err == nil && !isGitRepo {

ronyup/cmd/setup/setup_workspace_integration_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,104 @@ func TestSetupWorkspaceCommand_FullstackLayout(t *testing.T) {
189189
}
190190
}
191191

192+
func TestSetupWorkspaceCommand_FrontendOnlyLayout(t *testing.T) {
193+
tmpDir := t.TempDir()
194+
repoDir := "fe-repo"
195+
repoModule := "github.com/example/fe-repo"
196+
197+
stubBinDir := filepath.Join(tmpDir, "bin")
198+
if err := os.MkdirAll(stubBinDir, 0o755); err != nil {
199+
t.Fatalf("MkdirAll(stub bin) unexpected error: %v", err)
200+
}
201+
202+
if err := writeExecutable(filepath.Join(stubBinDir, "go"), "#!/bin/sh\nexit 0\n"); err != nil {
203+
t.Fatalf("writeExecutable(go) unexpected error: %v", err)
204+
}
205+
206+
if err := writeExecutable(filepath.Join(stubBinDir, "git"), "#!/bin/sh\nexit 0\n"); err != nil {
207+
t.Fatalf("writeExecutable(git) unexpected error: %v", err)
208+
}
209+
210+
t.Setenv("PATH", stubBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
211+
212+
oldOpt := opt
213+
t.Cleanup(func() {
214+
opt = oldOpt
215+
})
216+
217+
oldWD, err := os.Getwd()
218+
if err != nil {
219+
t.Fatalf("Getwd() unexpected error: %v", err)
220+
}
221+
if err := os.Chdir(tmpDir); err != nil {
222+
t.Fatalf("Chdir(tmpDir) unexpected error: %v", err)
223+
}
224+
t.Cleanup(func() {
225+
_ = os.Chdir(oldWD)
226+
})
227+
228+
opt.RepositoryRootDir = repoDir
229+
opt.RepositoryGoModule = repoModule
230+
opt.ApplicationName = "demo"
231+
opt.Kind = KindFrontend
232+
233+
Cmd.SetOut(os.Stdout)
234+
Cmd.SetErr(os.Stderr)
235+
Cmd.SetArgs([]string{"workspace"})
236+
237+
if err := Cmd.Execute(); err != nil {
238+
t.Fatalf("Cmd.Execute() unexpected error: %v", err)
239+
}
240+
241+
root := filepath.Join(tmpDir, repoDir)
242+
243+
// Frontend app, shared AI config, docs/, and the frontend stop hook exist.
244+
for _, rel := range []string{
245+
"frontend/README.MD", "frontend/verify.sh", "frontend/Makefile",
246+
"docs", "AGENTS.md", ".ai/mcp/mcp.json",
247+
".cursor/hooks.json", ".cursor/hooks/frontend-verify.sh",
248+
".agents/skills/frontend-design",
249+
} {
250+
if _, err := os.Stat(filepath.Join(root, rel)); err != nil {
251+
t.Fatalf("expected %s in frontend-only workspace: %v", rel, err)
252+
}
253+
}
254+
255+
// The Go workspace and backend-only artifacts must NOT be scaffolded.
256+
for _, rel := range []string{
257+
"cmd", "feature", "pkg", "Makefile", "verify.sh", ".golangci.yml",
258+
"backend", ".cursor/hooks/backend-verify.sh", ".agents/skills/go-modern",
259+
} {
260+
if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) {
261+
t.Fatalf("did not expect %s in frontend-only workspace (err=%v)", rel, err)
262+
}
263+
}
264+
265+
// Stop hook wires only the frontend verify gate.
266+
hooks, err := os.ReadFile(filepath.Join(root, ".cursor", "hooks.json"))
267+
if err != nil {
268+
t.Fatalf("ReadFile(.cursor/hooks.json) unexpected error: %v", err)
269+
}
270+
if !strings.Contains(string(hooks), "frontend-verify.sh") {
271+
t.Fatalf("frontend-only hooks.json should register the frontend stop hook, got:\n%s", hooks)
272+
}
273+
if strings.Contains(string(hooks), "backend-verify.sh") {
274+
t.Fatalf("frontend-only hooks.json should not register the backend stop hook, got:\n%s", hooks)
275+
}
276+
277+
// AGENTS.md renders frontend-only guidance and drops backend sections.
278+
agents, err := os.ReadFile(filepath.Join(root, "AGENTS.md"))
279+
if err != nil {
280+
t.Fatalf("ReadFile(AGENTS.md) unexpected error: %v", err)
281+
}
282+
if !strings.Contains(string(agents), "Frontend-only project") {
283+
t.Fatalf("AGENTS.md should render frontend-only guidance, got:\n%s", agents)
284+
}
285+
if strings.Contains(string(agents), "## Package Selection (mandatory)") {
286+
t.Fatalf("frontend-only AGENTS.md should not include the backend package-selection section, got:\n%s", agents)
287+
}
288+
}
289+
192290
func writeExecutable(path, content string) error {
193291
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
194292
return err

0 commit comments

Comments
 (0)