@smbcloud / cli / commits / 20f30ac

Add smbcloud-deploy-monorepo skill guide for monorepo deployment

keypair34 committed Apr 26, 2026 at 12:04 UTC 20f30acaf3ec26f69f654127dd5eb543fc28f836
1 file changed +534
.agents/skills/smbcloud-deploy-monorepo/SKILL.md new
+534
@@ -0,0 +1,534 @@
1 +---
2 +name: smbcloud-deploy-monorepo
3 +description: Use when deploying from a monorepo with smbCloud CLI, especially repositories that contain multiple deployable sub-projects with different strategies (Next.js SSR, Vite SPA, Rails, static) behind a single `.smb/config.toml` with `runner = 255` and `[[projects]]` entries.
4 +---
5 +
6 +# smbCloud Deploy Monorepo
7 +
8 +Use this skill when work touches monorepo deployment on smbCloud.
9 +
10 +Applies to:
11 +
12 +- repositories with `runner = 255` in `[project]`
13 +- `.smb/config.toml` files with `[[projects]]` arrays
14 +- `process_deploy.rs` monorepo resolution logic
15 +- sub-project routing to `nextjs-ssr`, `vite-spa`, `rails`, rsync, or git deploy paths
16 +- multi-tenant PM2 and Nginx server configuration
17 +- CI workflows that deploy individual sub-projects from a monorepo
18 +
19 +## What makes a monorepo
20 +
21 +A monorepo is a repository where `[project]` has `runner = 255` (the `Monorepo` variant) and deployable sub-projects are listed as `[[projects]]` entries. The root project is never deployed directly — it is a container.
22 +
23 +The CLI detects this in `process_deploy.rs`:
24 +
25 +```rust
26 +let resolved_name = match project_name {
27 + Some(name) => Some(name),
28 + None if config.project.runner == Runner::Monorepo => Some(prompt_select_project(&config)?),
29 + None => None,
30 +};
31 +```
32 +
33 +When `runner = 255` and no `--project` flag is passed, the CLI shows an interactive selection prompt. When `--project <name>` is passed, the prompt is skipped.
34 +
35 +After selection, `resolve_sub_project()` swaps `config.project` with the matching `[[projects]]` entry. From that point, all downstream deploy code operates on a single `Project` — it does not know or care that it came from a monorepo.
36 +
37 +## Config structure
38 +
39 +A monorepo `.smb/config.toml` has two layers:
40 +
41 +### Root project (container)
42 +
43 +```toml
44 +name = "splitfire"
45 +description = "SplitFire AI monorepo."
46 +
47 +[project]
48 +id = 38
49 +name = "splitfire"
50 +repository = "splitfire"
51 +runner = 255
52 +description = "SplitFire AI monorepo."
53 +created_at = "2025-09-20T16:33:05.154Z"
54 +updated_at = "2025-09-20T16:33:05.154Z"
55 +```
56 +
57 +Key rules:
58 +
59 +- `runner = 255` is mandatory — this is what activates monorepo behavior
60 +- `id` must be a valid smbCloud project ID (created via `smb init` or the web console)
61 +- the root project has no `kind`, `source`, `path`, or `pm2_app` — it is not deployable
62 +
63 +### Sub-projects (deployable units)
64 +
65 +Each `[[projects]]` entry is a self-contained deploy target with its own `kind`, `source`, `path`, `runner`, and strategy-specific fields.
66 +
67 +```toml
68 +[[projects]]
69 +id = 54
70 +name = "splitfireweb"
71 +repository = "splitfireweb"
72 +description = "SplitFire AI website."
73 +source = "frontend/splitfire-web"
74 +path = "apps/web/splitfireweb"
75 +runner = 0
76 +kind = "nextjs-ssr"
77 +package_manager = "pnpm"
78 +pm2_app = "splitfire-web"
79 +port = 3010
80 +created_at = "2025-09-20T16:33:05.154Z"
81 +updated_at = "2025-09-20T16:33:05.154Z"
82 +
83 +[[projects]]
84 +id = 98
85 +name = "musik88web"
86 +repository = "musik88-production"
87 +description = "Musik88 web or SplitFire AI API."
88 +source = "backend/musik88-web"
89 +runner = 2
90 +kind = "rails"
91 +shared_lib = "lib"
92 +compile_cmd = "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile"
93 +created_at = "2025-09-20T16:33:05.154Z"
94 +updated_at = "2025-09-20T16:33:05.154Z"
95 +
96 +[[projects]]
97 +id = 53
98 +name = "connecteddevices"
99 +repository = "connecteddevices"
100 +description = "KaroKowe connected devices TV web app."
101 +source = "frontend/karokowe-connected-devices"
102 +path = "apps/web/connecteddeviceskarokowe"
103 +runner = 0
104 +kind = "vite-spa"
105 +output = "dist"
106 +package_manager = "pnpm"
107 +created_at = "2025-09-20T16:33:05.154Z"
108 +updated_at = "2025-09-20T16:33:05.154Z"
109 +```
110 +
111 +## Sub-project field reference
112 +
113 +| Field | Required | Description |
114 +| ------------------- | -------- | ------------------------------------------------------------------------------------------- |
115 +| `id` | yes | smbCloud project ID |
116 +| `name` | yes | unique identifier, used with `--project` flag and in the selection prompt |
117 +| `repository` | yes | remote repository name on the smbCloud server (used for git deploy and SSH paths) |
118 +| `description` | no | human-readable description |
119 +| `source` | yes | local path to the sub-project directory, relative to the monorepo root |
120 +| `path` | depends | remote directory on the server, relative to `~/` — required for `nextjs-ssr` and `vite-spa` |
121 +| `runner` | yes | server tier: `0` (NodeJs), `1` (Static), `2` (Ruby), `3` (Swift) |
122 +| `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"vite-spa"`, `"rails"`, or omitted for generic deploy |
123 +| `package_manager` | depends | `"pnpm"` or `"npm"` — required for `nextjs-ssr` and `vite-spa` |
124 +| `pm2_app` | depends | PM2 process name — required for `nextjs-ssr` |
125 +| `port` | depends | runtime port — required for `nextjs-ssr`, defaults to `3000` if omitted |
126 +| `output` | depends | build output directory — required for `vite-spa`, typically `"dist"` |
127 +| `shared_lib` | no | path to shared library directory to rsync before deploy — used by `rails` |
128 +| `compile_cmd` | no | SSH command to run on the server after syncing shared libs — used by `rails` |
129 +| `deployment_method` | no | `0` (Git) or `1` (Rsync) — only matters when `kind` is not set |
130 +| `created_at` | yes | ISO 8601 timestamp |
131 +| `updated_at` | yes | ISO 8601 timestamp |
132 +
133 +## Deploy routing
134 +
135 +The `kind` field drives strategy selection. This happens after `resolve_sub_project()` has already swapped the active project, so routing logic is identical for standalone and monorepo projects.
136 +
137 +```
138 +smb deploy --project <name>
139 + │
140 + ▼
141 + resolve_sub_project()
142 + │
143 + ▼
144 + config.project.kind?
145 + │
146 + ┌────┼──────────┬──────────────┐
147 + ▼ ▼ ▼ ▼
148 + vite-spa nextjs-ssr rails (none)
149 + │ │ │ │
150 + ▼ ▼ ▼ ▼
151 + pnpm pnpm rsync deployment_method?
152 + build install lib/ │
153 + │ + build compile ┌──┴──┐
154 + ▼ │ git push Git Rsync
155 + rsync ▼ │ │ │
156 + dist/ rsync 3 ▼ ▼ ▼
157 + dirs post- git rsync
158 + │ receive push -a
159 + ▼ hook
160 + SSH PM2
161 + restart
162 +```
163 +
164 +### Strategy: `nextjs-ssr`
165 +
166 +Local build, rsync three directories, SSH PM2 restart. See `smbcloud-deploy-nextjs` skill for details.
167 +
168 +Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
169 +
170 +### Strategy: `vite-spa`
171 +
172 +Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
173 +
174 +Required fields: `kind`, `source`, `path`, `output`, `package_manager`
175 +
176 +### Strategy: `rails`
177 +
178 +Rsync shared libraries, SSH compile native extensions, git force-push the sub-project directory to the server's bare repo (triggers post-receive hook).
179 +
180 +Required fields: `kind`, `source`, `repository`, `runner = 2`
181 +
182 +Optional: `shared_lib`, `compile_cmd`
183 +
184 +### Strategy: generic git or rsync
185 +
186 +When `kind` is omitted, the `deployment_method` field controls the path. Git push uses libgit2 to push to the server's bare repo. Rsync uses system `rsync` to sync the source tree.
187 +
188 +## The `source` field
189 +
190 +The `source` field is the most important monorepo-specific field. It tells the CLI where the sub-project lives relative to the monorepo root.
191 +
192 +For `nextjs-ssr` and `vite-spa`, the CLI changes into the `source` directory before running build commands:
193 +
194 +- `pnpm install` runs inside `source/`
195 +- `pnpm build` runs inside `source/`
196 +- rsync sources are relative to `source/`
197 +
198 +For `rails`, the CLI:
199 +
200 +- rsyncs `shared_lib` from the monorepo root (not from `source`)
201 +- runs `compile_cmd` on the server via SSH
202 +- initializes a temporary git repo inside `source/` and force-pushes it
203 +
204 +The `source` path must be a valid directory relative to where `smb deploy` is run (the monorepo root).
205 +
206 +## Runner types and server tiers
207 +
208 +Each sub-project's `runner` determines which smbCloud server receives the deployment:
209 +
210 +| Runner | Value | SSH Host | Use Case |
211 +| ---------- | ----- | -------------------- | ----------------------------------------- |
212 +| `NodeJs` | `0` | `api.smbcloud.xyz` | Next.js, Vite, Node.js apps |
213 +| `Static` | `1` | `api.smbcloud.xyz` | Pure static sites (Nginx serves directly) |
214 +| `Ruby` | `2` | `api-1.smbcloud.xyz` | Rails apps |
215 +| `Swift` | `3` | `api-1.smbcloud.xyz` | Vapor apps |
216 +| `Monorepo` | `255` | — | Container only, never deployed directly |
217 +
218 +Sub-projects within the same monorepo can target different runners. The SplitFire monorepo demonstrates this: `splitfireweb` (runner 0, api.smbcloud.xyz) and `musik88web` (runner 2, api-1.smbcloud.xyz) deploy to different servers from the same repository.
219 +
220 +## Multi-tenant server layout
221 +
222 +Multiple apps share the same server, each in its own directory under the deploy user's home:
223 +
224 +```
225 +/home/git/
226 +├── apps/
227 +│ └── web/
228 +│ ├── splitfireweb/ # Next.js SSR (PM2: splitfire-web, port 3010)
229 +│ │ ├── server.js
230 +│ │ ├── .next/static/
231 +│ │ └── public/
232 +│ ├── connecteddeviceskarokowe/ # Vite SPA (no PM2, Nginx serves directly)
233 +│ │ └── index.html
234 +│ ├── ondeinference.com/ # Next.js SSR (PM2: ondeinference-web, port 3026)
235 +│ │ ├── server.js
236 +│ │ ├── .next/static/
237 +│ │ └── public/
238 +│ └── aircraftshubweb/ # Next.js SSR (PM2: aircraftshub-web, port 3022)
239 +│ ├── server.js
240 +│ ├── .next/static/
241 +│ └── public/
242 +└── musik88-production.git/ # Bare repo (Rails post-receive hook)
243 +```
244 +
245 +Each `nextjs-ssr` app runs as a separate PM2 process on a unique port. Nginx reverse-proxies each domain to the correct port. Vite SPA and static apps have no PM2 process — Nginx serves files directly with `try_files`.
246 +
247 +### Port allocation
248 +
249 +Every `nextjs-ssr` sub-project must have a unique `port` value. Ports are allocated manually and must be consistent across three places:
250 +
251 +1. `.smb/config.toml` — the `port` field in the `[[projects]]` entry
252 +2. PM2 — the `PORT` environment variable passed to `node server.js`
253 +3. Nginx — the `proxy_pass` upstream address
254 +
255 +If any of these disagree, the app will either not start, not be reachable, or serve the wrong app.
256 +
257 +Current allocations on `api.smbcloud.xyz`:
258 +
259 +| App | PM2 Name | Port |
260 +| ----------------- | ------------------- | ---- |
261 +| splitfire-web | `splitfire-web` | 3010 |
262 +| aircraftshub-web | `aircraftshub-web` | 3022 |
263 +| ondeinference-web | `ondeinference-web` | 3026 |
264 +
265 +### Nginx configuration per app type
266 +
267 +**Next.js SSR apps** use reverse proxy:
268 +
269 +```nginx
270 +upstream app_name {
271 + server 127.0.0.1:<port>;
272 + keepalive 64;
273 +}
274 +
275 +server {
276 + server_name example.com;
277 +
278 + location /_next/static/ {
279 + alias /home/git/apps/web/<app>/.next/static/;
280 + expires max;
281 + add_header Cache-Control "public, max-age=31536000, immutable";
282 + access_log off;
283 + }
284 +
285 + location / {
286 + proxy_pass http://app_name;
287 + proxy_http_version 1.1;
288 + proxy_set_header Host $host;
289 + proxy_set_header X-Real-IP $remote_addr;
290 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
291 + proxy_set_header X-Forwarded-Proto $scheme;
292 + proxy_set_header Upgrade $http_upgrade;
293 + proxy_set_header Connection $connection_upgrade;
294 + proxy_buffering off;
295 + }
296 +}
297 +```
298 +
299 +**Vite SPA / static apps** use direct file serving:
300 +
301 +```nginx
302 +server {
303 + server_name example.com;
304 + root /home/git/apps/web/<app>/;
305 +
306 + location / {
307 + try_files $uri $uri/ /index.html;
308 + }
309 +}
310 +```
311 +
312 +Do not mix these patterns. An SSR app with `try_files` will return 404 for every server-rendered route. A static app with `proxy_pass` will fail because there is no PM2 process to proxy to.
313 +
314 +## CLI usage
315 +
316 +### Interactive mode
317 +
318 +```sh
319 +cd my-monorepo/
320 +smb deploy
321 +# → "Select project to deploy"
322 +# → 1. splitfireweb
323 +# 2. musik88web
324 +# 3. connecteddevices
325 +```
326 +
327 +### Direct mode (CI and scripts)
328 +
329 +```sh
330 +smb deploy --project splitfireweb
331 +smb deploy -p musik88web
332 +smb deploy -p connecteddevices
333 +```
334 +
335 +The `--project` / `-p` value must match the `name` field in a `[[projects]]` entry exactly. It is case-sensitive.
336 +
337 +## CI workflow parity
338 +
339 +GitHub Actions workflows mirror the CLI deploy strategies. Each sub-project has its own workflow file triggered by a dedicated deploy branch.
340 +
341 +### Next.js SSR CI deploy
342 +
343 +Trigger: push to `release/splitfire-web`
344 +
345 +```yaml
346 +env:
347 + GIT_HOST: api.smbcloud.xyz
348 + GIT_USER: git
349 + REMOTE_PATH: apps/web/splitfireweb
350 + PM2_APP: splitfire-web
351 +
352 +steps:
353 + - name: Build
354 + run: |
355 + cd frontend/splitfire-web
356 + pnpm install --ignore-scripts
357 + pnpm build
358 +
359 + - name: Upload
360 + run: |
361 + cd frontend/splitfire-web
362 + rsync -az --delete --mkpath .next/standalone/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/
363 + rsync -az --delete --mkpath .next/static/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/.next/static/
364 + rsync -az --delete --mkpath public/ $GIT_USER@$GIT_HOST:$REMOTE_PATH/public/
365 +
366 + - name: Restart
367 + run: |
368 + ssh $GIT_USER@$GIT_HOST bash << 'EOF'
369 + set -e
370 + cd ~/apps/web/splitfireweb
371 + if pm2 describe "splitfire-web" > /dev/null 2>&1; then
372 + pm2 restart "splitfire-web" --update-env
373 + else
374 + PORT=3010 HOSTNAME=127.0.0.1 pm2 start node --name "splitfire-web" -- server.js
375 + fi
376 + pm2 save
377 + EOF
378 +```
379 +
380 +### Rails CI deploy
381 +
382 +Trigger: push to `release/backend-musik88-web`
383 +
384 +```yaml
385 +steps:
386 + - name: Upload shared lib
387 + run: rsync -r ./lib $GIT_USER@$GIT_HOST:~/
388 +
389 + - name: Compile native gem
390 + run: ssh $GIT_USER@$GIT_HOST 'bash -s' < compile.sh ./lib/gems/gem_error_codes
391 +
392 + - name: Git force-push
393 + run: |
394 + cd backend/musik88-web
395 + git init
396 + git add .
397 + git commit -m "Deploy to production"
398 + git remote add prod $GIT_USER@$GIT_HOST:musik88-production
399 + git push --set-upstream prod main --force
400 +```
401 +
402 +### Vite SPA CI deploy
403 +
404 +Trigger: push to the relevant deploy branch
405 +
406 +```yaml
407 +steps:
408 + - name: Build
409 + run: |
410 + cd frontend/karokowe-connected-devices
411 + pnpm install
412 + pnpm build
413 +
414 + - name: Upload
415 + run: |
416 + cd frontend/karokowe-connected-devices
417 + rsync -az --delete --mkpath dist/ $GIT_USER@$GIT_HOST:apps/web/connecteddeviceskarokowe/
418 +```
419 +
420 +### CI rsync flags
421 +
422 +All CI rsync commands must include:
423 +
424 +- `--mkpath` — creates remote directories if they do not exist (prevents failure on first deploy)
425 +- `--delete` — removes stale files on the remote (prevents serving old assets)
426 +- `-az` — archive mode with compression
427 +
428 +### CI PM2 restart
429 +
430 +The CI restart command must use `pm2 restart <app> --update-env` on subsequent deploys so environment variable changes from the ecosystem file are picked up. Without `--update-env`, PM2 keeps the environment from the original `pm2 start` invocation.
431 +
432 +## Deployment tracking
433 +
434 +Every deploy records status in the smbCloud API regardless of strategy:
435 +
436 +1. `POST /deployments` with `status: Started` before transferring files
437 +2. `PATCH /deployments/:id` with `status: Done` on success
438 +3. `PATCH /deployments/:id` with `status: Failed` on failure
439 +
440 +For non-git deploys (rsync, nextjs-ssr, vite-spa), the `commit_hash` field is a UTC timestamp (`20250920T163305Z`) since there is no git commit on the deploy path.
441 +
442 +## Adding a new sub-project to a monorepo
443 +
444 +1. Create the project in smbCloud (via `smb init` in a temporary directory, or the web console) to get an `id`
445 +2. Add a `[[projects]]` entry to the monorepo's `.smb/config.toml` with the correct `kind`, `source`, `path`, `runner`, and strategy-specific fields
446 +3. If `kind = "nextjs-ssr"`: allocate a unique port, configure PM2 ecosystem file on the server, add Nginx reverse proxy config
447 +4. If `kind = "vite-spa"`: add Nginx static file config
448 +5. If `kind = "rails"`: set up a bare git repo on the server with a post-receive hook
449 +6. Test with `smb deploy -p <name>`
450 +
451 +## Removing a sub-project
452 +
453 +1. Remove the `[[projects]]` entry from `.smb/config.toml`
454 +2. On the server: `pm2 delete <pm2_app> && pm2 save` (for SSR apps)
455 +3. On the server: remove the Nginx config and `sudo nginx -t && sudo systemctl reload nginx`
456 +4. On the server: remove the app directory
457 +
458 +## Standalone repos vs monorepo sub-projects
459 +
460 +A standalone repo (like `aircraftshub-web` or `ondeinference.com`) has its own `.smb/config.toml` with `kind` and deploy fields directly in `[project]`. It has no `[[projects]]` array and `runner` is not `255`.
461 +
462 +A monorepo sub-project has the same fields, but they live inside a `[[projects]]` entry. After `resolve_sub_project()`, the CLI treats them identically — all strategy code operates on `config.project` regardless of origin.
463 +
464 +To convert a standalone repo into a monorepo sub-project:
465 +
466 +1. Move the `[project]` fields into a `[[projects]]` entry
467 +2. Create a new `[project]` with `runner = 255`
468 +3. Set `source` to the sub-directory path
469 +4. Adjust `path` if the remote directory structure differs
470 +
471 +## Debugging monorepo deploys
472 +
473 +### "No [[projects]] entries found"
474 +
475 +The root `[project]` has `runner = 255` but there are no `[[projects]]` entries. Add at least one sub-project.
476 +
477 +### "Sub-project 'foo' not found in [[projects]]"
478 +
479 +The `--project` name does not match any `[[projects]]` entry's `name` field. Check spelling and case.
480 +
481 +### Wrong sub-project deploys
482 +
483 +If the interactive prompt selects the wrong project, verify the `[[projects]]` order in `.smb/config.toml` — the prompt lists them in file order.
484 +
485 +### Build runs in wrong directory
486 +
487 +The `source` field is incorrect. It must be relative to the monorepo root where `smb deploy` is run.
488 +
489 +### Port conflict on multi-tenant server
490 +
491 +Two `nextjs-ssr` apps have the same `port` value. PM2 will start both, but only one will bind the port. Check `pm2 list` and compare ports across all `.smb/config.toml` files that target the same server.
492 +
493 +### Stale assets after deploy
494 +
495 +The Nginx `alias` directive for `/_next/static/` or the `root` for a Vite SPA points to the wrong directory or a stale deploy path. Verify the Nginx config matches the `path` field in `.smb/config.toml`.
496 +
497 +### PM2 not picking up new environment variables
498 +
499 +Use `pm2 restart <app> --update-env && pm2 save`. Without `--update-env`, PM2 reuses the environment from the original start.
500 +
501 +## Validation
502 +
503 +### Config
504 +
505 +- every `[[projects]]` entry has a unique `name`
506 +- every `[[projects]]` entry has a unique `id`
507 +- every `nextjs-ssr` entry has a unique `port`
508 +- `source` directories exist on disk
509 +- `runner` values match the intended server tier
510 +
511 +### Local
512 +
513 +- `smb deploy -p <name>` completes without error for each sub-project
514 +- `cargo check -p smbcloud-cli` passes after any deploy code changes
515 +
516 +### Server
517 +
518 +- `pm2 list` shows expected processes with correct names and ports
519 +- `sudo nginx -t` passes
520 +- each domain resolves to the correct app content
521 +- `ls -la /home/git/apps/web/<app>/` confirms expected files
522 +
523 +## Common mistakes
524 +
525 +- setting `runner = 255` on a standalone project that has no `[[projects]]` entries
526 +- forgetting to set `runner = 255` on the root `[project]` of a monorepo — the CLI treats it as a standalone project and tries to deploy the root
527 +- duplicating `port` values across sub-projects on the same server
528 +- using `kind = "nextjs-ssr"` on a project without `output: "standalone"` in its Next.js config
529 +- running `smb deploy` from a subdirectory instead of the monorepo root — `source` paths will not resolve
530 +- forgetting `--mkpath` on rsync in CI workflows — first deploys fail because the remote directory does not exist
531 +- keeping stale Nginx configs pointing at old deploy paths after migrating to a new directory structure
532 +- mixing up `path` (remote destination) and `source` (local directory) — they serve different purposes
533 +- using `pm2 restart` without `--update-env` after changing ecosystem file variables
534 +- assuming the CLI generates Nginx configs — it does not; Nginx is always configured manually on the server