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